Multi-task DAGs - Control Flow, Retries, and Backfill

Estimated read: 21 min

Everything from this section so far scales up here: a realistic multi-task DAG with conditional branching on data quality results, alerting, retry policy, and the specific pattern for backfilling a job across a historical date range.

The DAG for this lecture

flowchart TD
    A[run_pipeline] --> B[gold_dq_check]
    B -->|pass| C[refresh_dashboard]
    B -->|fail: below threshold| D[send_low_data_alert]
    B -->|fail: quality violation| E[send_quality_alert]
    C --> F[report_success]

One pipeline run, a gold-layer data quality gate, and two distinct failure paths depending on what kind of problem was found – not just pass/fail, but differentiated alerting based on the failure category.

Control flow: Run if conditions

tasks:
  - task_key: gold_dq_check
    depends_on: [{task_key: run_pipeline}]
    notebook_task:
      notebook_path: /Repos/steprightproject/notebooks/gold_dq_check

  - task_key: refresh_dashboard
    depends_on: [{task_key: gold_dq_check}]
    run_if: ALL_SUCCESS
    notebook_task:
      notebook_path: /Repos/steprightproject/notebooks/refresh_dashboard

  - task_key: send_low_data_alert
    depends_on: [{task_key: gold_dq_check}]
    run_if: AT_LEAST_ONE_FAILED
    notebook_task:
      notebook_path: /Repos/steprightproject/notebooks/alert_low_data

run_if conditions (ALL_SUCCESS, AT_LEAST_ONE_FAILED, ALL_DONE, and others) control whether a task runs based on its dependencies’ outcomes – this is what makes a conditional branch possible: refresh_dashboard only runs on success, while an alert task specifically runs only on failure, rather than every task blindly waiting for “all dependencies finished, regardless of outcome.”

Differentiating failure types with task values

# gold_dq_check notebook
row_count = spark.table("gold.daily_revenue_by_tier").filter("order_date = current_date()").count()
expected_min = 100

if row_count == 0:
    dbutils.jobs.taskValues.set(key="failure_type", value="no_data")
    dbutils.notebook.exit("FAIL: no rows found")
elif row_count < expected_min:
    dbutils.jobs.taskValues.set(key="failure_type", value="low_data")
    dbutils.notebook.exit(f"FAIL: only {row_count} rows, expected at least {expected_min}")
else:
    dbutils.jobs.taskValues.set(key="failure_type", value=None)
    dbutils.notebook.exit("PASS")

A downstream alert task reads failure_type via dbutils.jobs.taskValues.get(...) (previous lecture) to route to the right notification – a low data warning (volume below an expected threshold, but not zero) is a meaningfully different signal than no data at all, and often warrants different urgency and different people notified.

Retry policy

tasks:
  - task_key: run_pipeline
    max_retries: 2
    min_retry_interval_millis: 60000
    retry_on_timeout: true
    pipeline_task:
      pipeline_id: ${var.pipeline_id}

Key term: retries handle transient failures (a momentary network blip, a brief source system outage) automatically – but retrying a task that failed for a persistent reason (bad data, a genuine bug) just delays the inevitable failure notification while burning compute on repeated identical attempts. Set retry counts based on how likely a failure genuinely is to be transient for that specific task, not as a blanket “more retries is safer” default.

Backfill: running a job across a historical date range

A backfill reruns a job’s logic for a range of past dates – common after fixing a bug that affected several days of processing, or when standing up a new pipeline against historical data for the first time.

# backfill_runner.py -- triggers the job once per date in a range via the Jobs API
from datetime import date, timedelta
import requests

start = date(2026, 8, 1)
end = date(2026, 8, 14)
current = start

while current <= end:
    requests.post(
        f"{DATABRICKS_HOST}/api/2.2/jobs/run-now",
        headers={"Authorization": f"Bearer {TOKEN}"},
        json={
            "job_id": JOB_ID,
            "job_parameters": {"run_date": current.isoformat()}
        }
    )
    current += timedelta(days=1)

This uses the Jobs REST API (covered fully in the next lecture) to trigger the same job repeatedly, once per historical date, reusing the job’s existing parameterization (run_date, from earlier in this section) rather than writing separate one-off backfill logic.

Idempotency matters even more here than for a normal rerun. Backfilling fourteen days means running each day’s logic potentially against data that’s already partially there from a prior attempt – non-idempotent writes compound across every date in the range, not just one. The MERGE INTO-based pipeline design from Sections 5 and 9 is what makes this backfill genuinely safe to run repeatedly.

Threshold-based reporting

Beyond pass/fail, a threshold-based check compares a metric against an expected range rather than a fixed value – useful when “normal” daily volume itself varies (weekday vs. weekend order counts, for instance):

avg_last_7_days = spark.sql("""
    SELECT avg(daily_count) FROM (
      SELECT order_date, count(*) AS daily_count
      FROM silver.orders
      WHERE order_date BETWEEN current_date() - 7 AND current_date() - 1
      GROUP BY order_date
    )
""").collect()[0][0]

today_count = spark.table("silver.orders").filter("order_date = current_date()").count()

if today_count < avg_last_7_days * 0.5:
    dbutils.jobs.taskValues.set(key="failure_type", value="low_data")

Comparing against a rolling average rather than a hardcoded constant is what Part 2’s StepRight data quality monitoring section builds out fully, with a real dashboard tracking these thresholds over time.

What this lecture ties together

Control flow, differentiated alerting, retries scoped to actual failure likelihood, and safe backfill – this is the difference between a job that merely runs and one a team actually trusts enough to stop watching manually every morning. The next lecture covers automating job management itself (creation, updates, triggering) via the REST API and CLI, closing out the hands-on portion of this section.


| ← Previous: Passing Parameters to Python Script Task, Task Value, Repair Runs | Next: Automating Jobs - REST API and Databricks CLI → | |:—|—:|


Back to top

Independent, self-authored Databricks companion notes.

This site uses Just the Docs, a documentation theme for Jekyll.