refactor: add fenced transfer recovery leases

This commit is contained in:
jxxghp
2026-08-27 14:39:38 +08:00
parent 22865cb35b
commit a62c541ec0
32 changed files with 4135 additions and 650 deletions
+8 -3
View File
@@ -1441,8 +1441,8 @@
"runtime_only": true
}
},
"edge_count": 6832,
"edge_sha256": "eb4b2f9b9689496a7821aeb151c64430cc8654b186aba50f2b7873ae09b71b39",
"edge_count": 6836,
"edge_sha256": "3709e09a49257075f48a44aba353be1f25db610471ec756e09cbbfd7070acb9a",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -7625,6 +7625,10 @@
"app.sdk._legacy.subscribe -> app.domain.context",
"app.sdk._legacy.transfer -> app.application",
"app.sdk._legacy.transfer -> app.application.transfer",
"app.sdk._legacy.transferpending -> app.db",
"app.sdk._legacy.transferpending -> app.db.base",
"app.sdk._legacy.transferpending -> app.db.models",
"app.sdk._legacy.transferpending -> app.db.models.transferpending",
"app.sdk._legacy.user -> app.api",
"app.sdk._legacy.user -> app.api.deps",
"app.sdk._legacy.user -> app.db",
@@ -8277,7 +8281,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 836,
"module_count": 837,
"modules": [
"app",
"app.adapters",
@@ -9058,6 +9062,7 @@
"app.sdk._legacy.history",
"app.sdk._legacy.subscribe",
"app.sdk._legacy.transfer",
"app.sdk._legacy.transferpending",
"app.sdk._legacy.user",
"app.sdk.browser",
"app.sdk.cache",
+1 -1
View File
@@ -1594,7 +1594,7 @@
"misc": 2,
"no-any-return": 3,
"no-untyped-call": 8,
"no-untyped-def": 14,
"no-untyped-def": 12,
"operator": 3,
"return-value": 2,
"truthy-function": 5,
+3 -3
View File
@@ -249,9 +249,9 @@
"app.db.transferpending_oper": {
"introduced": "v3.0.0",
"is_package": false,
"owner": "db",
"replacement": "app.db.oper.transferpending",
"target": "app.db.oper.transferpending"
"owner": "sdk",
"replacement": "app.application.transfer",
"target": "app.sdk._legacy.transferpending"
},
"app.db.user_oper": {
"introduced": "v3.0.0",
+6 -2
View File
@@ -828,15 +828,19 @@ def test_models_and_base_require_explicit_database_sessions():
def test_plugin_sdk_does_not_import_or_export_host_models():
"""插件 SDK 只能暴露 Oper,不得把宿主 ORM Model 作为插件接口"""
"""插件 SDK 不得暴露 ORM Model,只有精确旧 ABI 门面可在内部访问"""
internal_compat_imports = {
("app/sdk/_legacy/transferpending.py", "app.db.models.transferpending"),
}
violations: list[str] = []
for path in (APP_ROOT / "sdk").rglob("*.py"):
relative = path.relative_to(PROJECT_ROOT).as_posix()
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module and (
node.module == "app.db.models"
or node.module.startswith("app.db.models.")
):
) and (relative, node.module) not in internal_compat_imports:
violations.append(
f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}:{node.module}"
)
+14 -4
View File
@@ -158,8 +158,18 @@ def test_tables_without_identity_columns_are_untouched(db):
不带身份列的表不受影响——事件挂在 Mapper 上覆盖全部映射,必须靠列名检查收窄,
否则会去动一张根本没有这两列的表。
"""
TransferPending.register(db.session, storage="local", src_path="/mnt/a.mkv",
now_time="2026-08-14 10:00:00")
TransferPending.stage_admit(
db.session,
task_id="identity-free-table",
storage="local",
src_path="/mnt/a.mkv",
state="accepted",
now_time="2026-08-14 10:00:00",
)
rows = [r for r in TransferPending.list_all(db.session) if r.src_path == "/mnt/a.mkv"]
assert len(rows) == 1
row = TransferPending.get_by_identity(
db.session,
storage="local",
src_path="/mnt/a.mkv",
)
assert row.task_id == "identity-free-table"
+57 -198
View File
@@ -5,8 +5,10 @@
任何一件出偏差,都直接表现为文件被漏整理或被重复整理,而不是一个可见的报错。
因此这里对着真实数据库断言查回的内容,而不是断言调用了什么。
"""
import inspect
import pytest
from sqlalchemy import create_engine
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.db import base as db_base
@@ -21,185 +23,6 @@ def _track(db):
db.watermark(TransferPending)
def test_register_is_idempotent_and_keeps_first_time(db):
"""
同一文件重复登记只保留一条,且登记时间保持首次的值。
监控在挂载抖动时会对同一个文件反复触发事件,若每次都新增一条,回放时同一个
文件会被送进整理链多次。保留首次时间则保证回放顺序仍是「最早发现」的顺序。
"""
TransferPending.register(db.session, storage="local", src_path="/mnt/a.mkv",
now_time="2026-08-13 10:00:00")
TransferPending.register(db.session, storage="local", src_path="/mnt/a.mkv",
now_time="2026-08-13 12:00:00")
rows = TransferPending.list_all(db.session)
same_path = [r for r in rows if r.src_path == "/mnt/a.mkv"]
assert len(same_path) == 1
assert same_path[0].created_at == "2026-08-13 10:00:00"
def test_register_scopes_by_storage(db):
"""
存储不同即为不同文件——路径相同但分属不同存储时不能互相去重。
"""
TransferPending.register(db.session, storage="local", src_path="/data/x.mkv",
now_time="2026-08-13 10:00:00")
TransferPending.register(db.session, storage="alist", src_path="/data/x.mkv",
now_time="2026-08-13 10:00:01")
rows = [r for r in TransferPending.list_all(db.session) if r.src_path == "/data/x.mkv"]
assert {r.storage for r in rows} == {"local", "alist"}
@pytest.mark.parametrize("storage,src_path", [("", "/mnt/a.mkv"), ("local", ""), ("", "")])
def test_register_rejects_incomplete_identity(db, storage, src_path):
"""
缺少存储或路径的登记必须直接丢弃,不能写入半条记录。
半条记录回放时既定位不到文件、也无法被 discard 匹配,会永久留在表里。
"""
assert TransferPending.register(db.session, storage=storage, src_path=src_path,
now_time="2026-08-13 10:00:00") is None
def test_list_all_replays_in_registration_order(db):
"""
回放顺序必须是登记时间升序、同时间按主键升序。
乱序回放会让后发现的文件先进整理链,与原入队顺序不一致。
"""
for path, moment in [("/mnt/c.mkv", "2026-08-13 12:00:00"),
("/mnt/a.mkv", "2026-08-13 10:00:00"),
("/mnt/b.mkv", "2026-08-13 11:00:00")]:
TransferPending.register(db.session, storage="local", src_path=path, now_time=moment)
ordered = [r.src_path for r in TransferPending.list_all(db.session)
if r.src_path.startswith("/mnt/")]
assert ordered == ["/mnt/a.mkv", "/mnt/b.mkv", "/mnt/c.mkv"]
def test_list_all_honours_limit(db):
"""
回放上限必须生效——异常积压时一次性全放会把整理链直接压垮。
"""
for index in range(5):
TransferPending.register(db.session, storage="local", src_path=f"/mnt/{index}.mkv",
now_time=f"2026-08-13 10:00:0{index}")
assert len(TransferPending.list_all(db.session, limit=3)) == 3
def test_discard_removes_only_the_matching_row(db):
"""
注销只应删除匹配的那一条,并返回删除条数。
整理到达终态时按「存储 + 路径」注销,误删其他登记等于把别的文件也判成已完成。
"""
TransferPending.register(db.session, storage="local", src_path="/mnt/a.mkv",
now_time="2026-08-13 10:00:00")
TransferPending.register(db.session, storage="local", src_path="/mnt/b.mkv",
now_time="2026-08-13 10:00:01")
assert TransferPending.discard(db.session, storage="local", src_path="/mnt/a.mkv") == 1
remaining = [r.src_path for r in TransferPending.list_all(db.session)
if r.src_path.startswith("/mnt/")]
assert remaining == ["/mnt/b.mkv"]
@pytest.mark.parametrize("storage,src_path", [("", "/mnt/a.mkv"), ("local", "")])
def test_discard_rejects_incomplete_identity(db, storage, src_path):
"""
身份不全时必须直接返回 0,不能退化成「条件为空」的全表删除。
"""
TransferPending.register(db.session, storage="local", src_path="/mnt/a.mkv",
now_time="2026-08-13 10:00:00")
assert TransferPending.discard(db.session, storage=storage, src_path=src_path) == 0
assert TransferPending.list_all(db.session)
def test_discard_returns_zero_when_absent(db):
"""
注销不存在的登记返回 0,不抛异常——整理链的终态回调不应因此中断。
"""
assert TransferPending.discard(db.session, storage="local", src_path="/nope.mkv") == 0
def test_clear_empties_the_table(db):
"""
清空返回删除条数且表内不再有登记。
"""
TransferPending.register(db.session, storage="local", src_path="/mnt/a.mkv",
now_time="2026-08-13 10:00:00")
assert TransferPending.clear(db.session) >= 1
assert TransferPending.list_all(db.session) == []
def test_oper_returns_plain_tuples_not_orm_instances(db):
"""
回放接口必须返回纯元组。
回放发生在会话之外,ORM 实例脱离 session 后访问属性会抛
DetachedInstanceError——那时启动流程已经在跑,报错等于整批漏件。
"""
oper = TransferPendingOper(db=db.session)
oper.register(storage="local", src_path="/mnt/a.mkv")
listed = oper.list_all()
assert ("local", "/mnt/a.mkv") in listed
assert all(isinstance(item, tuple) for item in listed)
def test_oper_reuses_explicit_query_session(db, monkeypatch):
"""TransferPendingOper 查询必须复用调用方会话。"""
db.add(TransferPending(
storage="local",
src_path="/mnt/explicit.mkv",
created_at="2026-08-13 10:00:00",
))
monkeypatch.setattr(
db_base,
"run_sync_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
)
assert ("local", "/mnt/explicit.mkv") in TransferPendingOper(db.session).list_all()
def test_oper_drops_rows_with_missing_fields(db):
"""
回放时必须跳过字段残缺的历史遗留行,不能把空存储送进整理链。
列上有 NOT NULL 约束,残缺只可能表现为空串;直接绕过 register 写入,
模拟历史数据或外部写库留下的半条记录。
"""
db.add(TransferPending(storage="", src_path="/mnt/broken.mkv",
created_at="2026-08-13 10:00:00"))
oper = TransferPendingOper(db=db.session)
oper.register(storage="local", src_path="/mnt/ok.mkv")
assert oper.list_all() == [("local", "/mnt/ok.mkv")]
def test_oper_discard_and_clear_report_counts(db):
"""
注销与清空都要如实返回条数,调用方据此判断是否真的清理掉了。
"""
oper = TransferPendingOper(db=db.session)
oper.register(storage="local", src_path="/mnt/a.mkv")
oper.register(storage="local", src_path="/mnt/b.mkv")
assert oper.discard(storage="local", src_path="/mnt/a.mkv") == 1
assert oper.clear() >= 1
assert oper.list_all() == []
def test_stage_admit_is_idempotent_and_keeps_stable_task_id(db):
"""显式准入重复执行时必须复用首个稳定任务标识。"""
first = TransferPending.stage_admit(
@@ -224,8 +47,32 @@ def test_stage_admit_is_idempotent_and_keeps_stable_task_id(db):
assert second.updated_at == "2026-08-27 10:00:00"
def test_state_queries_failure_record_and_task_discard(db):
"""状态查询、失败留痕和按任务删除应共享同一稳定身份"""
def test_unfenced_legacy_mutation_apis_are_absent() -> None:
"""持久整理表不得重新暴露绕过稳定任务身份和租约的旧写入口"""
for owner in (TransferPending, TransferPendingOper):
for method_name in (
"register",
"discard",
"list_all",
"list_by_state",
"list_by_states",
"clear",
):
assert not hasattr(owner, method_name)
enqueue_failure_source = inspect.getsource(
TransferPending.record_enqueue_failure
)
assert "cls.lease_token.is_(None)" in enqueue_failure_source
claimable_source = inspect.getsource(
TransferPending.list_claimable_candidates
)
assert "after_cursor" in claimable_source
assert ".not_in(" not in claimable_source
def test_state_queries_and_failure_record_share_stable_identity(db):
"""状态查询与失败留痕应共享同一稳定身份。"""
TransferPending.stage_admit(
db.session,
task_id="task-accepted",
@@ -244,11 +91,11 @@ def test_state_queries_failure_record_and_task_discard(db):
))
db.session.flush()
accepted = TransferPending.list_by_state(
accepted = TransferPending.get_by_task_id(
db.session,
state="accepted",
task_id="task-accepted",
)
assert [item.task_id for item in accepted] == ["task-accepted"]
assert accepted.state == "accepted"
assert TransferPending.record_enqueue_failure(
db.session,
task_id="task-accepted",
@@ -263,10 +110,6 @@ def test_state_queries_failure_record_and_task_discard(db):
)
assert failed.last_error == "queue full"
assert failed.updated_at == "2026-08-27 10:01:00"
assert TransferPending.discard_task(
db.session,
task_id="task-accepted",
) == 1
def test_oper_staging_reuses_explicit_write_session(db, monkeypatch):
@@ -288,15 +131,13 @@ def test_oper_staging_reuses_explicit_write_session(db, monkeypatch):
now_time="2026-08-27 10:00:00",
)
assert pending.task_id == "task-explicit"
assert [item.task_id for item in oper.list_by_state(state="accepted")] == [
"task-explicit"
]
assert oper.get_by_task_id(task_id="task-explicit").state == "accepted"
assert oper.stage_record_enqueue_failure(
task_id="task-explicit",
error="queue full",
now_time="2026-08-27 10:01:00",
) == 1
assert oper.stage_discard_task(task_id="task-explicit") == 1
assert oper.get_by_task_id(task_id="task-explicit").last_error == "queue full"
def test_transactional_repository_commits_frozen_projections(tmp_path):
@@ -317,16 +158,34 @@ def test_transactional_repository_commits_frozen_projections(tmp_path):
assert repeated == admitted
assert admitted.task_id
assert admitted.state == "accepted"
assert repository.list_accepted() == [admitted]
repository.record_enqueue_failure(
task_id=admitted.task_id,
error="queue full",
)
failed = repository.list_accepted()[0]
assert failed.last_error == "queue full"
assert repository.discard_task(task_id=admitted.task_id) == 1
assert repository.list_accepted() == []
with factory() as session:
failed = session.execute(
select(TransferPending).where(
TransferPending.task_id == admitted.task_id
)
).scalar_one()
assert failed.last_error == "queue full"
claimed = repository.claim_task(
task_id=admitted.task_id,
owner_id="repository-test-worker",
lease_seconds=60,
)
assert claimed is not None
assert repository.discard_claimed(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
) == 1
with factory() as session:
assert session.execute(
select(TransferPending).where(
TransferPending.task_id == admitted.task_id
)
).scalar_one_or_none() is None
engine.dispose()
+6
View File
@@ -305,10 +305,16 @@ def test_legacy_transfer_task_hides_internal_admission_identity():
public_fields = set(task.to_dict())
task.bind_admission_task_id("internal-task-id")
task.bind_execution_lease(
owner_id="internal-worker",
lease_token="internal-lease-token",
)
assert set(task.to_dict()) == public_fields
assert "task_id" not in task.to_dict()
assert "admission_task_id" not in task.to_dict()
assert "lease_owner" not in task.to_dict()
assert "lease_token" not in task.to_dict()
def test_chain_media_legacy_scraping_symbols_resolve_to_scraping_chain():
+67 -1
View File
@@ -1,4 +1,5 @@
import unittest
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -8,7 +9,11 @@ from app.application.history import (
failed_retry_count,
record_transfer_failure,
)
from app.application.transfer import TransferPlanningInput, TransferTask
from app.application.transfer import (
TransferAdmission,
TransferPlanningInput,
TransferTask,
)
from app.chain.transfer import JobManager, TransferChain
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
@@ -151,6 +156,7 @@ def make_task(episode: int, season: int = 1) -> TransferTask:
def make_transfer_chain() -> TransferChain:
"""构造带内存 durable admission 契约的整理链测试骨架。"""
chain = object.__new__(TransferChain)
chain.jobview = JobManager()
chain._media_exts = settings.RMT_MEDIAEXT
@@ -161,6 +167,66 @@ def make_transfer_chain() -> TransferChain:
)
chain._success_target_files = {}
chain._scrape_batches = {}
admissions = MagicMock()
admissions_by_identity = {}
admissions_by_id = {}
def admit(*, storage, src_path, planning_input=None):
"""按源身份幂等返回测试用 durable admission。"""
identity = storage, src_path
existing = admissions_by_identity.get(identity)
if existing is not None:
return existing
admission = TransferAdmission(
task_id=f"test-task-{len(admissions_by_id) + 1}",
storage=storage,
src_path=src_path,
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
planning_input=planning_input,
)
admissions_by_identity[identity] = admission
admissions_by_id[admission.task_id] = admission
return admission
def claim_task(*, task_id, owner_id, lease_seconds):
"""为测试任务返回唯一 token,并保留正式 claim 的参数约束。"""
assert lease_seconds > 0
admission = admissions_by_id[task_id]
claimed = replace(
admission,
lease_owner=owner_id,
lease_token=f"lease-{task_id}",
lease_expires_at="2026-08-27 10:02:00.000000",
heartbeat_at="2026-08-27 10:00:00.000000",
attempt_count=admission.attempt_count + 1,
)
admissions_by_identity[(claimed.storage, claimed.src_path)] = claimed
admissions_by_id[task_id] = claimed
return claimed
def checkpoint_plan(*, task_id, lease_token, input_fingerprint, checkpoint):
"""回读带检查点的持久投影,供同步整理测试执行真实编排。"""
del input_fingerprint
admission = admissions_by_id[task_id]
assert admission.lease_token == lease_token
planned = replace(
admission,
state="provider_pending" if checkpoint.is_provider_pending else "planned",
checkpoint=checkpoint,
)
admissions_by_identity[(planned.storage, planned.src_path)] = planned
admissions_by_id[task_id] = planned
return planned
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.release_claim.return_value = True
chain._transfer_admissions = admissions
chain._TransferChain__ensure_lease_heartbeat_owner = MagicMock()
return chain
+197
View File
@@ -0,0 +1,197 @@
"""整理恢复租约字段的 Alembic 可逆迁移测试。"""
import importlib
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from app.db.models.transferpending import TransferPending
MIGRATION = "database.versions.d3a9e5f7b2c4_3_0_15"
LEASE_COLUMNS = {
"lease_owner",
"lease_token",
"lease_expires_at",
"heartbeat_at",
"attempt_count",
}
def _bind_migration(monkeypatch, connection):
"""把 3.0.15 迁移绑定到隔离数据库连接。"""
migration = importlib.import_module(MIGRATION)
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
return migration
def _create_planning_table(connection) -> None:
"""创建 3.0.14 时代包含完整规划检查点的登记表。"""
metadata = sa.MetaData()
table = 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(), nullable=True),
sa.Column("state", sa.String(32), nullable=False),
sa.Column("updated_at", sa.String(40), nullable=False),
sa.Column("last_error", sa.Text(), nullable=True),
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(), nullable=True),
sa.Column("checkpoint_payload", sa.JSON(), nullable=True),
sa.Column("planned_at", sa.String(40), nullable=True),
sa.UniqueConstraint("task_id", name="uq_transferpending_task_id"),
)
sa.Index(
"ux_transferpending_storage_path",
table.c.storage,
table.c.src_path,
unique=True,
)
sa.Index(
"ix_transferpending_state_created",
table.c.state,
table.c.created_at,
table.c.id,
)
metadata.create_all(connection)
connection.execute(
table.insert(),
{
"id": 1,
"task_id": "stable-task",
"storage": "local",
"src_path": "/downloads/Movie.mkv",
"created_at": "2026-08-27 10:00:00",
"state": "planned",
"updated_at": "2026-08-27 10:00:00",
"last_error": None,
"input_version": 1,
"planning_input": {
"schema_version": 1,
"source_fileitem": {
"storage": "local",
"path": "/downloads/Movie.mkv",
},
},
"input_fingerprint": "0" * 64,
"checkpoint_version": 1,
"checkpoint_payload": {"schema_version": 1},
"planned_at": "2026-08-27 10:00:00",
},
)
def test_transfer_lease_upgrade_downgrade_reupgrade(monkeypatch) -> None:
"""SQLite 应支持租约字段重复升级、降级和再次升级。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_planning_table(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
inspector = sa.inspect(connection)
assert {
column["name"]
for column in inspector.get_columns("transferpending")
} == {column.name for column in TransferPending.__table__.columns}
assert "ix_transferpending_recovery_lease" in {
index["name"]
for index in inspector.get_indexes("transferpending")
}
row = connection.execute(
sa.text(
"SELECT lease_owner, lease_token, lease_expires_at, "
"heartbeat_at, attempt_count FROM transferpending WHERE id = 1"
)
).mappings().one()
assert dict(row) == {
"lease_owner": None,
"lease_token": None,
"lease_expires_at": None,
"heartbeat_at": None,
"attempt_count": 0,
}
connection.execute(
sa.text(
"UPDATE transferpending SET lease_owner = 'worker', "
"lease_token = 'token', "
"lease_expires_at = '2026-08-27 10:05:00.000000', "
"heartbeat_at = '2026-08-27 10:00:00.000000', "
"attempt_count = 3 WHERE id = 1"
)
)
migration.downgrade()
downgraded = sa.inspect(connection)
assert LEASE_COLUMNS.isdisjoint({
column["name"]
for column in downgraded.get_columns("transferpending")
})
assert "ix_transferpending_recovery_lease" not in {
index["name"]
for index in downgraded.get_indexes("transferpending")
}
assert connection.execute(
sa.text("SELECT state FROM transferpending WHERE id = 1")
).scalar_one() == "planned"
migration.upgrade()
reupgraded = connection.execute(
sa.text(
"SELECT lease_owner, lease_token, attempt_count "
"FROM transferpending WHERE id = 1"
)
).mappings().one()
assert dict(reupgraded) == {
"lease_owner": None,
"lease_token": None,
"attempt_count": 0,
}
engine.dispose()
def test_partial_transfer_lease_upgrade_preserves_existing_owner(
monkeypatch,
) -> None:
"""迁移中断后重跑应补齐字段且不得覆盖已经写入的租约拥有者。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
_create_planning_table(connection)
connection.execute(
sa.text("ALTER TABLE transferpending ADD COLUMN lease_owner VARCHAR(128)")
)
connection.execute(
sa.text(
"UPDATE transferpending SET lease_owner = 'preserved-worker' "
"WHERE id = 1"
)
)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
row = connection.execute(
sa.text(
"SELECT lease_owner, lease_token, attempt_count "
"FROM transferpending WHERE id = 1"
)
).mappings().one()
assert dict(row) == {
"lease_owner": "preserved-worker",
"lease_token": None,
"attempt_count": 0,
}
engine.dispose()
+801
View File
@@ -0,0 +1,801 @@
"""整理恢复租约的原子 claim、续租和陈旧 token 防护测试。"""
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from threading import Barrier, Lock
from typing import Any
import pytest
from sqlalchemy import create_engine, select, text
from sqlalchemy.orm import sessionmaker
from app.application.transfer import (
TRANSFER_ADMISSION_PLANNED,
TransferAdmission,
TransferAdmissionProjectionError,
TransferLeaseLostError,
TransferPlanCheckpoint,
TransferPlanningInput,
TransferProviderInvocationSnapshot,
TransferProviderReference,
)
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
from app.db.models.transferpending import TransferPending
from app.db.oper.transferpending import TransferPendingOper
def _planning_input(path: str) -> TransferPlanningInput:
"""构造与测试源路径绑定的最小规划输入。"""
return TransferPlanningInput(
source_fileitem={
"storage": "local",
"path": path,
"type": "file",
"name": path.rsplit("/", maxsplit=1)[-1],
},
meta={"name": "Movie"},
mediainfo={"title": "Movie"},
)
def _checkpoint(planning_input: TransferPlanningInput) -> TransferPlanCheckpoint:
"""构造无需文件副作用的合法宿主跳过检查点。"""
return TransferPlanCheckpoint(
planning_input=planning_input,
target_storage="local",
root_target_path="/library",
final_target_path="/library",
resolved_transfer_type="copy",
items=(),
skip_reason="测试跳过计划",
)
def _provider_checkpoint(
planning_input: TransferPlanningInput,
) -> TransferPlanCheckpoint:
"""构造只冻结 provider ABI、尚未完成宿主规划的检查点。"""
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,
)
@pytest.fixture
def repository_factory(tmp_path):
"""创建允许多线程独立 Session 竞争的 SQLite 租约仓储工厂。"""
engine = create_engine(
f"sqlite:///{tmp_path / 'transfer-lease.db'}",
connect_args={"check_same_thread": False, "timeout": 10},
)
TransferPending.__table__.create(engine)
factory = sessionmaker(bind=engine)
yield lambda: TransactionalTransferAdmissionRepository(factory)
engine.dispose()
@pytest.fixture
def lease_clock(monkeypatch):
"""为所有仓储实例提供可推进的固定 UTC 时钟。"""
clock = {"now": datetime(2026, 8, 27, 10, 0, tzinfo=timezone.utc)}
monkeypatch.setattr(
TransactionalTransferAdmissionRepository,
"_lease_now",
staticmethod(lambda: clock["now"]),
)
return clock
@pytest.fixture
def business_clock(monkeypatch):
"""为业务审计字段提供与 UTC 租约时钟明确分离的宿主本地时间。"""
clock = {"now": "2026-08-27 18:00:00"}
monkeypatch.setattr(
TransactionalTransferAdmissionRepository,
"_now",
staticmethod(lambda: clock["now"]),
)
return clock
def _admit(
repository: TransactionalTransferAdmissionRepository,
path: str,
) -> TransferAdmission:
"""登记一个带完整版本化输入的测试任务。"""
return repository.admit(
storage="local",
src_path=path,
planning_input=_planning_input(path),
)
def _pending_snapshot(
repository: TransactionalTransferAdmissionRepository,
task_id: str,
) -> dict[str, Any]:
"""在 Session 关闭前冻结测试需要检查的持久登记字段。"""
with repository._session_factory() as session: # noqa: SLF001
pending = session.execute(
select(TransferPending).where(TransferPending.task_id == task_id)
).scalar_one()
return {
"lease_owner": pending.lease_owner,
"lease_token": pending.lease_token,
"lease_expires_at": pending.lease_expires_at,
"heartbeat_at": pending.heartbeat_at,
"last_error": pending.last_error,
"attempt_count": pending.attempt_count,
"updated_at": pending.updated_at,
"planned_at": pending.planned_at,
}
def test_claim_heartbeat_expired_takeover_and_stale_token_guards(
repository_factory,
lease_clock,
business_clock,
) -> None:
"""新 token 才增加 attempt,过期 token 不能续租、释放或删除接管者。"""
repository = repository_factory()
admitted = _admit(repository, "/downloads/movie.mkv")
assert admitted.created_at == "2026-08-27 18:00:00"
business_clock["now"] = "2026-08-27 18:01:00"
first = repository.claim_task(
task_id=admitted.task_id,
owner_id="worker-a",
lease_seconds=60,
)
assert first is not None
assert first.lease_owner == "worker-a"
assert first.lease_token
assert first.attempt_count == 1
assert first.updated_at == "2026-08-27 18:01:00"
assert repository.claim_task(
task_id=admitted.task_id,
owner_id="worker-a",
lease_seconds=60,
) is None
assert repository.claim_task(
task_id=admitted.task_id,
owner_id="worker-b",
lease_seconds=60,
) is None
lease_clock["now"] += timedelta(seconds=30)
business_clock["now"] = "2026-08-27 18:02:00"
renewed = repository.heartbeat(
task_id=admitted.task_id,
lease_token=first.lease_token,
lease_seconds=60,
)
assert renewed is not None
assert renewed.attempt_count == 1
assert renewed.heartbeat_at == "2026-08-27 10:00:30.000000"
assert renewed.updated_at == first.updated_at
lease_clock["now"] += timedelta(seconds=61)
business_clock["now"] = "2026-08-27 18:03:00"
assert repository.release_claim(
task_id=admitted.task_id,
lease_token=first.lease_token,
error="expired worker",
) is False
assert repository.discard_claimed(
task_id=admitted.task_id,
lease_token=first.lease_token,
) == 0
takeover = repository.claim_task(
task_id=admitted.task_id,
owner_id="worker-b",
lease_seconds=60,
)
assert takeover is not None
assert takeover.lease_token != first.lease_token
assert takeover.attempt_count == 2
assert takeover.updated_at == "2026-08-27 18:03:00"
assert repository.heartbeat(
task_id=admitted.task_id,
lease_token=first.lease_token,
lease_seconds=60,
) is None
business_clock["now"] = "2026-08-27 18:04:00"
assert repository.release_claim(
task_id=admitted.task_id,
lease_token=first.lease_token,
error="stale worker",
) is False
assert repository.discard_claimed(
task_id=admitted.task_id,
lease_token=first.lease_token,
) == 0
assert repository.release_claim(
task_id=admitted.task_id,
lease_token=takeover.lease_token,
error="retry later",
) is True
released = _pending_snapshot(repository, admitted.task_id)
assert released["lease_owner"] is None
assert released["lease_token"] is None
assert released["lease_expires_at"] is None
assert released["heartbeat_at"] is None
assert released["last_error"] == "retry later"
assert released["attempt_count"] == 2
assert released["updated_at"] == "2026-08-27 18:04:00"
third = repository.claim_task(
task_id=admitted.task_id,
owner_id="worker-c",
lease_seconds=60,
)
assert third is not None
assert third.attempt_count == 3
assert repository.discard_claimed(
task_id=admitted.task_id,
lease_token=third.lease_token,
) == 1
with repository._session_factory() as session: # noqa: SLF001
assert session.execute(
select(TransferPending).where(
TransferPending.task_id == admitted.task_id
)
).scalar_one_or_none() is None
def test_claim_recoverable_respects_order_limit_and_active_lease(
repository_factory,
lease_clock,
) -> None:
"""批量恢复跳过有效租约,并按登记顺序逐条 CAS 到请求上限。"""
repository = repository_factory()
first = _admit(repository, "/downloads/a.mkv")
second = _admit(repository, "/downloads/b.mkv")
third = _admit(repository, "/downloads/c.mkv")
active = repository.claim_task(
task_id=first.task_id,
owner_id="active-worker",
lease_seconds=60,
)
assert active is not None
claimed = repository.claim_recoverable(
owner_id="recovery-worker",
limit=2,
lease_seconds=60,
)
assert [item.task_id for item in claimed] == [second.task_id, third.task_id]
assert all(item.lease_owner == "recovery-worker" for item in claimed)
assert all(item.attempt_count == 1 for item in claimed)
assert repository.claim_recoverable(
owner_id="other-worker",
limit=10,
lease_seconds=60,
) == []
lease_clock["now"] += timedelta(seconds=61)
reclaimed = repository.claim_recoverable(
owner_id="takeover-worker",
limit=2,
lease_seconds=60,
)
assert [item.task_id for item in reclaimed] == [first.task_id, second.task_id]
assert reclaimed[0].attempt_count == 2
assert reclaimed[1].attempt_count == 2
def test_claim_recoverable_skips_corrupt_projection_and_claims_later_tasks(
repository_factory,
lease_clock,
business_clock,
monkeypatch,
) -> None:
"""毒行应留下单次诊断但不持有租约或饿死后续健康任务。"""
repository = repository_factory()
messages: list[str] = []
monkeypatch.setattr(
"app.db.adapters.transfer._diagnostic_logger.error",
messages.append,
)
corrupt = _admit(repository, "/downloads/a-corrupt.mkv")
healthy = [
_admit(repository, "/downloads/b-healthy.mkv"),
_admit(repository, "/downloads/c-healthy.mkv"),
]
with repository._session_factory() as session: # noqa: SLF001
pending = session.execute(
select(TransferPending).where(
TransferPending.task_id == corrupt.task_id
)
).scalar_one()
pending.input_fingerprint = "corrupt"
session.commit()
claimed = repository.claim_recoverable(
owner_id="recovery-worker",
limit=2,
lease_seconds=60,
)
assert [item.task_id for item in claimed] == [item.task_id for item in healthy]
corrupt_snapshot = _pending_snapshot(repository, corrupt.task_id)
assert corrupt_snapshot["lease_token"] is None
assert corrupt_snapshot["attempt_count"] == 0
assert corrupt_snapshot["last_error"].startswith("恢复投影失败:")
assert corrupt_snapshot["updated_at"] == "2026-08-27 18:00:00"
assert len(messages) == 1
business_clock["now"] = "2026-08-27 18:01:00"
assert repository.claim_recoverable(
owner_id="second-recovery-worker",
limit=1,
lease_seconds=60,
) == []
assert _pending_snapshot(repository, corrupt.task_id) == corrupt_snapshot
assert len(messages) == 1
with repository._session_factory() as session: # noqa: SLF001
pending = session.execute(
select(TransferPending).where(
TransferPending.task_id == corrupt.task_id
)
).scalar_one()
pending.input_fingerprint = _planning_input(
"/downloads/a-corrupt.mkv"
).fingerprint
session.commit()
repaired = repository.claim_recoverable(
owner_id="repaired-worker",
limit=1,
lease_seconds=60,
)
assert [item.task_id for item in repaired] == [corrupt.task_id]
def test_projection_diagnostic_changes_are_recorded_once_each(
repository_factory,
business_clock,
monkeypatch,
) -> None:
"""相同投影错误不重复写库,错误类型变化时才更新诊断并再次告警。"""
repository = repository_factory()
messages: list[str] = []
monkeypatch.setattr(
"app.db.adapters.transfer._diagnostic_logger.error",
messages.append,
)
admitted = _admit(repository, "/downloads/changing-corrupt.mkv")
with repository._session_factory() as session: # noqa: SLF001
pending = session.execute(
select(TransferPending).where(
TransferPending.task_id == admitted.task_id
)
).scalar_one()
pending.input_fingerprint = "corrupt"
session.commit()
assert repository.claim_recoverable(
owner_id="recovery-a",
limit=1,
lease_seconds=60,
) == []
first = _pending_snapshot(repository, admitted.task_id)
assert len(messages) == 1
business_clock["now"] = "2026-08-27 18:01:00"
assert repository.claim_recoverable(
owner_id="recovery-b",
limit=1,
lease_seconds=60,
) == []
assert _pending_snapshot(repository, admitted.task_id) == first
assert len(messages) == 1
with repository._session_factory() as session: # noqa: SLF001
pending = session.execute(
select(TransferPending).where(
TransferPending.task_id == admitted.task_id
)
).scalar_one()
pending.input_fingerprint = _planning_input(
"/downloads/changing-corrupt.mkv"
).fingerprint
pending.input_version = 999
session.commit()
business_clock["now"] = "2026-08-27 18:02:00"
assert repository.claim_recoverable(
owner_id="recovery-c",
limit=1,
lease_seconds=60,
) == []
changed = _pending_snapshot(repository, admitted.task_id)
assert changed["last_error"] != first["last_error"]
assert changed["updated_at"] == "2026-08-27 18:02:00"
assert len(messages) == 2
def test_projection_diagnostic_cas_is_concurrency_safe(
repository_factory,
monkeypatch,
) -> None:
"""并发恢复观察到同一损坏时只允许一个诊断写入者和一条运行日志。"""
repository = repository_factory()
admitted = _admit(repository, "/downloads/concurrent-corrupt.mkv")
messages: list[str] = []
message_lock = Lock()
def capture(message: str) -> None:
"""并发安全收集错误日志。"""
with message_lock:
messages.append(message)
monkeypatch.setattr(
"app.db.adapters.transfer._diagnostic_logger.error",
capture,
)
barrier = Barrier(2)
projection_error = TransferAdmissionProjectionError("same corruption")
def record(_: int) -> bool:
"""让两个独立 Session 同时竞争同一诊断 CAS。"""
barrier.wait(timeout=5)
return repository_factory()._record_projection_failure( # noqa: SLF001
task_id=admitted.task_id,
error=projection_error,
)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(record, range(2)))
assert sorted(results) == [False, True]
assert len(messages) == 1
snapshot = _pending_snapshot(repository, admitted.task_id)
assert snapshot["last_error"] == "恢复投影失败: same corruption"
def test_projection_diagnostic_does_not_overwrite_active_lease(
repository_factory,
monkeypatch,
) -> None:
"""诊断 CAS 不得覆盖已经由健康 worker 取得有效租约的任务。"""
repository = repository_factory()
admitted = _admit(repository, "/downloads/active-lease.mkv")
claimed = repository.claim_task(
task_id=admitted.task_id,
owner_id="active-worker",
lease_seconds=60,
)
assert claimed is not None
before = _pending_snapshot(repository, admitted.task_id)
messages: list[str] = []
monkeypatch.setattr(
"app.db.adapters.transfer._diagnostic_logger.error",
messages.append,
)
recorded = repository._record_projection_failure( # noqa: SLF001
task_id=admitted.task_id,
error=TransferAdmissionProjectionError("stale observation"),
)
assert recorded is False
assert _pending_snapshot(repository, admitted.task_id) == before
assert messages == []
def test_projection_diagnostic_database_failure_propagates(
repository_factory,
monkeypatch,
) -> None:
"""诊断留痕的数据库基础设施异常必须向上游传播而非静默跳过。"""
repository = repository_factory()
admitted = _admit(repository, "/downloads/db-error-corrupt.mkv")
with repository._session_factory() as session: # noqa: SLF001
pending = session.execute(
select(TransferPending).where(
TransferPending.task_id == admitted.task_id
)
).scalar_one()
pending.input_fingerprint = "corrupt"
session.commit()
def fail_diagnostic(*_args, **_kwargs):
"""模拟诊断短事务的底层数据库写入失败。"""
raise RuntimeError("database unavailable")
monkeypatch.setattr(
TransferPendingOper,
"stage_record_projection_failure",
fail_diagnostic,
)
with pytest.raises(RuntimeError, match="database unavailable"):
repository.claim_recoverable(
owner_id="recovery-worker",
limit=1,
lease_seconds=60,
)
def test_claim_task_wraps_persisted_json_decode_failure(
repository_factory,
) -> None:
"""持久 JSON 解码错误应归类为投影损坏,而不是数据库基础设施故障。"""
repository = repository_factory()
admitted = _admit(repository, "/downloads/invalid-json.mkv")
with repository._session_factory() as session: # noqa: SLF001
session.execute(
text(
"UPDATE transferpending SET planning_input = 'not-json' "
"WHERE task_id = :task_id"
),
{"task_id": admitted.task_id},
)
session.commit()
with pytest.raises(TransferAdmissionProjectionError, match="JSON"):
repository.claim_task(
task_id=admitted.task_id,
owner_id="recovery-worker",
lease_seconds=60,
)
with repository._session_factory() as session: # noqa: SLF001
snapshot = session.execute(
text(
"SELECT lease_token, attempt_count FROM transferpending "
"WHERE task_id = :task_id"
),
{"task_id": admitted.task_id},
).mappings().one()
assert snapshot["lease_token"] is None
assert snapshot["attempt_count"] == 0
def test_concurrent_recovery_callers_scan_past_lost_candidates(
repository_factory,
lease_clock,
monkeypatch,
) -> None:
"""并发 caller 竞争同一首批后应继续向后扫描并各自填满限额。"""
setup_repository = repository_factory()
admitted = [
_admit(setup_repository, f"/downloads/concurrent-{index}.mkv")
for index in range(4)
]
barrier = Barrier(2)
barrier_lock = Lock()
initial_scans = 0
original = TransferPending.list_claimable_candidates.__func__
def synchronized_candidates(cls, db, **kwargs):
"""强制两个 caller 在取得相同首批候选后再进入逐任务 CAS。"""
nonlocal initial_scans
candidates = original(cls, db, **kwargs)
if kwargs.get("after_cursor") is None:
with barrier_lock:
initial_scans += 1
barrier.wait(timeout=5)
return candidates
monkeypatch.setattr(
TransferPending,
"list_claimable_candidates",
classmethod(synchronized_candidates),
)
def recover(owner_id: str) -> list[TransferAdmission]:
"""使用独立仓储与 Session 执行一次有界恢复扫描。"""
return repository_factory().claim_recoverable(
owner_id=owner_id,
limit=2,
lease_seconds=60,
)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(recover, ("worker-a", "worker-b")))
assert initial_scans == 2
assert [len(result) for result in results] == [2, 2]
claimed_task_ids = [item.task_id for result in results for item in result]
assert len(set(claimed_task_ids)) == 4
assert set(claimed_task_ids) == {item.task_id for item in admitted}
def test_unclaimed_enqueue_failure_cannot_overwrite_claimed_task(
repository_factory,
business_clock,
) -> None:
"""task-id-only 入队失败入口不得改写已经由 worker claim 的登记。"""
repository = repository_factory()
admitted = _admit(repository, "/downloads/claimed.mkv")
business_clock["now"] = "2026-08-27 18:01:00"
claimed = repository.claim_task(
task_id=admitted.task_id,
owner_id="worker-a",
lease_seconds=60,
)
assert claimed is not None
before = _pending_snapshot(repository, admitted.task_id)
business_clock["now"] = "2026-08-27 18:02:00"
repository.record_enqueue_failure(
task_id=admitted.task_id,
error="stale queue failure",
)
after = _pending_snapshot(repository, admitted.task_id)
assert after == before
def test_planning_writes_require_current_unexpired_lease(
repository_factory,
lease_clock,
) -> None:
"""checkpoint 和规划错误均不得由已过期或已被接管的 worker 写入。"""
repository = repository_factory()
path = "/downloads/planning.mkv"
planning_input = _planning_input(path)
admitted = repository.admit(
storage="local",
src_path=path,
planning_input=planning_input,
)
first = repository.claim_task(
task_id=admitted.task_id,
owner_id="worker-a",
lease_seconds=30,
)
assert first is not None
lease_clock["now"] += timedelta(seconds=31)
takeover = repository.claim_task(
task_id=admitted.task_id,
owner_id="worker-b",
lease_seconds=60,
)
assert takeover is not None
with pytest.raises(TransferLeaseLostError, match="租约"):
repository.record_planning_failure(
task_id=admitted.task_id,
lease_token=first.lease_token,
error="stale planning failure",
)
with pytest.raises(TransferLeaseLostError, match="租约"):
repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=first.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=_checkpoint(planning_input),
)
repository.record_planning_failure(
task_id=admitted.task_id,
lease_token=takeover.lease_token,
error="retryable planning failure",
)
planned = repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=takeover.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=_checkpoint(planning_input),
)
repeated = repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=takeover.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=_checkpoint(planning_input),
)
assert planned.state == TRANSFER_ADMISSION_PLANNED
assert planned.last_error is None
assert repeated == planned
assert planned.attempt_count == 2
def test_provider_checkpoint_sets_planned_time_only_after_host_plan(
repository_factory,
lease_clock,
business_clock,
) -> None:
"""provider 快照不是规划完成,planned_at 只记录首次宿主完整计划。"""
repository = repository_factory()
path = "/downloads/provider-plan.mkv"
planning_input = _planning_input(path)
admitted = repository.admit(
storage="local",
src_path=path,
planning_input=planning_input,
)
business_clock["now"] = "2026-08-27 18:01:00"
claimed = repository.claim_task(
task_id=admitted.task_id,
owner_id="worker-a",
lease_seconds=60,
)
assert claimed is not None
business_clock["now"] = "2026-08-27 18:02:00"
repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=_provider_checkpoint(planning_input),
)
provider_snapshot = _pending_snapshot(repository, admitted.task_id)
assert provider_snapshot["planned_at"] is None
assert provider_snapshot["updated_at"] == "2026-08-27 18:02:00"
business_clock["now"] = "2026-08-27 18:03:00"
checkpoint = _checkpoint(planning_input)
repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=checkpoint,
)
planned_snapshot = _pending_snapshot(repository, admitted.task_id)
assert planned_snapshot["planned_at"] == "2026-08-27 18:03:00"
business_clock["now"] = "2026-08-27 18:04:00"
repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=checkpoint,
)
assert _pending_snapshot(repository, admitted.task_id) == planned_snapshot
def test_concurrent_claim_uses_rowcount_as_single_winner(
repository_factory,
lease_clock,
) -> None:
"""并发 worker 即使读取同一任务,也只能有一个 CAS 更新获胜。"""
setup_repository = repository_factory()
admitted = _admit(setup_repository, "/downloads/concurrent.mkv")
barrier = Barrier(2)
def claim(owner_id: str):
"""等待竞争者就绪后使用独立 Session claim 同一任务。"""
repository = repository_factory()
barrier.wait()
return repository.claim_task(
task_id=admitted.task_id,
owner_id=owner_id,
lease_seconds=60,
)
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(claim, ("worker-a", "worker-b")))
winners = [result for result in results if result is not None]
assert len(winners) == 1
assert winners[0].attempt_count == 1
@@ -0,0 +1,158 @@
"""旧待整理 Oper 的精确插件兼容与租约 fencing 测试。"""
import importlib
from collections.abc import Callable
from typing import Any
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from app.db import base as db_base
from app.db.models.transferpending import TransferPending
from app.db.oper.transferpending import TransferPendingOper as CanonicalTransferPendingOper
from app.runtime.compat.manifest import MODULE_ALIASES
@pytest.fixture
def legacy_session_factory(tmp_path, monkeypatch):
"""为无 Session 兼容 Oper 提供独占事务,并在提交后保留返回快照。"""
engine = create_engine(f"sqlite:///{tmp_path / 'legacy-transferpending.db'}")
TransferPending.__table__.create(engine)
factory = sessionmaker(bind=engine)
def run_transaction(operation: Callable[[Session], Any]) -> Any:
"""按生产组合根语义执行一次同步兼容事务。"""
with factory() as session:
session.expire_on_commit = False
try:
result = operation(session)
session.commit()
return result
except Exception:
session.rollback()
raise
monkeypatch.setattr(db_base, "run_sync_transaction", run_transaction)
yield factory
engine.dispose()
def test_legacy_import_targets_private_sdk_facade() -> None:
"""旧模块路径只能解析到私有 SDK 门面,不能回退到 canonical Oper。"""
alias = MODULE_ALIASES["app.db.transferpending_oper"]
legacy = importlib.import_module("app.db.transferpending_oper")
assert alias.target == "app.sdk._legacy.transferpending"
assert alias.owner == "sdk"
assert alias.replacement == "app.application.transfer"
assert legacy is importlib.import_module(alias.target)
assert legacy.__all__ == ["TransferPendingOper"]
assert not hasattr(legacy, "TransferPending")
assert legacy.TransferPendingOper is not CanonicalTransferPendingOper
for internal_method in (
"stage_admit",
"stage_claim_task",
"stage_discard_claimed",
"stage_checkpoint_plan",
):
assert not hasattr(legacy.TransferPendingOper, internal_method)
def test_legacy_no_session_queries_preserve_historical_shapes(
legacy_session_factory,
) -> None:
"""旧插件无需 Session 即可登记和查询,返回形态与原 ABI 一致。"""
legacy = importlib.import_module("app.db.transferpending_oper")
oper = legacy.TransferPendingOper()
first = oper.register("local", "/downloads/first.mkv")
repeated = oper.register("local", "/downloads/first.mkv")
second = oper.register("remote", "/downloads/second.mkv")
assert first is not None
assert repeated is not None
assert second is not None
assert repeated.task_id == first.task_id
assert oper.list_all() == [
("local", "/downloads/first.mkv"),
("remote", "/downloads/second.mkv"),
]
assert [item.task_id for item in oper.list_by_state(state="accepted")] == [
first.task_id,
second.task_id,
]
assert [
item.task_id
for item in oper.list_by_states(states=("accepted", "planned"), limit=1)
] == [first.task_id]
assert oper.get_by_identity(
storage="local",
src_path="/downloads/first.mkv",
).task_id == first.task_id
assert oper.get_by_task_id(task_id=second.task_id).src_path == second.src_path
assert oper.register("", "/downloads/invalid.mkv") is None
assert oper.list_by_state(state="") == []
assert oper.list_by_states(states=()) == []
def test_legacy_mutations_never_delete_claimed_rows(
legacy_session_factory,
) -> None:
"""旧 discard/clear 只处理未 claim 行,有效或过期 token 均受保护。"""
legacy = importlib.import_module("app.db.transferpending_oper")
oper = legacy.TransferPendingOper()
active = oper.register("local", "/downloads/active.mkv")
expired = oper.register("local", "/downloads/expired.mkv")
free = oper.register("local", "/downloads/free.mkv")
assert active is not None
assert expired is not None
assert free is not None
with legacy_session_factory() as session:
assert TransferPending.claim_task(
session,
task_id=active.task_id,
states=("accepted",),
owner_id="active-worker",
lease_token="active-token",
now_time="2026-08-27 10:00:00.000000",
lease_expires_at="2026-08-27 10:01:00.000000",
updated_at="2026-08-27 18:00:00",
) == 1
assert TransferPending.claim_task(
session,
task_id=expired.task_id,
states=("accepted",),
owner_id="expired-worker",
lease_token="expired-token",
now_time="2026-08-27 10:00:00.000000",
lease_expires_at="2026-08-27 09:59:00.000000",
updated_at="2026-08-27 18:00:00",
) == 1
session.commit()
repeated = oper.register("local", "/downloads/active.mkv")
assert repeated is not None
assert repeated.task_id == active.task_id
assert repeated.lease_token == "active-token"
assert oper.discard("local", "/downloads/active.mkv") == 0
assert oper.discard("local", "/downloads/expired.mkv") == 0
assert oper.discard("local", "/downloads/free.mkv") == 1
removable = oper.register("local", "/downloads/removable.mkv")
assert removable is not None
assert oper.clear() == 1
assert oper.list_all() == [
("local", "/downloads/active.mkv"),
("local", "/downloads/expired.mkv"),
]
with legacy_session_factory() as session:
rows = session.execute(
select(TransferPending).order_by(TransferPending.id.asc())
).scalars().all()
assert [(row.task_id, row.lease_token) for row in rows] == [
(active.task_id, "active-token"),
(expired.task_id, "expired-token"),
]
+140 -13
View File
@@ -8,6 +8,8 @@
这些测试固定三项不变量入队即落盘登记终态即注销重启能回放
"""
import threading
import time
from dataclasses import replace
from pathlib import Path
from unittest.mock import MagicMock
@@ -24,6 +26,17 @@ def _build_chain(admissions) -> TransferChain:
"""
chain = object.__new__(TransferChain)
chain._transfer_admissions = admissions
chain._worker_owner_id = "test-owner"
chain._owned_leases = {}
chain._queued_lease_tokens = set()
chain._worker_state_lock = threading.RLock()
chain._closing = False
chain._recovery_wakeup_event = threading.Event()
chain._replay_stop_event = threading.Event()
chain._lease_heartbeat_stop_event = threading.Event()
chain._lease_heartbeat_thread = None
chain._TransferChain__ensure_lease_heartbeat_owner = MagicMock()
chain._TransferChain__ensure_recovery_scheduler = MagicMock()
return chain
@@ -36,6 +49,11 @@ 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",
lease_owner="test-owner",
lease_token=f"lease-{task_id}",
lease_expires_at="2026-08-27 10:02:00.000000",
heartbeat_at="2026-08-27 10:00:00.000000",
attempt_count=1,
)
@@ -87,10 +105,19 @@ def test_discard_pending_on_terminal_state():
chain = _build_chain(admissions)
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")
chain._worker_owner_id = "test-owner"
chain._owned_leases = {
"task-1": ("lease-task-1", time.monotonic() + 120)
}
admissions.discard_claimed.return_value = 1
chain._TransferChain__discard_pending(task)
assert chain._TransferChain__discard_pending(task) is True
admissions.discard_task.assert_called_once_with(task_id="task-1")
admissions.discard_claimed.assert_called_once_with(
task_id="task-1",
lease_token="lease-task-1",
)
def test_replay_resends_pending_files_to_transfer(tmp_path, monkeypatch):
@@ -101,7 +128,7 @@ def test_replay_resends_pending_files_to_transfer(tmp_path, monkeypatch):
media.write_bytes(b"x" * 10)
admissions = MagicMock()
admissions.list_recoverable.return_value = [_admission(str(media))]
admissions.claim_recoverable.return_value = [_admission(str(media))]
chain = _build_chain(admissions)
transferred = []
@@ -128,14 +155,18 @@ def test_replay_discards_vanished_files(tmp_path):
"""
admissions = MagicMock()
missing = tmp_path / "gone.mkv"
admissions.list_recoverable.return_value = [_admission(str(missing))]
admissions.claim_recoverable.return_value = [_admission(str(missing))]
admissions.discard_claimed.return_value = 1
chain = _build_chain(admissions)
chain._execute_transfer = MagicMock()
chain._TransferChain__replay_pending()
chain._execute_transfer.assert_not_called()
admissions.discard_task.assert_called_once_with(task_id="task-1")
admissions.discard_claimed.assert_called_once_with(
task_id="task-1",
lease_token="lease-task-1",
)
def test_replay_keeps_registration_when_mount_unreadable(tmp_path, monkeypatch):
@@ -148,7 +179,8 @@ def test_replay_keeps_registration_when_mount_unreadable(tmp_path, monkeypatch):
media.write_bytes(b"x")
admissions = MagicMock()
admissions.list_recoverable.return_value = [_admission(str(media))]
admissions.claim_recoverable.return_value = [_admission(str(media))]
admissions.release_claim.return_value = True
chain = _build_chain(admissions)
chain._execute_transfer = MagicMock()
@@ -163,7 +195,12 @@ def test_replay_keeps_registration_when_mount_unreadable(tmp_path, monkeypatch):
chain._TransferChain__replay_pending()
chain._execute_transfer.assert_not_called()
admissions.discard_task.assert_not_called()
admissions.discard_claimed.assert_not_called()
admissions.release_claim.assert_called_once_with(
task_id="task-1",
lease_token="lease-task-1",
error="恢复源文件暂时不可读取",
)
def test_replay_restores_bluray_directory_type(tmp_path, monkeypatch):
@@ -175,7 +212,7 @@ def test_replay_restores_bluray_directory_type(tmp_path, monkeypatch):
src_path = f"{bluray.as_posix()}/"
admissions = MagicMock()
admissions.list_recoverable.return_value = [_admission(src_path)]
admissions.claim_recoverable.return_value = [_admission(src_path)]
chain = _build_chain(admissions)
transferred = []
@@ -197,7 +234,7 @@ def test_replay_is_noop_without_registrations():
没有登记时回放不应触碰整理链
"""
admissions = MagicMock()
admissions.list_recoverable.return_value = []
admissions.claim_recoverable.return_value = []
chain = _build_chain(admissions)
chain._execute_transfer = MagicMock()
@@ -211,7 +248,7 @@ def test_replay_survives_db_failure():
读取登记失败不能让启动流程报错
"""
admissions = MagicMock()
admissions.list_recoverable.side_effect = RuntimeError("db gone")
admissions.claim_recoverable.side_effect = RuntimeError("db gone")
chain = _build_chain(admissions)
chain._execute_transfer = MagicMock()
@@ -230,7 +267,7 @@ def test_replay_continues_after_single_file_failure(tmp_path, monkeypatch):
item.write_bytes(b"x")
admissions = MagicMock()
admissions.list_recoverable.return_value = [
admissions.claim_recoverable.return_value = [
_admission(str(first), "task-1"),
_admission(str(second), "task-2"),
]
@@ -261,7 +298,7 @@ def test_replay_stop_keeps_unprocessed_registrations(tmp_path, monkeypatch):
first.write_bytes(b"x")
missing_second = tmp_path / "gone.mkv"
admissions = MagicMock()
admissions.list_recoverable.return_value = [
admissions.claim_recoverable.return_value = [
_admission(str(first), "task-1"),
_admission(str(missing_second), "task-2"),
]
@@ -279,4 +316,94 @@ def test_replay_stop_keeps_unprocessed_registrations(tmp_path, monkeypatch):
chain._TransferChain__replay_pending(stop_event)
assert transferred == [first.as_posix()]
admissions.discard_task.assert_not_called()
admissions.discard_claimed.assert_not_called()
assert admissions.release_claim.call_count == 2
def test_replay_registers_entire_claimed_batch_before_first_source_stat(
tmp_path,
monkeypatch,
):
"""批量 claim 返回后必须先把全部 token 交给 heartbeat,再做逐条同步 I/O。"""
first = _admission(str(tmp_path / "A.mkv"), "task-1")
second = _admission(str(tmp_path / "B.mkv"), "task-2")
admissions = MagicMock()
admissions.claim_recoverable.return_value = [first, second]
admissions.release_claim.return_value = True
chain = _build_chain(admissions)
def observe_owned_batch(*_args, **_kwargs):
"""首个 stat 前观察两个 token 已同时进入续期集合。"""
assert set(chain._owned_leases) == {"task-1", "task-2"}
return None, False
monkeypatch.setattr(
chain,
"_TransferChain__build_replay_fileitem",
observe_owned_batch,
)
chain._TransferChain__replay_pending()
assert admissions.release_claim.call_count == 2
assert chain._owned_leases == {}
def test_replay_releases_claim_when_jobview_rejects_recovered_task(
tmp_path,
monkeypatch,
):
"""恢复任务未进入队列时必须立即 release,不能靠租约自然过期。"""
media = tmp_path / "Movie.2024.mkv"
media.write_bytes(b"x")
admission = _admission(str(media))
admission = replace(admission, checkpoint=MagicMock())
admissions = MagicMock()
admissions.claim_recoverable.return_value = [admission]
admissions.release_claim.return_value = True
chain = _build_chain(admissions)
monkeypatch.setattr(
chain,
"_TransferChain__queue_planned_replay",
MagicMock(return_value=False),
)
chain._TransferChain__replay_pending()
admissions.release_claim.assert_called_once_with(
task_id="task-1",
lease_token="lease-task-1",
error="恢复任务未进入内存队列",
)
assert chain._owned_leases == {}
def test_claimed_enqueue_failure_never_uses_unfenced_error_writer() -> None:
"""陈旧 token 入队失败只能 release_claim,不能覆盖新 owner 的 last_error。"""
admissions = MagicMock()
admissions.release_claim.return_value = False
chain = _build_chain(admissions)
chain._finish_scrape_batch_task = MagicMock()
chain.replay_pending = MagicMock()
task = _task("/downloads/stale-enqueue.mkv")
task.bind_admission_task_id("stale-task")
task.bind_execution_lease(
owner_id="test-owner",
lease_token="stale-token",
)
chain._owned_leases = {
"stale-task": ("stale-token", time.monotonic() + 120)
}
chain._TransferChain__record_enqueue_failure(
task,
RuntimeError("queue closed"),
)
admissions.record_enqueue_failure.assert_not_called()
admissions.release_claim.assert_called_once_with(
task_id="stale-task",
lease_token="stale-token",
error="queue closed",
)
assert chain._owned_leases == {}
+136 -21
View File
@@ -2,13 +2,14 @@
import ast
import inspect
import threading
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application import transfer as transfer_application
@@ -202,6 +203,31 @@ def _chain(*, repository=None, checkpoint=None, result=None) -> TransferChain:
"""构造只保留规划编排依赖的 TransferChain 骨架。"""
chain = object.__new__(TransferChain)
chain._transfer_admissions = repository or Mock()
chain._worker_owner_id = "planning-owner"
chain._owned_leases = {}
chain._queued_lease_tokens = set()
chain._worker_state_lock = threading.RLock()
chain._closing = False
chain._recovery_wakeup_event = threading.Event()
chain._TransferChain__ensure_lease_heartbeat_owner = Mock()
def claim_task(**kwargs):
"""为规划测试返回与进程 owner 匹配的稳定 claim。"""
return transfer_application.TransferAdmission(
task_id=kwargs["task_id"],
storage="local",
src_path="/downloads/Movie.2026.mkv",
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
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,
)
chain._transfer_admissions.claim_task.side_effect = claim_task
chain._module_dispatcher = Mock()
chain._module_dispatcher.freeze_plugin_providers.return_value = ()
chain.eventmanager = Mock()
@@ -217,6 +243,20 @@ def _chain(*, repository=None, checkpoint=None, result=None) -> TransferChain:
return chain
def _replay_chain(repository) -> TransferChain:
"""构造绑定固定恢复 owner 且不启动真实 heartbeat 线程的测试链。"""
chain = object.__new__(TransferChain)
chain._transfer_admissions = repository
chain._worker_owner_id = "replay-owner"
chain._owned_leases = {}
chain._queued_lease_tokens = set()
chain._worker_state_lock = threading.RLock()
chain._closing = False
chain._recovery_wakeup_event = threading.Event()
chain._TransferChain__ensure_lease_heartbeat_owner = Mock()
return chain
def _real_dispatcher(plugins: dict) -> ModuleInvocationDispatcher:
"""构造使用真实冻结解析与执行内核的内存插件调度器。"""
plugin_catalog = Mock()
@@ -725,6 +765,7 @@ def test_provider_empty_fallback_cas_failure_blocks_host_execution():
chain.execute_transfer_plan.assert_not_called()
repository.record_planning_failure.assert_called_once_with(
task_id="task-provider-cas-failure",
lease_token="lease-task-provider-cas-failure",
error="CAS failed",
)
@@ -804,7 +845,7 @@ def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending():
return SimpleNamespace(checkpoint=kwargs["checkpoint"])
repository.checkpoint_plan.side_effect = checkpoint_plan
repository.discard_task.side_effect = (
repository.discard_claimed.side_effect = (
lambda **_kwargs: calls.append("discard") or 1
)
result = TransferInfo(
@@ -830,8 +871,9 @@ def test_legacy_transfer_command_uses_durable_pipeline_and_settles_pending():
assert returned is result
assert calls == ["admit", "checkpoint", "execute", "discard"]
repository.discard_task.assert_called_once_with(
task_id="task-legacy-command"
repository.discard_claimed.assert_called_once_with(
task_id="task-legacy-command",
lease_token="lease-task-legacy-command",
)
@@ -898,16 +940,32 @@ def test_repository_rejects_checkpoint_with_mismatched_planning_fingerprint(tmp_
src_path="/downloads/Movie.2026.mkv",
planning_input=accepted_input,
)
claimed = repository.claim_task(
task_id=admission.task_id,
owner_id="fingerprint-test",
lease_seconds=120,
)
assert claimed is not None
assert claimed.lease_token is not None
mismatched = _checkpoint(target_path="/library/B/Movie.mkv")
with pytest.raises(ValueError, match="指纹|fingerprint|规划输入"):
repository.checkpoint_plan(
task_id=admission.task_id,
lease_token=claimed.lease_token,
input_fingerprint=accepted_input.fingerprint,
checkpoint=mismatched,
)
recovered = repository.list_recoverable()
assert repository.release_claim(
task_id=admission.task_id,
lease_token=claimed.lease_token,
) is True
recovered = repository.claim_recoverable(
owner_id="fingerprint-recovery",
limit=10,
lease_seconds=120,
)
assert len(recovered) == 1
assert recovered[0].state == "accepted"
assert recovered[0].checkpoint is None
@@ -926,23 +984,35 @@ def test_repository_round_trips_accepted_and_planned_recovery_states(tmp_path):
planning_input=planning_input,
)
accepted = repository.list_recoverable()
assert len(accepted) == 1
assert accepted[0].state == "accepted"
assert accepted[0].planning_input == planning_input
assert accepted[0].checkpoint is None
claimed = repository.claim_task(
task_id=admission.task_id,
owner_id="roundtrip-owner",
lease_seconds=120,
)
assert claimed is not None
assert claimed.lease_token is not None
assert claimed.state == "accepted"
assert claimed.planning_input == planning_input
assert claimed.checkpoint is None
repository.record_planning_failure(
task_id=admission.task_id,
lease_token=claimed.lease_token,
error="rename unavailable",
)
retryable = repository.list_recoverable()[0]
assert retryable.state == "accepted"
assert retryable.last_error == "rename unavailable"
with sessionmaker(bind=engine)() as session:
retryable = session.execute(
select(TransferPending).where(
TransferPending.task_id == admission.task_id
)
).scalar_one()
assert retryable.state == "accepted"
assert retryable.last_error == "rename unavailable"
checkpoint = _checkpoint()
planned = repository.checkpoint_plan(
task_id=admission.task_id,
lease_token=claimed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=checkpoint,
)
@@ -950,7 +1020,18 @@ def test_repository_round_trips_accepted_and_planned_recovery_states(tmp_path):
assert planned.state == "planned"
assert planned.checkpoint == checkpoint
assert planned.last_error is None
assert repository.list_recoverable() == [planned]
assert repository.release_claim(
task_id=admission.task_id,
lease_token=claimed.lease_token,
) is True
recovered = repository.claim_recoverable(
owner_id="roundtrip-recovery",
limit=10,
lease_seconds=120,
)
assert len(recovered) == 1
assert recovered[0].state == "planned"
assert recovered[0].checkpoint == checkpoint
engine.dispose()
@@ -982,6 +1063,10 @@ def test_checkpoint_commit_precedes_executor_and_sync_task_is_admitted():
repository.admit.side_effect = admit
repository.checkpoint_plan.side_effect = checkpoint_plan
chain = _chain(repository=repository, checkpoint=checkpoint, result=result)
claim_task = repository.claim_task.side_effect
repository.claim_task.side_effect = lambda **kwargs: (
order.append("claim") or claim_task(**kwargs)
)
chain.plan_transfer.side_effect = lambda *_args, **_kwargs: (
order.append("plan") or checkpoint
)
@@ -992,12 +1077,13 @@ def test_checkpoint_commit_precedes_executor_and_sync_task_is_admitted():
returned = chain._plan_checkpoint_and_execute(task)
assert returned is result
assert order == ["admit", "plan", "commit-checkpoint", "execute"]
assert order == ["admit", "claim", "plan", "commit-checkpoint", "execute"]
assert task.admission_task_id == "task-sync"
repository.admit.assert_called_once()
assert repository.admit.call_args.kwargs["planning_input"] is planning_input
repository.checkpoint_plan.assert_called_once_with(
task_id="task-sync",
lease_token="lease-task-sync",
input_fingerprint=planning_input.fingerprint,
checkpoint=checkpoint,
)
@@ -1192,11 +1278,15 @@ def test_accepted_replay_restores_explicit_context_without_online_lookup(
updated_at="2026-08-27 10:00:00",
input_fingerprint=planning_input.fingerprint,
planning_input=planning_input,
lease_owner="replay-owner",
lease_token="lease-task-accepted-offline",
lease_expires_at="2026-08-27 10:02:00.000000",
heartbeat_at="2026-08-27 10:00:00.000000",
attempt_count=1,
)
replay_repository = Mock()
replay_repository.list_recoverable.return_value = [admission]
replay_chain = object.__new__(TransferChain)
replay_chain._transfer_admissions = replay_repository
replay_repository.claim_recoverable.return_value = [admission]
replay_chain = _replay_chain(replay_repository)
replay_chain._execute_transfer = Mock()
queued_tasks = []
replay_chain.put_to_queue = Mock(
@@ -1230,6 +1320,10 @@ def test_accepted_replay_restores_explicit_context_without_online_lookup(
repository=execution_repository,
checkpoint=checkpoint,
)
execution_chain._worker_owner_id = "replay-owner"
execution_chain._owned_leases = {
admission.task_id: (str(admission.lease_token), float("inf"))
}
execution_chain.jobview = Mock()
execution_chain.eventmanager = Mock()
execution_chain.eventmanager.send_event.return_value = None
@@ -1292,11 +1386,15 @@ def test_accepted_replay_with_explicit_empty_episodes_stays_offline(
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
planning_input=planning_input,
lease_owner="replay-owner",
lease_token="lease-task-accepted-empty-episodes",
lease_expires_at="2026-08-27 10:02:00.000000",
heartbeat_at="2026-08-27 10:00:00.000000",
attempt_count=1,
)
replay_repository = Mock()
replay_repository.list_recoverable.return_value = [admission]
replay_chain = object.__new__(TransferChain)
replay_chain._transfer_admissions = replay_repository
replay_repository.claim_recoverable.return_value = [admission]
replay_chain = _replay_chain(replay_repository)
queued_tasks = []
replay_chain.put_to_queue = Mock(
side_effect=lambda task: queued_tasks.append(task) or True
@@ -1319,6 +1417,10 @@ def test_accepted_replay_with_explicit_empty_episodes_stays_offline(
repository=execution_repository,
checkpoint=checkpoint,
)
execution_chain._worker_owner_id = "replay-owner"
execution_chain._owned_leases = {
admission.task_id: (str(admission.lease_token), float("inf"))
}
execution_chain.jobview = Mock()
execution_chain.eventmanager = Mock()
execution_chain.eventmanager.send_event.return_value = None
@@ -1425,8 +1527,20 @@ def test_pre_checkpoint_recognition_failure_records_retryable_error(monkeypatch)
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._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(
@@ -1449,6 +1563,7 @@ def test_pre_checkpoint_recognition_failure_records_retryable_error(monkeypatch)
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="未识别到媒体信息",
)
+5
View File
@@ -57,6 +57,8 @@ def test_transfer_task_to_dict_keeps_exact_legacy_fields():
task.bind_admission_task_id("task-stable")
values = task.to_dict()
task.bind_execution_lease(owner_id="worker-owner", lease_token="lease-token")
leased_values = task.to_dict()
assert set(values) == {
"fileitem",
@@ -84,9 +86,12 @@ def test_transfer_task_to_dict_keeps_exact_legacy_fields():
}
assert values["fileitem"] == task.fileitem.model_dump()
assert values["target_path"] == Path("/library/Movie (2026)")
assert leased_values == values
assert "admission_task_id" not in values
assert "planning_input" not in values
assert "plan_checkpoint" not in values
assert "lease_owner" not in values
assert "lease_token" not in values
def test_transfer_chain_do_transfer_keeps_legacy_signature():
+19 -8
View File
@@ -24,12 +24,13 @@ except ModuleNotFoundError:
POSTGRESQL_DIALECT = "postgresql+psycopg"
MIGRATION = "database.versions.c2f8a4d6e1b3_3_0_14"
PLANNING_MIGRATION = "database.versions.c2f8a4d6e1b3_3_0_14"
LEASE_MIGRATION = "database.versions.d3a9e5f7b2c4_3_0_15"
def _bind_migration(monkeypatch, connection):
def _bind_migration(monkeypatch, connection, module_name=PLANNING_MIGRATION):
"""把迁移绑定到隔离数据库连接。"""
migration = importlib.import_module(MIGRATION)
migration = importlib.import_module(module_name)
monkeypatch.setattr(
migration,
"op",
@@ -91,10 +92,13 @@ def _planning_row(connection) -> dict[str, object]:
def _assert_upgrade_downgrade_reupgrade(connection, monkeypatch) -> None:
"""断言规划迁移在当前隔离连接上的完整可逆生命周期。"""
_create_admission_table(connection)
migration = _bind_migration(monkeypatch, connection)
planning_migration = _bind_migration(monkeypatch, connection)
lease_migration = _bind_migration(monkeypatch, connection, LEASE_MIGRATION)
migration.upgrade()
migration.upgrade()
planning_migration.upgrade()
planning_migration.upgrade()
lease_migration.upgrade()
lease_migration.upgrade()
inspector = sa.inspect(connection)
assert {
@@ -133,7 +137,9 @@ def _assert_upgrade_downgrade_reupgrade(connection, monkeypatch) -> None:
planned_at="2026-08-27 11:00:00",
)
)
migration.downgrade()
lease_migration.downgrade()
lease_migration.downgrade()
planning_migration.downgrade()
downgraded = sa.inspect(connection)
assert {
@@ -152,12 +158,17 @@ def _assert_upgrade_downgrade_reupgrade(connection, monkeypatch) -> None:
for index in downgraded.get_indexes("transferpending")
} == {"ix_transferpending_state_created", "ux_transferpending_storage_path"}
migration.upgrade()
planning_migration.upgrade()
lease_migration.upgrade()
reupgraded = _planning_row(connection)
assert reupgraded["task_id"] == "stable-task"
assert reupgraded["state"] == "accepted"
assert reupgraded["checkpoint_payload"] is None
assert reupgraded["input_fingerprint"] == planning_input.fingerprint
assert {
column["name"]
for column in sa.inspect(connection).get_columns("transferpending")
} == {column.name for column in TransferPending.__table__.columns}
def test_transfer_planning_upgrade_downgrade_reupgrade(monkeypatch) -> None:
+105 -22
View File
@@ -11,6 +11,7 @@ from app.application.transfer import (
TRANSFER_ADMISSION_PLANNED,
TRANSFER_ADMISSION_PROVIDER_PENDING,
TransferAdmissionConflictError,
TransferAdmissionProjectionError,
TransferPlanCheckpoint,
TransferPlanItem,
TransferPlanningInput,
@@ -30,6 +31,31 @@ def repository(tmp_path):
return TransactionalTransferAdmissionRepository(sessionmaker(bind=engine))
def _claim(repository, task_id: str):
"""为需要变更规划状态的测试取得独占租约。"""
claimed = repository.claim_task(
task_id=task_id,
owner_id="planning-test-worker",
lease_seconds=3600,
)
assert claimed is not None
assert claimed.lease_token
return claimed
def _pending_snapshot(repository, task_id: str) -> dict[str, object]:
"""使用隔离 Session 冻结测试所需的持久状态字段。"""
with repository._session_factory() as session: # noqa: SLF001
pending = session.execute(
select(TransferPending).where(TransferPending.task_id == task_id)
).scalar_one()
return {
"state": pending.state,
"last_error": pending.last_error,
"checkpoint_payload": pending.checkpoint_payload,
}
def _planning_input(*, target_path: str = "/library/Movies") -> TransferPlanningInput:
"""构造包含恢复所需媒体上下文的完整规划输入。"""
return TransferPlanningInput(
@@ -264,6 +290,7 @@ def test_resolved_context_does_not_change_admission_fingerprint(repository) -> N
src_path="/downloads/Movie.2026.mkv",
planning_input=planning_input,
)
claimed = _claim(repository, admitted.task_id)
checkpoint = replace(
_checkpoint(planning_input),
resolved_meta={"name": "Resolved Movie", "year": 2026},
@@ -275,6 +302,7 @@ def test_resolved_context_does_not_change_admission_fingerprint(repository) -> N
planned = repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=checkpoint,
)
@@ -287,6 +315,7 @@ def test_resolved_context_does_not_change_admission_fingerprint(repository) -> N
with pytest.raises(TransferPlanningStateError):
repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=replace(
checkpoint,
@@ -326,15 +355,18 @@ def test_checkpoint_atomically_advances_and_is_idempotent(repository) -> None:
src_path="/downloads/Movie.2026.mkv",
planning_input=planning_input,
)
claimed = _claim(repository, admitted.task_id)
checkpoint = _checkpoint(planning_input)
planned = repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=checkpoint,
)
repeated = repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=checkpoint,
)
@@ -350,8 +382,9 @@ def test_checkpoint_atomically_advances_and_is_idempotent(repository) -> None:
"plugin-provider-a",
)
assert repeated == planned
assert repository.list_accepted() == []
assert repository.list_recoverable() == [planned]
assert _pending_snapshot(repository, admitted.task_id)["state"] == (
TRANSFER_ADMISSION_PLANNED
)
def test_provider_pending_checkpoint_atomically_upgrades_to_host_plan(repository) -> None:
@@ -362,23 +395,34 @@ def test_provider_pending_checkpoint_atomically_upgrades_to_host_plan(repository
src_path="/downloads/Movie.2026.mkv",
planning_input=planning_input,
)
claimed = _claim(repository, admitted.task_id)
provider_checkpoint = _provider_checkpoint(planning_input)
provider_pending = repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=provider_checkpoint,
)
assert provider_pending.state == TRANSFER_ADMISSION_PROVIDER_PENDING
assert provider_pending.checkpoint == provider_checkpoint
assert repository.list_recoverable() == [provider_pending]
repository.record_planning_failure(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
error="host planning unavailable",
)
failed = repository.list_recoverable()[0]
assert repository.release_claim(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
error="host planning unavailable",
)
failed = repository.claim_recoverable(
owner_id="planning-recovery-worker",
limit=1,
lease_seconds=3600,
)[0]
assert failed.state == TRANSFER_ADMISSION_PROVIDER_PENDING
assert failed.checkpoint == provider_checkpoint
assert failed.last_error == "host planning unavailable"
@@ -386,11 +430,13 @@ def test_provider_pending_checkpoint_atomically_upgrades_to_host_plan(repository
host_checkpoint = _checkpoint(planning_input)
planned = repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=failed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=host_checkpoint,
)
repeated = repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=failed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=host_checkpoint,
)
@@ -402,6 +448,7 @@ def test_provider_pending_checkpoint_atomically_upgrades_to_host_plan(repository
with pytest.raises(TransferPlanningStateError):
repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=failed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=provider_checkpoint,
)
@@ -415,18 +462,19 @@ def test_checkpoint_rejects_fingerprint_without_partial_state(repository) -> Non
src_path="/downloads/Movie.2026.mkv",
planning_input=planning_input,
)
claimed = _claim(repository, admitted.task_id)
with pytest.raises(TransferAdmissionConflictError):
repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
input_fingerprint="0" * 64,
checkpoint=_checkpoint(planning_input),
)
recovered = repository.list_recoverable()
assert len(recovered) == 1
assert recovered[0].state == TRANSFER_ADMISSION_ACCEPTED
assert recovered[0].checkpoint is None
recovered = _pending_snapshot(repository, admitted.task_id)
assert recovered["state"] == TRANSFER_ADMISSION_ACCEPTED
assert recovered["checkpoint_payload"] is None
def test_planning_failure_stays_accepted_until_success(repository) -> None:
@@ -437,15 +485,21 @@ def test_planning_failure_stays_accepted_until_success(repository) -> None:
src_path="/downloads/Movie.2026.mkv",
planning_input=planning_input,
)
claimed = _claim(repository, admitted.task_id)
repository.record_planning_failure(task_id=admitted.task_id, error="rename failed")
failed = repository.list_recoverable()[0]
assert failed.state == TRANSFER_ADMISSION_ACCEPTED
assert failed.last_error == "rename failed"
assert failed.checkpoint is None
repository.record_planning_failure(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
error="rename failed",
)
failed = _pending_snapshot(repository, admitted.task_id)
assert failed["state"] == TRANSFER_ADMISSION_ACCEPTED
assert failed["last_error"] == "rename failed"
assert failed["checkpoint_payload"] is None
planned = repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=_checkpoint(planning_input),
)
@@ -459,6 +513,7 @@ def test_checkpoint_rejects_missing_task(repository) -> None:
with pytest.raises(TransferPlanningStateError):
repository.checkpoint_plan(
task_id="missing",
lease_token="missing-token",
input_fingerprint=planning_input.fingerprint,
checkpoint=_checkpoint(planning_input),
)
@@ -470,17 +525,25 @@ def test_direct_orm_defaults_create_valid_legacy_projection(tmp_path) -> None:
factory = sessionmaker(bind=engine)
TransferPending.__table__.create(engine)
with factory() as session:
session.add(TransferPending(
pending = TransferPending(
storage="local",
src_path="/downloads/legacy.mkv",
state=TRANSFER_ADMISSION_ACCEPTED,
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
))
)
session.add(pending)
session.commit()
task_id = pending.task_id
admitted = TransactionalTransferAdmissionRepository(factory).list_accepted()[0]
repository = TransactionalTransferAdmissionRepository(factory)
admitted = repository.claim_task(
task_id=task_id,
owner_id="legacy-projection-worker",
lease_seconds=3600,
)
assert admitted is not None
assert admitted.planning_input == TransferPlanningInput.legacy(
storage="local",
src_path="/downloads/legacy.mkv",
@@ -508,8 +571,12 @@ def test_projection_rejects_input_version_and_fingerprint_corruption(tmp_path) -
row.input_version = 2
session.commit()
with pytest.raises(TransferPlanningStateError, match="版本"):
repository.list_accepted()
with pytest.raises(TransferAdmissionProjectionError, match="版本"):
repository.claim_task(
task_id=admitted.task_id,
owner_id="corruption-worker",
lease_seconds=3600,
)
with factory() as session:
row = session.execute(
@@ -521,8 +588,12 @@ def test_projection_rejects_input_version_and_fingerprint_corruption(tmp_path) -
row.planning_input = corrupted
session.commit()
with pytest.raises(TransferAdmissionConflictError, match="指纹"):
repository.list_accepted()
with pytest.raises(TransferAdmissionProjectionError, match="指纹"):
repository.claim_task(
task_id=admitted.task_id,
owner_id="corruption-worker",
lease_seconds=3600,
)
engine.dispose()
@@ -538,8 +609,10 @@ def test_projection_rejects_checkpoint_version_corruption(tmp_path) -> None:
src_path="/downloads/Movie.2026.mkv",
planning_input=planning_input,
)
claimed = _claim(repository, admitted.task_id)
repository.checkpoint_plan(
task_id=admitted.task_id,
lease_token=claimed.lease_token,
input_fingerprint=planning_input.fingerprint,
checkpoint=_checkpoint(planning_input),
)
@@ -548,8 +621,18 @@ def test_projection_rejects_checkpoint_version_corruption(tmp_path) -> None:
select(TransferPending).where(TransferPending.task_id == admitted.task_id)
).scalar_one()
row.checkpoint_version = 2
row.lease_expires_at = "2000-01-01 00:00:00.000000"
session.commit()
with pytest.raises(TransferPlanningStateError, match="版本"):
repository.list_recoverable()
with pytest.raises(TransferAdmissionProjectionError, match="版本"):
repository.claim_task(
task_id=admitted.task_id,
owner_id="direct-corruption-test-worker",
lease_seconds=3600,
)
assert repository.claim_recoverable(
owner_id="batch-corruption-test-worker",
limit=1,
lease_seconds=3600,
) == []
engine.dispose()
+10 -6
View File
@@ -2,7 +2,7 @@ from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.transfer import TransferAdmission, TransferQueueService
@@ -118,7 +118,8 @@ def test_transfer_queue_service_commits_admission_before_failed_enqueue(tmp_path
"""真实仓储已提交后即使内存入队失败,任务也必须带原因留待恢复。"""
engine = create_engine(f"sqlite:///{tmp_path / 'durable-admission.db'}")
TransferPending.__table__.create(engine)
repository = TransactionalTransferAdmissionRepository(sessionmaker(bind=engine))
factory = sessionmaker(bind=engine)
repository = TransactionalTransferAdmissionRepository(factory)
task = make_task(1)
service, _ = _service(
admit_task=lambda item: repository.admit(
@@ -135,10 +136,13 @@ def test_transfer_queue_service_commits_admission_before_failed_enqueue(tmp_path
with pytest.raises(RuntimeError, match="queue closed"):
service.put(task, Mock())
admissions = repository.list_accepted()
assert len(admissions) == 1
assert admissions[0].task_id == task.admission_task_id
assert admissions[0].last_error == "queue closed"
with factory() as session:
pending = session.execute(
select(TransferPending).where(
TransferPending.task_id == task.admission_task_id
)
).scalar_one()
assert pending.last_error == "queue closed"
engine.dispose()
+7 -12
View File
@@ -1,12 +1,15 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
from app.chain.transfer import JobManager, TransferChain
from app.chain.transfer import TransferChain
from app.domain.meta.metabase import MetaBase
from app.runtime.config import settings
from app.schemas.file import FileItem
from app.schemas.transfer import EpisodeFormat
from app.schemas.types import MediaType
from tests.test_transfer_job_manager import (
make_transfer_chain as make_base_transfer_chain,
)
class FakeMeta(MetaBase):
@@ -46,16 +49,8 @@ def make_transfer_chain() -> TransferChain:
"""
构造不启动后台线程的整理链实例
"""
chain = object.__new__(TransferChain)
chain.jobview = JobManager()
chain._media_exts = settings.RMT_MEDIAEXT
chain._subtitle_exts = settings.RMT_SUBEXT
chain._audio_exts = settings.RMT_AUDIOEXT
chain._allowed_exts = (
chain._media_exts + chain._audio_exts + chain._subtitle_exts
)
chain._success_target_files = {}
chain._scrape_batches = {}
chain = make_base_transfer_chain()
chain._TransferChain__ensure_recovery_scheduler = MagicMock()
return chain
+422 -3
View File
@@ -15,13 +15,17 @@ from app.chain.transfer import TransferChain
from app.foundation.singleton import Singleton
from app.runtime.config import global_vars
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
from app.startup.initializers import transfer as transfer_initializer
def _build_chain(*, transfer_threads: int = 0) -> TransferChain:
"""构造只包含后台线程生命周期字段的 TransferChain 测试骨架。"""
chain = object.__new__(TransferChain)
chain.runtime_config = SimpleNamespace(transfer_threads=transfer_threads)
chain.runtime_config = SimpleNamespace(
transfer_threads=transfer_threads,
transfer_task_timeout=0,
)
chain._queue = queue.Queue()
chain._transfer_interval = 0.1
chain._threads = []
@@ -33,9 +37,59 @@ def _build_chain(*, transfer_threads: int = 0) -> TransferChain:
chain._closing = False
chain._replay_thread = None
chain._replay_stop_event = threading.Event()
chain._recovery_wakeup_event = threading.Event()
chain._lease_heartbeat_thread = None
chain._lease_heartbeat_stop_event = threading.Event()
chain._worker_owner_id = "worker-owner"
chain._owned_leases = {}
chain._queued_lease_tokens = set()
admissions = MagicMock()
admissions.admit.side_effect = lambda **kwargs: TransferAdmission(
task_id="admitted-task",
storage=kwargs["storage"],
src_path=kwargs["src_path"],
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
)
admissions.claim_task.side_effect = lambda **kwargs: TransferAdmission(
task_id=kwargs["task_id"],
storage="local",
src_path="/downloads/test.mkv",
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
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.release_claim.return_value = True
chain._transfer_admissions = admissions
chain._TransferChain__ensure_lease_heartbeat_owner = MagicMock()
chain._TransferChain__ensure_recovery_scheduler = MagicMock()
return chain
def _claimed_admission(task: TransferTask, task_id: str) -> TransferAdmission:
"""构造属于测试进程 owner 的有效 claim 投影。"""
return TransferAdmission(
task_id=task_id,
storage=task.fileitem.storage,
src_path=task.fileitem.path,
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
lease_owner="worker-owner",
lease_token=f"lease-{task_id}",
lease_expires_at="2026-08-27 10:02:00.000000",
heartbeat_at="2026-08-27 10:00:00.000000",
attempt_count=1,
)
def test_config_reload_replaces_worker_generation_and_keeps_accepting() -> None:
"""热更新应等待旧 worker 收敛,再启动使用独立停止信号的新一代。"""
chain = _build_chain(transfer_threads=1)
@@ -433,7 +487,11 @@ def test_durable_task_identity_flows_from_queue_to_terminal_discard(monkeypatch)
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
)
admissions.discard_task.side_effect = (
admissions.claim_task.return_value = _claimed_admission(
task,
"durable-task-id",
)
admissions.discard_claimed.side_effect = (
lambda **_kwargs: discarded.set() or 1
)
chain._transfer_admissions = admissions
@@ -471,7 +529,15 @@ def test_durable_task_identity_flows_from_queue_to_terminal_discard(monkeypatch)
assert worker.is_alive() is False
assert task.admission_task_id == "durable-task-id"
admissions.discard_task.assert_called_once_with(task_id="durable-task-id")
admissions.claim_task.assert_called_once_with(
task_id="durable-task-id",
owner_id="worker-owner",
lease_seconds=120,
)
admissions.discard_claimed.assert_called_once_with(
task_id="durable-task-id",
lease_token="lease-durable-task-id",
)
def test_claimed_task_prevents_progress_settlement_before_active_registration() -> None:
@@ -527,6 +593,7 @@ def test_claimed_task_prevents_progress_settlement_before_active_registration()
def test_replay_has_single_owner_and_close_waits_for_it() -> None:
"""重复回放只保留一个线程,关闭会通知并等待该线程退出。"""
chain = _build_chain()
del chain._TransferChain__ensure_recovery_scheduler
replay_started = threading.Event()
replay_calls = []
@@ -548,3 +615,355 @@ def test_replay_has_single_owner_and_close_waits_for_it() -> None:
assert chain.close_workers(timeout_seconds=1) is True
assert replay_thread.is_alive() is False
assert chain._replay_thread is None
def test_recovered_worker_reuses_claimed_token_without_second_claim(
monkeypatch,
) -> None:
"""恢复任务携带 token 入队后,普通 worker 必须直接执行而非二次 claim。"""
chain = _build_chain()
task = TransferTask(fileitem=FileItem(
storage="local",
path="/downloads/recovered.mkv",
type="file",
name="recovered.mkv",
basename="recovered",
extension="mkv",
))
task.bind_admission_task_id("recovered-task")
task.bind_execution_lease(
owner_id="worker-owner",
lease_token="lease-recovered-task",
)
chain._owned_leases = {
"recovered-task": ("lease-recovered-task", time.monotonic() + 120)
}
chain.jobview = MagicMock()
chain.jobview.pending_total.return_value = 1
chain._finish_scrape_batch_task = MagicMock()
chain._progress = MagicMock()
chain._active_tasks = 0
chain._processed_num = 0
chain._fail_num = 0
chain._total_num = 0
chain._transfer_admissions.discard_claimed.return_value = 1
stop_event = threading.Event()
def complete_recovery(*, task, callback):
"""模拟恢复任务成功提交检查点并让 worker 在本项后退出。"""
del callback
task.bind_plan_checkpoint(MagicMock())
stop_event.set()
return True, ""
chain._TransferChain__handle_transfer = complete_recovery
chain._queue.put(TransferQueue(task=task))
monkeypatch.setattr(global_vars, "STOP_EVENT", threading.Event())
worker = threading.Thread(
target=chain._TransferChain__start_transfer,
args=(stop_event,),
daemon=True,
)
worker.start()
worker.join(timeout=1)
assert worker.is_alive() is False
chain._transfer_admissions.claim_task.assert_not_called()
chain._transfer_admissions.discard_claimed.assert_called_once_with(
task_id="recovered-task",
lease_token="lease-recovered-task",
)
def test_heartbeat_refreshes_current_token_and_forgets_lost_lease() -> None:
"""heartbeat 成功应刷新本地期限,CAS 拒绝后必须立即停止本地推进资格。"""
chain = _build_chain()
task = TransferTask(fileitem=FileItem(
storage="local",
path="/downloads/heartbeat.mkv",
type="file",
))
current = _claimed_admission(task, "heartbeat-task")
initial_deadline = time.monotonic() + 1
chain._owned_leases = {
"heartbeat-task": ("lease-heartbeat-task", initial_deadline)
}
chain._transfer_admissions.heartbeat.return_value = current
chain._TransferChain__heartbeat_owned_leases()
assert chain._owned_leases["heartbeat-task"][1] > initial_deadline
chain._transfer_admissions.heartbeat.return_value = None
chain._TransferChain__heartbeat_owned_leases()
assert "heartbeat-task" not in chain._owned_leases
def test_close_timeout_keeps_heartbeat_alive_until_blocked_worker_converges() -> None:
"""阻塞 worker 未退出时关闭不得停止 heartbeat 或允许租约过期接管。"""
chain = _build_chain()
worker_release = threading.Event()
worker = threading.Thread(
target=worker_release.wait,
name="transfer-blocked-owner",
daemon=True,
)
heartbeat = threading.Thread(
target=chain._lease_heartbeat_stop_event.wait,
name="transfer-heartbeat-owner",
daemon=True,
)
chain._threads = [worker]
chain._lease_heartbeat_thread = heartbeat
chain._owned_leases = {
"blocked-task": ("blocked-token", time.monotonic() + 120)
}
worker.start()
heartbeat.start()
assert chain.close_workers(timeout_seconds=0.01) is False
assert heartbeat.is_alive() is True
assert chain._lease_heartbeat_stop_event.is_set() is False
worker_release.set()
assert chain.close_workers(timeout_seconds=1) is True
assert worker.is_alive() is False
assert heartbeat.is_alive() is False
chain._transfer_admissions.release_claim.assert_called_once_with(
task_id="blocked-task",
lease_token="blocked-token",
error="整理宿主关闭,释放未结算任务租约",
)
def test_worker_reports_failed_settlement_without_skipping_queue_bookkeeping(
monkeypatch,
) -> None:
"""终态 CAS=0 必须计为失败,同时仍完成 task_done 与 active 归零。"""
chain = _build_chain()
task = TransferTask(fileitem=FileItem(
storage="local",
path="/downloads/stale.mkv",
type="file",
name="stale.mkv",
basename="stale",
extension="mkv",
))
chain.jobview = MagicMock()
chain.jobview.add_task.return_value = True
chain.jobview.pending_total.return_value = 1
chain._register_scrape_batch_task = MagicMock()
chain._finish_scrape_batch_task = MagicMock()
chain._progress = MagicMock()
chain._active_tasks = 0
chain._processed_num = 0
chain._fail_num = 0
chain._total_num = 0
chain._transfer_admissions.discard_claimed.return_value = 0
chain._TransferChain__settle_transfer_progress_if_idle = MagicMock()
stop_event = threading.Event()
def complete_with_stale_lease(*, task, callback):
"""模拟文件副作用完成后终态 token 已被新 owner 接管。"""
del callback
task.bind_plan_checkpoint(MagicMock())
stop_event.set()
return True, ""
chain._TransferChain__handle_transfer = complete_with_stale_lease
monkeypatch.setattr(global_vars, "STOP_EVENT", threading.Event())
assert chain.put_to_queue(task) is True
worker = threading.Thread(
target=chain._TransferChain__start_transfer,
args=(stop_event,),
daemon=True,
)
worker.start()
worker.join(timeout=1)
assert worker.is_alive() is False
assert chain._active_tasks == 0
assert chain._fail_num == 1
assert chain._queue.unfinished_tasks == 0
def test_failed_claim_release_waits_for_fixed_recovery_poll() -> None:
"""失败释放不得即时唤醒恢复线程,避免确定性错误形成热重试。"""
chain = _build_chain()
del chain._TransferChain__ensure_recovery_scheduler
chain._RECOVERY_POLL_INTERVAL_SECONDS = 0.05
chain._TransferChain__replay_pending = MagicMock()
task = TransferTask(fileitem=FileItem(
storage="local",
path="/downloads/retry-later.mkv",
type="file",
))
task.bind_admission_task_id("retry-later")
task.bind_execution_lease(
owner_id="worker-owner",
lease_token="retry-token",
)
chain._owned_leases = {
"retry-later": ("retry-token", time.monotonic() + 120)
}
assert chain._TransferChain__release_task_claim(
task,
error="planning failed",
) is True
assert chain._recovery_wakeup_event.is_set() is False
assert chain._replay_thread is not None
time.sleep(0.01)
chain._TransferChain__replay_pending.assert_not_called()
deadline = time.monotonic() + 0.5
while (
not chain._TransferChain__replay_pending.called
and time.monotonic() < deadline
):
time.sleep(0.01)
chain._TransferChain__replay_pending.assert_called()
chain._transfer_admissions.release_claim.assert_called_once_with(
task_id="retry-later",
lease_token="retry-token",
error="planning failed",
)
assert chain.close_workers(timeout_seconds=1) is True
def test_worker_fenced_releases_lost_lease_and_completes_queue_bookkeeping(
monkeypatch,
) -> None:
"""本地租约失效时仍尝试 token CAS release,并完整结算内存队列。"""
chain = _build_chain()
task = TransferTask(fileitem=FileItem(
storage="local",
path="/downloads/lost-lease.mkv",
type="file",
))
task.bind_admission_task_id("lost-lease")
task.bind_execution_lease(
owner_id="worker-owner",
lease_token="lost-token",
)
chain.jobview = MagicMock()
chain._finish_scrape_batch_task = MagicMock()
chain._TransferChain__settle_transfer_progress_if_idle = MagicMock()
stop_event = threading.Event()
chain._transfer_admissions.release_claim.side_effect = (
lambda **_kwargs: stop_event.set() or True
)
chain._queue.put(TransferQueue(task=task))
monkeypatch.setattr(global_vars, "STOP_EVENT", threading.Event())
worker = threading.Thread(
target=chain._TransferChain__start_transfer,
args=(stop_event,),
daemon=True,
)
worker.start()
worker.join(timeout=1)
assert worker.is_alive() is False
chain._transfer_admissions.release_claim.assert_called_once_with(
task_id="lost-lease",
lease_token="lost-token",
error="整理任务租约已经失效:lost-lease",
)
assert chain._queue.unfinished_tasks == 0
assert chain._recovery_wakeup_event.is_set() is False
def test_success_callback_runs_only_after_terminal_cas_succeeds(monkeypatch) -> None:
"""终态 CAS 被拒绝时不得写成功历史、事件或通知。"""
chain = _build_chain()
task = TransferTask(fileitem=FileItem(
storage="local",
path="/downloads/fenced-success.mkv",
type="file",
name="fenced-success.mkv",
))
chain.jobview = MagicMock()
chain.jobview.add_task.return_value = True
chain.jobview.pending_total.return_value = 1
chain._register_scrape_batch_task = MagicMock()
chain._finish_scrape_batch_task = MagicMock()
chain._progress = MagicMock()
chain._active_tasks = 0
chain._processed_num = 0
chain._fail_num = 0
chain._total_num = 0
chain._transfer_admissions.discard_claimed.return_value = 0
chain._TransferChain__settle_transfer_progress_if_idle = MagicMock()
success_callback = MagicMock(return_value=(True, ""))
chain._TransferChain__default_callback = success_callback
stop_event = threading.Event()
def complete_with_success(*, task, callback):
"""模拟文件成功后进入受 durable 终态保护的回调。"""
task.bind_plan_checkpoint(MagicMock())
stop_event.set()
return callback(
task,
TransferInfo(success=True, fileitem=task.fileitem),
)
chain._TransferChain__handle_transfer = complete_with_success
monkeypatch.setattr(global_vars, "STOP_EVENT", threading.Event())
assert chain.put_to_queue(task) is True
worker = threading.Thread(
target=chain._TransferChain__start_transfer,
args=(stop_event,),
daemon=True,
)
worker.start()
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)
assert chain._fail_num == 1
assert chain._queue.unfinished_tasks == 0
def test_close_release_db_block_respects_deadline_and_keeps_heartbeat() -> None:
"""关闭租约释放被数据库阻塞时应按预算返回,并继续 heartbeat。"""
chain = _build_chain()
release_started = threading.Event()
release_db = threading.Event()
heartbeat = threading.Thread(
target=chain._lease_heartbeat_stop_event.wait,
name="transfer-heartbeat-release-test",
daemon=True,
)
chain._lease_heartbeat_thread = heartbeat
chain._owned_leases = {
"blocked-release": ("blocked-token", time.monotonic() + 120)
}
def block_release(**_kwargs):
"""模拟数据库锁住 release_claim,直到测试显式放行。"""
release_started.set()
release_db.wait()
return True
chain._transfer_admissions.release_claim.side_effect = block_release
heartbeat.start()
started_at = time.monotonic()
assert chain.close_workers(timeout_seconds=0.01) is False
assert time.monotonic() - started_at < 0.5
assert release_started.is_set()
assert heartbeat.is_alive() is True
assert chain._lease_heartbeat_stop_event.is_set() is False
assert chain._lease_release_thread is not None
assert chain._lease_release_thread.is_alive() is True
release_db.set()
assert chain.close_workers(timeout_seconds=1) is True
assert heartbeat.is_alive() is False
assert chain._lease_release_thread is None