Async Batch Processing for Catalog Updates

Operating within the broader Catalog Ingestion & ILS Sync Pipelines architecture, asynchronous batch processing is the layer that turns a pile of freshly harvested bibliographic records into a controlled, retry-safe stream of writes against a live integrated library system. Library-tech staff reach for this pattern the moment a nightly vendor dump, an OAI-PMH harvest, or a bulk authority refresh grows large enough that a single synchronous loop would either block circulation-facing services or trip the vendor’s rate limiter. This guide covers how to decouple ingestion from the commit stage, enforce idempotent writes, mask patron data before it reaches a log line, and recover cleanly when a worker dies mid-batch — with production-grade Python you can adapt directly into a Celery or RQ deployment.

Async batch pipeline data flow A single correlation_id is traced left to right across the whole pipeline. A source harvest of MARC and BIBFRAME records flows into an envelope-and-validate stage, then a broker task queue, then a pool of parallel workers that transform and diff each record, and finally an idempotent ILS commit into the ILS core. The validate stage branches down to a quarantine queue for invalid envelopes; the commit stage branches down to quarantine for permanent 4xx errors and to a dead-letter queue for transient errors that exhausted their retry budget. correlation_id — traced end to end Source harvest MARC · BIBFRAME Envelope + validate Broker / task queue Workers ×N transform + diff ILS commit Idempotency-Key ILS core system of record invalid envelope permanent 4xx transient exhausted Quarantine human review Dead-letter queue automated replay

Specification & Contract

Async batch processing has no single governing standard the way MARC or SIP2 does, but a reliable pipeline is built on an explicit internal contract: the task envelope. Every unit of work that enters the queue is a self-describing message carrying enough context to be validated, executed, retried, and audited independently of the batch it came from. Treat this envelope as a versioned schema — workers deployed at different times must agree on its shape, so pin a schema_version and validate on both produce and consume.

The envelope decouples three concerns the blueprint insists on keeping separate: data transformation, task orchestration, and the ILS commit itself. Transformation produces the payload; orchestration reads the routing and retry blocks; the commit stage consumes payload plus idempotency_key and reports back keyed on correlation_id.

Field Type Required Description
schema_version string (semver) yes Envelope contract version; workers reject unknown majors.
correlation_id UUID yes Stable ID traced from source harvest through final ILS acknowledgment.
batch_id UUID yes Groups tasks from one harvest window for reconciliation.
record_id string yes Source control number (MARC 001) or vendor primary key.
operation enum (create/update/delete) yes Derived from MARC leader position 05; drives commit routing.
idempotency_key string yes Deterministic hash of record_id + content digest; dedupes retries.
payload object yes Normalized, PII-masked record body ready for the ILS write.
content_digest string (sha256) yes Hash of the pre-mask source record for change detection and audit.
retry object no max_attempts, attempt, backoff_base for orchestration.
enqueued_at RFC 3339 timestamp yes Enqueue time; supports latency SLOs and stale-message eviction.

The operation field is the contract’s binding to the record itself: it is derived from the MARC leader, exactly as Parsing MARC Records with pymarc resolves leader position 05 (record status) into create, update, or delete routing. Getting that mapping wrong is the most common source of phantom deletes, so it belongs in the validated contract rather than in ad-hoc worker logic.

Prerequisites & Environment Setup

The reference implementation targets a modern async-capable runtime with a message broker and a distributed lock store. Confirm each item before wiring workers to a production ILS.

Never bake the ILS_TOKEN or PII_HASH_SALT into task payloads or source control. The salt in particular is load-bearing for irreversible patron anonymization; if it leaks, every hash it ever produced becomes attackable.

Core Implementation

The pipeline runs in four labeled stages: (1) build and validate the envelope, (2) enqueue with a stable idempotency key, (3) execute the transform under a distributed lock, and (4) commit to the ILS with explicit acknowledgment.

Step 1 — Define and validate the task envelope

Model the contract as a pydantic schema so malformed records are rejected at the boundary rather than three stages downstream. Validation failures raise before anything reaches the broker.

python
from __future__ import annotations

import hashlib
import uuid
from datetime import datetime, timezone
from enum import Enum

import structlog
from pydantic import BaseModel, Field, ValidationError

log = structlog.get_logger("catalog.batch")


class Operation(str, Enum):
    CREATE = "create"
    UPDATE = "update"
    DELETE = "delete"


class TaskEnvelope(BaseModel):
    schema_version: str = "1.0.0"
    correlation_id: uuid.UUID = Field(default_factory=uuid.uuid4)
    batch_id: uuid.UUID
    record_id: str
    operation: Operation
    idempotency_key: str
    payload: dict
    content_digest: str
    enqueued_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))


def build_envelope(batch_id: uuid.UUID, record_id: str,
                   operation: Operation, payload: dict) -> TaskEnvelope:
    """Construct a validated envelope with a deterministic idempotency key.

    The key binds record identity to content: a re-harvest with identical
    content yields the same key (safe to dedupe), while a genuine change
    produces a new key (safe to apply).
    """
    canonical = repr(sorted(payload.items())).encode("utf-8")
    content_digest = hashlib.sha256(canonical).hexdigest()
    idempotency_key = hashlib.sha256(
        f"{record_id}:{operation.value}:{content_digest}".encode("utf-8")
    ).hexdigest()
    try:
        return TaskEnvelope(
            batch_id=batch_id,
            record_id=record_id,
            operation=operation,
            idempotency_key=idempotency_key,
            payload=payload,
            content_digest=content_digest,
        )
    except ValidationError:
        log.error("envelope.invalid", record_id=record_id, operation=operation.value)
        raise

Pitfall: do not seed the idempotency key from a random UUID or a timestamp. If the key changes on every run, a network hiccup that triggers a retry will write the same record twice. The key must be a pure function of identity plus content.

Step 2 — Enqueue in idempotent chunks

Partition the batch into bounded chunks so a single failure never forces a full replay, and so back-pressure is observable. Each chunk is dispatched independently; the correlation ID rides along on every task.

python
from itertools import islice
from typing import Iterable, Iterator

from celery import Celery

app = Celery("catalog", broker="${BROKER_URL}", backend="${RESULT_BACKEND}")

CHUNK_SIZE = 250


def chunked(it: Iterable, size: int) -> Iterator[list]:
    iterator = iter(it)
    while batch := list(islice(iterator, size)):
        yield batch


def dispatch_batch(envelopes: Iterable[TaskEnvelope]) -> None:
    for chunk in chunked(envelopes, CHUNK_SIZE):
        for env in chunk:
            commit_record.apply_async(
                kwargs={"envelope": env.model_dump(mode="json")},
                task_id=env.idempotency_key,   # broker-level dedupe
                headers={"correlation_id": str(env.correlation_id)},
            )
        log.info("batch.chunk.dispatched", size=len(chunk))

Setting task_id to the idempotency key lets the broker itself reject an exact duplicate submission, giving you a second dedupe layer beneath the ILS-level guard in Step 4. The routing and backoff primitives that make this reliable across many worker nodes are covered in depth in Using Celery for Distributed Catalog Ingestion.

Step 3 — Execute the transform under a record-scoped lock

Concurrent workers will eventually pick up two updates to the same bibliographic or holdings record. Serialize those with a Redis lock scoped to the record identifier, with a TTL so a crashed worker cannot deadlock the queue.

python
import redis
from redis.exceptions import LockError

_redis = redis.Redis.from_url("${RESULT_BACKEND}")


def commit_under_lock(env: TaskEnvelope) -> None:
    lock = _redis.lock(
        name=f"lock:record:{env.record_id}",
        timeout=30,          # auto-release ceiling if the worker dies
        blocking_timeout=5,  # give up fast rather than pile up
    )
    acquired = lock.acquire()
    if not acquired:
        # Another worker holds this record; requeue rather than block.
        raise RecordLocked(env.record_id)
    try:
        _apply_to_ils(env)
    finally:
        try:
            lock.release()
        except LockError:
            # Lock already expired (slow commit); the TTL protected us.
            log.warning("lock.expired_before_release", record_id=env.record_id)

Pitfall: scope the lock to the record, never to the whole batch. A batch-wide lock silently collapses your concurrency back to one, and the symptom — a pipeline that is mysteriously as slow as the synchronous version you replaced — is hard to spot in metrics.

Step 4 — Commit to the ILS with explicit acknowledgment

The commit is the only stage allowed to talk to the vendor. It sends the idempotency key as a request header so the ILS can reject a duplicate write even if both broker and lock guards were bypassed, and it acknowledges the task only after a confirmed 2xx.

python
import httpx
from tenacity import (retry, stop_after_attempt, wait_exponential_jitter,
                      retry_if_exception_type)


class TransientILSError(Exception): ...
class PermanentILSError(Exception): ...


@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential_jitter(initial=1, max=30),
    retry=retry_if_exception_type(TransientILSError),
    reraise=True,
)
def _apply_to_ils(env: TaskEnvelope) -> None:
    endpoint = f"${ILS_BASE_URL}/bibs/{env.record_id}"
    method = "DELETE" if env.operation is Operation.DELETE else "PUT"
    bound = log.bind(correlation_id=str(env.correlation_id),
                     record_id=env.record_id, operation=env.operation.value)
    with httpx.Client(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
        resp = client.request(
            method, endpoint,
            headers={"Authorization": "Bearer ${ILS_TOKEN}",
                     "Idempotency-Key": env.idempotency_key},
            json=env.payload if method == "PUT" else None,
        )
    if resp.status_code in (429, 502, 503, 504):
        bound.warning("ils.transient", status=resp.status_code)
        raise TransientILSError(resp.status_code)
    if resp.status_code >= 400:
        bound.error("ils.permanent", status=resp.status_code)
        raise PermanentILSError(resp.status_code)
    bound.info("ils.committed", status=resp.status_code)

Distinguishing TransientILSError from PermanentILSError is what keeps retries honest: a 503 deserves an exponential-backoff retry, while a 422 schema rejection will fail identically on every attempt and belongs in the quarantine path from the next section. The backoff shape here mirrors the vendor-specific tuning in Configuring Exponential Backoff for Sierra API Calls.

PII & Compliance Checkpoints

Public sector deployments require that patron identifiers, circulation notes, and acquisition cost data never appear in a log line, a result backend, or an intermediate broker message. The batch pipeline has two mandatory checkpoints: mask before enqueue, and mask again before any telemetry is emitted.

Masking belongs before the envelope is built, so the payload that travels through the broker is already clean. Apply a deterministic salted hash to any identifier that must survive as an audit token, and replace free-text PII-bearing subfields with a structural placeholder. Because the transform is a pure function of the salt, reprocessing a record produces the same masked output — the operation is idempotent, which is what lets a dead-letter replay stay compliant.

python
import hashlib
import os

_SALT = os.environ["PII_HASH_SALT"].encode("utf-8")
_PII_SUBFIELDS = {"patron_barcode", "borrower_note", "email", "phone"}


def mask_payload(payload: dict) -> dict:
    """Irreversibly hash retained identifiers; redact free-text PII fields."""
    masked = dict(payload)
    for key in list(masked):
        if key in _PII_SUBFIELDS:
            value = str(masked[key]).encode("utf-8")
            masked[key] = "hash:" + hashlib.sha256(_SALT + value).hexdigest()[:16]
        elif key.endswith("_note"):
            masked[key] = "[REDACTED]"
    return masked

The broader retention rules that decide which fields may be retained as hashes versus dropped entirely are governed by Data Retention Policies for Public Libraries, and the field-level masking catalog is maintained under PII Masking in Patron Data Exports. Keep the content_digest computed over the pre-mask record so change detection stays accurate, but never persist the pre-mask body beyond the transform stack.

Error Handling & Quarantine Patterns

A record that cannot be committed must leave the main queue quickly and land somewhere it can be inspected, not retried into an infinite loop. Route by exception type: transient errors exhaust their retry budget and then go to the dead-letter queue for automated replay; permanent errors go straight to quarantine for human review.

python
class RecordLocked(Exception):
    def __init__(self, record_id: str):
        self.record_id = record_id
        super().__init__(record_id)


@app.task(bind=True, acks_late=True, max_retries=0)
def commit_record(self, envelope: dict) -> None:
    env = TaskEnvelope.model_validate(envelope)
    bound = log.bind(correlation_id=str(env.correlation_id), record_id=env.record_id)
    try:
        commit_under_lock(env)
    except RecordLocked:
        # Transient contention: requeue with a short countdown.
        raise self.retry(countdown=5, max_retries=3)
    except TransientILSError as exc:
        bound.warning("commit.transient_exhausted", error=str(exc))
        _dead_letter(env, reason="transient_exhausted")
    except (PermanentILSError, ValidationError) as exc:
        bound.error("commit.quarantined", error=str(exc))
        _quarantine(env, reason=type(exc).__name__)


def _quarantine(env: TaskEnvelope, reason: str) -> None:
    _redis.rpush("queue:quarantine", env.model_dump_json())
    log.info("record.quarantined", record_id=env.record_id, reason=reason)


def _dead_letter(env: TaskEnvelope, reason: str) -> None:
    _redis.rpush("queue:dead_letter", env.model_dump_json())
    log.info("record.dead_lettered", record_id=env.record_id, reason=reason)

Setting acks_late=True is essential: the task is acknowledged only after the body runs to completion, so a worker that is killed mid-commit leaves the message on the broker to be redelivered rather than silently lost. Structurally malformed records that fail even envelope validation should be routed through the same quarantine path that Schema Validation for Ingested Records uses at the ingestion boundary, so there is one place to review everything that could not be applied.

Performance Considerations

Throughput is governed by whether the bottleneck is I/O or CPU. Catalog commits are overwhelmingly I/O-bound — the worker spends its time waiting on the ILS — so the effective lever is concurrency, not raw core count. Two guidelines hold up in production:

For genuinely mixed workloads — where MARC-to-payload normalization is CPU-heavy — run a two-layer topology: an asyncio event loop for the ILS network calls, feeding a ProcessPoolExecutor for the CPU-bound transform so the Global Interpreter Lock never serializes your parsing. Emit event-loop lag as a metric; a rising lag figure is the earliest signal that CPU work has leaked onto the I/O loop.

python
import asyncio


async def bounded_commit(envelopes: list[TaskEnvelope], rps: int) -> None:
    sem = asyncio.Semaphore(rps)

    async def _one(env: TaskEnvelope) -> None:
        async with sem:
            await asyncio.to_thread(commit_under_lock, env)

    async with asyncio.TaskGroup() as tg:
        for env in envelopes:
            tg.create_task(_one(env))

Verification & Testing

Validate the pipeline against a mock ILS before pointing it at production. Three assertions catch the failures that matter most: idempotency, correct operation routing, and clean quarantine behavior.

python
import respx
from httpx import Response


@respx.mock
def test_idempotent_retry_writes_once():
    route = respx.put(url__regex=r".*/bibs/.*").mock(
        side_effect=[Response(503), Response(200)]
    )
    env = build_envelope(uuid.uuid4(), "b1001", Operation.UPDATE,
                         {"title": "Test", "patron_barcode": "X"})
    _apply_to_ils(env)  # first call 503 -> retry -> 200
    # Same idempotency key on both attempts proves no duplicate identity.
    keys = {c.request.headers["Idempotency-Key"] for c in route.calls}
    assert len(keys) == 1


def test_payload_is_masked_before_enqueue():
    env = build_envelope(uuid.uuid4(), "b1002", Operation.UPDATE,
                         mask_payload({"patron_barcode": "2100450"}))
    assert env.payload["patron_barcode"].startswith("hash:")
    assert "2100450" not in env.model_dump_json()

Run these under pytest with the broker mocked so the suite stays hermetic. For a full end-to-end check, dispatch a small batch against a staging ILS, then reconcile the batch_id group: every envelope should appear exactly once in the ILS audit log, with the quarantine and dead-letter queues empty for a clean run.

Troubleshooting & FAQ

Why are some records written to the ILS twice?

The idempotency key is not deterministic. Confirm it is derived purely from record_id, operation, and content_digest — never a UUID or timestamp. Also verify the ILS actually honors the Idempotency-Key header; some endpoints ignore it, in which case the broker-level task_id dedupe in Step 2 becomes your only guard and must be enabled.

The pipeline is no faster than the old synchronous script. What happened?

Almost always a lock scoped too broadly. A batch-wide or table-wide Redis lock serializes every worker back to one. Scope the lock to the individual record_id so unrelated records commit in parallel, and confirm blocking_timeout is short enough that contended tasks requeue instead of stalling.

Workers keep raising RecordLocked and tasks pile up.

Two records with the same identifier are being dispatched inside one chunk, so they fight for the same lock. Deduplicate by idempotency_key at enqueue time, and set a small countdown on the retry so contention resolves without a hot loop. Persistent contention usually means the upstream harvest is emitting duplicate 001 control numbers.

A killed worker lost records mid-batch. How do I prevent that?

Ensure every task uses acks_late=True so the message is acknowledged only after the commit succeeds. Without it, the broker acks on delivery and a worker crash drops the in-flight record silently. With late acks, the message is redelivered and — because the write is idempotent — safely reapplied.

The quarantine queue is filling with 422 errors.

A 422 is a schema rejection, not a transient fault, so retrying is pointless. Inspect a quarantined envelope’s payload against the target contract; the usual cause is a field-mapping drift between your normalizer and the ILS schema. Fix the mapping upstream and replay the quarantine queue rather than editing records in place.

Retries never stop and the same record loops forever.

A permanent error is being classified as transient. Audit the exception mapping in _apply_to_ils: only 429 and 5xx should raise TransientILSError. Any 4xx other than 429 must raise PermanentILSError so it exits to quarantine on the first attempt instead of consuming the full retry budget.