Async Polling Strategies for EDC Updates in Clinical Trial Sync Pipelines
Most Electronic Data Capture (EDC) platforms do not push subject-level changes to downstream consumers; they expose a query surface and expect the consumer to ask “what changed since I last looked?” on a schedule. Getting that question right is deceptively hard: poll too aggressively and you exhaust vendor quotas and destabilize a validated production system; poll too slowly and your monitoring dashboards, edit checks, and safety signals lag the site by hours. This guide is the reference for engineers building that polling layer inside Automated EDC Ingestion & Sync Pipelines — the parent discipline that governs how trial data moves from a site capturing a case report form (CRF) to an analysis-ready, audit-traceable dataset. It is written for clinical data managers (CDMs) accountable for data freshness, Python ETL engineers who operate the extraction tier, and quality teams who must prove that every incremental read is attributable, complete, and reproducible under 21 CFR Part 11. The regulatory stakes are concrete: a polling routine that silently skips a window, double-counts a record, or advances its cursor past unprocessed data is a data-integrity defect, not just a bug.
The Polling Cycle at a Glance
Each cycle reads from a persisted cursor, pulls only records newer than that cursor, validates them against the study schema, routes failures to quarantine, and advances the cursor only after a durable commit — all under an adaptive schedule that backs off when the source signals stress.
Concept and Prerequisites
Asynchronous polling for EDC is an incremental, idempotent read loop over an external system of record that the consumer must treat as read-only. The pipeline never mutates source data; it observes it. Three standards govern the design. The EDC’s interface contract — almost always a REST or CDISC ODM-based API documented under EDC API Architecture for Clinical Trials — defines how “changed since” is expressed (a lastUpdated filter, an audit sequence number, or an export job token). The 21 CFR Part 11 electronic-records rule defines what you must capture about each read. ALCOA+ (Attributable, Legible, Contemporaneous, Original, Accurate, plus Complete, Consistent, Enduring, Available) defines the integrity properties every increment must preserve.
Assumed environment and pinned dependencies for the examples below:
| Component | Version pin | Role |
|---|---|---|
python |
3.11.x |
Runtime, deterministic tomllib, decimal |
httpx |
0.27.* |
Async HTTP with timeouts and HTTP/2 |
tenacity |
8.2.* |
Declarative retry/backoff policy |
pydantic |
2.6.* |
Schema validation of API payloads |
sqlalchemy |
2.0.* |
Durable cursor + audit persistence |
All dependencies are pinned and committed to the repository alongside a lockfile; an unpinned EDC client is itself an audit finding because it makes a historical run impossible to reproduce. The polling service is assumed to run as a singleton per study (or a partition-leased worker set) so that two pollers can never advance the same cursor concurrently. Extraction and transformation logic reuses the patterns in Python ETL for EDC Data Extraction; this page covers only the acquisition loop that feeds it.
Implementation 1: Stateful Cursor Tracking and Idempotent Delta Extraction
A production poller begins with a precise, durable cursor rather than a full-table pull. The cursor is a monotonic high-water mark — a server-side lastUpdated timestamp or an audit sequence number — scoped to the granularity the EDC supports (study, site, subject, or form). Each cycle requests only records strictly newer than the persisted cursor, which makes the read idempotent: replaying the same cursor yields the same window. The cursor must be advanced after the batch is durably committed and audited, never before, so that a crash mid-batch resumes from the last fully processed point.
# ----------------------------------------------------------------------
# Idempotent EDC delta poll.
# ALCOA+ requirement: each polled window writes an immutable audit row
# (payload hash + cursor before/after) BEFORE the cursor advances, so a
# regulator can reconstruct exactly which records entered which run.
# 21 CFR Part 11 (e): operator actions are attributable and time-stamped.
# ----------------------------------------------------------------------
import hashlib
import httpx
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from .models import PollCursor, PollAudit
from .schema import EdcRecordBatch # pydantic model, see Implementation 2
def poll_once(session: Session, client: httpx.Client, study_oid: str) -> int:
cursor = (
session.query(PollCursor)
.filter_by(study_oid=study_oid)
.with_for_update() # row lock: serializes concurrent pollers
.one()
)
cursor_before = cursor.high_water_mark
# Server-side delta filter — never pull the full dataset.
resp = client.get(
f"/studies/{study_oid}/records",
params={
"updatedAfter": cursor_before.isoformat(),
"orderBy": "updatedAt",
"pageSize": 500,
},
timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0),
)
resp.raise_for_status()
payload = resp.content
batch = EdcRecordBatch.model_validate_json(payload)
if not batch.records:
return 0 # nothing new; do not advance, do not write a noise audit row
# Attributable, immutable evidence of exactly what was read.
digest = hashlib.sha256(payload).hexdigest()
new_mark = max(r.updated_at for r in batch.records)
session.add(
PollAudit(
study_oid=study_oid,
polled_at=datetime.now(timezone.utc),
cursor_before=cursor_before,
cursor_after=new_mark,
record_count=len(batch.records),
payload_sha256=digest,
endpoint=str(resp.request.url),
)
)
persist_to_staging(session, batch) # transactional with the audit row
cursor.high_water_mark = new_mark # advance ONLY after commit succeeds
session.commit()
return len(batch.records)
Two properties make this safe. The with_for_update() row lock serializes pollers so a horizontally scaled deployment cannot run the same window twice. And the audit row, staging write, and cursor advance share one transaction, so a partial failure rolls all three back — the next cycle re-reads the same window cleanly. Because EDC lastUpdated timestamps frequently collide at second granularity, treat the boundary as inclusive on re-read and deduplicate by record key, never as a strict > that can skip records sharing the cursor’s exact timestamp.
Implementation 2: Adaptive Scheduling, Quota Awareness, and Resilient Failure Handling
Polling frequency must balance data freshness against source stability. Static intervals either burn quota during quiet periods or fall behind during high-enrollment surges and nightly batch loads. An adaptive scheduler shortens the interval when deltas are non-empty and lengthens it (with randomized jitter) when windows come back empty or the server signals throttling — which also prevents a fleet of study pollers from synchronizing into a thundering herd. Quota-aware backoff is covered in depth under Handling API Rate Limits in Clinical Sync, and the retry primitives below are expanded in Building Retry Logic for EDC API Timeouts.
The non-negotiable rule: retry only transient transport failures, never application-level rejections. A 422 validation error or a 401 permission denial must not be retried — it is deterministic and will fail identically, wasting quota and masking a real defect.
# ----------------------------------------------------------------------
# Adaptive interval + bounded retry for the poll request only.
# 21 CFR Part 11 (e): every retry, backoff, and circuit transition is
# logged with a structured timestamp for post-incident reconstruction.
# Read-only-consumer principle: backoff protects the EDC system of record
# from consumer-induced load; we never trade integrity for throughput.
# ----------------------------------------------------------------------
import random
import structlog
from tenacity import (
retry, retry_if_exception_type, stop_after_attempt,
wait_exponential_jitter, before_sleep_log,
)
import httpx
log = structlog.get_logger()
TRANSIENT = (httpx.ConnectError, httpx.ReadTimeout, httpx.RemoteProtocolError)
@retry(
retry=retry_if_exception_type(TRANSIENT),
wait=wait_exponential_jitter(initial=1, max=60), # 1s..60s + jitter
stop=stop_after_attempt(5),
before_sleep=before_sleep_log(log, log_level="warning"),
reraise=True,
)
def fetch_with_backoff(client: httpx.Client, url: str, params: dict) -> httpx.Response:
resp = client.get(url, params=params)
if resp.status_code == 429: # explicit quota signal
retry_after = float(resp.headers.get("Retry-After", "30"))
raise httpx.ReadTimeout(f"quota throttled; honor Retry-After={retry_after}s")
resp.raise_for_status() # 4xx (except 429) is permanent — do NOT retry
return resp
def next_interval(base: float, had_changes: bool, throttled: bool) -> float:
if throttled:
return min(base * 4, 900.0) # back off hard on quota pressure
if had_changes:
return max(base / 2, 15.0) # speed up while data is flowing
return min(base * 1.5, 300.0) + random.uniform(0, 5) # decay + jitter
For sustained failures, wrap the request tier in a circuit breaker. After N consecutive transient failures the circuit opens, the poller stops hammering the EDC, and a single probe request periodically tests for recovery before closing the circuit again. This prevents one degraded vendor endpoint from cascading retries across every dependent service. Paginated responses — common when a long quiet period produces a large catch-up window — must be drained completely before the cursor advances; the cursor reflects the maximum updatedAt across all pages, not just the first. Vendor-specific paging quirks are documented under Handling Pagination in Veeva Vault EDC APIs.
Configuration and Parameterization
Every tunable that affects what data is read or how aggressively the source is queried must live in a version-controlled config file, not in code or ad-hoc environment values. A config change is a change-control event: it is reviewed, diffed, and tied to a study amendment. Secrets (tokens, client credentials) are the exception — they come from the environment or a secrets manager and are never committed.
# poll_config.yaml — version-controlled; reviewed under change control.
# GxP note: this file is a validated artifact. Any edit is traceable in
# git history and must reference the change ticket that authorized it.
study_oid: "PRO-2026-0148"
edc:
base_url: "https://rave.example-cro.com/api/v2"
auth_env_var: "EDC_BEARER_TOKEN" # value injected at runtime, never stored
page_size: 500
polling:
base_interval_seconds: 60
min_interval_seconds: 15
max_interval_seconds: 900
jitter_seconds: 5
retry:
max_attempts: 5
backoff_initial_seconds: 1
backoff_max_seconds: 60
circuit_breaker_threshold: 5
circuit_breaker_reset_seconds: 120
audit:
table: "poll_audit"
payload_hash_algorithm: "sha256"
retain_years: 8 # aligns with study archival policy
| Environment variable | Maps to | Notes |
|---|---|---|
EDC_BEARER_TOKEN |
edc.auth_env_var |
Rotated secret; never in version control |
POLL_CONFIG_PATH |
config file location | Defaults to ./poll_config.yaml |
STAGING_DSN |
staging DB connection | Read-write to staging only, never to EDC |
LOG_LEVEL |
structured logger | INFO in production; audit rows are independent of log level |
Loading config validates it against a pydantic model on startup, so a malformed interval or a missing study OID fails fast rather than silently polling with defaults.
Testing and Validation
Polling logic is validated with deterministic unit tests against mock API fixtures — never against a live EDC, which would be both non-reproducible and a load risk on a production system. The fixtures encode the exact shapes the real vendor returns: empty windows, timestamp-colliding records, partial pages, 429 throttles, and malformed payloads. Each test produces a GxP test artifact (a logged pass/fail with inputs and expected outputs) that becomes part of the OQ evidence package.
# ----------------------------------------------------------------------
# Mock-API regression test for cursor safety.
# GxP test artifact: asserts the cursor never advances past unprocessed
# data after a mid-batch failure (idempotent re-read guarantee).
# ----------------------------------------------------------------------
import respx
import httpx
import pytest
@respx.mock
def test_cursor_does_not_advance_on_staging_failure(session, client, monkeypatch):
respx.get(url__regex=r".*/records").mock(
return_value=httpx.Response(200, json={"records": [
{"record_oid": "DM.0001", "updated_at": "2026-06-27T10:00:00Z"},
{"record_oid": "DM.0002", "updated_at": "2026-06-27T10:00:01Z"},
]})
)
before = get_cursor(session, "PRO-2026-0148")
monkeypatch.setattr("poller.persist_to_staging", _raise_db_error)
with pytest.raises(StagingWriteError):
poll_once(session, client, "PRO-2026-0148")
# Cursor must be unchanged; the failed window is fully re-readable.
assert get_cursor(session, "PRO-2026-0148") == before
assert audit_row_count(session, "PRO-2026-0148") == 0 # no orphan audit
Cover at minimum: (1) an empty window does not advance the cursor or write a noise audit row; (2) timestamp collisions at the boundary are deduplicated, not skipped; (3) a 429 triggers Retry-After-honoring backoff, not an immediate retry; (4) a 422 is not retried; (5) a multi-page catch-up advances the cursor to the global maximum updatedAt. Schema validation of the payloads themselves reuses the cleaning patterns in Pandas DataFrames for Clinical Data Cleaning.
Production Gotchas and Failure Modes
Real deployments fail in a handful of recurring, study-jeopardizing ways. Each below names the root cause and the remediation.
- Clock skew between EDC and poller skips records. If the EDC server’s
updatedAtclock runs ahead of the poller’s host clock and the cursor is initialized from the poller’snow(), the first windows silently miss records. Remediation: always seed and advance the cursor from server-provided timestamps, never from local time, and verify referential integrity between source and ingested counts on a schedule. - Strict
>boundary drops timestamp-colliding records. Two records sharing one second ofupdatedAtstraddle a strict-greater cursor and one is lost. Remediation: re-read inclusively (>=) and deduplicate by record key, or use a monotonic audit sequence number instead of a wall-clock timestamp when the vendor exposes one. - Cursor advanced before a durable commit. A crash between “advance cursor” and “write staging” permanently loses a window. Remediation: keep audit row, staging write, and cursor advance in one transaction; advance last.
- Retrying application errors burns quota and hides defects. A blanket retry-on-any-exception turns a deterministic 422 into five wasted calls and a delayed alert. Remediation: retry only the transient transport exception types; let 4xx (except 429) propagate immediately.
- Schema drift after a mid-study amendment. A new CRF field or a changed code list breaks payload parsing for every subsequent poll. Remediation: validate against a versioned schema, route non-conforming payloads to a quarantine table with a machine-readable error code, and reconcile drift against CDISC ODM vs CDASH Schema Mapping. Quarantined records that represent genuine discrepancies feed Automated Clinical Query Generation rather than blocking the whole batch.
Auditability, Traceability, and 21 CFR Part 11
Every polling event, transformation step, and routing decision must land in a write-once, append-only log. For each cycle, capture the endpoint queried, the authentication context (identity, not the secret), the cursor before and after, the record count, and the SHA-256 of the ingested payload. These boundaries — what the poller is permitted to read, retain, and expose — are defined under Audit Trail Boundaries in EDC Systems. To satisfy 21 CFR Part 11, enforce role-based access controls on audit retrieval, align retention with the study archival policy, and guarantee that audit rows cannot be altered or deleted without cryptographic evidence of tampering. Verifying that source EDC timestamps and ingested record counts reconcile is the single most effective integrity check a CDM can schedule.
Compliance Checklist
Use this as the pre-release gate for any change to the polling layer. Each item maps to an ALCOA+ property or a Part 11 control.
Frequently Asked Questions
How often should an EDC poller run?
There is no universal number — it depends on the vendor’s quota and the trial’s monitoring needs. Start from an adaptive baseline (for example, 60 seconds) that halves when deltas are flowing and grows with jitter when windows are empty, capped by the vendor’s documented rate limit. Freshness requirements for safety-relevant domains may justify a tighter floor than for administrative forms.
Is timestamp-based polling or sequence-number polling safer?
A monotonic server-side audit sequence number is safer than a wall-clock lastUpdated because it is collision-free and immune to clock skew and daylight-saving discontinuities. Use the sequence number when the EDC exposes one; when only a timestamp is available, re-read the boundary inclusively and deduplicate by record key.
Does polling violate the read-only consumer principle?
No — polling is read-only by definition; it observes the EDC system of record and never writes back to it. The principle does require that backoff and circuit-breaking protect the source from consumer-induced load, which is why quota-aware scheduling is a compliance control, not just an optimization.
What belongs in the audit trail for each poll?
At minimum: the timestamp, the endpoint and parameters, the authenticated identity, the cursor value before and after, the record count, and a cryptographic hash of the exact payload ingested. This is what lets a regulator reconstruct precisely which records entered which run.
How do I handle a schema change introduced by a mid-study amendment?
Validate every payload against a versioned schema. When a payload no longer conforms, route it to a quarantine table with a machine-readable error code and raise a change-control event rather than letting the parser crash the loop or silently drop fields. Reconcile the new structure against your CDASH/ODM mapping before resuming normal flow.
Related
- Building Retry Logic for EDC API Timeouts — the bounded-retry and circuit-breaker primitives this loop depends on
- Handling API Rate Limits in Clinical Sync — quota-aware scheduling and throttle handling
- Python ETL for EDC Data Extraction — the extraction and transformation tier the poller feeds
- Audit Trail Boundaries in EDC Systems — what a read-only consumer may capture, retain, and expose
- Automated EDC Ingestion & Sync Pipelines — the parent reference for this discipline