Async Batch Processing for Large Datasets
Healthcare data ecosystems generate continuous, high-volume streams of clinical records spanning FHIR resources, HL7 v2 messages, CCD/CCDA documents, and proprietary EHR exports. Synchronous request/response pipelines routinely collapse under this load, introducing memory exhaustion, unbounded latency, and compliance bottlenecks. Async batch processing decouples ingestion from transformation, enabling horizontal scaling, deterministic retry semantics, and strict auditability. Within the broader Clinical Data Parsing & Transformation Workflows pipeline, this sub-problem sits at the boundary where multi-gigabyte feeds must be drained without saturating memory, while still enforcing the schema validation, type normalization, and lineage tracking that every downstream consumer depends on. For health tech engineers, clinical data scientists, ETL developers, and compliance teams, the architecture must balance throughput against deterministic state management — particularly when handling PHI/PII under HIPAA, GDPR, and 21 CFR Part 11.
This page covers the concrete engineering of an async batch worker: bounded-concurrency streaming, FHIR/HL7 parsing in isolated execution contexts, idempotent sinks, and the compliance controls that keep PHI out of logs and dead-letter queues.
Prerequisites & Context
Before implementing the patterns below, confirm the following are in place. These are written as a working checklist — each item is load-bearing for the implementation that follows.
If your source is a bulk FHIR $export, review FHIR REST vs Bulk Data Export first — the NDJSON output shape it produces is the canonical input format assumed throughout this page.
Concept & Spec Detail: The Async Batch Model
Async batch processing for clinical data is not “run a script overnight.” It is a bounded-concurrency consumer that streams fixed-size units of work, validates each in isolation, and commits offsets only after a durable downstream write. Three properties define a correct implementation:
1. Backpressure, not buffering. Clinical feeds rarely fit in memory. A correct worker pulls work only when it has capacity to process it, rather than draining the broker into RAM. Backpressure is enforced with a concurrency primitive (an asyncio.Semaphore or a bounded worker pool) so that the number of in-flight chunks is capped regardless of how fast the source produces.
2. Streaming deserialization. NDJSON exports and HL7 batch files are parsed line-by-line. A single physical file may hold hundreds of thousands of resources; loading the whole bundle into a Python object graph is the single most common cause of OOM kills in clinical ETL.
3. At-least-once delivery with idempotent sinks. Exactly-once semantics across a broker and a database are expensive and brittle. The industry-standard pattern is at-least-once delivery paired with deterministic, idempotent writes — covered in depth in implementing idempotent clinical data loads.
The table below summarizes the unit-of-work contract that the rest of this page builds on.
| Property | Synchronous pipeline | Async batch worker |
|---|---|---|
| Memory footprint | Grows with payload size | Bounded by concurrency limit |
| Failure blast radius | Whole request | Single chunk → quarantine |
| Delivery guarantee | Implicit, often lost on crash | At-least-once + idempotent sink |
| Retry semantics | Caller-driven, unbounded | Bounded retries with jittered backoff |
| Throughput scaling | Vertical only | Horizontal (stateless workers) |
Bounded Concurrency & Memory Constraints
The worker pulls from the broker or object store in fixed-size chunks and processes them under a semaphore. This is what keeps memory and connection-pool pressure flat:
- Bounded concurrency: Use
asyncio.Semaphoreor worker-pool limits to prevent TCP connection exhaustion and database connection-pool starvation. - Chunked deserialization: Parse NDJSON line-by-line with
aiofilesor memory-mapped buffers. Never materialize an entire bundle. - Circuit breakers & rate limiting: EHR APIs and FHIR servers enforce strict rate limits. Implement token-bucket throttling with exponential backoff to avoid 429 cascades.
- Graceful shutdown: Trap
SIGTERM/SIGINT, drain in-flight tasks, and commit offsets only after successful downstream writes.
Implementation
The following steps build a production-grade async batch worker incrementally. Each step is independently testable.
Step 1: Bounded streaming consumer
Drain the source under a semaphore so that in-flight work never exceeds the configured limit. The semaphore is the backpressure mechanism — process_stream will naturally block when all permits are held.
import asyncio
from typing import AsyncIterator
class StreamConsumer:
def __init__(self, max_concurrency: int = 50):
self.semaphore = asyncio.Semaphore(max_concurrency)
self._tasks: set[asyncio.Task] = set()
async def process_stream(self, chunks: AsyncIterator[bytes], handler):
async for chunk in chunks:
await self.semaphore.acquire()
task = asyncio.create_task(self._guarded(handler, chunk))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
# Drain in-flight work before returning (graceful shutdown).
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
async def _guarded(self, handler, chunk: bytes):
try:
await handler(chunk)
finally:
self.semaphore.release()
Validation: assert that concurrency never exceeds the limit under a synthetic firehose.
import asyncio
async def _test_bounded_concurrency():
peak = 0
live = 0
lock = asyncio.Lock()
async def handler(_chunk):
nonlocal peak, live
async with lock:
live += 1
peak = max(peak, live)
await asyncio.sleep(0.01)
async with lock:
live -= 1
async def fake_source():
for i in range(1000):
yield f"line-{i}".encode()
consumer = StreamConsumer(max_concurrency=50)
await consumer.process_stream(fake_source(), handler)
assert peak <= 50, f"concurrency breach: {peak}"
asyncio.run(_test_bounded_concurrency())
Step 2: Parse and validate in isolation
Each chunk is parsed in its own try/except so a single malformed record cannot poison the batch. FHIR payloads are validated against R4 models; HL7 v2 payloads go through segment extraction. Validation errors are terminal — they should never be retried, because the payload will fail identically every time.
from pydantic import ValidationError
from fhir.resources.bundle import Bundle
def parse_fhir_chunk(raw_chunk: bytes) -> Bundle:
# Pydantic-backed R4 validation rejects structural violations at the boundary.
return Bundle.parse_raw(raw_chunk)
The split between terminal validation failures and retryable transient failures (timeouts, 5xx, lock contention) is the core control-flow decision of the worker. Retrying a ValidationError wastes capacity and delays the rest of the batch.
Step 3: Deterministic type coercion
Type coercion is where clinical pipelines typically break. Dates must be normalized to ISO 8601 with explicit timezone offsets (+00:00), numeric lab values require UCUM unit harmonization, and coded concepts need crosswalk resolution. Apply these rules deterministically within the transformation boundary; the full ruleset lives in type coercion for clinical data types. Coded values should be validated against active value sets via a FHIR terminology server before projection, never against a hardcoded lookup.
Step 4: Idempotent upsert and offset commit
Generate a deterministic key, upsert with conflict resolution, then commit the offset. The key construction and watermarking strategy are covered in implementing idempotent clinical data loads; the rule that matters here is commit the offset only after the durable write succeeds.
- Deterministic keys: Compose from
resourceType, the logical identifier (system|value), andmeta.versionId; mitigate hash collisions with SHA-256 (truncate only with awareness of the birthday bound). - Upsert semantics: Use
INSERT ... ON CONFLICT DO UPDATE, applying the update only whenmeta.lastUpdatedis strictly greater than the stored version. - Watermarking: Maintain a high-water mark per source system and discard records older than the committed watermark to prevent out-of-order reprocessing.
Step 5: Assemble the worker
The full worker wires the previous steps together with bounded retries, jittered backoff, and quarantine routing.
import asyncio
import hashlib
import logging
from typing import AsyncIterator, Dict, Any
from pydantic import ValidationError
from fhir.resources.bundle import Bundle
logger = logging.getLogger(__name__)
class ClinicalBatchWorker:
def __init__(self, sink_db, quarantine_queue, max_retries: int = 3,
max_concurrency: int = 50):
self.sink = sink_db
self.dlq = quarantine_queue
self.max_retries = max_retries
self.semaphore = asyncio.Semaphore(max_concurrency)
async def process_stream(self, payload_stream: AsyncIterator[bytes]):
async for chunk in payload_stream:
async with self.semaphore:
await self._process_chunk(chunk)
async def _process_chunk(self, raw_chunk: bytes):
attempt = 0
while attempt <= self.max_retries:
try:
resource = Bundle.parse_raw(raw_chunk) # parse + validate
transformed = self._normalize_types(resource) # deterministic coercion
await self.sink.upsert(transformed) # idempotent write
await self._emit_audit_log(transformed, status="SUCCESS")
return
except ValidationError as ve:
# Terminal: identical payload will always fail. Do not retry.
await self._route_to_quarantine(raw_chunk, ve, attempt)
return
except Exception as e:
attempt += 1
if attempt > self.max_retries:
await self._route_to_quarantine(raw_chunk, e, attempt)
return
backoff = min(2 ** attempt, 30) + (hash(raw_chunk) % 1000) / 1000
await asyncio.sleep(backoff) # exponential backoff + jitter
def _normalize_types(self, bundle: Bundle) -> Dict[str, Any]:
# UCUM harmonization, ISO 8601 timezone enforcement, coding crosswalks.
return {"id": bundle.id, "meta": bundle.meta.dict(), "entries": []}
async def _route_to_quarantine(self, raw: bytes, error: Exception, attempt: int):
payload_hash = hashlib.sha256(raw).hexdigest()[:12]
await self.dlq.publish({
"hash": payload_hash,
"error_type": type(error).__name__,
"error_msg": str(error),
"attempt": attempt,
"raw_ref": f"s3://quarantine/{payload_hash}.ndjson", # reference, never inline PHI
})
async def _emit_audit_log(self, record: Dict[str, Any], status: str):
# Append-only, PHI-masked audit trail — hashed references and metadata only.
logger.info(
"AUDIT | %s | resource_id=%s | ts=%s",
status, record.get("id"), record.get("meta", {}).get("lastUpdated"),
)
Implementation notes:
- Bounded concurrency keeps the memory footprint and connection-pool usage predictable.
- Validation isolation routes
ValidationErrorstraight to quarantine, avoiding wasteful retries on malformed payloads. - Exponential backoff with jitter prevents retry storms during transient network or database failures.
- Audit-ready logging separates operational telemetry from the compliance audit trail; raw payloads are never logged.
Orchestration, Partitioning & Horizontal Scaling
Managing thousands of concurrent parsing tasks requires orchestration above the worker. DAG-based schedulers partition workloads by resource type, facility ID, or ingestion timestamp, giving predictable execution windows and resource isolation. For the Airflow-specific implementation — dynamic task mapping, pool quotas, and sensor design — see scaling FHIR batch processing with Apache Airflow.
- Dynamic task mapping: Partition NDJSON/HL7 files by line count or byte size, spawning parallel workers that respect cluster resource quotas.
- Retry policies with jitter: Bound retries (3–5 attempts) with exponential backoff and randomized jitter to prevent thundering-herd load on downstream databases.
- Stateless workers: Keep workers ephemeral; persist state in external stores (Redis, PostgreSQL, DynamoDB) for seamless horizontal scaling and zero-downtime deploys.
- Observability: Emit OpenTelemetry spans per chunk and track queue depth, worker CPU/memory, and validation error rate via Prometheus/Grafana.
Edge Cases & Vendor Deviations
Real EHR exports diverge from the specifications in predictable ways. Handle these explicitly rather than letting them surface as silent data loss.
| Source / quirk | Symptom in async batch | Mitigation |
|---|---|---|
Epic bulk $export |
Multi-GB NDJSON files; resources split across many .ndjson URLs |
Partition by file and by line offset; never assume one file = one batch |
| Cerner (Oracle Health) | Non-standard extensions and occasional unescaped delimiters in narrative text | Validate with lenient extension handling; quarantine on delimiter violations rather than guessing |
| athenahealth | Aggressive API rate limits on incremental FHIR reads | Token-bucket throttling + circuit breaker; back off on 429 instead of retrying immediately |
| Legacy HL7 v2 interfaces | Duplicate MSH-10 control IDs on resend; segment-order violations |
Windowed dedup on MSH-10 + MSH-7; route ACK/NACK deterministically |
| Mixed encodings | UTF-8 BOM, Windows-1252 in OBX narrative | Decode defensively; normalize to UTF-8 before hashing so idempotency keys stay stable |
| Partial/naive timestamps | 2015-03 or offset-less datetimes |
Reject implicit midnight; map partials to a period range or flag for review |
For HL7-specific resend and acknowledgement behavior, the deterministic ACK/NACK handling patterns reference covers how to acknowledge a message only after it is durably committed, which is what makes at-least-once delivery safe with these legacy feeds.
Compliance Note: PHI in Logs and Dead-Letter Queues
The highest-risk compliance failure in async batch processing is PHI leaking into operational surfaces — application logs, broker metadata, distributed-tracing headers, and the DLQ itself. A quarantined record still contains PHI, so the DLQ is in scope for the HIPAA Security Rule exactly like the primary store.
- Never inline raw payloads in the DLQ. Publish a hashed reference and store the raw payload in encrypted (AES-256), WORM-protected object storage with least-privilege IAM. The worker above does this via
raw_ref. - Mask before you log. Apply deterministic tokenization or field-level hashing to any identifier before it reaches an observability stack. Log
payload_hash,source_system_id, andprocessing_status— not patient identifiers. - Tokenize patient identifiers in staging. Replace
PID-3/Patient.identifierwith HMAC-SHA256 tokens derived from a KMS-managed key, enabling cross-system reconciliation without exposing raw MRNs or SSNs. - Immutable audit trail. Write append-only audit records capturing who/what/when/where/why for every mutation, including validation failures and DLQ routing, to satisfy 21 CFR Part 11 and HIPAA breach-investigation requirements.
- Retention and verified purge. Enforce configurable retention (e.g., six years for clinical records, 90 days for raw logs) with cryptographically verified deletion of quarantined payloads after the retention window.
Troubleshooting
My worker is killed with OOM even though I set a concurrency limit.
The semaphore bounds the number of in-flight chunks, not their size. If each chunk is an entire NDJSON file loaded into memory, 50 concurrent chunks can still exhaust RAM. Stream each file line-by-line with aiofiles and treat one resource (or a small fixed line window) as the unit of work, so chunk size is bounded independently of file size.
Duplicate records appear after a consumer restart.
This is expected under at-least-once delivery and is the reason idempotent sinks are mandatory. Confirm the offset is committed only after the upsert succeeds, and that your key is fully deterministic. If duplicates persist, your key likely includes a non-deterministic field (ingestion timestamp, surrogate ID) — rebuild it from resourceType + logical identifier + meta.versionId as described in implementing idempotent clinical data loads.
The DLQ keeps filling with the same payload retried dozens of times.
You are retrying a terminal error. Validation failures (ValidationError) and schema violations will fail identically on every attempt — catch them separately and route to quarantine immediately, reserving the retry loop for transient errors (timeouts, 5xx, lock contention). The worker above shows this split.
Throughput collapses when the FHIR server returns 429s.
Without a circuit breaker, every worker retries simultaneously and amplifies the overload. Add token-bucket rate limiting per source and an exponential backoff with jitter on 429s. For incremental reads against rate-limited vendors, prefer a bulk data export over many small REST calls.
Idempotency keys are unstable across resends from a single source.
Byte-level differences — UTF-8 BOM, key ordering, whitespace, transient meta.security tags — change the hash even when the clinical content is identical. Canonicalize before hashing: strip non-deterministic metadata, sort JSON keys lexicographically, and normalize encoding to UTF-8.