deer-flow/backend/tests/test_file_conversion_cancellation.py
EastonJiang 708f16766a
fix(uploads): offload document conversion for every file size (#6924)
* fix(uploads): offload document conversion for every file size

* test(uploads): clarify cancellation regression failures
2026-10-17 19:58:50 +08:00

87 lines
3.3 KiB
Python

"""A cancelled conversion must retain its source until its worker exits."""
from __future__ import annotations
import asyncio
import threading
import pytest
from deerflow.utils import file_conversion
@pytest.mark.asyncio
@pytest.mark.parametrize("file_size", [100, 1024 * 1024 + 1], ids=["small", "large"])
@pytest.mark.parametrize("conversion_fails", [False, True], ids=["success", "failure"])
async def test_cancelled_conversion_drains_worker_before_source_cleanup(tmp_path, monkeypatch, conversion_fails, file_size):
source = tmp_path / "document.pdf"
source.write_bytes(b"x" * file_size)
output = tmp_path / "converted.md"
started = threading.Event()
release = threading.Event()
finished = threading.Event()
source_read = threading.Event()
cleanup_before_finish = []
loop = asyncio.get_running_loop()
worker_started = asyncio.Event()
loop_thread = threading.get_ident()
worker_threads = []
def convert(path, _converter):
worker_threads.append(threading.get_ident())
started.set()
loop.call_soon_threadsafe(worker_started.set)
try:
# An inline regression must reach the test body's diagnostic
# instead of parking the event loop waiting for its own release.
if worker_threads[0] != loop_thread:
assert release.wait(timeout=10), "test did not release conversion worker"
# A converter may reopen/read the source throughout conversion.
assert path.read_bytes().startswith(b"x")
source_read.set()
if conversion_fails:
raise ValueError("synthetic converter failure")
return "# Converted document"
finally:
finished.set()
monkeypatch.setattr(file_conversion, "_get_pdf_converter", lambda: "auto")
monkeypatch.setattr(file_conversion, "_do_convert", convert)
async def ingest():
try:
return await file_conversion.convert_file_to_markdown(source, output_path=output)
finally:
# UploadIngestionSession removes its private source in a finally.
cleanup_before_finish.append(not finished.is_set())
source.unlink(missing_ok=True)
task = asyncio.create_task(ingest())
try:
await asyncio.wait_for(worker_started.wait(), timeout=10)
assert started.is_set()
assert len(worker_threads) == 1
assert worker_threads[0] != loop_thread, "converter must not block the event loop"
task.cancel()
# Yield scheduler turns, not a wall-clock delay, so cancellation reaches
# the suspended await while the worker is held by the release event.
await asyncio.sleep(0)
task.cancel()
await asyncio.sleep(0)
assert not cleanup_before_finish, "caller cleaned the source while the converter was still running"
assert not task.done()
release.set()
with pytest.raises(asyncio.CancelledError):
await task
assert await asyncio.to_thread(finished.wait, 10)
finally:
release.set()
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert cleanup_before_finish == [False]
assert source_read.is_set()
assert not source.exists()
assert not output.exists()