Skip to content

Default PDF parser holds the whole document in memory (2 copies on the #640 recovery path); Indexer actors have no max_restarts #846

Description

@andyne13

Summary

The default PDF parser holds the entire document in memory, and on one recovery path holds two full copies at once. The Indexer actors that run it have no restart policy, so a worker that OOMs is not recovered the way Marker/Docling workers are.

This is the memory-residency half of a third-party audit finding. The admission control half is already tracked in #663 — this issue is deliberately scoped to per-file residency and worker recovery.

1. The default PDF parser is fully resident

openrag/core/indexing/parsers/pdf/pymupdf.py opens from bytes, not a path:

def _extract_markdown(raw: bytes, filename: str):
    with pymupdf.open(stream=raw, filetype="pdf") as doc:      # whole file resident
        try:
            chunks = _to_markdown(doc)
        except RuntimeError:
            cleaned = doc.tobytes(garbage=4, clean=True)       # second full copy
            with pymupdf.open(stream=cleaned, filetype="pdf") as clean_doc:
                chunks = _to_markdown(clean_doc)

raw and cleaned are both live inside the except block, so peak usage is roughly double — on the malformed-document recovery path added in #640, which is exactly where unusual (often large) documents end up.

pymupdf4llm.to_markdown(doc, page_chunks=True, ...) chunks the output per page; the document is already fully loaded by then.

This is the shipped default in all three places: conf/config.yaml:202 (pdf: PyMuPDFLoader), infra/compose/.env.example:46, and infra/charts/openrag-stack/values.yaml:666. So a default chart or compose deployment is on this path.

Marker is not affected — it splits into page ranges (marker_workers.py:293-298) and passes a path plus page_range to each worker (marker_workers.py:176-191), so its memory is bounded per batch. Anyone who has switched PDFLOADER=MarkerLoader is not exposed here.

2. Indexer actors have no restart policy

indexer_pool.py:41 and :355 declare bare @ray.remote, with no max_restarts. Marker and Docling deliberately set it:

  • marker_workers.py:82 and docling_workers.py:85,97@ray.remote(max_restarts=5), with comments explaining that a worker which OOMs or CUDA-faults must come back.

So the tier most likely to OOM on a large PDF is the one with no recovery configured, while the GPU tiers have it.

3. .doc conversion reads the converted file whole

core/indexing/parsers/doc_parser.py:87return Path(out_path).read_bytes(), None after the Spire.Doc .doc.docx conversion. Smaller in practice, but same class of issue.

Suggested direction

Found by

An independent third-party audit of OpenRag; verified against v2.1.0. The audit stated this more broadly ("the indexing worker loads the complete file into memory"); narrowed here to what reproduces — most parsers do work from a path, and _sha256_file correctly streams in 1 MB chunks.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions