Files
MoviePilot/tests/test_transfer_worker_lifecycle.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

1385 lines
47 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
文件整理 worker 与 pending 回放的宿主生命周期测试。
"""
import asyncio
import queue
import threading
import time
from collections.abc import Iterator
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.application.transfer.workflow import (
JobManager,
TransferAdmission,
TransferPlanningInput,
TransferQueue,
TransferTask,
)
# 此用例覆盖运行时惰性导出,静态检查无法解析该公开入口。
from app.chain.transfer import TransferChain # pylint: disable=no-name-in-module
from app.chain.transfer.execution import _TransferManualReviewRequired
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
from tests.test_transfer_job_manager import make_task
def _planning_input(fileitem: FileItem) -> TransferPlanningInput:
"""
构造 worker 准入与 claim 投影使用的真实规划输入。
"""
return TransferPlanningInput(
source_fileitem=fileitem.model_dump(mode="json"),
meta=None,
mediainfo=None,
)
def _build_chain(*, transfer_threads: int = 0) -> TransferChain:
"""
构造只包含后台线程生命周期字段的 TransferChain 测试骨架。
"""
chain = object.__new__(TransferChain)
chain.runtime_config = SimpleNamespace(
transfer_threads=transfer_threads,
transfer_task_timeout=0,
video_extensions=(".mkv",),
subtitle_extensions=(".srt",),
audio_extensions=(".flac",),
)
chain._queue = queue.Queue()
chain._transfer_interval = 0.1
chain._threads = []
chain._retiring_threads = []
chain._queue_active = False
chain._worker_stop_event = threading.Event()
chain._worker_lifecycle_lock = threading.RLock()
chain._worker_state_lock = threading.RLock()
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",
planning_input=kwargs["planning_input"],
)
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",
planning_input=_planning_input(FileItem(
storage="local",
path="/downloads/test.mkv",
type="file",
)),
lease_owner=kwargs["owner_id"],
lease_token=f"lease-{kwargs['task_id']}",
lease_expires_at="2026-08-27 10:02:00.000000",
heartbeat_at="2026-08-27 10:00:00.000000",
attempt_count=1,
)
admissions.abandon_unstarted.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",
planning_input=_planning_input(task.fileitem),
lease_owner="worker-owner",
lease_token=f"lease-{task_id}",
lease_expires_at="2026-08-27 10:02:00.000000",
heartbeat_at="2026-08-27 10:00:00.000000",
attempt_count=1,
)
@pytest.fixture
def blocked_queue_admission(request: pytest.FixtureRequest) -> Iterator[SimpleNamespace]:
"""
在准入数据库边界暂停生产者,并保留可按事件触发的真实续租线程。
"""
chain = _build_chain()
chain.jobview = MagicMock()
chain.jobview.add_task.return_value = True
chain._register_scrape_batch_task = MagicMock()
task = TransferTask(fileitem=FileItem(
storage="local", path="/downloads/test.mkv", type="file",
))
task.bind_planning_input(_planning_input(task.fileitem))
current = _claimed_admission(task, "existing-task")
initial_deadline = time.monotonic() + 60
chain._owned_leases[current.task_id] = (current.lease_token, initial_deadline)
chain._transfer_admissions.heartbeat.return_value = current
database_entered = threading.Event()
release_database = threading.Event()
heartbeat_requested = threading.Event()
heartbeat_finished = threading.Event()
producer_results: list[bool] = []
failures: list[Exception] = []
database_method = getattr(chain._transfer_admissions, request.param)
original_operation = database_method.side_effect
def block_database(**kwargs) -> TransferAdmission:
"""
保持仓储调用未返回,直到测试完成并发行为断言后显式放行。
"""
database_entered.set()
release_database.wait()
return original_operation(**kwargs)
def enqueue_task() -> None:
"""
从独立生产者执行真实入队路径,并回传结果与异常。
"""
try:
producer_results.append(chain.put_to_queue(task))
except Exception as error:
failures.append(error)
def renew_lease() -> None:
"""
收到事件后执行真实续租,再等待宿主关闭信号。
"""
heartbeat_requested.wait()
try:
chain._TransferChain__heartbeat_owned_leases()
except Exception as error:
failures.append(error)
finally:
heartbeat_finished.set()
chain._lease_heartbeat_stop_event.wait()
database_method.side_effect = block_database
producer = threading.Thread(target=enqueue_task, daemon=True)
heartbeat = threading.Thread(target=renew_lease, daemon=True)
chain._lease_heartbeat_thread = heartbeat
state = SimpleNamespace(
chain=chain,
task=task,
initial_deadline=initial_deadline,
database_entered=database_entered,
release_database=release_database,
heartbeat_requested=heartbeat_requested,
heartbeat_finished=heartbeat_finished,
producer=producer,
heartbeat=heartbeat,
producer_results=producer_results,
failures=failures,
closer=None,
)
heartbeat.start()
producer.start()
try:
assert database_entered.wait(timeout=1)
yield state
finally:
release_database.set()
heartbeat_requested.set()
producer.join(timeout=1)
if state.closer is not None:
state.closer.join(timeout=1)
chain.close_workers(timeout_seconds=1)
chain._lease_heartbeat_stop_event.set()
heartbeat.join(timeout=1)
assert producer.is_alive() is False
assert heartbeat.is_alive() is False
assert state.closer is None or not state.closer.is_alive()
@pytest.mark.parametrize("blocked_queue_admission", ["admit", "claim_task"], indirect=True)
def test_slow_queue_admission_does_not_block_owned_lease_heartbeat(
blocked_queue_admission: SimpleNamespace,
) -> None:
"""
新任务数据库阻塞期间,已有任务必须独立续租并刷新本地期限。
"""
state = blocked_queue_admission
state.heartbeat_requested.set()
assert state.heartbeat_finished.wait(timeout=0.5)
assert state.release_database.is_set() is False
assert state.producer.is_alive() is True
assert state.failures == []
assert state.chain._owned_leases["existing-task"][1] > state.initial_deadline
state.release_database.set()
state.producer.join(timeout=1)
assert state.producer_results == [True]
assert state.chain._queue.get_nowait().task is state.task
@pytest.mark.parametrize("blocked_queue_admission", ["admit", "claim_task"], indirect=True)
def test_close_during_queue_admission_respects_budget_and_keeps_heartbeat(
blocked_queue_admission: SimpleNamespace,
) -> None:
"""
关闭不得越过阻塞生产者释放租约,超时后续租和已接收的入队仍可完成。
"""
state = blocked_queue_admission
close_finished = threading.Event()
close_results: list[tuple[bool, float]] = []
def close_chain() -> None:
"""
独立执行关闭,使旧实现无界等锁时测试仍能显式释放生产者。
"""
started_at = time.monotonic()
try:
result = state.chain.close_workers(timeout_seconds=0.02)
close_results.append((result, time.monotonic() - started_at))
finally:
close_finished.set()
state.closer = threading.Thread(target=close_chain, daemon=True)
state.closer.start()
assert close_finished.wait(timeout=0.5)
assert close_results[0][0] is False
assert close_results[0][1] < 0.5
assert state.chain._lease_heartbeat_stop_event.is_set() is False
assert state.heartbeat.is_alive() is True
state.heartbeat_requested.set()
assert state.heartbeat_finished.wait(timeout=0.5)
assert state.chain._owned_leases["existing-task"][1] > state.initial_deadline
assert state.release_database.is_set() is False
state.release_database.set()
state.producer.join(timeout=1)
assert state.producer_results == [True]
assert state.failures == []
assert state.chain.close_workers(timeout_seconds=1) is True
@pytest.mark.parametrize("owner", ["worker", "replay"])
def test_closing_owner_rejects_enqueue_without_waiting_for_lifecycle_lock(owner: str) -> None:
"""
已经等待准入锁的 owner 必须察觉关闭并退出,不能与关闭的 join 反等。
"""
chain = _build_chain()
lifecycle_lock = threading.RLock()
enqueue_waiting_for_lock = threading.Event()
enqueue_results: list[bool] = []
enqueue_failures: list[Exception] = []
def acquire_lifecycle_lock(*, timeout: float) -> bool:
"""
只观测 owner 已经开始等锁,实际竞争和主线程重入均交给真实 RLock。
"""
if threading.current_thread() is thread:
enqueue_waiting_for_lock.set()
return lifecycle_lock.acquire(timeout=timeout)
def enqueue_before_close() -> None:
"""
在关闭尚未开始时进入准入并阻塞于主线程持有的生命周期锁。
"""
try:
enqueue_results.append(chain.put_to_queue(MagicMock()))
except Exception as error:
enqueue_failures.append(error)
chain._worker_lifecycle_lock = SimpleNamespace(
acquire=acquire_lifecycle_lock,
release=lifecycle_lock.release,
)
thread = threading.Thread(target=enqueue_before_close, daemon=True)
if owner == "worker":
chain._threads = [thread]
else:
chain._replay_thread = thread
lifecycle_lock.acquire()
thread.start()
try:
assert enqueue_waiting_for_lock.wait(timeout=1)
assert chain._closing is False
started_at = time.monotonic()
assert chain.close_workers(timeout_seconds=1) is True
assert time.monotonic() - started_at < 0.5
assert enqueue_results == [False]
assert enqueue_failures == []
chain._transfer_admissions.admit.assert_not_called()
chain._transfer_admissions.claim_task.assert_not_called()
finally:
lifecycle_lock.release()
thread.join(timeout=1)
chain.close_workers(timeout_seconds=1)
assert thread.is_alive() is False
def test_config_reload_replaces_worker_generation_and_keeps_accepting() -> None:
"""
热更新应等待旧 worker 收敛,再启动使用独立停止信号的新一代。
"""
chain = _build_chain(transfer_threads=1)
started_workers: queue.Queue = queue.Queue()
def run_worker(stop_event: threading.Event) -> None:
"""
记录 worker 代际并等待该代专属停止信号。
"""
started_workers.put((threading.current_thread(), stop_event))
stop_event.wait()
chain._TransferChain__start_transfer = run_worker
assert chain._TransferChain__init() is True
first_thread, first_stop_event = started_workers.get(timeout=1)
chain.on_config_changed()
second_thread, second_stop_event = started_workers.get(timeout=1)
assert first_stop_event.is_set() is True
assert first_thread.is_alive() is False
assert second_thread is not first_thread
assert second_stop_event is not first_stop_event
assert second_stop_event.is_set() is False
service = MagicMock()
service.put.return_value = True
chain._transfer_queue_service = MagicMock(return_value=service)
task = MagicMock()
assert chain.put_to_queue(task) is True
service.put.assert_called_once()
assert chain.close_workers(timeout_seconds=1) is True
assert second_thread.is_alive() is False
def test_config_reload_hands_queue_to_new_generation_while_old_io_finishes() -> None:
"""
旧代同步 I/O 超时不应让后续队列永久失去 worker。
"""
chain = _build_chain(transfer_threads=1)
chain._WORKER_RESTART_TIMEOUT_SECONDS = 0.01
started_workers: queue.Queue = queue.Queue()
release_old_worker = threading.Event()
invocation_count = 0
invocation_lock = threading.Lock()
def run_worker(stop_event: threading.Event) -> None:
"""
首代模拟不可取消 I/O,后续代按各自停止信号正常收敛。
"""
nonlocal invocation_count
with invocation_lock:
generation = invocation_count
invocation_count += 1
started_workers.put((threading.current_thread(), stop_event))
if generation == 0:
release_old_worker.wait()
else:
stop_event.wait()
chain._TransferChain__start_transfer = run_worker
assert chain._TransferChain__init() is True
old_thread, old_stop_event = started_workers.get(timeout=1)
chain.on_config_changed()
new_thread, new_stop_event = started_workers.get(timeout=1)
assert old_stop_event.is_set() is True
assert old_thread.is_alive() is True
assert chain._retiring_threads == [old_thread]
assert chain._threads == [new_thread]
assert new_stop_event.is_set() is False
release_old_worker.set()
assert chain.close_workers(timeout_seconds=1) is True
assert old_thread.is_alive() is False
assert new_thread.is_alive() is False
def test_close_workers_is_bounded_and_retains_nonconverging_owner() -> None:
"""
同步 I/O 线程超时后应保留句柄并报告失败,不能伪装成已取消。
"""
chain = _build_chain()
release = threading.Event()
thread = threading.Thread(
target=release.wait,
name="transfer-blocked-test",
daemon=True,
)
chain._threads = [thread]
thread.start()
started_at = time.monotonic()
assert chain.close_workers(timeout_seconds=0.01) is False
assert time.monotonic() - started_at < 0.5
assert chain._threads == []
assert chain._retiring_threads == [thread]
assert thread.is_alive() is True
service = MagicMock()
chain._transfer_queue_service = MagicMock(return_value=service)
assert chain.put_to_queue(MagicMock()) is False
service.put.assert_not_called()
release.set()
assert chain.close_workers(timeout_seconds=1) is True
assert chain.close_workers(timeout_seconds=0) is True
assert chain._threads == []
assert chain._retiring_threads == []
def test_close_workers_lock_wait_uses_the_same_timeout_budget() -> None:
"""
生命周期锁竞争必须耗用关闭预算,超时返回后不得延迟修改 worker 状态。
"""
chain = _build_chain()
lock_acquired = threading.Event()
release_lock = threading.Event()
def hold_lifecycle_lock() -> None:
"""
在独立线程持锁,稳定制造无法重入的生命周期锁竞争。
"""
with chain._worker_lifecycle_lock:
lock_acquired.set()
assert release_lock.wait(timeout=1)
holder = threading.Thread(target=hold_lifecycle_lock, daemon=True)
holder.start()
assert lock_acquired.wait(timeout=1)
started_at = time.monotonic()
assert chain.close_workers(timeout_seconds=0.01) is False
assert time.monotonic() - started_at < 0.5
assert chain._closing is False
assert chain._worker_stop_event.is_set() is False
assert chain._queue.empty() is True
release_lock.set()
holder.join(timeout=1)
assert holder.is_alive() is False
assert chain.close_workers(timeout_seconds=1) is True
def test_close_keeps_failure_notification_when_workers_do_not_converge() -> None:
"""
活跃整理线程超时后,成功和失败通知 owner 必须继续供线程使用。
"""
chain = _build_chain()
chain.close_workers = MagicMock(return_value=False)
chain.failure_notification_aggregator = MagicMock()
chain.success_notification_aggregator = MagicMock()
completed = asyncio.run(chain.close(timeout_seconds=0.01))
assert completed is False
chain.close_workers.assert_called_once_with(0.01)
chain.failure_notification_aggregator.close.assert_not_called()
chain.success_notification_aggregator.close.assert_not_called()
def test_close_releases_failure_notification_after_workers_converge() -> None:
"""
worker 和回放退出后,整理链应刷新并关闭成功和失败通知 owner。
"""
chain = _build_chain()
chain.close_workers = MagicMock(return_value=True)
chain.failure_notification_aggregator = MagicMock()
chain.success_notification_aggregator = MagicMock()
completed = asyncio.run(chain.close(timeout_seconds=0.01))
assert completed is True
chain.failure_notification_aggregator.close.assert_called_once_with()
chain.success_notification_aggregator.close.assert_called_once_with()
def test_stop_transfer_runtime_does_not_construct_chain(monkeypatch) -> None:
"""
关闭入口在整理链从未使用时应直接成功,不能因关停而启动 worker。
"""
get_existing_instance = MagicMock(return_value=None)
monkeypatch.setattr(
transfer_initializer.TransferChain,
"get_existing_instance",
get_existing_instance,
)
completed = asyncio.run(
transfer_initializer.stop_transfer_runtime(timeout_seconds=0.01)
)
assert completed is True
get_existing_instance.assert_called_once_with()
def test_stop_transfer_runtime_closes_existing_chain(monkeypatch) -> None:
"""
关闭入口应把超时预算和真实收敛结果原样传给既有整理链。
"""
chain = MagicMock(close=AsyncMock(return_value=False))
monkeypatch.setattr(
transfer_initializer.TransferChain,
"get_existing_instance",
MagicMock(return_value=chain),
)
completed = asyncio.run(
transfer_initializer.stop_transfer_runtime(timeout_seconds=0.01)
)
assert completed is False
chain.close.assert_awaited_once_with(timeout_seconds=0.01)
def test_constructor_failure_publishes_started_worker_to_cleanup(monkeypatch) -> None:
"""
首个 worker 启动后构造失败时,stop-only 入口仍必须找到并等待它。
"""
instances = dict(Singleton._instances)
instances.pop((TransferChain, (), frozenset()), None)
monkeypatch.setattr(Singleton, "_instances", instances)
worker_started = threading.Event()
worker_release = threading.Event()
workers: list[threading.Thread] = []
def failing_init(chain: TransferChain) -> None:
"""
模拟第二个 owner 启动失败前已经成功启动一个整理线程。
"""
worker = threading.Thread(
target=lambda: (worker_started.set(), worker_release.wait()),
name="transfer-partial-construction",
daemon=True,
)
workers.append(worker)
worker.start()
async def close(*, timeout_seconds: float) -> bool:
"""
模拟真实 close 释放并等待半构造实例已经发布的 worker。
"""
worker_release.set()
worker.join(timeout=timeout_seconds)
return not worker.is_alive()
chain.close = close
raise RuntimeError("second worker failed")
monkeypatch.setattr(TransferChain, "__init__", failing_init)
with pytest.raises(RuntimeError, match="second worker failed"):
TransferChain()
assert worker_started.wait(timeout=1)
retained = TransferChain.get_existing_instance()
assert retained is not None
assert asyncio.run(
transfer_initializer.stop_transfer_runtime(timeout_seconds=1)
) is True
assert workers[0].is_alive() is False
def test_worker_requeues_item_taken_during_shutdown(monkeypatch) -> None:
"""
停止信号与 queue.get 竞态时,未开始处理的任务必须原样放回队列。
"""
chain = _build_chain()
work_queue = MagicMock()
chain._queue = work_queue
entered_get = threading.Event()
release_get = threading.Event()
item = TransferQueue()
def get_item(*_args, **_kwargs):
"""
让停止信号稳定落在阻塞取队列之后、任务处理之前。
"""
entered_get.set()
assert release_get.wait(timeout=1)
return item
work_queue.get.side_effect = get_item
monkeypatch.setattr(global_vars, "STOP_EVENT", threading.Event())
stop_event = threading.Event()
thread = threading.Thread(
target=chain._TransferChain__start_transfer,
args=(stop_event,),
daemon=True,
)
thread.start()
assert entered_get.wait(timeout=1)
stop_event.set()
release_get.set()
thread.join(timeout=1)
assert thread.is_alive() is False
work_queue.put.assert_called_once_with(item)
work_queue.task_done.assert_called_once_with()
def test_worker_settles_progress_when_only_stop_sentinel_remains(monkeypatch) -> None:
"""
真实任务完成时仅剩停止哨兵,仍应结束进度并重置本批计数。
"""
chain = _build_chain()
task = TransferTask(
fileitem=FileItem(
storage="local",
path="/downloads/movie.mkv",
type="file",
name="movie.mkv",
basename="movie",
extension="mkv",
)
)
chain.jobview = MagicMock()
chain.jobview.pending_total.return_value = 1
chain._progress = MagicMock()
chain._active_tasks = 0
chain._processed_num = 0
chain._fail_num = 0
chain._total_num = 0
task_started = threading.Event()
release_task = threading.Event()
def handle_transfer(*_args, **_kwargs):
"""
阻塞真实任务,让测试能在其完成前稳定插入停止哨兵。
"""
task_started.set()
assert release_task.wait(timeout=1)
return True, ""
chain._TransferChain__handle_transfer = handle_transfer
chain._TransferChain__start_job_execution = MagicMock()
chain._TransferChain__finish_job_execution = MagicMock()
chain._queue.put(TransferQueue(task=task))
monkeypatch.setattr(global_vars, "STOP_EVENT", threading.Event())
stop_event = threading.Event()
worker = threading.Thread(
target=chain._TransferChain__start_transfer,
args=(stop_event,),
daemon=True,
)
worker.start()
assert task_started.wait(timeout=1)
stop_event.set()
chain._queue.put(chain._QUEUE_STOP_SENTINEL)
release_task.set()
worker.join(timeout=1)
assert worker.is_alive() is False
chain._progress.end.assert_called_once_with()
assert chain._active_tasks == 0
assert chain._total_num == 0
assert chain._processed_num == 0
assert chain._fail_num == 0
with chain._queue.mutex:
assert list(chain._queue.queue) == [chain._QUEUE_STOP_SENTINEL]
def test_durable_task_identity_flows_to_unsettled_terminal_claim_release(monkeypatch) -> None:
"""
终态无原子回执时稳定身份必须用于释放 claim,pending 保持可恢复。
"""
chain = _build_chain()
chain.runtime_config.transfer_task_timeout = 0
task = TransferTask(fileitem=FileItem(
storage="local",
path="/downloads/durable.mkv",
type="file",
name="durable.mkv",
basename="durable",
extension="mkv",
))
discarded = threading.Event()
admissions = MagicMock()
admissions.admit.return_value = TransferAdmission(
task_id="durable-task-id",
storage="local",
src_path=task.fileitem.path,
state="accepted",
created_at="2026-08-27 10:00:00",
updated_at="2026-08-27 10:00:00",
planning_input=_planning_input(task.fileitem),
)
admissions.claim_task.return_value = _claimed_admission(
task,
"durable-task-id",
)
admissions.release_claim.side_effect = (
lambda **_kwargs: discarded.set() or 1
)
chain._transfer_admissions = admissions
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
def complete_with_checkpoint(*, task, callback):
"""
模拟真实 worker 只有提交 checkpoint 后才返回终态成功。
"""
task.bind_plan_checkpoint(MagicMock())
return True, ""
chain._TransferChain__handle_transfer = MagicMock(
side_effect=complete_with_checkpoint
)
monkeypatch.setattr(global_vars, "STOP_EVENT", threading.Event())
assert chain.put_to_queue(task) is True
assert task.lease_token == "lease-durable-task-id"
admissions.claim_task.assert_called_once_with(
task_id="durable-task-id",
owner_id="worker-owner",
lease_seconds=120,
)
stop_event = threading.Event()
worker = threading.Thread(
target=chain._TransferChain__start_transfer,
args=(stop_event,),
daemon=True,
)
worker.start()
assert discarded.wait(timeout=1)
stop_event.set()
worker.join(timeout=1)
assert worker.is_alive() is False
assert task.admission_task_id == "durable-task-id"
admissions.claim_task.assert_called_once()
admissions.release_claim.assert_called_once_with(
task_id="durable-task-id",
lease_token="lease-durable-task-id",
error="整理终态未完成 durable 原子结算",
)
admissions.abandon_unstarted.assert_not_called()
def test_claimed_task_prevents_progress_settlement_before_active_registration() -> None:
"""
其他 worker 已取走真实任务但尚未登记 active 时,当前批次不得提前结算。
"""
chain = _build_chain()
task = TransferTask(
fileitem=FileItem(
storage="local",
path="/downloads/claimed.mkv",
type="file",
name="claimed.mkv",
basename="claimed",
extension="mkv",
)
)
chain._progress = MagicMock()
chain._active_tasks = 0
chain._processed_num = 1
chain._fail_num = 0
chain._total_num = 2
claimed = threading.Event()
release_claim = threading.Event()
chain._queue.put(TransferQueue(task=task))
def hold_claimed_task() -> None:
"""
模拟 worker 已完成 queue.get、尚未取得 task_lock 登记 active 的窗口。
"""
item = chain._queue.get(timeout=1)
assert item.task is task
claimed.set()
assert release_claim.wait(timeout=1)
chain._queue.task_done()
chain._TransferChain__settle_transfer_progress_if_idle()
worker = threading.Thread(target=hold_claimed_task, daemon=True)
worker.start()
assert claimed.wait(timeout=1)
chain._TransferChain__settle_transfer_progress_if_idle()
chain._progress.end.assert_not_called()
assert chain._processed_num == 1
release_claim.set()
worker.join(timeout=1)
assert worker.is_alive() is False
chain._progress.end.assert_called_once_with()
assert chain._total_num == 0
assert chain._processed_num == 0
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 = []
def replay(stop_event: threading.Event) -> None:
"""
模拟可由逐项检查点收敛的 pending 回放。
"""
replay_calls.append(stop_event)
replay_started.set()
stop_event.wait()
chain._TransferChain__replay_pending = replay
chain.replay_pending()
assert replay_started.wait(timeout=1)
replay_thread = chain._replay_thread
chain.replay_pending()
assert chain._replay_thread is replay_thread
assert replay_calls == [chain._replay_stop_event]
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.release_claim.return_value = True
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.release_claim.assert_called_once_with(
task_id="recovered-task",
lease_token="lease-recovered-task",
error="整理终态未完成 durable 原子结算",
)
chain._transfer_admissions.abandon_unstarted.assert_not_called()
def test_worker_manual_review_logs_queue_guidance_without_traceback(monkeypatch) -> None:
"""
后台 worker 遇到人工复核时只告警并释放内存执行状态,保留 durable 任务。
"""
chain = _build_chain()
task = TransferTask(fileitem=FileItem(
storage="local",
path="/downloads/manual-review.mkv",
type="file",
name="manual-review.mkv",
basename="manual-review",
extension="mkv",
))
task.bind_admission_task_id("manual-review-task")
task.bind_execution_lease(
owner_id="worker-owner",
lease_token="lease-manual-review-task",
)
chain._owned_leases = {
"manual-review-task": (
"lease-manual-review-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
stop_event = threading.Event()
chain._transfer_admissions.release_claim.side_effect = (
lambda **_kwargs: stop_event.set() or True
)
error = _TransferManualReviewRequired(
"遗留步骤 operation-1 外部结果为 unknown,禁止自动重放"
)
chain._TransferChain__handle_transfer = MagicMock(side_effect=error)
fail_task = MagicMock()
chain._TransferChain__fail_transfer_task = fail_task
warnings: list[str] = []
errors: list[str] = []
monkeypatch.setattr("app.chain.transfer.workflow.logger.warning", warnings.append)
monkeypatch.setattr("app.chain.transfer.workflow.logger.error", errors.append)
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
assert len(warnings) == 1
assert "打开整理队列" in warnings[0]
assert errors == []
fail_task.assert_called_once_with(task, error)
chain._transfer_admissions.release_claim.assert_called_once_with(
task_id="manual-review-task",
lease_token="lease-manual-review-task",
error=None,
)
assert chain._queue.unfinished_tasks == 0
def test_worker_isolates_exception_from_plugin_replacement_finally(monkeypatch) -> None:
"""
插件替换整个执行方法并在 finally 抛异常时,worker 外层仍必须隔离持久任务。
"""
chain = _build_chain()
task = make_task(1)
task.bind_admission_task_id("plugin-task")
task.bind_execution_lease(owner_id="worker-owner", lease_token="plugin-lease")
chain._owned_leases = {"plugin-task": ("plugin-lease", time.monotonic() + 120)}
chain.jobview = MagicMock()
chain.jobview.pending_total.return_value = 1
chain._progress = MagicMock()
chain._active_tasks = chain._processed_num = chain._fail_num = chain._total_num = 0
chain._transfer_executions = MagicMock()
chain._transfer_executions.isolate_unhandled_failure.return_value = True
chain.transfer_execution_repository = chain._transfer_executions
chain._TransferChain__fail_transfer_task = MagicMock()
stop_event = threading.Event()
def plugin_replacement(*, task, callback):
"""
重现第三方补丁在 finally 调用已不存在的私有方法。
"""
del callback
try:
return True, ""
finally:
stop_event.set()
chain._TransferChain__finish_scrape_batch_task(task)
chain._TransferChain__handle_transfer = plugin_replacement
chain._queue.put(TransferQueue(task=task))
monkeypatch.setattr(global_vars, "STOP_EVENT", threading.Event())
chain._TransferChain__start_transfer(stop_event)
isolation = chain._transfer_executions.isolate_unhandled_failure
isolation.assert_called_once()
assert isolation.call_args.kwargs["task_id"] == "plugin-task"
assert "_TransferChain__finish_scrape_batch_task" in isolation.call_args.kwargs["error"]
assert chain._queue.unfinished_tasks == 0
assert chain._owned_leases == {}
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.release_claim.return_value = False
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 = make_task(1)
task.bind_admission_task_id("lost-lease")
task.bind_execution_lease(
owner_id="worker-owner",
lease_token="lost-token",
)
chain.jobview = JobManager()
assert chain.jobview.add_task(task)
assert chain.jobview.add_task(make_task(2))
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
assert chain.jobview.pending_total() == 1
restart = make_task(1)
restart.manual = True
restart.bind_planning_input(_planning_input(restart.fileitem))
chain._register_scrape_batch_task = MagicMock()
assert chain.put_to_queue(restart) is True
assert chain.jobview.pending_total() == 2
assert chain._queue.get_nowait().task is restart
chain._finish_queue_item(restart)
def test_callback_without_terminal_settlement_releases_claim_and_counts_failure(
monkeypatch,
) -> None:
"""
回调未给出原子结算回执时必须保留 pending、释放 claim 并计失败。
"""
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.release_claim.return_value = False
chain._TransferChain__settle_transfer_progress_if_idle = MagicMock()
success_callback = MagicMock(return_value=(True, ""))
chain._TransferChain__default_callback = success_callback
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_called_once()
chain._transfer_admissions.release_claim.assert_called_once_with(
task_id="admitted-task",
lease_token="lease-admitted-task",
error="整理终态未完成 durable 原子结算",
)
chain._transfer_admissions.abandon_unstarted.assert_not_called()
assert chain._fail_num == 1
assert chain._queue.unfinished_tasks == 0
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