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, anddatetimeUTC handling that assume 3.11 or newer. -
Libraries — install the async S3 client and validation layer:
bashpip 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:PutObjecton the data-lake prefix only, with noDeleteObjectgrant, 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.
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:
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:
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.
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.
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.
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. |
Related
- Async batching strategies for high-volume sensor data — flush triggers, backpressure, and the ingestion lifecycle this processor sits in.
- Aligning asynchronous sensor timestamps in Python — the UTC normalization stage that should run before batching.
- Validating JSON schemas for IoT temperature payloads — payload-shape enforcement feeding the validation gate.
- Polling vs push architectures for pharma IoT sensors — the upstream transport that determines burst behavior.
- Mapping FDA 21 CFR Part 11 to cold chain sensors — the clause-by-clause compliance reference behind the audit boundary.
For broader context, see the parent topic Async Batching Strategies for High-Volume Sensor Data, part of IoT Sensor Data Ingestion & Time-Series Synchronization.