Paths Subjects Questions Quizzes Pricing Search
Intermediate Open Free

A New DAG Silently Reprocessed a Year of History

Your team ships a new DAG to populate a daily_revenue_snapshot table from an internal billing API:

from datetime import datetime, timedelta
from airflow.decorators import dag, task

@dag(
    dag_id="daily_revenue_snapshot",
    schedule="0 7 * * *",
    start_date=datetime(2025, 1, 1),
    default_args={"retries": 2, "retry_delay": timedelta(minutes=5)},
)
def daily_revenue_snapshot():

    @task
    def pull_revenue(data_interval_start=None) -> str:
        return _call_billing_api(data_interval_start)  # rate-limited, 500 req/day

    @task
    def write_snapshot(payload: str, data_interval_start=None) -> None:
        _insert_snapshot_row(payload, data_interval_start)

    write_snapshot(pull_revenue())

daily_revenue_snapshot()

The moment this DAG is deployed and turned on (in mid-2026), roughly 540 DAG runs fire back to back within the hour. The billing API's rate limiter starts rejecting requests, several write_snapshot calls partially succeed after retries, and the daily_revenue_snapshot table ends up with duplicate rows for a number of historical dates.

  1. Identify the exact configuration decision that caused ~540 runs to fire immediately, and explain the mechanism.
  2. The table also has duplicate rows for several dates — explain why, connecting it to a second, independent problem in this DAG beyond the one in part 1.
  3. Rewrite the DAG to fix both problems, and describe how you would still get the desired one year of history populated, safely.
Solution

1. What caused ~540 runs to fire immediately

catchup was never set, and it defaults to True. With start_date=datetime(2025, 1, 1) and a daily schedule, the instant the DAG is turned on in mid-2026, the scheduler creates a DAG run for every daily interval between January 1, 2025 and now — roughly 540 days — and schedules them all essentially at once, bounded only by available worker slots and max_active_runs (which wasn't set either, so it fell back to the default concurrency limit rather than something deliberately chosen). This is documented Airflow behavior, not a bug: nobody asked for a backfill, but leaving catchup at its default is an implicit request for exactly that.

2. Why there are also duplicate rows

This is a second, independent bug: write_snapshot calls _insert_snapshot_row, which is a plain insert with no delete/overwrite of the target date's existing row first. Combined with retries: 2, any write_snapshot task instance that fails partway through (e.g., succeeds at inserting but then hits a transient DB timeout before returning success, or is retried after a scheduler hiccup) will insert a second row for the same date on its retry. This would be a latent bug even without the catchup incident — the incident just multiplied the number of opportunities for it to fire, because 540 runs firing under rate-limiting pressure (which itself increases the rate of task failures and therefore retries) is exactly the condition that exposes a non-idempotent write. The two problems are independent: fixing catchup alone would still leave the insert able to duplicate rows on any future retry; fixing the insert alone would not have prevented the rate-limiter incident.

3. The fix

@dag(
    dag_id="daily_revenue_snapshot",
    schedule="0 7 * * *",
    start_date=datetime(2025, 1, 1),
    catchup=False,                     # no automatic historical runs
    max_active_runs=1,                 # bound concurrency for any future backfill
    default_args={"retries": 2, "retry_delay": timedelta(minutes=5)},
)
def daily_revenue_snapshot():

    @task
    def pull_revenue(data_interval_start=None) -> str:
        return _call_billing_api(data_interval_start)

    @task
    def write_snapshot(payload: str, data_interval_start=None) -> None:
        date_str = data_interval_start.strftime("%Y-%m-%d")
        _delete_snapshot_row(date_str)      # make the write idempotent
        _insert_snapshot_row(payload, date_str)

    write_snapshot(pull_revenue())

daily_revenue_snapshot()

With catchup=False, turning the DAG on only schedules runs going forward from now, and write_snapshot deleting the target date's existing row before inserting means any retry, manual re-run, or future backfill of a given date produces the same final state instead of a duplicate. To still populate the desired year of history, run an explicit, bounded backfill rather than relying on the deploy-time default: airflow dags backfill -s 2025-01-01 -e 2026-06-30 daily_revenue_snapshot, ideally with --rate-limit-aware pacing or a lowered max_active_runs for the backfill window specifically, so the billing API's 500-requests/day limit isn't hit again — an explicit backfill is auditable and controllable in a way the accidental catchup-triggered flood never was, and because the write is now idempotent, even a backfill that gets interrupted and re-run is safe.

Share this question

← Back to Airflow & Workflow Orchestration practice

We use cookies for product analytics to improve OmniAtlas. See our Privacy Policy.