Merge pull request #449 from bd-mkt/bd_concurrency2
perf: move CPU-bound ingest work off the event loop
This commit is contained in:
commit
23f5b3c47a
11 changed files with 320 additions and 54 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
import asyncio
|
||||||
import re
|
import re
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
@ -99,9 +100,13 @@ class DoclingServeChunker(DocumentChunker):
|
||||||
else:
|
else:
|
||||||
endpoint = "/v1/chunk/hybrid/file/async"
|
endpoint = "/v1/chunk/hybrid/file/async"
|
||||||
|
|
||||||
# Export document to JSON
|
# Export document to JSON off the event loop. model_dump_json over a
|
||||||
doc_json = document.model_dump_json()
|
# document carrying inlined base64 page/picture images is CPU-heavy and
|
||||||
doc_bytes = doc_json.encode("utf-8")
|
# proportional to document size; running it inline would block every
|
||||||
|
# other worker's coroutine for the duration of the serialization.
|
||||||
|
doc_bytes = await asyncio.to_thread(
|
||||||
|
lambda: document.model_dump_json().encode("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
# Prepare multipart request with DoclingDocument JSON
|
# Prepare multipart request with DoclingDocument JSON
|
||||||
files = {"files": ("document.json", BytesIO(doc_bytes), "application/json")}
|
files = {"files": ("document.json", BytesIO(doc_bytes), "application/json")}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,43 @@ MAX_ATTACHMENT_DEPTH = 3
|
||||||
_RESERVED_METADATA_KEYS = frozenset({"content_type", "md5", "source_revision"})
|
_RESERVED_METADATA_KEYS = frozenset({"content_type", "md5", "source_revision"})
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_document_from_docling_sync(
|
||||||
|
document: Document, docling_document: "DoclingDocument"
|
||||||
|
) -> str:
|
||||||
|
"""Populate content/docling blobs from a DoclingDocument.
|
||||||
|
|
||||||
|
This performs size-proportional serialization, JSON splitting, and
|
||||||
|
compression via ``Document.set_docling``. Async ingestion paths should call
|
||||||
|
it through ``_prepare_document_from_docling`` so large image-bearing
|
||||||
|
documents do not block the event loop.
|
||||||
|
"""
|
||||||
|
content = docling_document.export_to_markdown()
|
||||||
|
document.content = content
|
||||||
|
document.set_docling(docling_document)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
async def _prepare_document_from_docling(
|
||||||
|
document: Document, docling_document: "DoclingDocument"
|
||||||
|
) -> str:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_prepare_document_from_docling_sync, document, docling_document
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_fetch_body_sync(body: bytes, suffix: str) -> Path:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="wb", suffix=suffix, delete=False
|
||||||
|
) as temp_file:
|
||||||
|
temp_file.write(body)
|
||||||
|
temp_file.flush()
|
||||||
|
return Path(temp_file.name)
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_fetch_body(body: bytes, suffix: str) -> Path:
|
||||||
|
return await asyncio.to_thread(_write_fetch_body_sync, body, suffix)
|
||||||
|
|
||||||
|
|
||||||
def parent_uri_filter(parent_uri: str) -> str:
|
def parent_uri_filter(parent_uri: str) -> str:
|
||||||
"""SQL `WHERE` clause matching documents whose ``metadata.parent_uri``
|
"""SQL `WHERE` clause matching documents whose ``metadata.parent_uri``
|
||||||
equals ``parent_uri``. ``metadata`` is stored as a JSON string produced by
|
equals ``parent_uri``. ``metadata`` is stored as a JSON string produced by
|
||||||
|
|
@ -184,18 +221,18 @@ async def create_document(
|
||||||
chunks = await client.chunk(docling_document)
|
chunks = await client.chunk(docling_document)
|
||||||
embedded_chunks = await embed_chunks(chunks, client.embedder, client._config)
|
embedded_chunks = await embed_chunks(chunks, client.embedder, client._config)
|
||||||
|
|
||||||
stored_content = docling_document.export_to_markdown()
|
|
||||||
|
|
||||||
if title is None:
|
|
||||||
title = await resolve_title(client._config, docling_document, stored_content)
|
|
||||||
|
|
||||||
document = Document(
|
document = Document(
|
||||||
content=stored_content,
|
content="",
|
||||||
uri=uri,
|
uri=uri,
|
||||||
title=title,
|
title=title,
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
)
|
)
|
||||||
document.set_docling(docling_document)
|
stored_content = await _prepare_document_from_docling(document, docling_document)
|
||||||
|
|
||||||
|
if title is None:
|
||||||
|
document.title = await resolve_title(
|
||||||
|
client._config, docling_document, stored_content
|
||||||
|
)
|
||||||
|
|
||||||
return await _store_document_with_chunks(
|
return await _store_document_with_chunks(
|
||||||
client, document, embedded_chunks, docling_document
|
client, document, embedded_chunks, docling_document
|
||||||
|
|
@ -215,17 +252,15 @@ async def import_document(
|
||||||
Use this when conversion, chunking, and embedding were done externally.
|
Use this when conversion, chunking, and embedding were done externally.
|
||||||
Chunks without embeddings will be automatically embedded.
|
Chunks without embeddings will be automatically embedded.
|
||||||
"""
|
"""
|
||||||
content = docling_document.export_to_markdown()
|
|
||||||
if title is None:
|
|
||||||
title = await resolve_title(client._config, docling_document, content)
|
|
||||||
|
|
||||||
document = Document(
|
document = Document(
|
||||||
content=content,
|
content="",
|
||||||
uri=uri,
|
uri=uri,
|
||||||
title=title,
|
title=title,
|
||||||
metadata=metadata or {},
|
metadata=metadata or {},
|
||||||
)
|
)
|
||||||
document.set_docling(docling_document)
|
content = await _prepare_document_from_docling(document, docling_document)
|
||||||
|
if title is None:
|
||||||
|
document.title = await resolve_title(client._config, docling_document, content)
|
||||||
|
|
||||||
return await _store_document_with_chunks(client, document, chunks, docling_document)
|
return await _store_document_with_chunks(client, document, chunks, docling_document)
|
||||||
|
|
||||||
|
|
@ -291,18 +326,17 @@ async def import_documents(
|
||||||
|
|
||||||
prepared: list[tuple[Document, list[Chunk], DoclingDocument]] = []
|
prepared: list[tuple[Document, list[Chunk], DoclingDocument]] = []
|
||||||
for item in imports:
|
for item in imports:
|
||||||
content = item.docling_document.export_to_markdown()
|
|
||||||
title = item.title
|
|
||||||
if title is None:
|
|
||||||
title = await resolve_title(client._config, item.docling_document, content)
|
|
||||||
|
|
||||||
document = Document(
|
document = Document(
|
||||||
content=content,
|
content="",
|
||||||
uri=item.uri,
|
uri=item.uri,
|
||||||
title=title,
|
title=item.title,
|
||||||
metadata=item.metadata or {},
|
metadata=item.metadata or {},
|
||||||
)
|
)
|
||||||
document.set_docling(item.docling_document)
|
content = await _prepare_document_from_docling(document, item.docling_document)
|
||||||
|
if document.title is None:
|
||||||
|
document.title = await resolve_title(
|
||||||
|
client._config, item.docling_document, content
|
||||||
|
)
|
||||||
prepared.append((document, item.chunks, item.docling_document))
|
prepared.append((document, item.chunks, item.docling_document))
|
||||||
|
|
||||||
return await _store_documents_with_chunks(client, prepared)
|
return await _store_documents_with_chunks(client, prepared)
|
||||||
|
|
@ -405,13 +439,8 @@ async def _ingest_fetch_result(
|
||||||
target_path = result.disk_path
|
target_path = result.disk_path
|
||||||
cleanup_path: Path | None = None
|
cleanup_path: Path | None = None
|
||||||
else:
|
else:
|
||||||
with tempfile.NamedTemporaryFile(
|
target_path = await _write_fetch_body(result.body, file_extension)
|
||||||
mode="wb", suffix=file_extension, delete=False
|
cleanup_path = target_path
|
||||||
) as temp_file:
|
|
||||||
temp_file.write(result.body)
|
|
||||||
temp_file.flush()
|
|
||||||
target_path = Path(temp_file.name)
|
|
||||||
cleanup_path = target_path
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with logfire.span("document.convert", uri=result.uri):
|
with logfire.span("document.convert", uri=result.uri):
|
||||||
|
|
@ -427,13 +456,13 @@ async def _ingest_fetch_result(
|
||||||
if cleanup_path is not None:
|
if cleanup_path is not None:
|
||||||
cleanup_path.unlink(missing_ok=True)
|
cleanup_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
stored_content = docling_document.export_to_markdown()
|
|
||||||
final_metadata = {**user_metadata, **source_metadata}
|
final_metadata = {**user_metadata, **source_metadata}
|
||||||
|
|
||||||
if existing_doc:
|
if existing_doc:
|
||||||
existing_doc.content = stored_content
|
|
||||||
existing_doc.metadata = final_metadata
|
existing_doc.metadata = final_metadata
|
||||||
existing_doc.set_docling(docling_document)
|
stored_content = await _prepare_document_from_docling(
|
||||||
|
existing_doc, docling_document
|
||||||
|
)
|
||||||
if title is not None:
|
if title is not None:
|
||||||
existing_doc.title = title
|
existing_doc.title = title
|
||||||
elif existing_doc.title is None:
|
elif existing_doc.title is None:
|
||||||
|
|
@ -448,15 +477,17 @@ async def _ingest_fetch_result(
|
||||||
await _reconcile_pdf_attachments(client, updated, result.body, depth=depth)
|
await _reconcile_pdf_attachments(client, updated, result.body, depth=depth)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
if title is None:
|
|
||||||
title = await resolve_title(client._config, docling_document, stored_content)
|
|
||||||
document = Document(
|
document = Document(
|
||||||
content=stored_content,
|
content="",
|
||||||
uri=stored_uri,
|
uri=stored_uri,
|
||||||
title=title,
|
title=title,
|
||||||
metadata=final_metadata,
|
metadata=final_metadata,
|
||||||
)
|
)
|
||||||
document.set_docling(docling_document)
|
stored_content = await _prepare_document_from_docling(document, docling_document)
|
||||||
|
if document.title is None:
|
||||||
|
document.title = await resolve_title(
|
||||||
|
client._config, docling_document, stored_content
|
||||||
|
)
|
||||||
with logfire.span("document.store", uri=result.uri, op="create") as store_span:
|
with logfire.span("document.store", uri=result.uri, op="create") as store_span:
|
||||||
created = await _store_document_with_chunks(
|
created = await _store_document_with_chunks(
|
||||||
client, document, embedded_chunks, docling_document
|
client, document, embedded_chunks, docling_document
|
||||||
|
|
@ -807,8 +838,7 @@ async def update_document(
|
||||||
|
|
||||||
if chunks is not None:
|
if chunks is not None:
|
||||||
if docling_document is not None:
|
if docling_document is not None:
|
||||||
existing_doc.content = docling_document.export_to_markdown()
|
await _prepare_document_from_docling(existing_doc, docling_document)
|
||||||
existing_doc.set_docling(docling_document)
|
|
||||||
elif content is not None:
|
elif content is not None:
|
||||||
existing_doc.content = content
|
existing_doc.content = content
|
||||||
|
|
||||||
|
|
@ -817,8 +847,7 @@ async def update_document(
|
||||||
)
|
)
|
||||||
|
|
||||||
if docling_document is not None:
|
if docling_document is not None:
|
||||||
existing_doc.content = docling_document.export_to_markdown()
|
await _prepare_document_from_docling(existing_doc, docling_document)
|
||||||
existing_doc.set_docling(docling_document)
|
|
||||||
|
|
||||||
new_chunks = await client.chunk(docling_document)
|
new_chunks = await client.chunk(docling_document)
|
||||||
embedded_chunks = await embed_chunks(
|
embedded_chunks = await embed_chunks(
|
||||||
|
|
@ -832,7 +861,7 @@ async def update_document(
|
||||||
existing_doc.content = content
|
existing_doc.content = content
|
||||||
converter = get_converter(client._config)
|
converter = get_converter(client._config)
|
||||||
converted_docling = await converter.convert_text(existing_doc.content, format="md")
|
converted_docling = await converter.convert_text(existing_doc.content, format="md")
|
||||||
existing_doc.set_docling(converted_docling)
|
await _prepare_document_from_docling(existing_doc, converted_docling)
|
||||||
|
|
||||||
new_chunks = await client.chunk(converted_docling)
|
new_chunks = await client.chunk(converted_docling)
|
||||||
embedded_chunks = await embed_chunks(new_chunks, client.embedder, client._config)
|
embedded_chunks = await embed_chunks(new_chunks, client.embedder, client._config)
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,11 @@ class DoclingServeConverter(DocumentConverter):
|
||||||
data=data,
|
data=data,
|
||||||
name=name,
|
name=name,
|
||||||
)
|
)
|
||||||
return self._parse_zip_to_docling(zip_bytes, name)
|
# Parse off the event loop: the zip decompress, per-image base64
|
||||||
|
# re-encoding, and DoclingDocument.model_validate are all synchronous
|
||||||
|
# and CPU-heavy (full-resolution page rasters when generate_page_images
|
||||||
|
# is on), so running inline would stall every other worker's coroutine.
|
||||||
|
return await asyncio.to_thread(self._parse_zip_to_docling, zip_bytes, name)
|
||||||
|
|
||||||
async def convert_file(
|
async def convert_file(
|
||||||
self, path: Path, source_uri: str | None = None
|
self, path: Path, source_uri: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -136,4 +136,7 @@ async def convert_pdf_with_splitting(
|
||||||
# Off the event loop because the close path acquires the lock.
|
# Off the event loop because the close path acquires the lock.
|
||||||
await asyncio.to_thread(it.close)
|
await asyncio.to_thread(it.close)
|
||||||
|
|
||||||
return DoclingDocument.concatenate(converted)
|
# Merge off the event loop: concatenating slice documents that carry
|
||||||
|
# inlined base64 page/picture images is CPU-heavy and proportional to the
|
||||||
|
# total document size, so running it inline would block other coroutines.
|
||||||
|
return await asyncio.to_thread(DoclingDocument.concatenate, converted)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
|
@ -87,23 +88,33 @@ class FSSource:
|
||||||
return None
|
return None
|
||||||
return str(path.stat().st_mtime_ns)
|
return str(path.stat().st_mtime_ns)
|
||||||
|
|
||||||
|
def _read_body(self, path: Path, uri: str) -> tuple[bytes, str, str]:
|
||||||
|
"""Size-check, read, and hash the file. Runs in a worker thread (see
|
||||||
|
``fetch``) because the read and the md5 are both proportional to file
|
||||||
|
size and would otherwise block the event loop for the whole read."""
|
||||||
|
check_file_size(path.stat().st_size, self._max_file_size, uri)
|
||||||
|
body = path.read_bytes()
|
||||||
|
content_hash = hashlib.md5(body, usedforsecurity=False).hexdigest()
|
||||||
|
# mtime_ns rather than st_mtime: nanosecond integer avoids float
|
||||||
|
# precision collisions on rapid edits.
|
||||||
|
revision = str(path.stat().st_mtime_ns)
|
||||||
|
return body, content_hash, revision
|
||||||
|
|
||||||
async def fetch(self, uri: str) -> FetchResult:
|
async def fetch(self, uri: str) -> FetchResult:
|
||||||
path = self._resolve_within_root(uri)
|
path = self._resolve_within_root(uri)
|
||||||
if path is None:
|
if path is None:
|
||||||
raise UnsupportedSourceError(f"Path escapes FS root ({self.root}): {uri}")
|
raise UnsupportedSourceError(f"Path escapes FS root ({self.root}): {uri}")
|
||||||
check_file_size(path.stat().st_size, self._max_file_size, uri)
|
body, content_hash, revision = await asyncio.to_thread(
|
||||||
body = path.read_bytes()
|
self._read_body, path, uri
|
||||||
|
)
|
||||||
content_type, _ = mimetypes.guess_type(path.name)
|
content_type, _ = mimetypes.guess_type(path.name)
|
||||||
if content_type is None:
|
if content_type is None:
|
||||||
content_type = "application/octet-stream"
|
content_type = "application/octet-stream"
|
||||||
# mtime_ns rather than st_mtime: nanosecond integer avoids float
|
|
||||||
# precision collisions on rapid edits.
|
|
||||||
revision = str(path.stat().st_mtime_ns)
|
|
||||||
return FetchResult(
|
return FetchResult(
|
||||||
uri=path.as_uri(),
|
uri=path.as_uri(),
|
||||||
body=body,
|
body=body,
|
||||||
content_type=content_type,
|
content_type=content_type,
|
||||||
content_hash=hashlib.md5(body, usedforsecurity=False).hexdigest(),
|
content_hash=content_hash,
|
||||||
revision=revision,
|
revision=revision,
|
||||||
disk_path=path,
|
disk_path=path,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -300,3 +300,33 @@ async def test_fs_source_fetch_no_limit_when_max_size_is_none(fs_root: Path):
|
||||||
src = FSSource(root=fs_root, max_file_size=None)
|
src = FSSource(root=fs_root, max_file_size=None)
|
||||||
result = await src.fetch((fs_root / "a.md").as_uri())
|
result = await src.fetch((fs_root / "a.md").as_uri())
|
||||||
assert result.body == b"alpha"
|
assert result.body == b"alpha"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fs_source_fetch_reads_off_event_loop_thread(fs_root: Path):
|
||||||
|
"""The file read and md5 are both proportional to file size and must run
|
||||||
|
off the event-loop thread, or a large file would freeze every other
|
||||||
|
worker's coroutine for the duration of the read. Capture the thread the
|
||||||
|
read+hash runs on and assert it is not the event-loop thread."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
src = FSSource(root=fs_root)
|
||||||
|
target = fs_root / "a.md"
|
||||||
|
|
||||||
|
event_loop_thread = threading.current_thread()
|
||||||
|
called_from: list[threading.Thread] = []
|
||||||
|
original = src._read_body
|
||||||
|
|
||||||
|
def spy(path, uri):
|
||||||
|
called_from.append(threading.current_thread())
|
||||||
|
return original(path, uri)
|
||||||
|
|
||||||
|
src._read_body = spy # type: ignore[method-assign] # ty: ignore[invalid-assignment]
|
||||||
|
|
||||||
|
result = await src.fetch(target.as_uri())
|
||||||
|
assert result.body == b"alpha"
|
||||||
|
assert called_from, "_read_body was never called"
|
||||||
|
assert called_from[0] is not event_loop_thread, (
|
||||||
|
"FSSource._read_body ran on the event-loop thread; the read+hash must "
|
||||||
|
"be dispatched via asyncio.to_thread"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -609,6 +609,45 @@ This is content.
|
||||||
assert meta1.headings == ["Chapter 1", "Section 1.1"]
|
assert meta1.headings == ["Chapter 1", "Section 1.1"]
|
||||||
assert meta1.page_numbers == [1, 2]
|
assert meta1.page_numbers == [1, 2]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("haiku.rag.providers.docling_serve.httpx.AsyncClient")
|
||||||
|
async def test_chunk_serializes_document_off_event_loop_thread(
|
||||||
|
self, mock_client_class, chunker
|
||||||
|
):
|
||||||
|
"""model_dump_json over a document carrying inlined base64 page/picture
|
||||||
|
images is CPU-heavy and proportional to document size; it must run off
|
||||||
|
the event-loop thread or it stalls every other worker's coroutine.
|
||||||
|
|
||||||
|
A minimal fake document records the thread its model_dump_json runs on;
|
||||||
|
the API response carries no doc_items so the document is touched only
|
||||||
|
for serialization."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
result_data = {"chunks": [{"text": "Chunk 1", "chunk_index": 0}]}
|
||||||
|
submit_resp, poll_resp, result_resp = create_async_workflow_mocks(result_data)
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=submit_resp)
|
||||||
|
mock_client.get = AsyncMock(side_effect=[poll_resp, result_resp])
|
||||||
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||||
|
|
||||||
|
event_loop_thread = threading.current_thread()
|
||||||
|
called_from: list[threading.Thread] = []
|
||||||
|
|
||||||
|
class FakeDoc:
|
||||||
|
def model_dump_json(self):
|
||||||
|
called_from.append(threading.current_thread())
|
||||||
|
return "{}"
|
||||||
|
|
||||||
|
chunks = await chunker.chunk(FakeDoc())
|
||||||
|
|
||||||
|
assert len(chunks) == 1
|
||||||
|
assert called_from, "model_dump_json was never called"
|
||||||
|
assert called_from[0] is not event_loop_thread, (
|
||||||
|
"DoclingDocument.model_dump_json ran on the event-loop thread; it "
|
||||||
|
"must be dispatched via asyncio.to_thread"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
from docling_core.types.doc.labels import DocItemLabel
|
||||||
|
|
||||||
from haiku.rag.client import HaikuRAG
|
from haiku.rag.client import HaikuRAG
|
||||||
from haiku.rag.client.documents import DocumentImport
|
from haiku.rag.client.documents import (
|
||||||
|
DocumentImport,
|
||||||
|
_prepare_document_from_docling,
|
||||||
|
_write_fetch_body,
|
||||||
|
)
|
||||||
from haiku.rag.config import Config
|
from haiku.rag.config import Config
|
||||||
from haiku.rag.store.compression import decompress_json
|
from haiku.rag.store.compression import decompress_json
|
||||||
from haiku.rag.store.models.chunk import Chunk
|
from haiku.rag.store.models.chunk import Chunk
|
||||||
|
|
@ -19,6 +26,63 @@ def vcr_cassette_dir():
|
||||||
return str(Path(__file__).parent / "cassettes" / "test_client")
|
return str(Path(__file__).parent / "cassettes" / "test_client")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prepare_document_from_docling_runs_off_event_loop_thread(monkeypatch):
|
||||||
|
import haiku.rag.client.documents as documents
|
||||||
|
|
||||||
|
event_loop_thread = threading.current_thread()
|
||||||
|
called_from: list[threading.Thread] = []
|
||||||
|
|
||||||
|
docling_doc = DoclingDocument(name="thread-check")
|
||||||
|
docling_doc.add_text(label=DocItemLabel.TEXT, text="Threaded content")
|
||||||
|
document = Document(content="")
|
||||||
|
original = documents._prepare_document_from_docling_sync
|
||||||
|
|
||||||
|
def spy(doc, docling):
|
||||||
|
called_from.append(threading.current_thread())
|
||||||
|
return original(doc, docling)
|
||||||
|
|
||||||
|
monkeypatch.setattr(documents, "_prepare_document_from_docling_sync", spy)
|
||||||
|
|
||||||
|
content = await _prepare_document_from_docling(document, docling_doc)
|
||||||
|
|
||||||
|
assert content == "Threaded content"
|
||||||
|
assert document.content == "Threaded content"
|
||||||
|
assert document.docling_document is not None
|
||||||
|
assert called_from, "Document.set_docling was never called"
|
||||||
|
assert called_from[0] is not event_loop_thread, (
|
||||||
|
"Document.set_docling ran on the event-loop thread; document prep must "
|
||||||
|
"be dispatched via asyncio.to_thread"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_write_fetch_body_runs_off_event_loop_thread(monkeypatch):
|
||||||
|
import haiku.rag.client.documents as documents
|
||||||
|
|
||||||
|
event_loop_thread = threading.current_thread()
|
||||||
|
called_from: list[threading.Thread] = []
|
||||||
|
original = documents._write_fetch_body_sync
|
||||||
|
|
||||||
|
def spy(body, suffix):
|
||||||
|
called_from.append(threading.current_thread())
|
||||||
|
return original(body, suffix)
|
||||||
|
|
||||||
|
monkeypatch.setattr(documents, "_write_fetch_body_sync", spy)
|
||||||
|
|
||||||
|
path = await _write_fetch_body(b"payload", ".bin")
|
||||||
|
try:
|
||||||
|
assert path.read_bytes() == b"payload"
|
||||||
|
finally:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
assert called_from, "_write_fetch_body_sync was never called"
|
||||||
|
assert called_from[0] is not event_loop_thread, (
|
||||||
|
"_write_fetch_body_sync ran on the event-loop thread; fetched body "
|
||||||
|
"writes must be dispatched via asyncio.to_thread"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.vcr()
|
@pytest.mark.vcr()
|
||||||
async def test_client_document_crud(qa_corpus: list[dict[str, str]], temp_db_path):
|
async def test_client_document_crud(qa_corpus: list[dict[str, str]], temp_db_path):
|
||||||
"""Test HaikuRAG CRUD operations for documents."""
|
"""Test HaikuRAG CRUD operations for documents."""
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,43 @@ def create_async_workflow_zip_mocks(
|
||||||
return submit_response, poll_response, result_response
|
return submit_response, poll_response, result_response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_zip_runs_off_event_loop_thread():
|
||||||
|
"""_parse_zip_to_docling does zip decompress, per-image base64 re-encoding,
|
||||||
|
and DoclingDocument.model_validate — all synchronous and CPU-heavy (full-
|
||||||
|
resolution page rasters when generate_page_images is on). It must run off
|
||||||
|
the event-loop thread, or it stalls every other worker's coroutine. Capture
|
||||||
|
the thread it runs on and assert it is not the event-loop thread."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
config = AppConfig()
|
||||||
|
config.processing.converter = "docling-serve"
|
||||||
|
converter = get_converter(config)
|
||||||
|
assert isinstance(converter, DoclingServeConverter)
|
||||||
|
|
||||||
|
converter.client.submit_and_poll_zip = AsyncMock( # ty: ignore[invalid-assignment]
|
||||||
|
return_value=b"zip-bytes"
|
||||||
|
)
|
||||||
|
|
||||||
|
event_loop_thread = threading.current_thread()
|
||||||
|
called_from: list[threading.Thread] = []
|
||||||
|
|
||||||
|
def spy(zip_bytes, name):
|
||||||
|
called_from.append(threading.current_thread())
|
||||||
|
return Mock()
|
||||||
|
|
||||||
|
converter._parse_zip_to_docling = spy # type: ignore[method-assign] # ty: ignore[invalid-assignment]
|
||||||
|
|
||||||
|
files = {"files": ("doc.pdf", b"pdf", "application/octet-stream")}
|
||||||
|
await converter._make_request(files, "doc.pdf")
|
||||||
|
|
||||||
|
assert called_from, "_parse_zip_to_docling was never called"
|
||||||
|
assert called_from[0] is not event_loop_thread, (
|
||||||
|
"_parse_zip_to_docling ran on the event-loop thread; it must be "
|
||||||
|
"dispatched via asyncio.to_thread"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestTextFileHandler:
|
class TestTextFileHandler:
|
||||||
"""Tests for TextFileHandler utility class."""
|
"""Tests for TextFileHandler utility class."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -494,7 +494,8 @@ async def test_extract_pdf_attachments_called_off_event_loop_thread(
|
||||||
duration of pdfium I/O, stalling every other concurrent worker.
|
duration of pdfium I/O, stalling every other concurrent worker.
|
||||||
|
|
||||||
We verify this by capturing the thread identity inside a spy wrapper: if
|
We verify this by capturing the thread identity inside a spy wrapper: if
|
||||||
asyncio.to_thread is used correctly the spy runs on a non-main thread."""
|
asyncio.to_thread is used correctly the spy runs off the event-loop thread."""
|
||||||
|
event_loop_thread = threading.current_thread()
|
||||||
called_from: list[threading.Thread] = []
|
called_from: list[threading.Thread] = []
|
||||||
|
|
||||||
def spy(body, uri, *, depth):
|
def spy(body, uri, *, depth):
|
||||||
|
|
@ -514,7 +515,7 @@ async def test_extract_pdf_attachments_called_off_event_loop_thread(
|
||||||
await _reconcile_pdf_attachments(client, parent, pdf_bytes, depth=0)
|
await _reconcile_pdf_attachments(client, parent, pdf_bytes, depth=0)
|
||||||
|
|
||||||
assert called_from, "_extract_pdf_attachments was never called"
|
assert called_from, "_extract_pdf_attachments was never called"
|
||||||
assert called_from[0] is not threading.main_thread(), (
|
assert called_from[0] is not event_loop_thread, (
|
||||||
"_extract_pdf_attachments ran on the event-loop thread; "
|
"_extract_pdf_attachments ran on the event-loop thread; "
|
||||||
"it must be dispatched via asyncio.to_thread to avoid blocking the loop"
|
"it must be dispatched via asyncio.to_thread to avoid blocking the loop"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,49 @@ async def test_convert_aborts_and_cleans_up_on_mid_stream_slice_failure(
|
||||||
assert len(calls) == 2
|
assert len(calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_concatenate_runs_off_event_loop_thread(tmp_path, monkeypatch):
|
||||||
|
"""DoclingDocument.concatenate merges slice documents that carry inlined
|
||||||
|
base64 page/picture images — CPU-heavy and proportional to total document
|
||||||
|
size. It must run off the event-loop thread so it doesn't stall other
|
||||||
|
workers' coroutines. Capture the thread it runs on and assert it is not the
|
||||||
|
event-loop thread."""
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
|
||||||
|
src = _make_pdf(4, tmp_path)
|
||||||
|
|
||||||
|
class _Converter:
|
||||||
|
async def convert_file(self, path: Path, *, source_uri):
|
||||||
|
return DoclingDocument(name="slice")
|
||||||
|
|
||||||
|
event_loop_thread = threading.current_thread()
|
||||||
|
called_from: list[threading.Thread] = []
|
||||||
|
|
||||||
|
def spy(docs):
|
||||||
|
called_from.append(threading.current_thread())
|
||||||
|
# Return a slice doc rather than exercising the real concatenate —
|
||||||
|
# this test only asserts the dispatch thread, not merge correctness
|
||||||
|
# (covered by test_concatenate_shifts_page_nos_and_unique_self_refs).
|
||||||
|
return docs[0]
|
||||||
|
|
||||||
|
monkeypatch.setattr(DoclingDocument, "concatenate", staticmethod(spy))
|
||||||
|
|
||||||
|
await convert_pdf_with_splitting(
|
||||||
|
_Converter(), # ty: ignore[invalid-argument-type]
|
||||||
|
src,
|
||||||
|
source_uri=None,
|
||||||
|
slice_size=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert called_from, "concatenate was never called"
|
||||||
|
assert called_from[0] is not event_loop_thread, (
|
||||||
|
"DoclingDocument.concatenate ran on the event-loop thread; it must be "
|
||||||
|
"dispatched via asyncio.to_thread"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_concatenate_shifts_page_nos_and_unique_self_refs():
|
def test_concatenate_shifts_page_nos_and_unique_self_refs():
|
||||||
"""Pins the docling-core contract we rely on: when two docs (each with
|
"""Pins the docling-core contract we rely on: when two docs (each with
|
||||||
items on page 1) are concatenated, the second doc's items move to page 2
|
items on page 1) are concatenated, the second doc's items move to page 2
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue