Skip to content

fix(autoindex): parse PDFs in isolated workers, and use pdf_oxide for more complex PDF parsing - #20

Merged
xerj-org merged 2 commits into
xerj-org:mainfrom
probelabs:fix/pdf-unicode-worker
Jul 25, 2026
Merged

fix(autoindex): parse PDFs in isolated workers, and use pdf_oxide for more complex PDF parsing#20
xerj-org merged 2 commits into
xerj-org:mainfrom
probelabs:fix/pdf-unicode-worker

Conversation

@buger

@buger buger commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Replace autoindex's raw PDF stream scanner with pdf_oxide 0.3.75 and run each PDF in a fresh same-binary worker process.

The old extractor searched compressed PDF streams and interpreted font character codes without resolving the object graph, page resources, font encodings, or /ToUnicode mappings. On real annual reports this silently produced NUL-separated strings, shifted glyphs, and binary-looking content. Downstream inference then correctly refused to elect body as semantic_text.

This change makes PDF extraction fail closed: parsed Unicode page text is retrieval-sized with page/section provenance, suspicious output is rejected, partial parser failures do not create partial indices, and image-only or damaged/encrypted inputs receive actionable repair/decrypt/OCR guidance. There is no raw-byte fallback.

User-facing behavior

xerj autoindex <folder> now launches a fresh hidden xerj __extract-pdf worker for each PDF. The worker returns a versioned JSON response and the parent validates both xerj-autoindex and pdf_oxide provenance.

Every accepted PDF record includes:

  • one-based page;
  • stable locator pN-sN;
  • pdf_pages_total;
  • pdf_pages_with_text;
  • pdf_pages_omitted;
  • pdf_ocr_warning when pages contain no usable text.

New controls:

--pdf-workers <N>       concurrent parser processes, default min(cores,4), maximum 4
--pdf-timeout-secs <N>  per-document timeout, default 120, maximum 3600

xerj autoindex --help documents limits, OCR behavior, process isolation, the trusted developer override, parent-memory guidance, and the current double-parse cost.

Architecture and safety limits

  • Input PDF: 512 MiB maximum.
  • Declared pages: 100,000 maximum.
  • Extracted page text: 16 MiB maximum.
  • Extracted document text: 64 MiB maximum.
  • Retained worker stdout: 32 MiB maximum per active PDF.
  • Retained worker stderr: 64 KiB maximum.
  • Unix worker address space: 1,536 MiB RLIMIT_AS.
  • Unix worker cleanup: dedicated process group, killed and reaped on timeout/monitor failure; descendants are also killed after normal leader exit.
  • Non-Unix: direct-child cleanup, but no OS memory cap.

The pipe readers continue draining after their retained prefixes overflow, so an oversized response cannot deadlock on a full pipe. This is crash/resource isolation, not a security sandbox: the parser keeps the invoking user's filesystem and network authority.

XERJ_PDF_WORKER_BIN remains a trusted developer/test hook. Its response is schema-, extractor-version-, parser-version-, and JSON-validated, but the executable itself runs with user authority.

Four-report FinanceBench dry run

Command shape:

xerj autoindex ./financebench-pdfs \
  --dry-run --json \
  --url http://127.0.0.1:9200 \
  --state-dir ./autoindex-state \
  --workers 2 \
  --pdf-workers 2

The input contained these previously blocking annual reports:

File Bytes Extracted records Text pages / total Omitted pages Body word ratio
INTEL_2019_10K.pdf 38,484,035 279 129 / 129 0 0.7276
WALMART_2018_10K.pdf 1,599,075 676 302 / 304 2 0.7126
CVSHEALTH_2022_10K.pdf 1,898,137 617 212 / 213 1 0.7690
PG_E_2017_10K.pdf 1,530,105 618 240 / 267 27 0.7171

Result:

  • exit 0;
  • four files / 41 MiB;
  • one PDF dataset;
  • zero junk or skipped files;
  • 1,779 inference-sampled records;
  • body automatically elected as semantic_text;
  • aggregate word ratio 0.77;
  • mean 226.8 tokens per value;
  • average body length 1,540.88 characters;
  • 61.567 seconds wall time with two PDF workers.

This was an autoindex dry run. It measured extraction and schema inference only: it did not write an index, generate embeddings, or finalize segments. The per-file record total is greater than 1,779 because inference caps each file's sample contribution.

The omitted-page count does not claim that every omitted page is scanned; it can include intentional blank pages. The warning deliberately says the page may require OCR.

Quality gate

The parser applies a narrow allowlist of known legacy symbol-font corrections, then rejects page text when more than 0.5% of visible characters are controls, replacement glyphs, or private-use codes, or fewer than 5% are alphanumeric.

On the pinned 368-document FinanceBench PDF corpus this accepted 367 documents and rejected one suspicious document. That is narrow calibration evidence, not a claim that the threshold is universal across all PDFs.

Binary and dependency cost

Both binaries were built from e6e8e6ac3b017e9c7e03d40e3b9cf4f4e3bee55e with:

cd engine
cargo build --release -j 32 -p xerj-server --no-default-features
Artifact Bytes MiB
Clean base 34,160,328 32.578
PDF worker 39,018,184 37.211
Increase 4,857,856 4.633

The stripped binary grows 14.22%. The server dependency graph gains 62 unique crate names and the lockfile changes by 747 additions and 33 removals. pdf_oxide is pinned to =0.3.75, uses default-features = false plus legacy-crypto, and is licensed MIT OR Apache-2.0.

Verification

cargo fmt -p xerj-autoindex -p xerj-server -- --check
PASS

cargo clippy -p xerj-autoindex -p xerj-server --no-default-features --all-targets -- -D warnings
PASS

cargo test -p xerj-autoindex --no-default-features
34 passed, 0 failed

cargo test -p xerj-server --test pdf_worker --no-default-features
5 passed, 0 failed

es-yaml-runner --url http://127.0.0.1:9280 --dir tests/es-compat-yaml/yaml
1360 passed, 0 failed, 3 skipped

The subprocess suite covers deterministic output, exact page provenance and protocol versions, corrupt-PDF errors with empty stdout, mixed text/empty-page coverage, non-UTF-8 Unix filenames, stdout and stderr overflow without deadlock, timeout cleanup, descendant cleanup, and process reaping.

Main file pointers

  • engine/crates/xerj-autoindex/src/extract/pdf.rs: parser, worker protocol, quality gate, limits, cleanup, and provenance.
  • engine/crates/xerj-autoindex/src/cli.rs: PDF controls and discoverability.
  • engine/crates/xerj-autoindex/src/lib.rs: autoindex worker configuration.
  • engine/crates/xerj-server/src/main.rs: pre-runtime hidden worker dispatch.
  • engine/crates/xerj-server/tests/pdf_worker.rs: same-binary process and failure-mode coverage.

Remaining limitations and follow-ups

  1. Phase-A sampling still parses and materializes the complete PDF, and phase-B indexing parses it again. Replace the full-response JSON protocol with framed streaming and an explicit sampling limit.
  2. Operational error paths clean up explicitly, but a general RAII child/process-group guard would also cover exceptional panics during post-spawn setup.
  3. Add broader public fixtures, especially real mixed scanned/text documents, multi-column layouts, rotated text, forms, encrypted files, malformed xrefs, and page-local finance ground truth.
  4. Measure peak parent and worker RSS at PDF concurrency 1 and 4, and validate non-Unix behavior in CI.
  5. Run the repository's complete transitive-license release audit before release.

buger added 2 commits July 24, 2026 21:16
Motivation:
The previous PDF extractor scanned raw streams and interpreted font character codes without resolving page resources, font encodings, or ToUnicode maps. Real financial reports therefore produced NUL-separated strings, shifted glyphs, and binary-looking content; schema inference correctly refused to elect that corrupted body as semantic_text.

Root cause and implementation:
Replace the byte scanner with pdf_oxide 0.3.75 and dispatch one document per fresh same-binary worker before Tokio starts. Preserve one-based page and pN-sN section provenance, disclose omitted-page/OCR coverage, normalize only a narrow legacy symbol allowlist, and reject suspicious Unicode or partial parser results instead of falling back to raw bytes. Add versioned parent/worker provenance validation.

Containment and DX:
Drain stdout/stderr after bounded retained prefixes to prevent pipe deadlock. On Unix, place workers in process groups, apply a 1536 MiB RLIMIT_AS, kill descendants, and reap on timeout/monitor failure. Cap concurrency at four and retained stdout at 32 MiB. Document that this is resource isolation rather than a security sandbox, that non-Unix has no OS memory cap, and that sampling currently fully parses/materializes PDFs before phase B parses again. Add --pdf-workers and --pdf-timeout-secs with actionable help.

Measured four-report dry run:
INTEL 2019: 279 records, 129/129 text pages, word ratio 0.7276. Walmart 2018: 676 records, 302/304 pages, 0.7126. CVS Health 2022: 617 records, 212/213 pages, 0.7690. PG&E 2017: 618 records, 240/267 pages, 0.7171. Autoindex accepted 4/4 with zero junk, elected body semantic_text at aggregate word ratio 0.77, and completed extraction/inference in 61.567 s with two PDF workers. This was dry-run evidence, not embedding or full indexing throughput.

Cost:
The same-base no-default release binary grows from 34,160,328 to 39,018,184 bytes: +4,857,856 bytes (+4.633 MiB, 14.22%). The graph gains 62 unique crate names; Cargo.lock changes by +747/-33 lines.

Verification:
- cargo fmt --check
- clippy for autoindex/server, all targets, no default features, warnings denied
- xerj-autoindex: 34 passed
- same-binary PDF integration: 5 passed
- ES-YAML: 1360 passed, 0 failed, 3 skipped

Key files:
- engine/crates/xerj-autoindex/src/extract/pdf.rs
- engine/crates/xerj-autoindex/src/cli.rs
- engine/crates/xerj-server/src/main.rs
- engine/crates/xerj-server/tests/pdf_worker.rs
PR CI evaluates the PDF worker branch against current main, whose newly added route expressions require wrapping under the current stable rustfmt. Rebase the contribution onto current main and apply only rustfmt's mechanical router layout; no route, parser, or worker behavior changes.
@buger
buger force-pushed the fix/pdf-unicode-worker branch from af3d812 to dedaa85 Compare July 24, 2026 19:18
@buger buger changed the title fix(autoindex): parse PDFs in isolated Unicode workers fix(autoindex): parse PDFs in isolated workers, and use pdf_oxide for more complex PDF parsing Jul 24, 2026
@xerj-org
xerj-org merged commit 9605917 into xerj-org:main Jul 25, 2026
4 checks passed
@xerj-org

Copy link
Copy Markdown
Owner

Merged. One fast-follow to file: terminate_worker_descendants(child.id()) in the normal-exit path (pdf.rs ~152) fires after try_wait has reaped the leader, so the pgid can be recycled and the SIGKILL can hit an unrelated process group. The kill is load-bearing (it unblocks the reader-join when a stray descendant holds the pipe), so the fix is to observe exit without reaping (waitid + WNOWAIT), kill the group, then reap — the timeout path already does this in the safe order. Also worth aligning MAX_EXTRACTED_TEXT (64 MiB worker-side) under MAX_WORKER_OUTPUT (32 MiB parent-side) — the current pair makes 25-64 MiB documents parse fully and then always fail, and restoring /Title extraction from the info dictionary (the old extractor read it; the new one uses filename stem only).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants