• Stefy Lanza (nextime / spora )'s avatar
    ocr: rasterise PDFs one document per process (v0.3.67) · 9c643e75
    Stefy Lanza (nextime / spora ) authored
    0.3.66 serialised every pdfium call behind a lock, because pdfium permits one
    caller at a time and calling it concurrently corrupts its state into "PDFium:
    Data format error" on valid documents. Correct, and it made rasterisation
    single-threaded for the whole install -- a ceiling of about one core.
    
    pdfium IS safe one-document-per-process, so `ocr.raster_workers` (0 = auto at
    half the cores capped at 4, 1 = in-process under the lock) hands documents to
    worker processes. Measured on 12 concurrent 47-page PDFs at 150 DPI: 21.9s ->
    10.3s, 2.13x. Not 4x -- the parent is single-threaded receiving the pixels and
    rebuilding the images, and ~4.5 GB crosses the pipes. The ceiling is the parent.
    
    Three things this had to get right, each of which cost a bug first:
    
    - A worker is borrowed per CHUNK, not per document. The caller OCRs each window
      between pulls, which takes far longer than rasterising it, so holding a worker
      across that would idle it for most of its tenure and cap the install at
      `raster_workers` concurrent documents -- worse than the lock it replaces.
    
    - A pool that cannot start must cost nothing. The first version waited 300s for
      a worker that could never arrive (missing script), stalling every document for
      five minutes: the optimisation becoming an outage, which is the exact failure
      its test was written for. Creation failure is now remembered rather than
      retried per document, and a busy pool falls back in-process after 30s.
    
    - Workers are executed, never imported. `import codai.ocr` costs 377 MB and
      1.5s via codai.models.manager -> litellm; paying that per worker, in the
      subsystem whose other half exists to bound memory, would be self-defeating.
      The worker imports pypdfium2 and PIL only, ~25 MB. This is why it is not a
      ProcessPoolExecutor: spawn pickles a function by qualified name and re-imports
      the package to find it.
    
    Also fixes a cost 0.3.65 introduced and nothing but a stopwatch would surface:
    pricing a document called `pdf[i].get_size()` per page, which LOADS each page
    object -- 593 ms for a 47-page document, paid UNDER THE LOCK, so every document
    in the install queued behind every other being priced.
    `pdf.get_page_size(i)` reads the same numbers from the page tree by index.
    page_estimate: ~600 ms -> 9.6 ms.
    
    Every failure path degrades to a correct in-process render: no pool, a dead
    worker mid-document, a missing script, an unusable module, an abandoned
    document. Correctness never depends on the optimisation working.
    
    87 tests across the budget and pool suites. Full suite at the 25 pre-existing
    failures.
    Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01HXRMe9ZPUQCCCtBai9uqt3
    9c643e75
raster_worker.py 6.01 KB