fix: isolate PDF parse in a subprocess so a bad file can't OOM the pod

The document processor crash-looped on one pathological PDF: pymupdf4llm's
table/graphics detection over a page with ~1M vector path items ballooned past
the 2 GiB pod limit. The parse ran in a thread, so nothing could interrupt or
memory-bound it -- a single bad file OOM-killed the whole pod.

Run the parse in an isolated worker subprocess (anyio.to_process, cancellable)
with an RLIMIT_AS memory cap and a wall-clock timeout, so a pathological file
fails THAT document instead of the pod (new document_processors/_isolation.py).
Also pass graphics_limit (default 5000) to to_markdown -- validated to cut the
known trigger page from 112 s to 23 s with bounded memory.

On a permanent parse failure the processor returns success=False (instead of
raising, which would retry 3x); vector/processor.py marks the placeholder
"failed" and skips indexing, and the scanner stops re-queuing failed placeholders
until the file changes -- so a doomed file no longer churns.

New per-tenant (per-pod env) settings: DOCUMENT_PDF_GRAPHICS_LIMIT,
DOCUMENT_PARSE_TIMEOUT_SECONDS, DOCUMENT_PARSE_MEM_LIMIT_MB. New metric
astrolabe_document_parse_failed_total{reason=timeout|oom|error} surfaces hard
failures that previously killed the process before any except ran.

First PR of the tiered document-processor effort (Deck #199); tier 0/1/3
pipeline tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-04 22:32:34 +02:00
co-authored by Claude Opus 4.8
parent 09e84783e5
commit 7db8d3e301
7 changed files with 450 additions and 19 deletions
@@ -8,13 +8,17 @@ from typing import Any, Optional
import anyio
# NOTE: Do NOT call pymupdf.layout.activate() here!
# It changes the behavior of pymupdf4llm.to_markdown() when page_chunks=True,
# causing it to return a string instead of a list[dict].
# pymupdf is used here only for the cheap metadata open. The heavy
# pymupdf4llm.to_markdown extraction runs in an isolated worker subprocess
# (see _isolation.py) so a pathological file can't OOM the pod.
# NOTE: Do NOT call pymupdf.layout.activate()! It changes the behavior of
# pymupdf4llm.to_markdown() when page_chunks=True (returns str, not list[dict]).
# See: https://github.com/pymupdf/pymupdf4llm/issues/323
import pymupdf
import pymupdf4llm
from nextcloud_mcp_server.config import get_settings
from ._isolation import PdfParseFailed, run_isolated_pdf_parse
from .base import DocumentProcessor, ProcessingResult, ProcessorError
logger = logging.getLogger(__name__)
@@ -122,19 +126,44 @@ class PyMuPDFProcessor(DocumentProcessor):
pdf_image_dir = self.image_dir / pdf_id
pdf_image_dir.mkdir(exist_ok=True, parents=True)
# Extract all pages in a single call with page_chunks=True
def do_extract() -> list[dict[str, Any]]:
# When page_chunks=True, to_markdown returns list[dict] not str
return pymupdf4llm.to_markdown( # type: ignore[return-value]
doc,
# Extract all pages (page_chunks=True) in an isolated worker
# subprocess with a memory rlimit + wall-clock timeout, so a
# pathological file (e.g. a page with ~1M vector paths that drives
# table detection past the pod memory limit) fails THIS document
# instead of OOM-killing the pod. graphics_limit caps per-page
# vector-graphics analysis (the known trigger).
settings = get_settings()
try:
page_chunks: list[dict[str, Any]] = await run_isolated_pdf_parse(
content,
write_images=self.extract_images,
image_path=pdf_image_dir if self.extract_images else None,
page_chunks=True,
graphics_limit=settings.document_pdf_graphics_limit,
timeout_seconds=settings.document_parse_timeout_seconds,
mem_limit_mb=settings.document_parse_mem_limit_mb,
)
except PdfParseFailed as exc:
doc.close()
logger.warning(
"Isolated PDF parse failed for %s (reason=%s): %s",
filename or "<bytes>",
exc.reason,
exc,
extra={
"processor": self.name,
"tier": self.tier,
"status": "error",
"reason": exc.reason,
},
)
metadata["parse_failed_reason"] = exc.reason
return ProcessingResult(
text="",
metadata=metadata,
processor=self.name,
success=False,
error=f"isolated parse failed ({exc.reason}): {exc}",
)
page_chunks: list[dict[str, Any]] = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
do_extract
)
if progress_callback:
await progress_callback(90, 100, "Building result")