mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
refactor(transfer): make queue admission durable
This commit is contained in:
@@ -1,18 +1,32 @@
|
||||
from unittest.mock import Mock
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app.application.transfer import TransferQueueService
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.application.transfer import TransferAdmission, TransferQueueService
|
||||
from app.db.adapters.transfer import TransactionalTransferAdmissionRepository
|
||||
from app.db.models.transferpending import TransferPending
|
||||
from app.schemas.file import FileItem
|
||||
|
||||
from tests.test_transfer_job_manager import make_task
|
||||
from tests.test_transfer_job_manager import make_task, make_transfer_chain
|
||||
|
||||
|
||||
def _service(**overrides):
|
||||
"""构造可观测整理队列服务及其默认依赖。"""
|
||||
dependencies = {
|
||||
"register_task": Mock(return_value=True),
|
||||
"admit_task": Mock(return_value=TransferAdmission(
|
||||
task_id="task-1",
|
||||
storage="local",
|
||||
src_path="/tmp/demo.mkv",
|
||||
state="accepted",
|
||||
created_at="2026-08-27 10:00:00",
|
||||
updated_at="2026-08-27 10:00:00",
|
||||
)),
|
||||
"enqueue": Mock(),
|
||||
"before_enqueue": Mock(),
|
||||
"after_enqueue": Mock(),
|
||||
"enqueue_failed": Mock(),
|
||||
"remove_task": Mock(),
|
||||
"list_tasks": Mock(return_value=["job"]),
|
||||
"expire_tasks": Mock(),
|
||||
@@ -22,17 +36,26 @@ def _service(**overrides):
|
||||
|
||||
|
||||
def test_transfer_queue_service_put_preserves_registration_order():
|
||||
"""入队必须先登记视图,再登记批次、写队列并落盘。"""
|
||||
"""入队必须先登记视图和 durable admission,再登记批次并写队列。"""
|
||||
calls = []
|
||||
service, _ = _service(
|
||||
register_task=lambda _task: calls.append("register") or True,
|
||||
admit_task=lambda _task: calls.append("admit") or TransferAdmission(
|
||||
task_id="task-1",
|
||||
storage="local",
|
||||
src_path="/tmp/demo.mkv",
|
||||
state="accepted",
|
||||
created_at="2026-08-27 10:00:00",
|
||||
updated_at="2026-08-27 10:00:00",
|
||||
),
|
||||
before_enqueue=lambda _task: calls.append("batch"),
|
||||
enqueue=lambda _item: calls.append("queue"),
|
||||
after_enqueue=lambda _task: calls.append("pending"),
|
||||
)
|
||||
|
||||
assert service.put(make_task(1), Mock()) is True
|
||||
assert calls == ["register", "batch", "queue", "pending"]
|
||||
task = make_task(1)
|
||||
assert service.put(task, Mock()) is True
|
||||
assert calls == ["register", "admit", "batch", "queue"]
|
||||
assert task.admission_task_id == "task-1"
|
||||
|
||||
|
||||
def test_transfer_queue_service_rejects_duplicate_without_side_effects():
|
||||
@@ -42,7 +65,81 @@ def test_transfer_queue_service_rejects_duplicate_without_side_effects():
|
||||
assert service.put(make_task(1), Mock()) is False
|
||||
dependencies["before_enqueue"].assert_not_called()
|
||||
dependencies["enqueue"].assert_not_called()
|
||||
dependencies["after_enqueue"].assert_not_called()
|
||||
dependencies["admit_task"].assert_not_called()
|
||||
|
||||
|
||||
def test_transfer_queue_service_blocks_enqueue_when_admission_fails():
|
||||
"""持久化失败必须撤销作业视图,不能继续加入内存队列。"""
|
||||
service, dependencies = _service(
|
||||
admit_task=Mock(side_effect=RuntimeError("db locked")),
|
||||
)
|
||||
task = make_task(1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="db locked"):
|
||||
service.put(task, Mock())
|
||||
|
||||
dependencies["remove_task"].assert_called_once_with(task.fileitem)
|
||||
dependencies["before_enqueue"].assert_not_called()
|
||||
dependencies["enqueue"].assert_not_called()
|
||||
|
||||
|
||||
def test_transfer_queue_service_keeps_admission_when_enqueue_fails():
|
||||
"""内存入队失败必须记录原因并清理视图,durable admission 由仓储保留。"""
|
||||
error = RuntimeError("queue closed")
|
||||
service, dependencies = _service(
|
||||
enqueue=Mock(side_effect=error),
|
||||
)
|
||||
task = make_task(1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="queue closed"):
|
||||
service.put(task, Mock())
|
||||
|
||||
dependencies["enqueue_failed"].assert_called_once_with(task, error)
|
||||
dependencies["remove_task"].assert_called_once_with(task.fileitem)
|
||||
|
||||
|
||||
def test_transfer_queue_service_cleans_up_when_batch_registration_fails():
|
||||
"""准入后的批次登记异常也必须留痕并撤销作业视图。"""
|
||||
error = RuntimeError("batch registration failed")
|
||||
service, dependencies = _service(
|
||||
before_enqueue=Mock(side_effect=error),
|
||||
)
|
||||
task = make_task(1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="batch registration failed"):
|
||||
service.put(task, Mock())
|
||||
|
||||
dependencies["enqueue_failed"].assert_called_once_with(task, error)
|
||||
dependencies["remove_task"].assert_called_once_with(task.fileitem)
|
||||
dependencies["enqueue"].assert_not_called()
|
||||
|
||||
|
||||
def test_transfer_queue_service_commits_admission_before_failed_enqueue(tmp_path):
|
||||
"""真实仓储已提交后即使内存入队失败,任务也必须带原因留待恢复。"""
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'durable-admission.db'}")
|
||||
TransferPending.__table__.create(engine)
|
||||
repository = TransactionalTransferAdmissionRepository(sessionmaker(bind=engine))
|
||||
task = make_task(1)
|
||||
service, _ = _service(
|
||||
admit_task=lambda item: repository.admit(
|
||||
storage=item.fileitem.storage,
|
||||
src_path=item.fileitem.path,
|
||||
),
|
||||
enqueue=Mock(side_effect=RuntimeError("queue closed")),
|
||||
enqueue_failed=lambda item, error: repository.record_enqueue_failure(
|
||||
task_id=item.admission_task_id,
|
||||
error=str(error),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="queue closed"):
|
||||
service.put(task, Mock())
|
||||
|
||||
admissions = repository.list_accepted()
|
||||
assert len(admissions) == 1
|
||||
assert admissions[0].task_id == task.admission_task_id
|
||||
assert admissions[0].last_error == "queue closed"
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_transfer_queue_service_lists_and_removes_through_ports():
|
||||
@@ -56,3 +153,38 @@ def test_transfer_queue_service_lists_and_removes_through_ports():
|
||||
dependencies["expire_tasks"].assert_called_once_with()
|
||||
dependencies["list_tasks"].assert_called_once_with()
|
||||
dependencies["remove_task"].assert_called_once_with(fileitem)
|
||||
|
||||
|
||||
def test_do_transfer_reports_durable_admission_failure():
|
||||
"""背景整理准入失败必须返回批次失败,不能伪装成重复任务成功。"""
|
||||
chain = make_transfer_chain()
|
||||
fileitem = make_task(1).fileitem
|
||||
chain._TransferChain__get_trans_fileitems = lambda _item, **_kwargs: [
|
||||
(fileitem, False)
|
||||
]
|
||||
chain.put_to_queue = Mock(side_effect=RuntimeError("db locked"))
|
||||
no_history = SimpleNamespace(
|
||||
get_by_src=lambda _src, storage=None: None,
|
||||
get_success_by_src=lambda _src, storage=None: None,
|
||||
)
|
||||
no_download = SimpleNamespace(
|
||||
get_by_hash=lambda _hash: None,
|
||||
get_file_by_fullpath=lambda _path: None,
|
||||
get_files_by_savepath=lambda _path: [],
|
||||
get_by_path=lambda _path: None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.chain.transfer.get_chain_transfer_history_port",
|
||||
return_value=no_history,
|
||||
), patch(
|
||||
"app.chain.transfer.get_chain_download_history_port",
|
||||
return_value=no_download,
|
||||
), patch(
|
||||
"app.chain.transfer.get_configured_system_config",
|
||||
return_value=SimpleNamespace(get=lambda _key: None),
|
||||
):
|
||||
state, message = chain.do_transfer(fileitem=fileitem, background=True)
|
||||
|
||||
assert state is False
|
||||
assert "加入整理队列失败:db locked" in message
|
||||
|
||||
Reference in New Issue
Block a user