Building async batch processors for cold chain data lakes

Pharmaceutical cold chain operations generate continuous, high-frequency telemetry from distributed IoT sensors monitoring refrigerated storage, lyophilization chambers, and transport vehicles. When scaling from hundreds to tens of thousands of endpoints, synchronous ingestion architectures introduce unacceptable latency, memory pressure, and audit-trail fragmentation. This guide builds an async batch processor that decouples ingestion from persistence while enforcing data integrity at the batch boundary, extending the async batching strategies for high-volume sensor data covered in the parent topic.

Regulatory hook

A batch processor that writes pharmaceutical temperature telemetry into a data lake is a regulated record-keeping component, not a generic ETL job. FDA 21 CFR §11.10(e) requires secure, computer-generated, time-stamped audit trails that record system events without obscuring previously recorded information, so every batch must carry a tamper-evident digest and preserve the sensor-originated UTC timestamp untouched. EU GDP Annex 11 §4.2 extends ALCOA+ data integrity across the full data lifecycle, which means malformed payloads must be quarantined rather than silently mutated or dropped, and USP <1079> requires that the continuous temperature record — including any excursion states — remain complete and retrievable. The processor below treats these three constraints as hard boundaries that gate persistence, mirroring the clause-by-clause approach in mapping FDA 21 CFR Part 11 to cold chain sensors.

Prerequisites

  • Python 3.11+ — the code uses asyncio.TaskGroup-friendly patterns, tuple[...] builtin generics, and datetime UTC handling that assume 3.11 or newer.

  • Libraries — install the async S3 client and validation layer:

    bash
    pip install "pydantic>=2.6,<3" "aioboto3>=12.3"
  • Object storage — an S3-compatible, append-only bucket (AWS S3, MinIO, or Cloudflare R2) with object versioning enabled so an accidental overwrite is itself recoverable for audit.

  • Upstream contract — telemetry arrives pre-normalized to UTC. If your gateways emit mixed epoch/ISO formats, run aligning asynchronous sensor timestamps in Python ahead of this stage, and validate payload shape with schema validation pipelines for temperature telemetry.

  • Access control — the writer’s IAM principal must hold scoped s3:PutObject on the data-lake prefix only, with no DeleteObject grant, so the append-only property is enforced by policy and not just by code (§11.10(d) limiting system access to authorized individuals).

Step-by-step implementation

Step 1 — Define a compliance-enforced telemetry schema

The validation gate is the first trust boundary: invalid records must never reach the primary data lake. A frozen Pydantic model with extra="forbid" rejects unexpected fields introduced by firmware drift and makes each accepted record immutable in memory.

python
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field, ValidationError, ConfigDict

class ComplianceState(str, Enum):
    WITHIN_SPEC = "within_spec"
    EXCURSION = "excursion"
    UNKNOWN = "unknown"

class TelemetryRecord(BaseModel):
    # frozen + extra="forbid" enforces ALCOA+ "Original/Accurate": accepted
    # records are immutable and unknown fields are rejected, not coerced.
    model_config = ConfigDict(frozen=True, extra="forbid")

    sensor_id: str = Field(..., min_length=4, max_length=64, description="Unique hardware identifier")
    timestamp_utc: datetime = Field(..., description="Original sensor-generated UTC timestamp")
    temperature_c: float = Field(..., ge=-100.0, le=100.0, description="Temperature in Celsius")
    zone_id: str = Field(..., pattern=r"^[A-Z0-9\-]+$", description="Cold storage zone identifier")
    compliance_state: ComplianceState = ComplianceState.UNKNOWN
    raw_payload_hash: Optional[str] = None

    @classmethod
    def from_raw(cls, payload: dict) -> tuple[Optional["TelemetryRecord"], Optional[str]]:
        """Validate payload; return (record, None) or (None, error_msg)."""
        try:
            return cls(**payload), None
        except ValidationError as e:
            # §11.10(a): payloads that fail validation are not discarded —
            # the error is captured for the dead-letter quarantine path.
            return None, str(e)

Verify the gate accepts good records and rejects malformed ones:

python
ok, err = TelemetryRecord.from_raw(
    {"sensor_id": "CC-01A", "timestamp_utc": "2026-06-28T10:00:00Z",
     "temperature_c": 4.1, "zone_id": "FRZ-A1"}
)
assert ok is not None and err is None
bad, err = TelemetryRecord.from_raw({"sensor_id": "x", "temperature_c": 999})
assert bad is None and err is not None  # rejected, not coerced

The processor combines this validation path (with a dead-letter quarantine for malformed payloads) and a flush controller (size- and time-bounded), then partitions each batch by the median record timestamp so late-arriving telemetry lands in the partition the data actually belongs to:

Async cold chain batch processor data flow Pipeline diagram. A raw payload passes through a Pydantic validation decision. Valid records flow into an in-memory deque buffer; invalid records branch down to a dead-letter quarantine. The buffer feeds a flush-trigger decision (size at or above max, an explicit flush event, or a timeout); when it fires, the batch is sealed with a canonical SHA-256 audit digest, partitioned by the median record timestamp, and persisted to an append-only S3 data lake. A dashed feedback loop shows that a write failure re-buffers the batch back into the deque. raw payload in-memory buffer (deque) SHA-256 digest (canonical) partition by median record ts S3 data lake (append-only) Pydantic validate size ≥ max · event · timeout DLQ quarantine (invalid records) re-buffer on write failure valid yes invalid write failure

Step 2 — Build the async buffer and flush controller

The buffer accumulates validated records and flushes on whichever trigger fires first: size threshold, explicit flush event, or timeout. Each flush seals the batch with a SHA-256 digest over its canonical JSON, partitions by the median sensor timestamp, and writes an idempotent object key.

python
import asyncio
import hashlib
import json
import logging
from collections import deque
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional

logger = logging.getLogger(__name__)

class AsyncColdChainBatcher:
    def __init__(self, max_batch_size: int = 5000, flush_interval_sec: float = 10.0, s3_client=None):
        self.max_batch_size = max_batch_size
        self.flush_interval_sec = flush_interval_sec
        self.s3_client = s3_client
        self._buffer: deque = deque()
        self._lock = asyncio.Lock()
        self._flush_event = asyncio.Event()
        self._running = False
        self._task: Optional[asyncio.Task] = None
        self._dead_letter_queue: List[Dict[str, Any]] = []
        self._dlq_lock = asyncio.Lock()

    async def start(self):
        self._running = True
        self._task = asyncio.create_task(self._periodic_flush_loop())
        logger.info("Async batch processor started")

    async def stop(self):
        self._running = False
        if self._task:
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass
        await self._flush_batch(force=True)  # §11.10(c): no buffered record is lost on shutdown
        logger.info("Async batch processor stopped")

    async def ingest(self, raw_payload: Dict[str, Any]):
        record, error = TelemetryRecord.from_raw(raw_payload)
        if record:
            async with self._lock:
                self._buffer.append(record)
                if len(self._buffer) >= self.max_batch_size:
                    self._flush_event.set()
        else:
            async with self._dlq_lock:
                # Annex 11 §4.2: malformed records are quarantined with the
                # rejection reason and time, never coerced into the lake.
                self._dead_letter_queue.append({
                    "raw": raw_payload,
                    "validation_error": error,
                    "rejected_at_utc": datetime.now(timezone.utc).isoformat(),
                })
                if len(self._dead_letter_queue) >= 1000:
                    await self._flush_dlq()

    async def _periodic_flush_loop(self):
        while self._running:
            try:
                await asyncio.wait_for(self._flush_event.wait(), timeout=self.flush_interval_sec)
            except asyncio.TimeoutError:
                pass
            except asyncio.CancelledError:
                raise
            try:
                await self._flush_batch()
            except Exception:
                # A single flush failure must never kill the loop; the error is
                # logged in _write_to_datalake and the records are re-buffered.
                logger.exception("Periodic flush cycle failed; loop continues")
            finally:
                # Clear AFTER the flush so a set() arriving mid-flush re-triggers.
                async with self._lock:
                    if not self._buffer:
                        self._flush_event.clear()

Step 3 — Seal and persist each batch (append-only, idempotent)

The flush computes a tamper-evident digest, derives a deterministic object key from it, and writes to a date-partitioned prefix. Identical keys make retries idempotent, and a write failure re-buffers the batch rather than losing it.

python
    async def _flush_batch(self, force: bool = False):
        async with self._lock:
            if not self._buffer:
                return
            batch_records = list(self._buffer)
            self._buffer.clear()

        # §11.10(e): tamper-evident, computer-generated seal over canonical JSON.
        canonical_json = json.dumps(
            [r.model_dump(mode="json") for r in batch_records],
            sort_keys=True, separators=(",", ":"),
        )
        batch_digest = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()

        # Partition by each record's OWN timestamp_utc (median), not flush
        # wall-clock, so late-arriving telemetry lands in the right partition.
        timestamps = sorted(r.timestamp_utc for r in batch_records)
        midpoint = timestamps[len(timestamps) // 2]
        partition_key = f"year={midpoint:%Y}/month={midpoint:%m}/day={midpoint:%d}"
        object_key = f"coldchain/{partition_key}/batch_{batch_digest}.json"

        await self._write_to_datalake(object_key, batch_records, batch_digest)

    async def _write_to_datalake(self, key: str, records: List[TelemetryRecord], digest: str):
        if not self.s3_client:
            logger.info("[SIMULATION] Flushing %d records to %s | Digest: %s", len(records), key, digest)
            return
        try:
            payload = json.dumps({
                "metadata": {
                    "batch_digest": digest,
                    "record_count": len(records),
                    "flush_utc": datetime.now(timezone.utc).isoformat(),
                },
                "records": [r.model_dump(mode="json") for r in records],
            }).encode("utf-8")
            await self.s3_client.put_object(
                Bucket="pharma-coldchain-datalake",
                Key=key,  # deterministic key => idempotent overwrite on retry
                Body=payload,
            )
            logger.info("Successfully flushed batch to %s", key)
        except Exception as e:
            # ALCOA+ "Complete": never silently drop a batch. Re-buffer at the
            # head of the deque and retry next cycle; bound retries by alerting
            # on deque depth.
            logger.error("Data lake write failed for %s: %s; re-buffering batch", key, e)
            async with self._lock:
                self._buffer.extendleft(reversed(records))
                self._flush_event.set()
            raise

    async def _flush_dlq(self):
        async with self._dlq_lock:
            if not self._dead_letter_queue:
                return
            dlq_dump = self._dead_letter_queue.copy()
            self._dead_letter_queue.clear()
        # §11.10(a): quarantined records persist to a separate, reviewable store.
        logger.warning("Flushing %d invalid records to dead-letter storage", len(dlq_dump))

Step 4 — Run the processor as a long-running service

Initialize the aioboto3 session once and reuse it to avoid connection-pool exhaustion. Yielding to the event loop during bursts keeps ingestion non-blocking.

python
import aioboto3

async def run_processor():
    async with aioboto3.Session().client("s3") as s3:
        processor = AsyncColdChainBatcher(max_batch_size=2000, flush_interval_sec=15.0, s3_client=s3)
        await processor.start()
        for i in range(10000):
            await processor.ingest({
                "sensor_id": f"CC-SENSOR-{i % 50:03d}",
                "timestamp_utc": datetime.now(timezone.utc).isoformat(),
                "temperature_c": 4.2 + (i % 10) * 0.1,
                "zone_id": "FRZ-A1",
                "compliance_state": "within_spec",
            })
            if i % 500 == 0:
                await asyncio.sleep(0.01)  # yield to event loop during bursts
        await processor.stop()

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    asyncio.run(run_processor())

Run it against a local MinIO endpoint and verify the seal is reproducible: re-serialize any written batch’s records with the same canonical settings and confirm the SHA-256 matches the batch_digest embedded in the object key. Equality proves the record set has not been altered since persistence — the property a Part 11 audit depends on. For the upstream transport decision that feeds this stage, compare polling vs push architectures for pharma IoT sensors; for how persisted excursion states are later evaluated, see duration-based scoring for temperature excursions.

Compliance validation checklist

Use this checklist to confirm the processor satisfies what an inspector will verify for this specific control:

Troubleshooting

Symptom Root cause Fix
OOM kill under sustained outage Unbounded deque growth while writes fail and re-buffer Cap at max_batch_size * 2; return HTTP 429/503 to gateways and apply backpressure upstream. Track with tracemalloc.
Readings land in the wrong date partition Partitioning on ingestion wall-clock instead of sensor time Partition by the median timestamp_utc of the batch so post-blackout telemetry lands where it occurred.
Duplicate records after a network blip Retry produced a second write with a new key Derive the object key from the batch digest so identical content overwrites idempotently; add exponential backoff with jitter.
Valid-looking payloads silently rejected Firmware update added fields and extra="forbid" rejects them Version the schema per deployment, route unrecognized payloads to a versioned DLQ, and migrate under change control.
MKT query returns wrong thermal history Excursion readings split across partition boundaries Confirm median-timestamp partitioning is active; ensure compliance queries scan the full date range covering transit gaps.

For broader context, see the parent topic Async Batching Strategies for High-Volume Sensor Data, part of IoT Sensor Data Ingestion & Time-Series Synchronization.