Lab 06 — Break the DAG mid-run, replay it, prove idempotency
Lesson 06 · Data Engineering with Python · ~45 min
By the end of this lab you will have concrete evidence — matching checksums across two independent runs of the same hour — that your DAG is exactly-once even after a mid-run failure and recovery.
Prerequisites
- Lab 05 completed: the Airflow stack is running,
gharchive_hourlyis scheduled and has produced at least a few hourly Parquet files indata/raw/. - Docker Desktop (or
docker composev2+) available and the stack still up. Verify withdocker compose psfromdata-engineering/sample-pipeline/airflow/.
Step 1 — Update the DAG and helpers
The sample-pipeline code was updated as part of the lesson scaffolding. Read through both files before running anything — understanding the code is the goal.
dags/_helpers.py — introduces three building blocks:
def transactional_write_path(final_path: pathlib.Path, run_id: str) -> pathlib.Path:
"""Build a per-run staging path next to the final file.
Encodes run_id so concurrent runs for the same hour don't collide."""
safe_run = run_id.replace(":", "_").replace("+", "_")
return final_path.with_suffix(f".staging.{safe_run}.parquet")
def atomic_replace(src: pathlib.Path, dst: pathlib.Path) -> None:
"""os.replace with explicit failure if src doesn't exist."""
if not src.exists():
raise FileNotFoundError(f"staging file missing: {src}")
os.replace(src, dst)
EXTERNAL_API_RETRIES = dict(
retries=3, retry_delay=timedelta(seconds=30),
retry_exponential_backoff=True, max_retry_delay=timedelta(minutes=10),
)
PURE_COMPUTE_RETRIES = dict(retries=0)
dags/gharchive_hourly.py — three changes from L05:
-
Staging-then-rename in extract:
fetch_hourwrites the raw.json.gzto a staging directory;atomic_replacerenames it to the final path. If the task dies between the write and the rename, the staging file is visible on disk (visibly incomplete) but the final path is untouched. -
Staging-then-rename in transform:
count_by_typewrites to a staging CSV derived fromtransactional_write_path; the final rename happens only after the write completes successfully. -
Retry budgets split by task character: extract uses
**EXTERNAL_API_RETRIES(3 retries, exponential backoff); transform uses**PURE_COMPUTE_RETRIES(0 retries, because transform failures are real bugs).
XCom now carries the final raw-file path from extract to transform, so transform doesn't hard-code a path — it reads exactly what extract produced.
Step 2 — Reload Airflow
Airflow re-parses the dags/ directory every ~30 seconds. Wait 30 seconds after the
files are in place, then open
http://localhost:8080. Confirm there is no "DAG Import Errors" banner at the top of the page.
If an error appears, check docker compose logs airflow-scheduler for the Python
traceback.
Step 3 — Clean slate
To get a deterministic starting point for the checksum test, wipe the data directory:
docker compose exec airflow-scheduler \
rm -rf /opt/airflow/sample-pipeline/data
This removes all previously produced raw and output files. The DAG will recreate everything from scratch in the steps below.
Step 4 — Run a single hour and record the checksum
Use airflow dags test to run a single DAG run in the foreground (no scheduler
needed — useful for quick local testing):
docker compose exec airflow-scheduler \
airflow dags test gharchive_hourly 2026-05-01T12:00:00
After it completes, verify the output on the host (the mount is shared):
ls data-engineering/sample-pipeline/data/raw/
# Expected: 2026-05-01-12.json.gz
ls data-engineering/sample-pipeline/data/out/
# Expected: 2026-05-01-12.csv
Record the checksum of the raw file:
md5sum data-engineering/sample-pipeline/data/raw/2026-05-01-12.json.gz
Note the hash — you will compare it after the replay in Step 7.
Step 5 — Force a mid-run failure
In the Airflow UI, trigger a manual run for hour 13 (2026-05-01T13:00:00). While the
extract task is actively running (yellow/green in the Grid view), restart the
scheduler container to simulate an executor crash:
docker compose restart airflow-scheduler
The running task instance will land in failed (or remain running until
the scheduler heartbeat times it out — allow up to 60 seconds). Now inspect the data directory:
ls data-engineering/sample-pipeline/data/raw/
You should see a file named something like
2026-05-01-13.json.gz.staging.scheduled__2026-05-01T13_00_00_00_00.parquet
but no 2026-05-01-13.json.gz. This is the point of the staging
pattern: a partial download is visibly partial — named differently, easy to find and
clean up — rather than silently corrupting the final file.
Step 6 — Clear the failed run and let it retry
In the Airflow UI:
- Open the
gharchive_hourlyDAG. - In the Grid view, click the red (failed)
extractcell for the hour-13 run. - Click Clear. In the dialog, confirm Past=No, Recursive=No (clear only this task instance, not its downstream). Click Clear.
Airflow re-queues the extract task. Wait for it to run. If the retry budget hasn't been exhausted (it hasn't — the task was failed by a crash, not by three consecutive errors), the task should succeed. Confirm:
ls data-engineering/sample-pipeline/data/raw/
# Now includes: 2026-05-01-13.json.gz
ls data-engineering/sample-pipeline/data/out/
# Now includes: 2026-05-01-13.csv
Any orphan staging file from the crashed attempt is now irrelevant — the final file is complete.
Step 7 — Prove replay is idempotent
Re-run hour 12 — the one you already produced in Step 4:
docker compose exec airflow-scheduler \
airflow dags test gharchive_hourly 2026-05-01T12:00:00
md5sum data-engineering/sample-pipeline/data/raw/2026-05-01-12.json.gz
Compare this checksum with the one you recorded in Step 4. They should be identical. The gharchive.org file for a past hour does not change — the same bytes are downloaded every time. Combined with the staging-then-rename write pattern, the final output is byte-for-byte reproducible across any number of replays. That is exactly-once.
Step 8 — Clean up orphan staging files
After a force-killed run, orphan staging files may remain in the data directory. A safe one-liner to clean them up (deletes only files older than 1 hour, so concurrent in-progress runs are never affected):
docker compose exec airflow-scheduler \
find /opt/airflow/sample-pipeline/data -name "*.staging.*" -mmin +60 -delete
In production you would run this as a separate Airflow DAG (a scheduled maintenance task) rather than as a manual one-liner.
What you should leave with
- Concrete evidence (matching checksums) that your DAG is replayable regardless of how many times it runs.
- Understanding of the staging-then-rename pattern and why POSIX atomic rename is the right primitive.
- Awareness that orphan staging files are a normal side-effect of interrupted runs and need a cleanup policy.
- Confidence that the retry policy split (external API vs pure compute) catches real failures without masking bugs.
Troubleshooting
- "Replay produces a different checksum" — gharchive.org may have updated the archive for that hour (data corrections happen occasionally). This is not a bug in your pipeline; it is correct behaviour.
-
"Retry storm — task fails immediately every time" — check the underlying
error in the task logs. Retries don't fix real bugs; they delay surfacing them. Set
retries=0temporarily to surface the root cause fast. -
"XCom payload too large" warning in logs — you are pushing a DataFrame or
large string instead of a path. Return a path string from
_extract, not the file contents. -
"No
.staging.file visible after crash" — the task may have crashed before the write started (e.g., during the HTTP request setup). That is fine — there is nothing to clean up, and no final file was touched. -
No Docker locally? The matching-checksum demonstration in Steps 4 and 7 is
the key conceptual exercise. Read through both steps carefully — the staging-then-rename
pattern and the checksum comparison are the load-bearing ideas. You can verify the Python
files parse correctly with
python -m py_compile data-engineering/sample-pipeline/airflow/dags/gharchive_hourly.pywithout running the full stack.