← Kỹ sư dữ liệu← Data Engineer
Kỹ sư dữ liệuData Engineer19 Th7, 2026Jul 19, 202628 phút đọc22 min read

Workflow OrchestrationWorkflow Orchestration

Thuộc bộ kiến thức Data Engineer Roadmap.

Tổng quan

Bất kỳ data pipeline nào không quá đơn giản thực chất đều là một đồ thị các bước phải chạy theo đúng thứ tự, theo một lịch trình (schedule) nhất định, và phải sống sót qua những lỗi không thể tránh khỏi của hệ thống phân tán. Extract dữ liệu từ một API, chờ nó được load vào staging table, transform nó, load vào warehouse, rồi refresh vài data mart và dashboard downstream — mỗi bước phụ thuộc vào bước trước đó, và mỗi bước có thể fail độc lập. Workflow orchestration là kỷ luật (và nhóm công cụ) quản lý việc này: biểu diễn dependency một cách tường minh, chạy task theo lịch, retry khi có lỗi tạm thời, cảnh báo (alert) con người khi có sự cố, và cho bạn một nơi duy nhất để xem pipeline đêm qua có thực sự chạy thành công hay không.

Bài viết này bao quát abstraction cốt lõi đứng sau hầu hết mọi orchestrator hiện đại — DAG — rồi đi sâu vào Apache Airflow, công cụ thống trị trong lĩnh vực này, trước khi so sánh nó với các lựa chọn mới hơn (Prefect, Dagster) và công cụ đã khởi đầu mọi thứ ở quy mô lớn (Luigi). Bài viết cũng bao gồm các khái niệm scheduling (cron, catchup/backfill, SLA), và các vấn đề vận hành về retry và idempotency giúp pipeline được orchestrate trở nên bền vững thay vì mong manh. Về các pipeline được orchestrate, xem ./08-etl-elt-and-data-pipelines.md; về việc theo dõi pipeline khi đang chạy, xem ./18-monitoring-and-observability.md; về việc test và deploy DAG code, xem ./17-cicd-for-data-engineering.md.

Vì sao cron thuần không đủ

Một dòng cron chạy script mỗi đêm hoạt động tốt — cho đến khi script đó cần gọi ba script khác theo đúng thứ tự, một trong số đó cần retry hai lần trước khi bỏ cuộc, và ai đó cần được page khi cả chuỗi mất hơn một giờ. Hãy xem một pipeline dùng cron thuần túy trông như thế nào:

0 2 * * * /usr/local/bin/extract.sh && /usr/local/bin/transform.sh && /usr/local/bin/load.sh

Nhìn có vẻ ổn cho đến khi bạn đặt những câu hỏi vận hành cơ bản:

Câu hỏiCron thuầnOrchestrator
Cái gì đã chạy, khi nào, và có thành công không?Grep log file bằng tayWeb UI với lịch sử từng lần chạy
Retry chỉ bước bị fail?Chạy lại toàn bộ chuỗiRetry đúng task bị fail
Task B cần output của task A, không chỉ exit codeKhông được mô hình hóaDependency tường minh + trao đổi dữ liệu (XCom, artifact)
Chạy song song hai nhánh độc lậpCho chạy nền thủ công (&), dễ vỡChạy task song song có sẵn
Backfill cho 30 ngày lịch sử bị bỏ lỡTự viết vòng lặpcatchup=True / lệnh backfill
Cảnh báo on-call khi job 2 giờ sáng không xong trước 4 giờScript giám sát tự chếSLA / hook cảnh báo có sẵn
Nhìn tổng thể hình dạng pipelineĐọc shell scriptGiao diện đồ thị DAG

Điều này không có nghĩa cron xấu — cron vẫn hoàn toàn ổn cho một script đơn lẻ, độc lập, idempotent, không có dependency. Vấn đề là data pipeline hiếm khi giữ được sự đơn giản đó lâu, và những kiểu lỗi ở trên cộng dồn khi số lượng script tăng lên. Orchestrator tồn tại để biến việc quản lý dependency, scheduling, retry và observability thành những mối quan tâm khai báo (declarative) hạng nhất, thay vì keo dán shell script ứng biến.

Kiến thức nền tảng

DAG: abstraction cốt lõi

Gần như mọi workflow orchestrator — Airflow, Prefect, Dagster, Luigi, thậm chí cả dbt bên trong nó — đều mô hình hóa pipeline như một Directed Acyclic Graph (DAG):

Hình dạng này khớp tự nhiên với data pipeline vì các phép transform dữ liệu vốn dĩ có tính một chiều: raw data chảy vào cleaned data, cleaned data chảy vào aggregated data, aggregated data chảy vào dashboard. Không có kịch bản hợp lý nào mà dashboard cần hoàn thành trước khi raw extract chạy. Ràng buộc “acyclic” không phải là hạn chế do công cụ áp đặt — nó phản ánh một tính chất thực sự của luồng dữ liệu đúng đắn. Nếu bạn thấy mình muốn có một cycle (“task C nên trigger lại task A”), đó thường là dấu hiệu bạn thực sự muốn một DAG run mới, một vòng lặp bên trong một task đơn, hoặc một sensor chờ một điều kiện bên ngoài — chứ không phải một cycle thực sự trong dependency graph.

Một DAG chỉ định nghĩa cấu trúc (structure): những task nào tồn tại và chúng phải chạy theo thứ tự nào. Nó không nói gì về việc một task thực sự làm gì — đó là công việc của task/operator bản thân nó. Sự tách biệt này (cấu trúc vs. thực thi) là điều cho phép cùng một engine DAG có thể lên lịch những loại công việc rất khác nhau: một hàm Python, một lệnh shell, một câu SQL, một job Spark được submit, hay đơn giản là “chờ đến khi file này xuất hiện trong S3.”

Một mô hình tư duy tối giản, độc lập với bất kỳ công cụ cụ thể nào:

extract_orders     extract_customers
      \                   /
       \                 /
        v               v
         transform_and_join
                |
                v
           load_to_warehouse
                |
                v
        refresh_dashboard_mart

transform_and_join không thể bắt đầu cho đến khi cả hai task extract hoàn thành; load_to_warehouse không thể bắt đầu cho đến khi bước join xong. extract_ordersextract_customers không phụ thuộc lẫn nhau, vì vậy một orchestrator tốt sẽ chạy chúng song song.

Ôn lại cú pháp cron

Hầu hết orchestrator chấp nhận biểu thức cron chuẩn (hoặc mở rộng của nó) cho việc lên lịch. Năm trường là:

┌───────────── phút (0 - 59)
│ ┌───────────── giờ (0 - 23)
│ │ ┌───────────── ngày trong tháng (1 - 31)
│ │ │ ┌───────────── tháng (1 - 12)
│ │ │ │ ┌───────────── ngày trong tuần (0 - 6) (Chủ nhật đến Thứ bảy)
│ │ │ │ │
* * * * *
Biểu thứcÝ nghĩa
0 2 * * *Mỗi ngày lúc 02:00
*/15 * * * *Mỗi 15 phút
0 0 * * 0Mỗi Chủ nhật lúc nửa đêm
0 9-17 * * 1-5Mỗi giờ từ 9h sáng–5h chiều, Thứ 2–Thứ 6
0 0 1 * *Nửa đêm ngày mùng 1 mỗi tháng

Airflow còn hỗ trợ thêm các preset cron như @daily, @hourly, @weekly, và — từ Airflow 2.4+ — DatasetsTimetables cho lịch trình dựa trên sự kiện (event-driven) và không theo cron (ví dụ: “chạy bất cứ khi nào dataset upstream này được cập nhật,” hoặc “chạy vào ngày làm việc cuối cùng của tháng”).

Catchup và backfill

Một điểm tinh tế khiến gần như ai mới dùng Airflow cũng vấp phải: một DAG được lên lịch chạy hàng ngày bắt đầu từ 2026-01-01 không chạy vào 2026-01-02 cho kỳ kết thúc 2026-01-02 — nó chạy sau khi 2026-01-02 00:00 kết thúc, cho khoảng thời gian [2026-01-01, 2026-01-02). Đây là mô hình data interval: mỗi lần chạy đại diện cho dữ liệu được tạo ra trong một kỳ đã hoàn tất, không phải “bây giờ.”

Backfill cũng là nơi idempotency không còn là tùy chọn: chạy lại một task cho 2026-01-01 phải cho ra kết quả giống nhau dù đó là lần chạy đầu tiên hay lần thứ năm, nếu không backfill sẽ âm thầm làm hỏng dữ liệu (đếm trùng dòng, insert trùng lặp). Đây chính là nguyên tắc idempotency đã đề cập trong ./08-etl-elt-and-data-pipelines.md — tầng orchestration chính là nơi nguyên tắc này được kiểm chứng, vì retry và backfill đều là “chạy lại task này cho cùng một kỳ logic.”

SLA và cảnh báo

SLA theo nghĩa của Airflow là một kỳ vọng dựa trên thời gian: “task này nên hoàn thành trong X phút/giờ kể từ khi DAG run bắt đầu.” Khi một task vượt quá SLA, Airflow không kill nó — nó ghi log SLA miss và (nếu được cấu hình) kích hoạt một callback, thường tới Slack, email, hoặc PagerDuty. Điều này khác với việc task fail: một task có thể thành công nhưng vẫn bị miss SLA, và một task có thể fail rất lâu trước khi chạm đến SLA. Cả hai đều là tín hiệu hữu ích — failure cho bạn biết có gì đó đã hỏng, SLA miss cho bạn biết có gì đó đang xuống cấp (một bảng tăng trưởng chậm, một worker pool bão hòa) trước khi nó trở thành một failure thực sự.

Khái niệm chính

Kiến trúc Apache Airflow

Airflow, ban đầu được xây dựng tại Airbnb và giờ là một Apache top-level project, là orchestrator tiêu chuẩn thực tế (de facto) cho data pipeline. Kiến trúc của nó có bốn thành phần cốt lõi:

Thành phầnVai trò
SchedulerTrái tim của Airflow. Parse các file DAG, đánh giá lịch trình, và quyết định task instance nào sẵn sàng chạy, đưa chúng vào hàng đợi để thực thi.
ExecutorQuyết định tasks thực sự chạy như thế nào và ở đâu — trong cùng tiến trình (SequentialExecutor, chỉ dùng dev), song song cục bộ nhiều tiến trình (LocalExecutor), hoặc phân tán trên một fleet worker (CeleryExecutor, KubernetesExecutor).
WorkersCác tiến trình thực sự thực thi logic task (liên quan đến Celery/Kubernetes executor — mỗi task có thể chạy trong worker/pod riêng).
WebserverPhục vụ UI: giao diện graph/grid của DAG, log, xu hướng thời lượng task, điều khiển trigger/retry thủ công.
Metadata databaseCơ sở dữ liệu Postgres/MySQL lưu lịch sử DAG run, trạng thái task, connections, variables, XComs. Đây là nguồn sự thật (source of truth) mà cả scheduler và webserver đều đọc từ đó.

Một DAG được định nghĩa dưới dạng Python code — không phải config YAML, không phải canvas kéo-thả. Đây là một trong những quyết định thiết kế đặc trưng của Airflow: vì DAG chỉ là Python, bạn có toàn bộ sức mạnh của ngôn ngữ (vòng lặp để sinh task động, import, điều kiện, thư viện dùng chung) miễn phí, đổi lại là các file DAG cần được scheduler parse trong mỗi chu kỳ parse, đây là nguồn phổ biến gây chậm scheduler nếu file DAG làm việc nặng lúc import (ví dụ: truy vấn database để quyết định tạo task nào).

Một DAG Airflow thực tế

DAG dưới đây extract orders và customers, join/transform chúng, rồi load kết quả vào warehouse — phản ánh mô hình extract → transform → load từ ./08-etl-elt-and-data-pipelines.md, giờ được biểu diễn như một pipeline được orchestrate, có lịch trình, và có thể retry.

from datetime import datetime, timedelta

from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.operators.bash import BashOperator
from airflow.sensors.filesystem import FileSensor

default_args = {
    "owner": "data-engineering",
    "retries": 3,
    "retry_delay": timedelta(minutes=5),
    "retry_exponential_backoff": True,
    "max_retry_delay": timedelta(minutes=30),
    "email_on_failure": True,
    "email": ["data-eng-alerts@example.com"],
}

with DAG(
    dag_id="daily_orders_pipeline",
    description="Extract, transform, and load daily order data into the warehouse",
    default_args=default_args,
    schedule="0 2 * * *",       # every day at 02:00
    start_date=datetime(2026, 1, 1),
    catchup=False,              # do not backfill historical runs on deploy
    max_active_runs=1,          # don't let overlapping runs step on each other
    sla_miss_callback=None,     # plug in a Slack/PagerDuty callback here
    tags=["orders", "warehouse"],
) as dag:

    # Wait until the upstream export file has landed before doing anything else.
    wait_for_export = FileSensor(
        task_id="wait_for_export_file",
        filepath="/data/exports/orders_{{ ds_nodash }}.csv",
        poke_interval=60,
        timeout=60 * 60,        # give up after 1 hour
        mode="reschedule",      # free the worker slot between pokes
    )

    def extract_orders(**context):
        """Pull raw orders for this DAG run's data interval and stage them."""
        import pandas as pd

        ds = context["ds"]  # logical date, e.g. "2026-01-01"
        df = pd.read_csv(f"/data/exports/orders_{context['ds_nodash']}.csv")
        df.to_parquet(f"/data/staging/orders_{ds}.parquet", index=False)
        # Push a small summary to XCom for downstream tasks/observability
        context["ti"].xcom_push(key="row_count", value=len(df))

    extract = PythonOperator(
        task_id="extract_orders",
        python_callable=extract_orders,
    )

    def transform_orders(**context):
        """Clean and enrich the staged orders (idempotent: overwrite, not append)."""
        import pandas as pd

        ds = context["ds"]
        df = pd.read_parquet(f"/data/staging/orders_{ds}.parquet")
        df = df.dropna(subset=["order_id", "customer_id"])
        df["order_total"] = df["quantity"] * df["unit_price"]
        df.to_parquet(f"/data/transformed/orders_{ds}.parquet", index=False)

    transform = PythonOperator(
        task_id="transform_orders",
        python_callable=transform_orders,
    )

    # Idempotent load: DELETE + INSERT for this partition, never a blind append,
    # so retries and backfills for the same ds never double-count rows.
    load = BashOperator(
        task_id="load_orders_to_warehouse",
        bash_command=(
            "python /opt/pipelines/load_orders.py "
            "--input /data/transformed/orders_{{ ds }}.parquet "
            "--partition {{ ds }} "
            "--mode upsert"
        ),
    )

    # Trigger the dbt models that depend on the raw orders table.
    run_dbt_models = BashOperator(
        task_id="run_dbt_transform_models",
        bash_command=(
            "cd /opt/dbt_project && "
            "dbt run --select tag:orders --target prod "
            "--vars '{\"run_date\": \"{{ ds }}\"}'"
        ),
    )

    # Structure: wait -> extract -> transform -> load -> dbt models
    wait_for_export >> extract >> transform >> load >> run_dbt_models

Vài điểm đáng chú ý trong ví dụ này:

DAG nội bộ của dbt, được orchestrate bên trong Airflow

./08-etl-elt-and-data-pipelines.md đã đề cập dbt như tầng transformation trong ELT. Từ góc độ orchestration, điều quan trọng là dbt có DAG riêng của nó, được xây dựng tự động từ các lệnh gọi ref() giữa các model:

-- models/staging/stg_orders.sql
select * from {{ source('raw', 'orders') }}

-- models/marts/fct_orders.sql
select
    o.order_id,
    o.customer_id,
    o.order_total
from {{ ref('stg_orders') }} o   -- this ref() call creates a DAG edge

dbt run (hoặc dbt build) tự giải quyết dependency graph này và thực thi các model theo đúng thứ tự, chạy song song khi có thể (dbt run --select ... --threads 4) — về bản chất, dbt là một orchestrator thu nhỏ của riêng nó, giới hạn trong phạm vi các phép transform SQL. Điều này có nghĩa một data platform thường có hai DAG lồng vào nhau: DAG của Airflow quản lý toàn bộ pipeline (extract → load raw data → chạy dbt → refresh BI extract), trong khi DAG nội bộ của dbt quản lý thứ tự dependency chi tiết bên trong một bước “chạy dbt” duy nhất. Task run_dbt_transform_models trong ví dụ trên chính xác là như vậy: từ góc nhìn của Airflow, đó là một task nguyên tử duy nhất, nhưng bên trong nó tỏa ra hàng chục lần chạy model theo thứ tự. Một số team đi xa hơn và dùng dbt-airflow hoặc astronomer-cosmos để “mở” DAG của dbt thành các task Airflow riêng lẻ (mỗi model một task) để retry và quan sát chi tiết hơn — đổi lại là một đồ thị DAG Airflow lớn hơn và phức tạp hơn nhiều.

Prefect, Dagster, và Luigi

Airflow chiếm ưu thế nhưng không phải là lựa chọn duy nhất, và các lựa chọn thay thế tồn tại phần lớn như phản ứng trước những điểm đau cụ thể của Airflow — việc parse file DAG nặng nề, dynamic workflow gượng gạo, và đường cong học tập vận hành dốc.

Luigi (Spotify mở mã nguồn năm 2012) là một trong những công cụ workflow Python phổ biến đầu tiên, ra đời trước cả Airflow. Nó mô hình hóa pipeline dưới dạng các lớp Task với các phương thức requires(), output(), và run(), và dùng cách kiểm tra “target đã tồn tại chưa” (thường là một file) cho idempotency: nếu output target đã tồn tại, task được coi là xong và bị bỏ qua. Luigi không có scheduler tích hợp riêng (thường được cron điều khiển) và web UI mỏng hơn nhiều so với Airflow. Ngày nay nó hiếm khi được chọn cho dự án mới nhưng vẫn chạy production tại nhiều công ty đã áp dụng nó cách đây một thập kỷ, và mô hình dependency dựa trên requires() của nó đã ảnh hưởng trực tiếp đến các công cụ ra đời sau.

Prefect định vị mình là một lựa chọn Pythonic hơn, ít boilerplate hơn: một hàm Python thuần được decorate bằng @task, ghép vào bên trong một hàm được decorate bằng @flow, trở thành một workflow được orchestrate — không có đối tượng DAG, không có operator, dependency được suy ra từ cách bạn gọi hàm và truyền kết quả của chúng cho nhau:

from prefect import flow, task

@task(retries=3, retry_delay_seconds=30)
def extract_orders(date: str) -> list[dict]:
    ...

@task
def transform(orders: list[dict]) -> list[dict]:
    ...

@task
def load(rows: list[dict]) -> None:
    ...

@flow(name="daily-orders-pipeline")
def daily_orders_pipeline(date: str):
    orders = extract_orders(date)
    clean = transform(orders)
    load(clean)

Tính năng chủ đạo khác của Prefect là mô hình thực thi hybrid: Prefect Cloud (hoặc Prefect server tự host) xử lý orchestration/scheduling/state, trong khi code task thực sự chạy hoàn toàn trên hạ tầng riêng của người dùng thông qua các agent nhẹ — code và dữ liệu không bao giờ phải rời khỏi mạng của bạn để control plane điều phối lần chạy. Prefect cũng hỗ trợ dynamic workflow hoàn toàn một cách tự nhiên hơn Airflow — các task graph phân nhánh dựa trên giá trị runtime (ví dụ: “tạo một task cho mỗi file tìm thấy trong thư mục này, và chúng ta không biết có bao nhiêu file cho đến runtime”) mà không cần các cách lách luật mà Airflow trước đây từng cần (dynamic task mapping được thêm sau này ở Airflow 2.3+ nhưng vốn là tính năng gốc của Prefect từ trước).

Dagster đi theo một quan điểm mạnh mẽ hơn nữa: nó được xây dựng xoay quanh software-defined assets thay vì task. Thay vì mô tả “chạy script này, rồi chạy script kia,” bạn khai báo “bảng/dataframe/model này là một asset, và nó phụ thuộc vào những asset khác này” — Dagster sau đó tự tìm ra thứ tự thực thi từ asset graph, và coi việc materialize mỗi asset (kèm metadata, freshness, và data quality check) là đơn vị quan sát chính, không chỉ đơn thuần là “task có exit 0 hay không.” Góc nhìn lấy asset làm trung tâm này khớp một cách bất thường tốt với một data stack hiện đại lấy warehouse làm trung tâm, nơi những thứ người ta thực sự quan tâm là các bảng và dashboard, không phải các script.

Bảng so sánh orchestrator

AirflowPrefectDagsterLuigi
Abstraction chínhDAG các taskFlow các hàm PythonĐồ thị software-defined assetTask với requires()/output()
Ngôn ngữPython (DAG-as-code)Python (hàm thuần + decorator)Python (decorator asset/op)Python
Mô hình thực thiScheduler + executor + worker (Celery/Kubernetes/Local)Hybrid: control plane cloud/server, thực thi ở bất kỳ đâu qua agentDagster daemon + executor cấu hình được (in-process, multiprocess, Celery, K8s)Không có scheduler tích hợp; thường do cron điều khiển, có tiến trình scheduler trung tâm để giới hạn concurrency
Dynamic workflowLịch sử khá cứng nhắc; dynamic task mapping thêm ở 2.3+Native, hạng nhấtHỗ trợ qua dynamic outputHạn chế
Mô hình idempotencyTheo quy ước (bạn tự thiết kế)Theo quy ước (bạn tự thiết kế)Theo quy ước, được củng cố bởi việc theo dõi asset materializationTích hợp sẵn qua kiểm tra target tồn tại của output()
Độ chín / hệ sinh tháiRất chín, hệ sinh thái plugin khổng lồ (provider cho mọi cloud, DB, công cụ SaaS)Chín, hệ sinh thái nhỏ hơn nhưng phát triển nhanhChín, tích hợp dbt mạnh, hệ sinh thái nhỏ hơn AirflowChín nhưng phần lớn đã lỗi thời; phát triển tiếp tục thấp
Trải nghiệm dev cục bộLịch sử khá cồng kềnh (cần scheduler + webserver + DB chạy)Nhẹ nhàng; một flow chỉ là một script PythonTốt; asset graph nhìn thấy được mà không cần deploy đầy đủĐơn giản nhưng công cụ tối thiểu
Phù hợp nhấtPipeline rộng, đa dạng qua nhiều hệ thống; team muốn hệ sinh thái và cộng đồng lớn nhấtTeam muốn workflow Pythonic, động, và ít boilerplate orchestration hơnTeam lấy asset/chất lượng dữ liệu làm trung tâm, đặc biệt cùng với dbtHệ thống legacy; hiếm khi được chọn cho công việc mới

Không công cụ nào trong số này giải quyết vấn đề scheduling/dependency nền tảng theo cách khác biệt về nguyên tắc — tất cả vẫn tạo ra thứ tương đương với một DAG bên dưới. Sự khác biệt nằm ở trải nghiệm phát triển, mức độ động của đồ thị, gánh nặng vận hành của control plane, và mức độ gắn chặt của công cụ với một mô hình tư duy cụ thể (task vs. asset).

Idempotency và retry ở tầng orchestration

Retry là tuyến phòng thủ đầu tiên của orchestrator trước những lỗi tạm thời (transient) — một trục trặc mạng khi gọi API, một kết nối warehouse timeout do tải cao, một worker mất kết nối trong chốc lát. Cấu hình retry rất rẻ:

default_args = {
    "retries": 3,
    "retry_delay": timedelta(minutes=5),
    "retry_exponential_backoff": True,   # 5m, 10m, 20m, ... instead of flat 5m
    "max_retry_delay": timedelta(minutes=30),
}

Nhưng retry chỉ an toàn nếu task là idempotent: chạy nó hai lần cho cùng một kỳ logic phải cho ra cùng một trạng thái cuối cùng như chạy một lần. Đây chính là nguyên tắc từ ./08-etl-elt-and-data-pipelines.md, và tầng orchestration chính là nơi nguyên tắc này thực sự được kiểm chứng, vì retry (và backfill) đều, về mặt cơ chế, là “chạy lại task này cho một kỳ mà nó có thể đã chạy một phần rồi.” Cụ thể:

Giám sát sức khỏe pipeline, sơ lược

Orchestrator là nơi tự nhiên để quan sát sức khỏe pipeline vì nó đã theo dõi mọi lần chạy và trạng thái, thời lượng, kết quả của mọi task. Ít nhất, hầu hết các team thiết lập:

Đây chỉ là lát cắt tầng orchestration của observability — data quality check, giám sát độ tươi (freshness) của dữ liệu, và xử lý sự cố đầy đủ được bao quát sâu hơn trong ./18-monitoring-and-observability.md. Việc deploy và test chính DAG code (lint DAG, chạy dbt test trong CI, deploy staging) được bao quát trong ./17-cicd-for-data-engineering.md.

Best Practices

Tài liệu tham khảo

Part of the Data Engineer Roadmap knowledge base.

Overview

Every non-trivial data pipeline is really a graph of steps that have to run in a specific order, on a schedule, and survive the inevitable failures of distributed systems. Extract from an API, wait for it to land in a staging table, transform it, load it into a warehouse, then refresh a handful of downstream marts and dashboards — each of those steps depends on the one before it, and each can fail independently. Workflow orchestration is the discipline (and the class of tools) that manages this: expressing dependencies explicitly, running tasks on a schedule, retrying transient failures, alerting humans when things go wrong, and giving you a single place to see whether last night’s pipeline actually succeeded.

This note covers the core abstraction behind almost every modern orchestrator — the DAG — and then goes deep on Apache Airflow, the dominant tool in this space, before comparing it with newer alternatives (Prefect, Dagster) and the tool that started it all at scale (Luigi). It also covers scheduling concepts (cron, catchup/backfill, SLAs), and the operational concerns of retries and idempotency that make orchestrated pipelines resilient rather than fragile. For the pipelines being orchestrated themselves, see ./08-etl-elt-and-data-pipelines.md; for watching those pipelines once they’re running, see ./18-monitoring-and-observability.md; for testing and deploying DAG code, see ./17-cicd-for-data-engineering.md.

Why plain cron isn’t enough

A single cron entry that runs a script every night works fine — right up until that script needs to call three other scripts in order, one of them needs to retry twice before giving up, and someone needs to be paged when the whole thing takes more than an hour. Consider what a naive cron-based pipeline looks like:

0 2 * * * /usr/local/bin/extract.sh && /usr/local/bin/transform.sh && /usr/local/bin/load.sh

This looks reasonable until you ask basic operational questions:

QuestionPlain cronOrchestrator
What ran, when, and did it succeed?Grep log files by handWeb UI with per-run history
Retry only the step that failed?Re-run the whole chainRetry just the failed task
Task B needs task A’s output, not just its exit codeNot modeledExplicit dependency + data handoff (XCom, artifacts)
Run two independent branches in parallelManual backgrounding (&), fragileParallel task execution built in
Backfill for 30 days of missed historyWrite a loop manuallycatchup=True / backfill command
Alert on-call when the 2am job doesn’t finish by 4amCustom monitoring scriptBuilt-in SLA / alerting hooks
See the whole pipeline’s shape at a glanceRead shell scriptDAG graph view

None of this means cron is bad — cron is still perfectly fine for a single, independent, idempotent script with no dependencies. The problem is that data pipelines rarely stay that simple, and the failure modes above compound as the number of scripts grows. Orchestrators exist to make dependency management, scheduling, retries, and observability first-class, declarative concerns instead of ad hoc shell glue.

Fundamentals

DAGs: the core abstraction

Almost every workflow orchestrator — Airflow, Prefect, Dagster, Luigi, even dbt internally — models a pipeline as a Directed Acyclic Graph (DAG):

This shape is a natural fit for data pipelines because data transformations are inherently one-directional: raw data flows into cleaned data, which flows into aggregated data, which flows into a dashboard. There’s no legitimate scenario where the dashboard needs to finish before the raw extract runs. The “acyclic” constraint isn’t a limitation imposed by the tooling — it reflects a real property of correct data flow. If you ever find yourself wanting a cycle (“task C should re-trigger task A”), that’s usually a sign you actually want a new DAG run, a loop within a single task, or a sensor waiting on an external condition — not a literal cycle in the dependency graph.

A DAG only defines structure: which tasks exist and what order they must run in. It says nothing about what a task actually does — that’s the job of the task/operator itself. This separation (structure vs. execution) is what lets the same DAG engine schedule wildly different kinds of work: a Python function, a shell command, a SQL query, a Spark job submission, or simply “wait until this file appears in S3.”

A minimal mental model, independent of any specific tool:

extract_orders     extract_customers
      \                   /
       \                 /
        v               v
         transform_and_join
                |
                v
           load_to_warehouse
                |
                v
        refresh_dashboard_mart

transform_and_join cannot start until both extract tasks finish; load_to_warehouse cannot start until the join finishes. extract_orders and extract_customers have no dependency on each other, so a good orchestrator runs them in parallel.

Cron syntax refresher

Most orchestrators accept standard cron expressions (or a superset) for scheduling. The five fields are:

┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday)
│ │ │ │ │
* * * * *
ExpressionMeaning
0 2 * * *Every day at 02:00
*/15 * * * *Every 15 minutes
0 0 * * 0Every Sunday at midnight
0 9-17 * * 1-5Every hour from 9am–5pm, Mon–Fri
0 0 1 * *Midnight on the first of every month

Airflow additionally supports cron presets like @daily, @hourly, @weekly, and — as of Airflow 2.4+ — Datasets and Timetables for event-driven and non-cron schedules (e.g., “run whenever this upstream dataset is updated,” or “run on the last business day of the month”).

Catchup and backfill

A subtlety that trips up almost everyone new to Airflow: a DAG scheduled to run daily starting 2026-01-01 doesn’t run at 2026-01-02 for the period ending 2026-01-02 — it runs after 2026-01-02 00:00 ends, for the interval [2026-01-01, 2026-01-02). This is the data interval model: each run represents the data produced during a completed period, not “now.”

Backfills are also where idempotency stops being optional: re-running a task for 2026-01-01 must produce the same result whether it’s the first run or the fifth, otherwise a backfill silently corrupts data (double-counted rows, duplicated inserts). This is the same idempotency principle covered in ./08-etl-elt-and-data-pipelines.md — the orchestration layer is exactly where it gets tested, because retries and backfills are both “run this task again for the same logical period.”

SLAs and alerting

An SLA in Airflow’s sense is a time-based expectation: “this task should finish within X minutes/hours of the DAG run starting.” When a task blows past its SLA, Airflow doesn’t kill it — it logs an SLA miss and (if configured) fires a callback, typically to Slack, email, or PagerDuty. This is distinct from a task failure: a task can succeed and still have missed its SLA, and a task can fail well before any SLA is reached. Both are useful signals — failures tell you something broke, SLA misses tell you something is degrading (a slow-growing table, a saturated worker pool) before it becomes an outright failure.

Key Concepts

Apache Airflow architecture

Airflow, originally built at Airbnb and now an Apache top-level project, is the de facto standard orchestrator for data pipelines. Its architecture has four core components:

ComponentRole
SchedulerThe heart of Airflow. Parses DAG files, evaluates schedules, and decides which task instances are ready to run, queuing them for execution.
ExecutorDetermines how and where tasks actually run — in-process (SequentialExecutor, dev only), locally in parallel processes (LocalExecutor), or distributed across a worker fleet (CeleryExecutor, KubernetesExecutor).
WorkersThe processes that actually execute task logic (relevant for Celery/Kubernetes executors — each task can run in its own worker/pod).
WebserverServes the UI: DAG graph/grid views, logs, task duration trends, manual trigger/retry controls.
Metadata databasePostgres/MySQL backing store holding DAG run history, task state, connections, variables, XComs. This is the source of truth the scheduler and webserver both read from.

A DAG is defined as Python code — not a YAML config, not a drag-and-drop canvas. This is one of Airflow’s defining design choices: because DAGs are just Python, you get the full power of the language (loops to generate tasks dynamically, imports, conditionals, shared libraries) for free, at the cost of DAG files needing to be parsed by the scheduler on every parse cycle, which is a common source of scheduler slowness if DAG files do expensive work at import time (e.g., hitting a database to decide what tasks to create).

A real Airflow DAG

The following DAG extracts orders and customers, joins/transforms them, and loads the result into a warehouse — mirroring the extract → transform → load pattern from ./08-etl-elt-and-data-pipelines.md, now expressed as an orchestrated, scheduled, retriable pipeline.

from datetime import datetime, timedelta

from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.operators.bash import BashOperator
from airflow.sensors.filesystem import FileSensor

default_args = {
    "owner": "data-engineering",
    "retries": 3,
    "retry_delay": timedelta(minutes=5),
    "retry_exponential_backoff": True,
    "max_retry_delay": timedelta(minutes=30),
    "email_on_failure": True,
    "email": ["data-eng-alerts@example.com"],
}

with DAG(
    dag_id="daily_orders_pipeline",
    description="Extract, transform, and load daily order data into the warehouse",
    default_args=default_args,
    schedule="0 2 * * *",       # every day at 02:00
    start_date=datetime(2026, 1, 1),
    catchup=False,              # do not backfill historical runs on deploy
    max_active_runs=1,          # don't let overlapping runs step on each other
    sla_miss_callback=None,     # plug in a Slack/PagerDuty callback here
    tags=["orders", "warehouse"],
) as dag:

    # Wait until the upstream export file has landed before doing anything else.
    wait_for_export = FileSensor(
        task_id="wait_for_export_file",
        filepath="/data/exports/orders_{{ ds_nodash }}.csv",
        poke_interval=60,
        timeout=60 * 60,        # give up after 1 hour
        mode="reschedule",      # free the worker slot between pokes
    )

    def extract_orders(**context):
        """Pull raw orders for this DAG run's data interval and stage them."""
        import pandas as pd

        ds = context["ds"]  # logical date, e.g. "2026-01-01"
        df = pd.read_csv(f"/data/exports/orders_{context['ds_nodash']}.csv")
        df.to_parquet(f"/data/staging/orders_{ds}.parquet", index=False)
        # Push a small summary to XCom for downstream tasks/observability
        context["ti"].xcom_push(key="row_count", value=len(df))

    extract = PythonOperator(
        task_id="extract_orders",
        python_callable=extract_orders,
    )

    def transform_orders(**context):
        """Clean and enrich the staged orders (idempotent: overwrite, not append)."""
        import pandas as pd

        ds = context["ds"]
        df = pd.read_parquet(f"/data/staging/orders_{ds}.parquet")
        df = df.dropna(subset=["order_id", "customer_id"])
        df["order_total"] = df["quantity"] * df["unit_price"]
        df.to_parquet(f"/data/transformed/orders_{ds}.parquet", index=False)

    transform = PythonOperator(
        task_id="transform_orders",
        python_callable=transform_orders,
    )

    # Idempotent load: DELETE + INSERT for this partition, never a blind append,
    # so retries and backfills for the same ds never double-count rows.
    load = BashOperator(
        task_id="load_orders_to_warehouse",
        bash_command=(
            "python /opt/pipelines/load_orders.py "
            "--input /data/transformed/orders_{{ ds }}.parquet "
            "--partition {{ ds }} "
            "--mode upsert"
        ),
    )

    # Trigger the dbt models that depend on the raw orders table.
    run_dbt_models = BashOperator(
        task_id="run_dbt_transform_models",
        bash_command=(
            "cd /opt/dbt_project && "
            "dbt run --select tag:orders --target prod "
            "--vars '{\"run_date\": \"{{ ds }}\"}'"
        ),
    )

    # Structure: wait -> extract -> transform -> load -> dbt models
    wait_for_export >> extract >> transform >> load >> run_dbt_models

A few things worth calling out in this example:

dbt’s internal DAG, orchestrated inside Airflow

./08-etl-elt-and-data-pipelines.md covers dbt as the transformation layer in ELT. From an orchestration point of view, the important thing is that dbt has its own DAG, built automatically from ref() calls between models:

-- models/staging/stg_orders.sql
select * from {{ source('raw', 'orders') }}

-- models/marts/fct_orders.sql
select
    o.order_id,
    o.customer_id,
    o.order_total
from {{ ref('stg_orders') }} o   -- this ref() call creates a DAG edge

dbt run (or dbt build) resolves this dependency graph itself and executes models in the correct order, in parallel where possible (dbt run --select ... --threads 4) — dbt is, internally, its own mini-orchestrator scoped to SQL transformations. This means a data platform typically has two DAGs nested inside each other: Airflow’s DAG governs the whole pipeline (extract → load raw data → run dbt → refresh BI extracts), while dbt’s internal DAG governs the fine-grained dependency order within the single “run dbt” step. The run_dbt_transform_models task in the example above is exactly this: from Airflow’s perspective it’s one atomic task, but internally it fans out into dozens of ordered model runs. Teams sometimes go further and use dbt-airflow or astronomer-cosmos to unpack dbt’s DAG into individual Airflow tasks (one task per model) for finer-grained retries and visibility — at the cost of a much larger, more complex Airflow DAG graph.

Prefect, Dagster, and Luigi

Airflow is dominant but not the only option, and the alternatives exist largely as reactions to specific Airflow pain points — heavyweight DAG-file parsing, awkward dynamic workflows, and a steep operational learning curve.

Luigi (open-sourced by Spotify in 2012) was one of the first popular Python workflow tools and predates Airflow. It models pipelines as Task classes with requires(), output(), and run() methods, and uses a “target exists” check (usually a file) for idempotency: if the output target already exists, the task is considered done and is skipped. Luigi has no built-in scheduler of its own (it’s typically driven by cron) and a much thinner web UI than Airflow. It’s rarely chosen for new projects today but is still running in production at plenty of companies that adopted it a decade ago, and its requires()-based dependency model directly influenced later tools.

Prefect positions itself as a more Pythonic, lower-boilerplate alternative: a plain Python function decorated with @task, composed inside a function decorated with @flow, becomes an orchestrated workflow — no DAG object, no operators, dependencies are inferred from how you call functions and pass their results to each other:

from prefect import flow, task

@task(retries=3, retry_delay_seconds=30)
def extract_orders(date: str) -> list[dict]:
    ...

@task
def transform(orders: list[dict]) -> list[dict]:
    ...

@task
def load(rows: list[dict]) -> None:
    ...

@flow(name="daily-orders-pipeline")
def daily_orders_pipeline(date: str):
    orders = extract_orders(date)
    clean = transform(orders)
    load(clean)

Prefect’s other headline feature is a hybrid execution model: Prefect Cloud (or a self-hosted Prefect server) handles orchestration/scheduling/state, while the actual task code runs entirely in the user’s own infrastructure via lightweight agents — code and data never have to leave your network for the control plane to coordinate the run. Prefect also supports fully dynamic workflows more naturally than Airflow — task graphs that branch based on runtime values (e.g., “spawn one task per file found in this directory, and we don’t know how many files there’ll be until runtime”) without the workarounds Airflow historically needed (dynamic task mapping was added later in Airflow 2.3+ but was a native Prefect feature earlier).

Dagster takes a stronger opinion still: it’s built around software-defined assets rather than tasks. Instead of describing “run this script, then run that script,” you declare “this table/dataframe/model is an asset, and it depends on these other assets” — Dagster then figures out execution order from the asset graph, and treats each asset’s materialization (with metadata, freshness, and data quality checks attached) as the primary unit of observability, not just “did the task exit 0.” This asset-centric view maps unusually well onto a modern warehouse-centric data stack where the things people actually care about are tables and dashboards, not scripts.

Orchestrator comparison

AirflowPrefectDagsterLuigi
Primary abstractionDAG of tasksFlow of Python tasksGraph of software-defined assetsTask with requires()/output()
LanguagePython (DAG-as-code)Python (plain functions + decorators)Python (asset/op decorators)Python
Execution modelScheduler + executor + workers (Celery/Kubernetes/Local)Hybrid: cloud/server control plane, execution anywhere via agentsDagster daemon + configurable executors (in-process, multiprocess, Celery, K8s)No built-in scheduler; usually cron-driven, central scheduler process for concurrency limits
Dynamic workflowsHistorically rigid; dynamic task mapping added in 2.3+Native, first-classSupported via dynamic outputsLimited
Idempotency modelConvention (you design it)Convention (you design it)Convention, reinforced by asset materialization trackingBuilt-in via output() target existence check
Maturity / ecosystemVery mature, huge plugin ecosystem (providers for every cloud, DB, SaaS tool)Mature, smaller but fast-growing ecosystemMature, strong dbt integration, smaller ecosystem than AirflowMature but largely legacy; low ongoing development
Local dev experienceHistorically clunky (needs scheduler + webserver + DB running)Lightweight; a flow is just a Python scriptGood; asset graph visible without a full deploymentSimple but minimal tooling
Best fitBroad, heterogeneous pipelines across many systems; teams wanting the largest ecosystem and communityTeams wanting Pythonic, dynamic workflows and less orchestration boilerplateTeams centered on data assets/quality, especially alongside dbtLegacy systems; rarely chosen for new work

None of these tools solve the underlying scheduling/dependency problem differently in principle — they all still produce something equivalent to a DAG under the hood. The differences are in developer ergonomics, how dynamic the graph can be, how much operational overhead the control plane carries, and how tightly the tool couples to a particular mental model (tasks vs. assets).

Idempotency and retries at the orchestration layer

Retries are the orchestrator’s first line of defense against transient failures — a network blip calling an API, a warehouse connection timing out under load, a worker briefly losing connectivity. Configuring retries is cheap:

default_args = {
    "retries": 3,
    "retry_delay": timedelta(minutes=5),
    "retry_exponential_backoff": True,   # 5m, 10m, 20m, ... instead of flat 5m
    "max_retry_delay": timedelta(minutes=30),
}

But retries are only safe if the task is idempotent: running it twice for the same logical period must produce the same end state as running it once. This is the same principle from ./08-etl-elt-and-data-pipelines.md, and the orchestration layer is where it actually gets exercised, because retries (and backfills) are both, mechanically, “run this task again for a period it may have partially already run for.” Concretely:

Monitoring pipeline health, briefly

The orchestrator is a natural place to observe pipeline health because it already tracks every run and every task’s state, duration, and outcome. At minimum, most teams wire up:

This is only the orchestration-layer slice of observability — data quality checks, freshness monitoring, and full incident response are covered in depth in ./18-monitoring-and-observability.md. Deploying and testing the DAG code itself (linting DAGs, running dbt test in CI, staging deployments) is covered in ./17-cicd-for-data-engineering.md.

Best Practices

References