-
Stefy Lanza (nextime / spora ) authored
`ocr.max_concurrency` bounds how many pages are BEING READ. Nothing bounded how many were RESIDENT, and those differed by a factor of the document length: ocr_document rasterised every page up front and held the list until the response was written, so pages queued behind the semaphore were in memory too. The real figure was (documents in flight x their page count), which no setting expressed. A production install was OOM-killed twice inside forty minutes -- 90.7 GB with max_concurrency at 1024, then 92.7 GB with it at 128. Eight times smaller, half a gigabyte worse, which is the proof the semaphore was never holding the memory. Two bounds, on the two things that grow: - `ocr.page_window` (0 = follow max_concurrency, capped at 32) reads a document a window of pages at a time -- rendered, read, measured, detected on, dropped, then the next window. Residency is O(window) not O(document). Measured on a 40-page A4 PDF at 250 DPI: 40 pages held = +974 MB RSS, 32 = +768 MB, 8 = +215 MB. - `ocr.max_resident_mb` (0 = derive it) is a ceiling on the bytes in-flight documents may hold as pages, charged before a page exists: page_estimate() prices a PDF from its page tree without rendering it. Derived from the smaller of the cgroup limit and host RAM -- the container limit first, since a 2 GB container sizing itself from a 128 GB host has no ceiling at all. The estimate carries a measured overhead factor (PAGE_OVERHEAD = 1.6): the RGB pixel arithmetic under-predicts real residency by 40-55%, and a budget that under-charges admits half again as much as it meant to. A document larger than the whole budget still runs, clamped and alone -- a gate that can refuse forever is a deadlock with a polite error message. Reservations are returned on cancellation too, or a disconnected client shrinks the budget permanently. Also: rendering now runs off the event loop (a long PDF used to stall every other request in the process), the engine is sticky across windows so a document is not read by a different engine in each one, and pages already read keep their results when a later window falls back. The comment in config.py that reasoned "64 in flight is ~1.2 GB" is corrected in place -- it assumed resident == admitted. 59 tests. Not bounded, and not measured: /v1/ocr/batch still buffers every uploaded file; source bytes are ~1% of rasterised pages, so that was not the term behind the kills. Co-Authored-By:
Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HXRMe9ZPUQCCCtBai9uqt3
2bd7a726