Writing Idempotent Airflow DAGs for EDC Sync

An Airflow-orchestrated EDC sync runs clean for weeks, then a single task retry — or a well-meaning backfill — doubles the row count for one site, leaves a half-loaded visit after a mid-task crash, and produces a reconciliation gap no one can explain to an auditor. That symptom is what this deep-dive resolves: non-idempotent DAGs where a re-run does not converge to the same state. Clinical data managers, biotech and pharma Python engineers, and regulatory reviewers hit it because Airflow will re-execute tasks — retries, manual clears, scheduler failover, and backfills are all normal — and any task that appends instead of upserting, or reads “now” instead of its assigned window, turns each re-execution into a data-integrity defect. This page is the idempotency primitive inside Orchestrating EDC Sync Pipelines with Apache Airflow, which sits within the broader Automated EDC Ingestion & Sync Pipelines discipline. Built correctly, every task instance becomes safe to re-run at any time, which is the property that keeps the pipeline defensible under 21 CFR Part 11.

Idempotent Run Flow at a Glance

Each run is pinned to a deterministic interval window; the extract reads only that window, the load upserts on natural keys, and the cursor is committed externally only after a verified write — so a retry re-runs the identical window with no duplication.

Idempotent Airflow EDC sync run pinned to a deterministic interval A top-to-bottom flow. The run starts by resolving a deterministic window from data_interval_start and data_interval_end. That window feeds an extract scoped strictly to the interval. A decision asks whether the write succeeded and its hash verified: if no, the flow retries the identical window without advancing the cursor, looping back to the extract; if yes, the load performs an idempotent upsert on natural keys, then the externalized cursor is committed, and finally an audit record is written. A retry path re-enters the extract with the same interval, guaranteeing convergence. Resolve deterministic window data_interval_start / _end Extract (interval-scoped) since/until = window Write ok + hash verified? Retry same window cursor unchanged no yes Idempotent upsert natural keys Commit external cursor + audit hash audit record written

Root Cause: Why EDC DAGs Duplicate and Partially Load

Idempotency failures in an EDC DAG almost always trace to one of three specific mistakes, and each maps to a normal Airflow behavior that a non-idempotent task turns into a defect.

The first is a task that reads a moving target instead of its assigned window. Airflow hands every run a data_interval_start and data_interval_end, but a task that filters the EDC extract on datetime.now() or a “last N hours” relative window will read different data every time it runs. When the scheduler retries that task, or a backfill replays it, the second execution pulls a different set of records than the first, so counts drift and the run is not reproducible.

The second is a mutable cursor stored in XCom. XCom is convenient, but it is scoped to a task instance and re-computed on retry, so using it to hold “the last timestamp I processed” means a retried task can advance the cursor twice or read a stale value. Worse, a cursor in XCom lives in the metadata database, and if it ever carries a subject-level value it becomes a PHI leak — the boundary the parent guide draws around Orchestrating EDC Sync Pipelines with Apache Airflow.

The third is a catchup flood. A DAG unpaused with catchup=True and a historical start_date enqueues one run per missed interval at once; if the load task appends rather than upserts, those overlapping runs race and double-load. The fix is not one trick but a discipline: pin each run to a deterministic window, make every write converge, and keep cursor state outside XCom.

Step-by-Step Implementation

Each step owns one responsibility and produces a runnable building block. Compose them into a single sync DAG per study.

1. Scope each run to a deterministic data_interval window

Derive the extract bounds strictly from the run’s interval, never from wall-clock time, so a retry or backfill of the same logical run always reads the identical window.

# 21 CFR Part 11 relevance: reproducibility — the extract window is a pure
# function of the run's data_interval, so any re-run reads the same records.
from airflow.decorators import task
from mypipeline import edc, store

@task(retries=3)
def extract(site: str, data_interval_start=None, data_interval_end=None) -> dict:
    # Bounds come ONLY from the interval — never datetime.now() or "last 4h".
    ref = edc.extract_site(
        study="ONC2024", site=site,
        since=data_interval_start,          # inclusive lower bound
        until=data_interval_end,            # exclusive upper bound
    )
    blob = ref.bytes
    uri = store.write(blob, key=f"ONC2024/{site}/{data_interval_start.isoformat()}")
    return {"site": site, "uri": uri, "sha256": store.sha256(blob),
            "records": ref.count}           # references only — no PHI in XCom

2. Upsert on natural keys so a re-run converges

Make the load a no-op when the same records are seen twice by keying every row on its ODM natural-key tuple. A replayed run updates in place instead of inserting, so duplication is structurally impossible.

# ALCOA+ requirement: Consistent + Complete — the upsert converges, so a retried
# or backfilled load produces identical state with zero duplicate rows.
NATURAL_KEY = ("StudyOID", "SiteRef", "SubjectKey",
               "EventOID", "FormOID", "ItemGroupOID", "ItemOID")

def upsert_rows(conn, rows: list[dict]) -> int:
    conn.executemany(
        "INSERT INTO item_data "
        "(study, site, subject, event, form, item_group, item, value) "
        "VALUES (:StudyOID,:SiteRef,:SubjectKey,:EventOID,"
        ":FormOID,:ItemGroupOID,:ItemOID,:Value) "
        "ON CONFLICT(study, site, subject, event, form, item_group, item) "
        "DO UPDATE SET value = excluded.value",   # idempotent on the natural key
        rows,
    )
    conn.commit()
    return len(rows)

3. Externalize the cursor instead of storing it in XCom

Persist the watermark in a durable store keyed by study and site, and commit it only after the upsert succeeds — so a crash between load and commit re-runs the window rather than skipping it.

# 21 CFR Part 11 relevance: the cursor is durable control-plane state, not XCom;
# it advances only after a verified write, so no window is silently skipped.
import sqlite3

def commit_cursor(db: sqlite3.Connection, study: str, site: str,
                  interval_end: str, batch_sha256: str) -> None:
    db.execute(
        "INSERT INTO edc_cursor(study, site, watermark, batch_sha256, ts) "
        "VALUES(?,?,?,?,strftime('%s','now')) "
        "ON CONFLICT(study, site) DO UPDATE SET "
        "watermark=excluded.watermark, batch_sha256=excluded.batch_sha256, "
        "ts=excluded.ts",
        (study, site, interval_end, batch_sha256),
    )
    db.commit()                                   # atomic: cursor advances or it does not

4. Guard the load with the interval bounds and a hash check

Verify the payload hash and confine every write to the run’s interval before committing, so a stale or partial blob cannot corrupt state.

# ALCOA+ requirement: Accurate + Original — the write is gated on a hash match
# and confined to the run's window before the cursor is allowed to advance.
from airflow.exceptions import AirflowException
from mypipeline import store, warehouse, transform as xf

@task(retries=3)
def load(ref: dict, data_interval_end=None) -> dict:
    blob = store.read(ref["uri"])
    if store.sha256(blob) != ref["sha256"]:       # integrity gate
        raise AirflowException(f"hash mismatch for {ref['site']}")
    rows = xf.flatten_odm(blob)
    with warehouse.transaction() as conn:         # single atomic unit of work
        written = upsert_rows(conn, rows)
        commit_cursor(conn, "ONC2024", ref["site"],
                      data_interval_end.isoformat(), ref["sha256"])
    return {"site": ref["site"], "rows": written}

5. Fan out per site with independent idempotency and disarm catchup

Map one load instance per site so each site’s window commits independently, and ship the DAG with catchup=False and max_active_runs=1 so no backfill flood or overlapping run can race the upsert.

# 21 CFR Part 11 relevance: controlled scheduling — no catchup storm, one active
# run per study, and per-site mapping isolates each site's idempotent window.
import pendulum
from airflow.decorators import dag

@dag(
    dag_id="edc_sync_study_ONC2024",
    schedule="0 */4 * * *",
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
    catchup=False,                                # no historical storm on deploy
    max_active_runs=1,                            # no overlapping runs of one study
    default_args={"retries": 3, "owner": "clinical-data-eng"},
)
def edc_sync():
    from mypipeline import config
    refs = extract.expand(site=config.active_sites(study="ONC2024"))
    load.expand(ref=refs)                         # each site commits independently

edc_sync()

Verification and Audit Trail

A DAG is GxP-relevant software, so “the re-run was fine” must be provable from the record, not asserted. Confirm idempotency by running the same logical interval twice and asserting the second run inserts zero new rows and reproduces the batch hash; capture the per-run evidence below to a write-once store so an inspector can reconstruct every execution. The boundaries of what this control plane may record follow Audit Trail Boundaries in EDC Systems.

Field Purpose (regulatory)
run_id Ties every task instance to one logical run (Attributable)
logical_date + data_interval The exact deterministic window the run processed (Consistent)
site Scopes the evidence to one mapped instance (Accurate)
records Reconciles ingested rows against the EDC source count (Complete)
batch_sha256 Immutable proof of the bytes loaded before any transform (Original)
rows_written vs rows_new Demonstrates a re-run converged with zero duplicates (Consistent)
config_git_sha Proves which schedule and policy governed the run (change control)

To confirm the fix end to end, assert three properties against a mocked EDC endpoint: an extract scoped to a fixed interval returns the same sha256 on every call; a load re-run for the same (site, data_interval) reports rows_new == 0; and a simulated crash between upsert_rows and commit_cursor leaves the cursor un-advanced so the next run re-processes the window. Genuine discrepancies the load surfaces feed Automated Clinical Query Generation rather than blocking the run, and unrecoverable batches route to Dead-Letter Queues and Error Recovery.

Edge Cases and Vendor-Specific Gotchas

Backfills need a reset, not a re-run. Re-triggering a DAG run that already partially loaded can double-count if any task in the chain is not yet idempotent. Run historical loads with airflow dags backfill --reset-dagruns under a tracked change request so the run state is cleared first, and confirm the upsert converges before backfilling more than one interval.

Late-arriving EDC edits break a naive watermark. A source that back-dates an edit into an already-processed interval will be missed by a strictly forward-only cursor. Pair the interval scope here with an overlap window or change-data-capture watermark — the pattern in Implementing Watermark Cursors for EDC Delta Sync — so a re-opened form is re-pulled and upserted rather than lost.

Dynamic mapping over a shifting site list. If active_sites() returns a different list on a retry than on the original run, mapped task indices no longer line up and a cleared run can load the wrong site. Snapshot the site list into the run (via a params value or a first task whose output is the mapping source) so the fan-out is stable across every re-execution of that run.

Frequently Asked Questions

Why not just store the sync cursor in XCom — it is already per-task?

Because XCom is scoped to a task instance and re-materializes on retry, so a retried task can read a stale watermark or advance it twice, and XCom lives in the metadata database where a subject-level cursor would be a PHI leak. Persist the watermark in a durable external store keyed by study and site, and advance it only inside the same transaction that commits the load, so the cursor and the data move atomically.

How do I make a load task idempotent when the warehouse only supports INSERT?

Use the database’s native upsert — INSERT ... ON CONFLICT DO UPDATE on Postgres and SQLite, or MERGE on Oracle and SQL Server — keyed on the ODM natural-key tuple. If the target truly cannot upsert, delete the interval’s rows for that key range inside the same transaction before inserting, so the net effect of a re-run is still convergence rather than duplication.

Is catchup=False enough to prevent duplicate loads?

No — catchup=False only stops the historical scheduling storm on deploy; it does not make a task idempotent. A single interval can still be retried, cleared, or backfilled, so the load must upsert on natural keys regardless. Treat catchup=False and max_active_runs=1 as guards that reduce the blast radius, and the idempotent upsert as the property that actually guarantees correctness.

Should the extract filter on logical_date or on data_interval_start and data_interval_end?

Filter on data_interval_start and data_interval_end. In Airflow 2 the logical_date marks the start of the interval, but the pair of interval bounds is the explicit, unambiguous window a run owns, and using them keeps the extract a pure function of the run. Never derive bounds from datetime.now(), or a retry will read a different window than the original execution.