Async Batch Processing Workflows
Async batch processing is the execution backbone that sits between raw document ingestion and downstream rate validation in a freight audit pipeline. Its job is narrow and well-defined: take normalized payloads handed off by the Automated Invoice Parsing & EDI/XML Ingestion tier, group them into right-sized work units, distribute those units across a worker pool, track their state idempotently, and isolate failures so one corrupt freight bill never stalls a 40,000-invoice run. This page covers where that layer sits, the prerequisites it depends on, a stage-by-stage implementation, how to test and tune it, the failure modes that bite in production, and the field contract it must honour when it hands results to the validation stage.
When carriers submit invoices across heterogeneous formats during peak windows, synchronous processing introduces latency that delays payment cycles, obscures audit trails, and exhausts memory. Decoupling ingestion from validation and dispute routing is what lets operations teams scale throughput horizontally while keeping strict rate-contract compliance intact. The patterns below are written for Python ETL developers building on a distributed task queue such as Celery, and they assume the broader pipeline already attaches a contract_version_id upstream so that batches stay deterministic regardless of when a worker picks them up.
Scope & Pipeline Positioning
The async batch layer operates strictly between document ingestion and rate validation. Its responsibilities are task decomposition, state tracking, resource allocation, and fault isolation. It deliberately does not perform OCR, schema extraction, or rate-table lookups. It receives normalized payloads from the ingestion tier, groups them into discrete batches by carrier ID, document format, or submission timestamp, serializes them onto a distributed task queue, and lets worker processes pull chunks, execute format-specific routing, and pass structured payloads forward.
State management relies on an idempotent task registry. Every invoice carries a unique audit_trace_id that persists across parsing, validation, and dispute routing. If a worker dies mid-execution, the registry guarantees the task is requeued without duplicating any downstream side effect.
Prerequisites
Before this layer can run, the upstream and runtime contracts below must be satisfied. Treat these as hard preconditions — most batch-layer incidents trace back to one of them being violated rather than to the orchestration code itself.
| Prerequisite | Provided by | Contract |
|---|---|---|
| Normalized payload | EDI 210/810 Processing, PDF Invoice Parsing with Python, XML Freight Bill Ingestion | One canonical dict per invoice, header-level, UTF-8 |
audit_trace_id |
Ingestion gateway | Stable, globally unique, present on every record |
contract_version_id |
Freight Contract Architecture & Rate Mapping | Pins the batch to a point-in-time tariff snapshot |
| Message broker | Infrastructure | Redis or RabbitMQ reachable from all workers |
| State store | Infrastructure | Redis or PostgreSQL for the idempotent registry |
The Python runtime needs celery>=5.3, a broker driver (redis>=5.0 or kombu with AMQP), and pydantic>=2 for payload validation. The config keys this layer reads — chunk size, payload cap, retry policy, routing thresholds — are defined in the next section.
Batch Configuration & Chunking Schema
Oversized batches trigger garbage-collection pauses and out-of-memory failures; undersized batches inflate queue overhead and broker round-trips. The YAML below defines the operational thresholds the orchestrator reads at startup:
batch_config:
max_chunk_size: 750 # Invoices per worker task
max_payload_mb: 45 # Hard memory cap per batch
timeout_seconds: 180 # Worker execution limit
retry_policy:
max_attempts: 3
backoff_multiplier: 2.0
jitter_seconds: 5
routing_rules:
pdf_threshold: 0.6 # Route to PDF parser if >60% of batch
edi_threshold: 0.3 # Route to EDI parser if >30% of batch
xml_fallback: true # Default to XML schema validation
Chunking must operate at the invoice header level, never at the individual line-item level. Freight bills frequently bundle multiple shipment legs under a single master invoice; splitting at the header boundary preserves rate-contract context and prevents partial validation states where some legs of one bill are audited and others are not.
Step-by-Step Implementation
Stage 1 — Define and validate the batch payload
Give every batch a typed contract before it touches the queue. Validating at the boundary turns a class of runtime explosions into cheap, early rejections.
from typing import Any, Dict, List
from pydantic import BaseModel, Field, field_validator
class BatchPayload(BaseModel):
audit_trace_id: str = Field(min_length=1)
carrier_id: str = Field(min_length=1)
document_type: str
contract_version_id: str
payload_chunks: List[Dict[str, Any]]
@field_validator("document_type")
@classmethod
def known_format(cls, value: str) -> str:
if value not in {"PDF", "EDI", "XML"}:
raise ValueError(f"unsupported document_type: {value}")
return value
Common mistake: trusting the upstream
document_typeblindly. Carriers mislabel transmissions constantly — an EDI 210 arrives taggedXML. Validate the declared type here, but let the worker fall back to content sniffing (Stage 3) rather than aborting the batch.
Stage 2 — Chunk normalized records into batches
Group by carrier and format, then cap each chunk by both record count and estimated payload size so a handful of fat PDF records cannot blow the memory ceiling.
import sys
from collections import defaultdict
from typing import Any, Dict, Iterator, List
def chunk_records(
records: List[Dict[str, Any]],
max_chunk_size: int = 750,
max_payload_bytes: int = 45 * 1024 * 1024,
) -> Iterator[Dict[str, Any]]:
grouped: Dict[tuple, List[Dict[str, Any]]] = defaultdict(list)
for rec in records:
grouped[(rec["carrier_id"], rec["document_type"])].append(rec)
for (carrier_id, document_type), group in grouped.items():
chunk: List[Dict[str, Any]] = []
chunk_bytes = 0
for rec in group:
rec_bytes = sys.getsizeof(rec.get("raw_payload", b""))
over_count = len(chunk) >= max_chunk_size
over_bytes = chunk_bytes + rec_bytes > max_payload_bytes
if chunk and (over_count or over_bytes):
yield _build_batch(carrier_id, document_type, chunk)
chunk, chunk_bytes = [], 0
chunk.append(rec)
chunk_bytes += rec_bytes
if chunk:
yield _build_batch(carrier_id, document_type, chunk)
def _build_batch(carrier_id: str, document_type: str,
chunk: List[Dict[str, Any]]) -> Dict[str, Any]:
return {
"audit_trace_id": chunk[0]["audit_trace_id"],
"carrier_id": carrier_id,
"document_type": document_type,
"contract_version_id": chunk[0]["contract_version_id"],
"payload_chunks": chunk,
}
Common mistake: measuring chunk size only by record count. A 750-record chunk of EDI segments is a few hundred kilobytes; a 750-record chunk of embedded PDF bytes is gigabytes. Always cap on both dimensions.
Stage 3 — Orchestrate the worker task
The worker is intentionally thin. It routes by format, enforces idempotency, distinguishes transient from permanent failures, and hands off — it never parses or validates inline.
import logging
from celery import Celery
logger = logging.getLogger(__name__)
app = Celery("freight_audit")
app.conf.update(
task_acks_late=True,
task_reject_on_worker_lost=True,
worker_prefetch_multiplier=1,
task_default_retry_delay=5,
)
@app.task(bind=True, max_retries=3, acks_late=True)
def process_freight_batch(self, batch: dict) -> dict:
"""Route a chunk by format and hand off to validation.
Contains no inline parsing or rate-validation logic.
"""
audit_trace_id = batch.get("audit_trace_id")
carrier_id = batch.get("carrier_id")
document_type = batch.get("document_type")
if registry_is_terminal(audit_trace_id):
logger.info("Skipping already-finalized batch %s", audit_trace_id)
return {"status": "skipped", "trace_id": audit_trace_id}
try:
logger.info("Processing batch %s for carrier %s",
audit_trace_id, carrier_id)
if document_type == "PDF":
from .parsers import route_pdf_batch
return route_pdf_batch(batch)
elif document_type == "EDI":
from .parsers import route_edi_batch
return route_edi_batch(batch)
else:
from .parsers import route_xml_batch
return route_xml_batch(batch)
except ConnectionError as exc:
logger.warning("Transient failure on batch %s: %s",
audit_trace_id, exc)
raise self.retry(exc=exc, countdown=2 ** self.request.retries)
except ValueError as exc:
logger.error("Permanent payload error on batch %s: %s",
audit_trace_id, exc)
route_to_dlq(batch, error=str(exc))
return {"status": "failed", "trace_id": audit_trace_id}
Setting acks_late=True means a task is acknowledged only after it finishes, so an abrupt worker kill requeues the work instead of losing it. Format-specific routing delegates to dedicated handlers — the same modules documented in PDF Invoice Parsing with Python and EDI 210/810 Processing — keeping this orchestration layer lightweight. Because Celery serializes arguments (JSON by default), batch arrives as a plain dict; rehydrate it through BatchPayload.model_validate(batch) inside the route handlers if you want typed access.
Common mistake: leaving
worker_prefetch_multiplierat its default of 4. With long-running freight batches, prefetch causes a single slow worker to hoard tasks that idle workers could be draining. Pin it to 1 for fair distribution.
For end-to-end broker setup, deployment topology, and parser-resilience patterns, the dedicated walkthrough in Implementing async batch invoice processing with Celery extends this skeleton into a full deployment.
Validation & Testing
The batch layer has no business logic to assert against, so tests target its three real contracts: chunk boundaries, idempotency, and failure classification. Build fixtures from realistic mixed-format inputs, including the malformed records that crash naive code.
import pytest
@pytest.fixture
def mixed_batch():
return [
{"audit_trace_id": "t-1", "carrier_id": "ODFL",
"document_type": "EDI", "contract_version_id": "v7",
"raw_payload": b"ISA*00*..."},
{"audit_trace_id": "t-2", "carrier_id": "ODFL",
"document_type": "PDF", "contract_version_id": "v7",
"raw_payload": b"%PDF-1.7" + b"0" * 60_000_000}, # 60 MB
]
def test_oversized_record_isolated_into_own_chunk(mixed_batch):
chunks = list(chunk_records(mixed_batch, max_chunk_size=750))
pdf_chunks = [c for c in chunks if c["document_type"] == "PDF"]
assert all(len(c["payload_chunks"]) == 1 for c in pdf_chunks)
def test_terminal_trace_is_skipped(monkeypatch):
monkeypatch.setattr("tasks.registry_is_terminal", lambda _id: True)
result = process_freight_batch.run({"audit_trace_id": "t-9",
"document_type": "EDI"})
assert result["status"] == "skipped"
Cover at least these edge cases in fixtures: a record missing contract_version_id (should reject at Stage 1), a chunk whose combined payload exceeds max_payload_mb (should split), a carrier that mislabels document_type, and a duplicate audit_trace_id already in a terminal state (should short-circuit). Run worker tasks with task_always_eager=True in CI so assertions execute synchronously without a live broker.
Performance & Tuning
Throughput is governed by chunk size, worker concurrency, and the memory footprint of the heaviest format in the queue. PDF batches are memory-bound; EDI and XML batches are CPU-bound on parsing. Tune the two classes separately rather than forcing one concurrency setting across both.
| Lever | Memory-bound (PDF) | CPU-bound (EDI/XML) |
|---|---|---|
max_chunk_size |
100–250 | 500–750 |
| Worker concurrency | --concurrency 2 (prefork) |
--concurrency = vCPU count |
max_payload_mb |
30–45 | 60+ |
| Pool type | prefork |
prefork or gevent |
A useful baseline: keep resident memory per worker under 70% of the container limit at steady state, leaving headroom for the GC and for one oversized record slipping through. If you see memory climbing run-over-run, set worker_max_tasks_per_child so workers recycle and release any leaked allocations from C-extension parsers. The asyncio-versus-Celery trade-off for the worker pool itself is a deeper architecture decision; this layer assumes a prefork worker model because freight parsing is CPU- and memory-bound, not I/O-bound.
Failure Modes
Production freight pipelines must separate transient infrastructure faults from permanent data defects and treat each differently. The five scenarios below cover the bulk of batch-layer incidents.
- Transient broker disconnect. Root cause: network blip or broker failover. Diagnostic:
ConnectionErrorin worker logs with a rising retry count. Resolution: thecountdown=2 ** self.request.retriesexponential backoff already handles this; verifymax_retriesis non-zero and that retries are not being swallowed by a broadexcept. - Permanent payload defect. Root cause: malformed header, missing carrier ID, corrupted bytes. Diagnostic:
ValueErrorraised before any routing. Resolution: route directly to the dead-letter queue with theaudit_trace_id; never retry a structurally invalid record — it will fail identically forever and burn retry budget. - Duplicate execution after restart. Root cause: a worker died after side effects but before acking. Diagnostic: two ledger writes share one
audit_trace_id. Resolution: the registry check at the top of the task (registry_is_terminal) short-circuits any trace already inCOMPLETEDorFAILEDstate. - Memory exhaustion on a fat chunk. Root cause: chunking by count only, so a chunk of large PDFs exceeds the container limit. Diagnostic: worker OOM-killed, task requeued, OOM repeats. Resolution: enforce
max_payload_mbinchunk_records(Stage 2) and lower PDF concurrency. - Thundering herd on recovery. Root cause: a broker outage clears and every worker retries in lockstep. Diagnostic: a synchronized spike in broker load and CPU. Resolution: keep
jitter_secondsin the retry policy so backoff windows are desynchronized across workers.
Integration Points
This layer’s output is the validation tier’s input, so the field contract must be stable and explicit. Each routed batch emits one result envelope per invoice with the fields below; downstream rate validation reads them and never re-derives the trace identity.
| Field | Type | Consumed by |
|---|---|---|
audit_trace_id |
str | All downstream stages (correlation key) |
carrier_id |
str | Lane and contract resolution |
contract_version_id |
str | Point-in-time tariff lookup |
document_type |
str | Routing audit / metrics |
normalized_record |
dict | Rule-Based Rate Validation & Accessorial Auditing |
status |
enum | completed | failed | skipped |
Records that complete here flow into rate validation, where expected charges are computed and any variance is classified; batches whose throughput or error rate cross the alert thresholds tracked in Threshold Tuning & Alerting trigger operational paging. Emit structured JSON logs containing audit_trace_id, carrier_id, processing_stage, and latency_ms from every worker, and alert when queue depth exceeds 2× the average hourly submission volume, when task duration exceeds timeout_seconds, or when the dead-letter ingestion rate passes 2% of total throughput.
In this section
- Implementing async batch invoice processing with Celery — a full production deployment of the worker skeleton above, covering broker selection, prefork tuning, parser-resilience patterns, and memory containment under freight load.
Related
- Automated Invoice Parsing & EDI/XML Ingestion — the ingestion tier that hands normalized payloads to this layer.
- EDI 210/810 Processing — the format handler invoked for EDI batches.
- PDF Invoice Parsing with Python — the memory-bound handler this layer rate-limits.
- XML Freight Bill Ingestion — the default fallback handler for batches.
- Rule-Based Rate Validation & Accessorial Auditing — the downstream tier that consumes this layer’s output.
Up: Automated Invoice Parsing & EDI/XML Ingestion
External references: Python’s structured logging guidelines and Celery’s task design best practices.