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:
co-authored by
Claude Opus 4.8
parent
09e84783e5
commit
7db8d3e301
@@ -0,0 +1,126 @@
|
||||
"""Subprocess isolation for PDF parsing.
|
||||
|
||||
PDF text/markdown extraction (pymupdf + pymupdf4llm) is CPU-bound C code that can
|
||||
balloon memory or hang on a pathological document -- e.g. a page with ~1M vector
|
||||
path items drives pymupdf4llm's table detection past the pod memory limit and
|
||||
OOM-kills the whole process. Running the parse in a worker *subprocess* (via
|
||||
``anyio.to_process``) with an address-space rlimit and a wall-clock timeout means
|
||||
one bad file fails *that document*, not the pod: an rlimit breach raises
|
||||
``MemoryError`` in the worker, a hang is killed when the timeout cancels the call.
|
||||
|
||||
The worker function is module-level (picklable) so ``anyio.to_process`` can run it
|
||||
in its process pool.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import resource
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import anyio.to_process
|
||||
from anyio import BrokenWorkerProcess
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Guard so the address-space limit is applied once per (reused) worker process.
|
||||
_MEM_LIMIT_APPLIED = False
|
||||
|
||||
|
||||
class PdfParseFailed(Exception):
|
||||
"""A PDF parse failed in the isolated worker.
|
||||
|
||||
``reason`` is one of ``timeout`` | ``oom`` | ``error`` and maps directly to
|
||||
the ``astrolabe_document_parse_failed_total{reason}`` metric label.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str, message: str | None = None) -> None:
|
||||
self.reason = reason
|
||||
super().__init__(message or f"PDF parse failed ({reason})")
|
||||
|
||||
|
||||
def _apply_mem_limit(mem_limit_mb: int) -> None:
|
||||
"""Cap the worker's address space (RLIMIT_AS) so a bomb raises MemoryError.
|
||||
|
||||
Applied once per worker process. The hard limit is left untouched (we only
|
||||
lower the soft limit), and we never set a soft limit above the hard limit.
|
||||
"""
|
||||
global _MEM_LIMIT_APPLIED
|
||||
if _MEM_LIMIT_APPLIED or mem_limit_mb <= 0:
|
||||
return
|
||||
target = mem_limit_mb * 1024 * 1024
|
||||
soft, hard = resource.getrlimit(resource.RLIMIT_AS)
|
||||
soft_target = target if hard == resource.RLIM_INFINITY else min(target, hard)
|
||||
resource.setrlimit(resource.RLIMIT_AS, (soft_target, hard))
|
||||
_MEM_LIMIT_APPLIED = True
|
||||
|
||||
|
||||
def _parse_pdf_worker(
|
||||
content: bytes,
|
||||
write_images: bool,
|
||||
image_path: str | None,
|
||||
graphics_limit: int,
|
||||
mem_limit_mb: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Run pymupdf4llm.to_markdown in the worker subprocess (positional args only).
|
||||
|
||||
Returns the ``page_chunks`` list (picklable dicts of text + metadata). Imports
|
||||
pymupdf lazily so the parent process isn't forced to load them here.
|
||||
"""
|
||||
_apply_mem_limit(mem_limit_mb)
|
||||
|
||||
# Imported inside the worker so the parent process (and any module that
|
||||
# imports this one) doesn't load pymupdf4llm -- which prints a banner to
|
||||
# stdout on import, the channel anyio's worker uses for IPC.
|
||||
import pymupdf # noqa: PLC0415
|
||||
import pymupdf4llm # noqa: PLC0415
|
||||
|
||||
doc = pymupdf.open("pdf", content)
|
||||
try:
|
||||
# page_chunks=True makes to_markdown return list[dict], not str.
|
||||
return pymupdf4llm.to_markdown( # type: ignore[return-value]
|
||||
doc,
|
||||
write_images=write_images,
|
||||
image_path=image_path if write_images else None,
|
||||
page_chunks=True,
|
||||
graphics_limit=graphics_limit,
|
||||
)
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
|
||||
async def run_isolated_pdf_parse(
|
||||
content: bytes,
|
||||
*,
|
||||
write_images: bool,
|
||||
image_path: Path | None,
|
||||
graphics_limit: int,
|
||||
timeout_seconds: float,
|
||||
mem_limit_mb: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Parse a PDF in an isolated worker subprocess with a memory cap and timeout.
|
||||
|
||||
Raises ``PdfParseFailed`` (reason ``timeout`` | ``oom`` | ``error``) instead of
|
||||
taking the pod down. On timeout the worker process is killed (``cancellable``).
|
||||
"""
|
||||
with anyio.move_on_after(timeout_seconds):
|
||||
try:
|
||||
return await anyio.to_process.run_sync(
|
||||
_parse_pdf_worker,
|
||||
content,
|
||||
write_images,
|
||||
str(image_path) if image_path is not None else None,
|
||||
graphics_limit,
|
||||
mem_limit_mb,
|
||||
cancellable=True,
|
||||
)
|
||||
except MemoryError as e:
|
||||
raise PdfParseFailed("oom", str(e)) from e
|
||||
except BrokenWorkerProcess as e:
|
||||
# Worker died without a clean exception (e.g. SIGKILL from the OS OOM
|
||||
# killer beating the rlimit). Treat as an out-of-memory failure.
|
||||
raise PdfParseFailed("oom", str(e)) from e
|
||||
except Exception as e:
|
||||
raise PdfParseFailed("error", f"{type(e).__name__}: {e}") from e
|
||||
# Reached only when move_on_after swallowed the timeout cancellation.
|
||||
raise PdfParseFailed("timeout", f"parse exceeded {timeout_seconds}s")
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user