mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
1385 lines
47 KiB
Python
1385 lines
47 KiB
Python
"""
|
||
文件整理 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
|