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.

The async batch layer, from ingestion to validation Normalized payloads from the ingestion tier enter a chunker that groups records by carrier_id and document_type and caps each chunk at 750 records or 45 MB. Chunks are serialized as BatchPayload units onto a message broker (Redis or RabbitMQ). A worker pool pulls one chunk at a time with prefetch set to 1 and acks_late enabled; each worker routes by format to a PDF, EDI or XML handler, then emits a structured result envelope to the validation tier. Every worker reads and writes task state in an idempotent registry keyed by audit_trace_id, and permanent payload defects are branched off to a dead-letter queue instead of being retried. INGESTION CHUNK & GROUP BROKER WORKER POOL FORMAT ROUTE VALIDATION prefetch=1 state ValueError Ingestion normalized payloads Chunker group by carrier_id + document_type cap: 750 rec / 45 MB Broker Redis / RabbitMQ BatchPayload Worker pool acks_late=True worker · route worker · route worker · route PDF handler memory-bound EDI handler 210 / 810 XML handler fallback Validation result envelopes Idempotent task registry audit_trace_id → PENDING / COMPLETED / FAILED Dead-letter queue permanent defects, no retry

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_type blindly. Carriers mislabel transmissions constantly — an EDI 210 arrives tagged XML. 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_multiplier at 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.

  1. Transient broker disconnect. Root cause: network blip or broker failover. Diagnostic: ConnectionError in worker logs with a rising retry count. Resolution: the countdown=2 ** self.request.retries exponential backoff already handles this; verify max_retries is non-zero and that retries are not being swallowed by a broad except.
  2. Permanent payload defect. Root cause: malformed header, missing carrier ID, corrupted bytes. Diagnostic: ValueError raised before any routing. Resolution: route directly to the dead-letter queue with the audit_trace_id; never retry a structurally invalid record — it will fail identically forever and burn retry budget.
  3. 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 in COMPLETED or FAILED state.
  4. 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_mb in chunk_records (Stage 2) and lower PDF concurrency.
  5. 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_seconds in 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

Up: Automated Invoice Parsing & EDI/XML Ingestion

External references: Python’s structured logging guidelines and Celery’s task design best practices.