Chunking large receipt batches for OCR throughput
A 40,000-receipt month-end upload OOM-kills a worker the instant it is read into a list, because peak memory scales with the size of the run rather than the size of one unit of work. This guide splits that upload into bounded, order-tagged chunks streamed through a process pool, so OCR throughput grows with cores while resident memory stays flat — the chunk-sizing and result-reassembly detail delegated by the parent Async Batch Processing guide, inside the broader Receipt Ingestion & OCR Data Extraction framework.
The goal is not raw speed alone. It is bounded, deterministic speed: a fixed memory ceiling regardless of how large a batch arrives, and downstream records emitted in the same order they were submitted so the audit ledger stays reconstructable.
Why Standard Approaches Fail
The obvious approaches to a very large batch each fail in a way that either exhausts memory or corrupts the audit trail:
- List materialization.
receipts = list(db.stream_receipts())followed by aforloop reads every record — and often every decoded image buffer — into RAM before any OCR runs. Peak resident memory becomes a function of total volume, so a batch that is fine at 2,000 receipts OOM-kills the same fixed-size worker at 40,000. The break is discontinuous: it works in staging and dies in production month-end. - Naive
Pool.mapover the whole list. Handing the entire list tomultiprocessing.Pool.mappreserves order but still materializes all inputs and all results at once, so both ends of the pipe balloon.imapstreams inputs but a slow chunk stalls the ordered iterator behind it, re-introducing head-of-line blocking on the exact faded scans that take longest to OCR — the ones tuned in Tesseract OCR configuration. - Discarding submission order. Firing every receipt at a pool and collecting results as they finish is memory-friendly but loses ordering. When downstream posting to the ledger is order-sensitive — sequential voucher numbers, first-seen-wins duplicate resolution — nondeterministic completion order produces a non-reproducible audit trail that internal-control reviews reject.
The remedy is a generator that yields fixed-size, sequence-numbered chunks; a bounded window of chunks in flight across a process pool; and a small reorder buffer that restores submission order on the way out.
Architecture & Algorithm
Chunking has three moving parts: a lazy generator that never holds more than one chunk of source records, a bounded submission window that caps how many chunks are in flight (this, not the pool size, is what bounds memory), and a reorder buffer that absorbs out-of-order completion and re-emits in sequence.
The generator consumes its source as an iterator and tags each chunk with a monotonic seq. Because it appends to a small buffer and clears it on every yield, resident memory holds chunk_size records, not the full batch.
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Iterable, Iterator
logger = logging.getLogger("expense.ingest.chunking")
@dataclass(frozen=True)
class ReceiptRef:
receipt_id: str
image_uri: str
@dataclass(frozen=True)
class Chunk:
seq: int
receipts: tuple[ReceiptRef, ...]
def stream_chunks(receipts: Iterable[ReceiptRef], chunk_size: int = 200) -> Iterator[Chunk]:
"""Yield fixed-size, sequence-numbered chunks lazily so RSS stays flat.
The source is consumed as an iterator, so only chunk_size ReceiptRef
objects are resident at once regardless of how many receipts arrive.
"""
if chunk_size < 1:
raise ValueError("chunk_size must be >= 1")
buffer: list[ReceiptRef] = []
seq = 0
for receipt in receipts:
buffer.append(receipt)
if len(buffer) == chunk_size:
yield Chunk(seq=seq, receipts=tuple(buffer))
seq += 1
buffer.clear()
if buffer: # final short chunk
yield Chunk(seq=seq, receipts=tuple(buffer))
The unit of CPU work is a top-level function so it is picklable into worker processes. It returns its seq alongside results, which is what makes ordered reassembly possible without a shared data structure.
def _run_ocr(image_uri: str) -> str:
"""Bind to your Tesseract wrapper; kept pure so chunks stay picklable."""
return f"OCR::{image_uri}"
def ocr_chunk(chunk: Chunk) -> tuple[int, list[dict[str, Any]]]:
"""CPU-bound OCR for one chunk, executed in a worker process.
Returns (seq, results) so the parent can restore submission order after
workers complete out of order.
"""
results = [
{"receipt_id": r.receipt_id, "text": _run_ocr(r.image_uri)}
for r in chunk.receipts
]
return chunk.seq, results
The driver keeps at most max_in_flight chunks submitted at once. Each time a future completes it submits the next chunk, so the pool stays saturated without ever queuing the whole batch. A min-heap keyed by seq, plus a next_emit cursor, releases results only when the contiguous next chunk is ready — bounding the reorder buffer to at most the number of in-flight chunks.
import heapq
from concurrent.futures import FIRST_COMPLETED, Future, ProcessPoolExecutor, wait
def process_in_order(
receipts: Iterable[ReceiptRef],
*,
chunk_size: int = 200,
pool_width: int = 4,
max_in_flight: int = 8,
) -> Iterator[tuple[int, list[dict[str, Any]]]]:
"""Process chunks in parallel but yield them in submission order.
Completion order is nondeterministic, so results land in a small heap
keyed by seq and are released only when the contiguous next seq is ready.
Memory is bounded by max_in_flight chunks, never by the total batch.
"""
chunks = stream_chunks(receipts, chunk_size=chunk_size)
pending: dict[int, list[dict[str, Any]]] = {}
ready: list[int] = []
next_emit = 0
with ProcessPoolExecutor(max_workers=pool_width) as pool:
in_flight: set[Future] = set()
for _ in range(max_in_flight): # prime the submission window
chunk = next(chunks, None)
if chunk is None:
break
in_flight.add(pool.submit(ocr_chunk, chunk))
while in_flight:
done, in_flight = wait(in_flight, return_when=FIRST_COMPLETED)
for fut in done:
seq, results = fut.result()
pending[seq] = results
heapq.heappush(ready, seq)
logger.info("chunk_done seq=%d size=%d", seq, len(results))
nxt = next(chunks, None) # refill to keep the pool busy
if nxt is not None:
in_flight.add(pool.submit(ocr_chunk, nxt))
while ready and ready[0] == next_emit: # emit contiguous prefix
s = heapq.heappop(ready)
yield s, pending.pop(s)
next_emit += 1
The invariant that matters for capacity planning: at any instant the process holds max_in_flight chunks of inputs plus at most max_in_flight chunks of buffered results, so peak memory is chunk_size × (pool_width + max_in_flight) × avg_receipt_bytes and is completely independent of the batch length.
Chunk size itself is a throughput/memory tradeoff, not a constant. Larger chunks amortize per-task pickling and dispatch overhead but raise peak RSS and lengthen the tail a single slow chunk holds. Derive it from the memory budget rather than guessing:
def recommended_chunk_size(
mem_budget_mb: int,
pool_width: int,
avg_receipt_mb: float,
max_in_flight: int = 8,
floor: int = 25,
ceil: int = 500,
) -> int:
"""Largest chunk_size whose worst-case resident set fits the budget."""
if avg_receipt_mb <= 0:
raise ValueError("avg_receipt_mb must be positive")
slots = pool_width + max_in_flight
raw = int(mem_budget_mb / (slots * avg_receipt_mb))
return max(floor, min(ceil, raw))
Step-by-Step Integration
-
Stream the source, never list it. Feed
process_in_ordera lazy iterator — a server-side cursor oryield-based reader — so the generator, not a materialized list, is the only thing holding records. -
Set
chunk_sizefrom the memory budget. Callrecommended_chunk_size(mem_budget_mb, pool_width, avg_receipt_mb)against the container limit, then pin the result in config so a capacity change is reviewed, not silent. -
Match
pool_widthto physical cores, notmax_in_flight. OCR is CPU-bound, sopool_width = os.cpu_count()maximizes parallelism;max_in_flightshould exceedpool_widthby a small margin (2×) so a worker never idles waiting for the driver to submit. -
Verify order and completeness before wiring to the ledger. Emitted sequence numbers must be strictly increasing and cover every chunk with none dropped or duplicated:
refs = [ReceiptRef(f"R-{i}", f"s3://b/{i}.jpg") for i in range(1000)] seqs = [seq for seq, _ in process_in_order(refs, chunk_size=200, pool_width=4)] assert seqs == sorted(seqs) # emitted in submission order assert seqs == list(range(len(seqs))) # contiguous: nothing dropped or repeated assert len(seqs) == 5 # ceil(1000 / 200) -
Confirm RSS is flat across batch sizes. Run 1,000 and 40,000 receipts with the same
chunk_sizeandmax_in_flight; peak RSS should be nearly identical. A rising curve means something upstream is still materializing the batch. -
Hand ordered results to the next stage. Pass the ordered stream into line-item parsing and policy validation; because order is preserved, sequential voucher assignment and first-seen duplicate resolution stay deterministic across re-runs.
Edge Cases & Gotchas
| Edge condition | What breaks | Mitigation |
|---|---|---|
| One slow chunk (faded scans) | Reorder buffer holds later chunks until the laggard finishes, growing memory | Cap max_in_flight; keep chunks small so a stall blocks fewer records |
| Final short chunk dropped | Off-by-one loses the last < chunk_size receipts |
Yield the trailing buffer after the loop (handled in stream_chunks) |
| Unpicklable chunk payload | ProcessPoolExecutor.submit raises on non-picklable objects |
Keep chunks as plain dataclasses of URIs/ids; never embed open file handles |
chunk_size too large |
Per-worker RSS spikes; a single failure re-does more work | Derive size from the memory budget; prefer more, smaller chunks |
Exception inside ocr_chunk |
fut.result() re-raises and aborts the whole run |
Catch per chunk inside the worker and return a partial/failed marker |
| Ordering assumed but not needed | Reorder buffer adds latency for no benefit | Drop the heap and yield on completion when downstream is order-agnostic |
| Pool re-spawned per batch | Interpreter start-up cost dominates on many small batches | Reuse one ProcessPoolExecutor across batches; the parent Async Batch Processing engine owns its lifecycle |
FAQ
How do I choose a chunk size?
Derive it from the memory budget, not intuition. Worst-case resident memory is roughly chunk_size × (pool_width + max_in_flight) × avg_receipt_bytes, so solve that for the largest chunk_size that fits your container limit, then clamp it to a sane floor and ceiling. Start around 100–200 receipts, watch peak RSS and per-chunk latency, and change one variable at a time.
Why keep submission order at all — isn’t completing as fast as possible the point?
Throughput and ordering are independent. Chunks are processed fully in parallel; the reorder buffer only controls the sequence in which finished results are released. You need that order when downstream posting is order-sensitive — sequential voucher numbers or first-seen-wins duplicate resolution — because nondeterministic completion order otherwise yields a non-reproducible audit trail.
Does chunking help if OCR is the bottleneck?
Yes, because chunking is what lets OCR run on every core at once instead of serially. A ProcessPoolExecutor gives each worker its own interpreter and GIL, so pool_width receipts are OCR’d simultaneously. Chunking bounds the memory that parallelism would otherwise consume; the two together turn a serial, memory-fragile job into a saturated, bounded one.
What is the difference between this and the queue-based approach?
A bounded queue decouples producers from consumers over time and across processes or hosts; chunking decides how a single large batch is sliced and how its results are stitched back together in order. They compose: the async batch queue delivers work, chunking sizes each unit for the pool, and backpressure on the worker pool keeps producers from outrunning consumers.
How do I stop one slow chunk from stalling everything?
A laggard cannot stall processing — other workers keep going — but it does hold the reorder buffer, since chunks after it cannot be emitted until it lands. Keep chunk_size small so a stall blocks fewer records, cap max_in_flight so the buffer cannot grow without bound, and route pathologically slow substrates to the faded-text restoration path rather than letting them dominate the tail.
Related
- Async Batch Processing — the parent guide whose pipeline consumes ordered, chunked results.
- Applying backpressure to OCR worker pools under load — the sibling guide that keeps producers from outrunning the pool this one feeds.
- Building async batch queues for high-volume receipt uploads — the transport layer that delivers batches to chunk.
- Tesseract OCR configuration — tuning the CPU-bound step each chunk fans out across the pool.