Async Batch Processing for High Volume

High-volume customs brokerage operations need an ingestion architecture that decouples document receipt from the CPU- and I/O-bound work of parsing, validation, and Harmonized System classification. When a broker ingests thousands of commercial invoices, packing lists, and certificates of origin inside a narrow clearance window, a synchronous request-response path collapses under thread exhaustion and unbounded latency: a single slow OCR pass or a rate-limited tariff lookup stalls the entire queue. Within the broader Document Ingestion & Parsing Workflows domain, this page specifies the async batch pattern that resolves that gap — a broker-mediated pipeline that buffers payloads, fans work out to stateless Celery workers, and guarantees at-least-once, idempotent processing so that every line item reaches the classification engine exactly once, with a complete audit trail from receipt to duty determination.

Problem Framing: Where Synchronous Ingestion Breaks

The failure mode is specific. A REST endpoint that parses inline holds a worker thread for the full duration of pdfplumber extraction, an OCR call, and a database write — hundreds of milliseconds to several seconds per document. At peak filing volume (tens of thousands of documents per day, arriving in bursts around vessel ETAs and cutoff times), the thread pool saturates, health checks time out, and the load balancer sheds traffic that carries legally-required entry data. Worse, a partial failure midway through a synchronous batch leaves ambiguous state: some line items committed, some not, and no clean way to replay only the failures.

Async batch processing removes the coupling. Documents are acknowledged at the ingestion boundary in single-digit milliseconds, serialized into an immutable envelope, and pushed to a broker. Workers claim micro-batches on their own schedule, and every stage transition is recorded so a compliance officer can reconstruct exactly what happened to a shipment reference. The design goal is not raw speed — it is deterministic, replayable, audit-defensible throughput under load.

Async batch processing data flow Inbound documents are acknowledged at the ingestion boundary in single-digit milliseconds and serialized into immutable envelopes that are pushed onto a priority broker queue. A pool of stateless workers claims micro-batches of fifty to two hundred records and runs each envelope through four ordered stages: envelope validation, HTS validation, concurrent async OCR and database I/O, and an idempotent commit. Validated records flow to the success sink; permanent schema or nomenclature failures branch to the dead letter queue, while transient I/O failures re-enter the broker on an exponential-backoff, at-least-once redelivery loop. A circuit breaker over the worker pool halts consumption when the rolling failure rate exceeds fifteen percent. Inbound documents invoices · packing lists Ingestion boundary ack in ms immutable envelope Priority broker queue · backpressure acks_late redelivery WORKER POOL · CIRCUIT BREAKER 15% Micro-batch 50–200 records 1 Envelope validation Pydantic schema gate 2 HTS validation HS-6 · 8/10-digit line 3 Async I/O · gather OCR verify · tariff lookup idempotent DB upsert 4 Commit & record upsert on correlation_id Success sink to classification DLQ quarantine permanent transient · backoff

Schema / Data Contract

The contract between producer and consumer is an immutable envelope. It carries a correlation ID (the audit anchor), a SHA-256 file hash (deduplication and integrity), source metadata, and the extracted compliance payload. The payload models the WCO Data Model fields a customs entry depends on: line items with HS codes, currency, and origin. Formalizing this with Pydantic gives the worker a single deterministic validation gate — anything that fails the model never reaches the classification engine.

from datetime import datetime, timezone
from enum import Enum
from typing import Any

from pydantic import BaseModel, Field


class ProcessingState(str, Enum):
    QUEUED = "queued"
    PROCESSING = "processing"
    VALIDATED = "validated"
    FAILED = "failed"
    DLQ = "dead_letter_queue"


class LineItem(BaseModel):
    description: str
    quantity: float = Field(gt=0)
    unit_price: float = Field(ge=0)
    currency: str = Field(pattern=r"^[A-Z]{3}$")          # ISO 4217
    hs_code: str = Field(pattern=r"^(?:\d{6}|\d{8}|\d{10})$")  # HS-6 or national line
    origin_country: str = Field(pattern=r"^[A-Z]{2}$")    # ISO 3166-1 alpha-2


class CompliancePayload(BaseModel):
    invoice_number: str
    line_items: list[LineItem]
    total_value: float
    incoterms: str = Field(pattern=r"^[A-Z]{3}$")         # Incoterms 2020 three-letter


class DocumentEnvelope(BaseModel):
    correlation_id: str = Field(..., description="Immutable audit anchor")
    file_hash: str = Field(..., min_length=64, max_length=64)   # SHA-256 hex
    source_system: str
    compliance_data: CompliancePayload
    received_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

The envelope is written once and never mutated in place; state transitions are recorded as separate audit rows keyed on correlation_id. This is what makes redelivery safe — a redelivered envelope is byte-identical, so an idempotent consumer can detect the duplicate by hash and skip it.

Step-by-Step Implementation

The pipeline runs in four ordered stages. Each stage has a defined purpose, inputs, outputs, and error condition, and each is isolated so a fault in parsing cannot corrupt classification state.

Stage 1 — Envelope validation (input: raw broker message; output: DocumentEnvelope; error: schema violation → Dead Letter Queue). The worker first deserializes and validates the envelope. A malformed envelope is a permanent failure — retrying will never fix it — so it routes straight to the Dead Letter Queue with full context rather than consuming retry budget.

Stage 2 — HTS/HS validation (input: CompliancePayload; output: validated payload; error: ValueError → DLQ). Digit-length and nomenclature rules are enforced deterministically before any downstream work.

Stage 3 — Async I/O (input: validated payload; output: persisted record; error: transient exception → retry). OCR verification, tariff lookups, and the database write run concurrently under asyncio.gather. These are the operations that fail transiently, so they are the ones wrapped in retry logic. Field-level OCR quality is handled by OCR Drift Correction & Validation, and locale-aware value parsing by Multi-language Invoice Parsing.

Stage 4 — Commit and record success. The record is UPSERTed idempotently on correlation_id, and the circuit-breaker success counter is incremented.

Because Celery workers are synchronous, the async pipeline is driven by asyncio.run on a per-task event loop — the task wrapper stays thin, and all awaitable work lives in the coroutine.

import asyncio
import logging
from typing import Any

from celery import Celery
from pydantic import ValidationError

logging.basicConfig(format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("customs.async_batch")

CELERY_APP = Celery(
    "customs_etl",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/1",
)
MAX_RETRIES = 3


async def _process_document_batch_async(
    envelopes: list[dict[str, Any]],
    retries: int,
    retry_handler,
) -> dict[str, int]:
    """Async pipeline. Kept separate from the Celery wrapper because Celery
    workers do not natively await coroutines."""
    check_circuit_breaker()  # halts consumption when downstream is degraded
    results = {"processed": 0, "dlq_routed": 0}

    for raw_env in envelopes:
        correlation_id = raw_env.get("correlation_id", "<unknown>")
        try:
            env = DocumentEnvelope(**raw_env)                    # Stage 1
            validate_hts_compliance(env.compliance_data)         # Stage 2
            await asyncio.gather(                                # Stage 3
                _verify_ocr(env.file_hash),
                _persist_idempotent(env.correlation_id, env.compliance_data),
            )
            results["processed"] += 1                            # Stage 4
            record_success()

        except (ValidationError, ValueError) as exc:
            # Permanent: schema or nomenclature violation — do not retry.
            log.warning("Permanent failure %s: %s", correlation_id, exc)
            await _route_to_dlq(raw_env, ProcessingState.DLQ, str(exc))
            results["dlq_routed"] += 1
            record_failure()

        except Exception as exc:  # noqa: BLE001 — transient I/O
            log.error("Transient failure %s: %s", correlation_id, exc)
            record_failure()
            countdown = (2 ** retries) * 1.5  # exponential backoff (jitter added below)
            retry_handler(exc=exc, countdown=countdown)

    return results


@CELERY_APP.task(bind=True, max_retries=MAX_RETRIES, acks_late=True)
def process_document_batch(self, envelopes: list[dict[str, Any]]) -> dict[str, int]:
    """Celery wrapper. `acks_late=True` ensures the broker redelivers a batch
    if the worker dies mid-flight — idempotent Stage 4 makes that safe."""
    def _retry(exc: Exception, countdown: float) -> None:
        raise self.retry(exc=exc, countdown=countdown, max_retries=MAX_RETRIES)

    return asyncio.run(
        _process_document_batch_async(envelopes, self.request.retries, _retry)
    )


async def _verify_ocr(file_hash: str) -> None:
    await asyncio.sleep(0.1)  # placeholder: call OCR engine, check drift confidence


async def _persist_idempotent(correlation_id: str, payload: "CompliancePayload") -> None:
    await asyncio.sleep(0.1)  # placeholder: asyncpg UPSERT keyed on correlation_id


async def _route_to_dlq(envelope: dict[str, Any], state: ProcessingState, reason: str) -> None:
    log.info("Routed %s to DLQ (%s): %s", envelope.get("correlation_id"), state.value, reason)

Batch size is not fixed. It is tuned against queue depth, per-worker memory, and downstream API rate limits — micro-batches of roughly 50–200 records keep memory bounded while throughput scales linearly with added workers. The exact broker-backpressure tuning is worked through in Implementing async queues for bulk customs docs.

Validation & Determinism

Validation is the point of the pipeline. Every line item must satisfy Harmonized System digit-length rules before it can reach duty calculation: HS-6 is the globally standardized base under WCO HS 2022, while 8- and 10-digit national lines (HTSUS under the USITC schedule, or EU ATLAS/TARIC subdivisions) must resolve against the correct jurisdictional tariff. Items with an invalid HS length, a missing or ambiguous description, or a non-ISO currency code are quarantined, not propagated — a corrupt classification input is far more expensive than a delayed one.

def validate_hts_compliance(payload: "CompliancePayload") -> None:
    """Enforce HS digit-length and WCO nomenclature rules before classification."""
    for idx, item in enumerate(payload.line_items):
        code = item.hs_code
        if len(code) == 6:
            log.debug("HS-6 base validated (line %d): %s", idx, code)          # WCO global
        elif len(code) in (8, 10):
            log.debug("National tariff line validated (line %d): %s", idx, code)  # HTSUS / TARIC
        else:
            raise ValueError(f"Invalid HS code length at line {idx}: {code!r}")

        if item.currency == "XXX":  # ISO 4217 'no currency' sentinel — never valid on an entry
            raise ValueError(f"Unresolvable currency at line {idx}")

Determinism extends to redelivery: because Stage 4 UPSERTs on correlation_id, replaying a batch produces identical state. The SHA-256 file hash in the envelope lets the consumer reject byte-identical duplicates outright, so at-least-once broker delivery never becomes at-least-twice processing.

Downstream Integration

This stage sits between raw extraction and classification, and its validated output is the contract every downstream consumer depends on. Once a worker claims a document it invokes Commercial Invoice PDF Extraction to parse structured fields, line items, and Incoterms, then reconciles physical quantities through Packing List Data Normalization so gross/net weights, package counts, and container seals agree before duty is assessed. The clean, schema-valid payload that leaves this pipeline is what the classification and duty engines consume — parsing faults are isolated upstream so classification logic only ever sees structurally sound inputs.

Scaling & Resilience

Two controls keep the pipeline stable under peak load. Retry strategy: transient failures — OCR timeouts, dropped database connections, rate-limited tariff APIs — retry with exponential backoff and jitter while preserving the original correlation_id; permanent failures (schema violations, unsupported formats, hash mismatches, unresolvable HS codes) skip retries entirely and route to the DLQ for manual review. The backoff schedule and its interaction with acks_late redelivery is derived in Designing exponential backoff for failed parsing jobs.

Circuit breaker: when the failure rate exceeds a threshold (for example, 15% over a rolling 5-minute window), the breaker opens, halts new task consumption, and drains in-flight batches — protecting the downstream classification database from a flood of corrupt writes during a broker outage or an upstream API degradation. After a cooldown it moves to half-open and lets a single probe batch verify recovery before full throughput resumes. In production the breaker state lives in Redis (not process memory) so every worker shares one view.

from datetime import datetime, timezone

CIRCUIT_FAILURE_THRESHOLD = 0.15
CIRCUIT_WINDOW_SECONDS = 300

# Demonstration state; back this with Redis so all workers share it in production.
_circuit: dict[str, Any] = {"failures": 0, "processed": 0, "last_reset": datetime.now(timezone.utc)}


def check_circuit_breaker() -> None:
    now = datetime.now(timezone.utc)
    if (now - _circuit["last_reset"]).total_seconds() > CIRCUIT_WINDOW_SECONDS:
        _circuit.update(failures=0, processed=0, last_reset=now)  # roll the window
    total = _circuit["processed"] + _circuit["failures"]
    if total and _circuit["failures"] / total >= CIRCUIT_FAILURE_THRESHOLD:
        raise RuntimeError(
            "Circuit breaker OPEN: halting consumption to protect downstream compliance systems."
        )


def record_success() -> None:
    _circuit["processed"] += 1


def record_failure() -> None:
    _circuit["failures"] += 1

Semaphore limits on concurrent OCR and tariff-API calls bound the memory footprint of each worker, keeping micro-batch processing within its allocation even when a burst of large multi-page invoices arrives at once.

Compliance Obligations

Async batch processing is, above all, a compliance control. Decoupling ingestion from parsing lets the pipeline enforce deterministic validation gates, preserve cryptographic integrity, and isolate transient infrastructure faults from tariff classification — all while producing the records an audit demands. Every state transition (QUEUED → PROCESSING → VALIDATED/DLQ) is logged with its correlation_id, file hash, engine version, and timestamp, giving a point-in-time reconstruction of any shipment’s path from receipt to HS assignment. That trail satisfies CBP recordkeeping obligations under 19 CFR §163 and equivalent EU customs requirements, and it drives the human-in-the-loop escalation gate: anything in the DLQ carries enough context for a broker to correct and replay it without guesswork. Retention rules apply to the DLQ payloads and the audit rows equally, since both are part of the entry record. When a tariff bulletin or Federal Register notice changes a national line, the immutable envelope history makes it possible to identify exactly which prior entries used the superseded code.

Up: Document Ingestion & Parsing Workflows

Authoritative references: WCO HS Nomenclature 2022 · USITC Harmonized Tariff Schedule · CBP recordkeeping — 19 CFR Part 163