Lesson 06 Idempotency & Retries
Theme:
Lesson 06 · Slide 1

Lesson 06 — Idempotency & Retries

Why pipelines must be idempotent — and how to make them so.

Data Engineering Module 2 Orchestration
Lesson 06 · Slide 2

The silent bug from Lesson 05

In the Lab 05 bonus step, re-running an interval overwrote the Parquet file. Looks fine — right? What if the second run failed halfway through the write? Now you have a half-written file: corrupt, but named like a complete one. The filesystem shows it exists; downstream reads return garbage.

Idempotency means: running the same task with the same inputs N times produces the same output as running it once. The orchestrator gives you at-least-once execution. Only the task itself can give you exactly-once.

Lesson 06 · Slide 3

Why pipelines retry

Every task should expect to run more than once. Design for the second run from the start.

Lesson 06 · Slide 4

At-least-once vs exactly-once

At-least-once

Easy guarantee from the orchestrator. Just retry on failure. Airflow gives you this out of the box with retries > 0.

Downside: the task may run 2, 3, or more times.

Exactly-once

Hard guarantee — requires the task itself to be idempotent. Airflow can't give you this; you build it.

Result: N runs, same final state as 1 run.

Lesson 06 · Slide 5

The transactional load pattern

import os
from pathlib import Path

def transactional_write(df, final_path: str):
    staging = f"{final_path}.staging.tmp"
    df.to_parquet(staging)           # write to staging
    os.replace(staging, final_path)  # atomic rename — POSIX guarantees
Lesson 06 · Slide 6

Run-key-based dedupe

When you can't atomically replace a single file (e.g., appending to a partitioned table), encode the logical date into the output path. Re-runs overwrite the same partition. Other partitions stay untouched.

# Partition by hour — replay overwrites only this hour's partition
output = f"data/event_date={date}/hour={hour}/events.parquet"
df.to_parquet(output)  # second run for same (date, hour) is safe

This is the lakehouse pattern — Module 4 builds on it with Iceberg and Delta.

Lesson 06 · Slide 7

XCom — pass metadata between tasks

def _extract(**context):
    from extract.gharchive import fetch_hour
    path = fetch_hour(hour=_hour_key(context))
    return path   # automatically pushed to XCom

def _transform(**context):
    from transform.events import count_by_type
    path = context["task_instance"].xcom_pull(task_ids="extract")
    count_by_type(input_path=path, output_path=f"data/out/{_hour_key(context)}.csv")

XCom is metadata-only — small JSON-serializable values (paths, counts, IDs). Not for DataFrames.

Lesson 06 · Slide 8

XCom vs side-effects

XCom

Small metadata between tasks: a path, a count, a record ID. Backed by the Airflow metadata DB. Visible in the UI.

Limit: keep payloads < a few KB. The metadata DB will choke on large payloads.

Side-effects

Write to S3 / Parquet / warehouse; let downstream read by convention (same partition path). For big data, the side-effect is the contract. XCom carries the address, not the data.

Lesson 06 · Slide 9

Retry configuration

from datetime import timedelta
from airflow.operators.python import PythonOperator

extract = PythonOperator(
    task_id="extract",
    python_callable=_extract,
    retries=3,
    retry_delay=timedelta(seconds=30),
    retry_exponential_backoff=True,
    max_retry_delay=timedelta(minutes=10),
)

Exponential backoff schedule: 30 s → 60 s → 120 s → capped at 10 min. After exhausting retries, task lands in failed and the DAG run is marked failed.

Lesson 06 · Slide 10

Retry budgets

Don't set retries=100. A task that fails 100 times in 100 minutes is broken, not flaky. Retries are for transient failures, not real bugs.

  • External-API tasks (fetch from gharchive.org): retries=3 with exponential backoff
  • Pure-compute tasks (transform, aggregate): retries=0 — failure is a real bug, surface it fast
Lesson 06 · Slide 11

Non-idempotent counterexamples

Lesson 06 · Slide 12

L06 sample-pipeline upgrade

Three changes to dags/gharchive_hourly.py:

  1. Staging-then-rename in both tasks — extract writes to data/<hour>.parquet.staging.<run_id>.parquet, then os.replace() to data/<hour>.parquet. Transform does the same for data/out/<hour>.csv.
  2. Retry budgets split by task character — extract uses EXTERNAL_API_RETRIES (3 retries, exponential backoff); transform uses PURE_COMPUTE_RETRIES (0 retries).
  3. XCom carries the path — extract returns the final path string; transform pulls it via xcom_pull instead of reconstructing it.

All presets live in a new dags/_helpers.py shared by all Module 2 DAGs.

Lesson 06 · Slide 13

Common gotchas

Lesson 06 · Slide 14

Recap & what's next

Coming up: Module 3 — SQL for engineers, then dbt.
1 / 14