"""文件整理 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