mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-31 04:57:23 +08:00
955 lines
33 KiB
Python
955 lines
33 KiB
Python
"""文件整理 worker 与 pending 回放的宿主生命周期测试。"""
|
|
|
|
import asyncio
|
|
import queue
|
|
import threading
|
|
import time
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from app.application.transfer.workflow import (
|
|
TransferAdmission,
|
|
TransferPlanningInput,
|
|
TransferQueue,
|
|
TransferTask,
|
|
)
|
|
from app.chain.transfer import TransferChain
|
|
from app.foundation.singleton import Singleton
|
|
from app.runtime.config import global_vars
|
|
from app.schemas.file import FileItem
|
|
from app.schemas.transfer import TransferInfo
|
|
from app.startup.initializers import transfer as transfer_initializer
|
|
|
|
|
|
def _planning_input(fileitem: FileItem) -> TransferPlanningInput:
|
|
"""构造 worker 准入与 claim 投影使用的真实规划输入。"""
|
|
return TransferPlanningInput(
|
|
source_fileitem=fileitem.model_dump(mode="json"),
|
|
meta=None,
|
|
mediainfo=None,
|
|
)
|
|
|
|
|
|
def _build_chain(*, transfer_threads: int = 0) -> TransferChain:
|
|
"""构造只包含后台线程生命周期字段的 TransferChain 测试骨架。"""
|
|
chain = object.__new__(TransferChain)
|
|
chain.runtime_config = SimpleNamespace(
|
|
transfer_threads=transfer_threads,
|
|
transfer_task_timeout=0,
|
|
)
|
|
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,
|
|
)
|
|
|
|
|
|
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()
|
|
|
|
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()
|
|
|
|
|
|
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()
|
|
|
|
completed = asyncio.run(chain.close(timeout_seconds=0.01))
|
|
|
|
assert completed is True
|
|
chain.failure_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
|
|
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_with(
|
|
task_id="durable-task-id",
|
|
owner_id="worker-owner",
|
|
lease_seconds=120,
|
|
)
|
|
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_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 = 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_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
|