docling-studio/document-parser/tests/test_analysis_service.py
Pier-Jean Malandrino af10c200f0 fix: cancel running conversion when analysis job is deleted (#64)
Track background tasks per job ID. On delete, cancel the task to release
the converter lock instead of letting phantom jobs run to completion.
2026-04-07 17:07:55 +02:00

188 lines
6.4 KiB
Python

"""Tests for AnalysisService — callbacks, concurrency, and orchestration."""
from __future__ import annotations
import asyncio
import functools
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from services.analysis_service import AnalysisService, _on_task_done
class TestOnTaskDone:
"""Bug #1: _on_task_done must call _mark_failed when the task raises."""
@pytest.mark.asyncio
async def test_exception_marks_job_failed(self):
"""When a background task raises, the job should be marked FAILED."""
job_id = "job-123"
# Create a task that raises
async def failing_task():
raise RuntimeError("unexpected failure")
task = asyncio.create_task(failing_task())
await asyncio.sleep(0) # let the task fail
with patch("services.analysis_service._mark_failed", new_callable=AsyncMock) as mock_mark:
_on_task_done(task, job_id=job_id)
# ensure_future schedules it; give the event loop a tick
await asyncio.sleep(0)
mock_mark.assert_called_once_with(job_id, "unexpected failure")
@pytest.mark.asyncio
async def test_exception_uses_classify_error(self):
"""_on_task_done should route exceptions through _classify_error."""
job_id = "job-classify"
async def timeout_task():
raise TimeoutError("timeout exceeded while processing")
task = asyncio.create_task(timeout_task())
await asyncio.sleep(0)
with patch("services.analysis_service._mark_failed", new_callable=AsyncMock) as mock_mark:
_on_task_done(task, job_id=job_id)
await asyncio.sleep(0)
mock_mark.assert_called_once_with(
job_id, "Processing took too long — try with fewer pages or simpler options"
)
@pytest.mark.asyncio
async def test_cancelled_task_marks_job_failed(self):
"""When a background task is cancelled, the job should be marked FAILED."""
job_id = "job-456"
async def slow_task():
await asyncio.sleep(999)
import contextlib
task = asyncio.create_task(slow_task())
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
with patch("services.analysis_service._mark_failed", new_callable=AsyncMock) as mock_mark:
_on_task_done(task, job_id=job_id)
await asyncio.sleep(0)
mock_mark.assert_called_once_with(job_id, "Task was cancelled")
@pytest.mark.asyncio
async def test_successful_task_does_not_mark_failed(self):
"""When a background task succeeds, _mark_failed should not be called."""
job_id = "job-789"
async def ok_task():
return "done"
task = asyncio.create_task(ok_task())
await task
with patch("services.analysis_service._mark_failed", new_callable=AsyncMock) as mock_mark:
_on_task_done(task, job_id=job_id)
await asyncio.sleep(0)
mock_mark.assert_not_called()
class TestAnalysisServiceCancellation:
"""Verify delete cancels running tasks."""
@pytest.mark.asyncio
async def test_delete_cancels_running_task(self):
"""Deleting a job while running should cancel its task."""
converter = MagicMock()
service = AnalysisService(converter=converter)
blocker = asyncio.Event()
async def slow_analysis():
await blocker.wait()
task = asyncio.create_task(slow_analysis())
service._running_tasks["j1"] = task
with patch("services.analysis_service.analysis_repo") as mock_repo:
mock_repo.delete = AsyncMock(return_value=True)
result = await service.delete("j1")
assert result is True
assert task.cancelling() or task.cancelled()
assert "j1" not in service._running_tasks
@pytest.mark.asyncio
async def test_delete_completed_job_no_error(self):
"""Deleting a completed job should not raise even if no task tracked."""
converter = MagicMock()
service = AnalysisService(converter=converter)
with patch("services.analysis_service.analysis_repo") as mock_repo:
mock_repo.delete = AsyncMock(return_value=True)
result = await service.delete("j-gone")
assert result is True
@pytest.mark.asyncio
async def test_task_cleaned_from_running_on_completion(self):
"""After a task completes, it should be removed from _running_tasks."""
converter = MagicMock()
service = AnalysisService(converter=converter)
async def instant():
pass
task = asyncio.create_task(instant())
service._running_tasks["j1"] = task
task.add_done_callback(functools.partial(service._on_task_done, job_id="j1"))
await task
assert "j1" not in service._running_tasks
class TestAnalysisServiceConcurrency:
"""Verify that the semaphore limits concurrent analysis jobs."""
def test_semaphore_initialized_with_max_concurrent(self):
converter = MagicMock()
service = AnalysisService(converter=converter, max_concurrent=5)
assert service._semaphore._value == 5
def test_default_max_concurrent(self):
converter = MagicMock()
service = AnalysisService(converter=converter)
assert service._semaphore._value == 3
@pytest.mark.asyncio
async def test_semaphore_limits_parallel_jobs(self):
"""Only max_concurrent jobs should run in parallel; others must wait."""
call_order: list[str] = []
blocker = asyncio.Event()
converter = MagicMock()
service = AnalysisService(converter=converter, max_concurrent=1)
async def fake_inner(self, *args, **kwargs):
call_order.append("start")
await blocker.wait()
call_order.append("end")
with patch.object(AnalysisService, "_run_analysis_inner", fake_inner):
t1 = asyncio.create_task(service._run_analysis("j1", "/f", "f.pdf"))
t2 = asyncio.create_task(service._run_analysis("j2", "/f", "f.pdf"))
await asyncio.sleep(0.05)
# With max_concurrent=1, only one task should have started
assert call_order.count("start") == 1
blocker.set()
await asyncio.gather(t1, t2)
# Both should have completed
assert call_order.count("start") == 2
assert call_order.count("end") == 2