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ỏi | Cron thuần | Orchestrator |
|---|---|---|
| Cái gì đã chạy, khi nào, và có thành công không? | Grep log file bằng tay | Web 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ỗi | Retry đúng task bị fail |
| Task B cần output của task A, không chỉ exit code | Không được mô hình hóa | Dependency tường minh + trao đổi dữ liệu (XCom, artifact) |
| Chạy song song hai nhánh độc lập | Cho 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ặp | catchup=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 script | Giao 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):
- Directed (có hướng): mỗi edge có một hướng. Một edge từ task A đến task B nghĩa là “A phải hoàn thành trước khi B bắt đầu” — không phải chiều ngược lại.
- Acyclic (không chu trình): không có chu trình (cycle). Bạn không thể để A phụ thuộc vào B trong khi B cũng (một cách gián tiếp) phụ thuộc vào A. Một task không bao giờ được phụ thuộc vào output của chính nó.
- Graph (đồ thị): node là task, edge là dependency.
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_orders và extract_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 * * 0 | Mỗi Chủ nhật lúc nửa đêm |
0 9-17 * * 1-5 | Mỗ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+ — Datasets và Timetables 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ờ.”
catchup: nếustart_datecủa một DAG nằm trong quá khứ vàcatchup=True(mặc định lịch sử của Airflow), scheduler sẽ tự động tạo và chạy một DAG run cho mỗi khoảng thời gian bị bỏ lỡ giữastart_datevà hiện tại, theo thứ tự. Deploy một DAG chạy hàng ngày vớistart_datecách đây ba tháng, và Airflow sẽ khởi động ~90 lần chạy backfill ngay khi nó được bật lên — thường không phải điều bạn muốn với một pipeline hoàn toàn mới chỉ quan tâm đến dữ liệu từ giờ trở đi. Hầu hết các team đặt tường minhcatchup=Falsetrừ khi backfill lịch sử thực sự được dự định.- Backfill: hành động có chủ đích, thường là thủ công, chạy một DAG cho một khoảng ngày trong quá khứ — thường vì logic thay đổi và output lịch sử cần được tính lại, hoặc vì một pipeline mới cần populate lịch sử. Airflow hỗ trợ việc này qua
airflow dags backfill -s 2026-01-01 -e 2026-01-31 my_dag, về bản chất chỉ là catchup được trigger theo yêu cầu cho một khoảng giới hạn.
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ần | Vai trò |
|---|---|
| Scheduler | Trá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. |
| Executor | Quyế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). |
| Workers | Cá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). |
| Webserver | Phụ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 database | Cơ 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:
- Toán tử
>>thiết lập dependency:a >> bnghĩa là “b chạy sau a.” Bạn cũng có thể viếta.set_downstream(b), hoặc xây các hình dạng fan-out/fan-in bằng list:extract >> [transform_a, transform_b] >> merge. - Operator vs. DAG:
PythonOperator,BashOperator, vàFileSensorđều là các operator — mỗi cái là một template cho một loại task cụ thể. Đối tượng DAG chỉ nối chúng lại với nhau; nó không biết và không quan tâmPythonOperatorthực sự thực thi cái gì bên trong. Đây chính là sự tách “DAG định nghĩa cấu trúc, task thực thi công việc” đã nhắc ở trên — thayBashOperatorbằngKubernetesPodOperatorthay đổi nơi và cách bước load chạy mà không đụng gì đến dependency graph. - Sensor (
FileSensorở đây) là một nhóm operator đặc biệt chờ một điều kiện — một file xuất hiện, một partition tồn tại, một DAG bên ngoài kết thúc — thay vì trực tiếp làm việc.mode="reschedule"giải phóng worker slot giữa các lần kiểm tra thay vì giữ nó suốt, điều này quan trọng ở quy mô lớn (một sensor ở modepokeblock trong một giờ sẽ giữ một worker suốt cả giờ đó). retries+retry_exponential_backoffthiết lập retry tự động với backoff ở mức task — xem phần Idempotency và Retry bên dưới để hiểu vì sao điều này an toàn ở đây.catchup=Falsevàmax_active_runs=1là hai trong số những cấu hình bị quên phổ biến nhất gây ra sự cố thực sự: quên cái đầu tiên khiến backfill bất ngờ xảy ra khi deploy; quên cái thứ hai cho phép một lần chạy chậm chồng lấn với lần chạy theo lịch tiếp theo và làm hỏng trạng thái dùng chung.
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
| Airflow | Prefect | Dagster | Luigi | |
|---|---|---|---|---|
| Abstraction chính | DAG các task | Flow các hàm Python | Đồ thị software-defined asset | Task 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 thi | Scheduler + executor + worker (Celery/Kubernetes/Local) | Hybrid: control plane cloud/server, thực thi ở bất kỳ đâu qua agent | Dagster 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 workflow | Lịch sử khá cứng nhắc; dynamic task mapping thêm ở 2.3+ | Native, hạng nhất | Hỗ trợ qua dynamic output | Hạn chế |
| Mô hình idempotency | Theo 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 materialization | Tích hợp sẵn qua kiểm tra target tồn tại của output() |
| Độ chín / hệ sinh thái | Rấ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 nhanh | Chín, tích hợp dbt mạnh, hệ sinh thái nhỏ hơn Airflow | Chí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 Python | Tố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ất | Pipeline 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ất | Team muốn workflow Pythonic, động, và ít boilerplate orchestration hơn | Team lấy asset/chất lượng dữ liệu làm trung tâm, đặc biệt cùng với dbt | Hệ 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ể:
- Việc load nên là
DELETE + INSERThoặcMERGE/upsert trên một khóa partition, không bao giờ làINSERT/append mù quáng. Trong DAG ví dụ,load_orders_to_warehousechạy ở--mode upserttrên partition{{ ds }}cụ thể để một retry sau lỗi một phần không tạo ra dòng trùng lặp. - Các bước extract ghi file nên ghi đè lên đường dẫn target một cách xác định (deterministic) (
orders_{ds}.parquet, không phảiorders_{run_timestamp}.parquet) để retry thay thế chứ không nhân đôi dữ liệu đã staging. - Các side effect không có “hoàn tác” tự nhiên (gửi email, gọi một API bên thứ ba không idempotent, tính phí thanh toán) cần logic khử trùng lặp tường minh — một idempotency key, một kiểm tra “tôi đã gửi cái này chưa” — vì orchestrator retry task sẽ gọi lại side effect đó y hệt.
- Retry nên được dành cho những lỗi có khả năng là tạm thời. Một task fail vì bug trong logic transform sẽ fail y hệt ở mọi lần retry, đốt hết ngân sách retry và trì hoãn cảnh báo failure — đây là điều mà exponential backoff giảm nhẹ một phần (tốn ít thời gian thực hơn để phát hiện retry sẽ không giúp ích) nhưng không giải quyết triệt để; một số team chủ động phân nhánh hành vi retry theo loại exception (retry với
ConnectionError, fail nhanh với lỗi validation dữ liệu).
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:
- Cảnh báo failure — thông báo Slack hoặc PagerDuty ngay khi một task hết retry và fail, lý tưởng là kèm link trực tiếp đến log của task bị fail.
- Cảnh báo SLA miss — một tín hiệu nhẹ nhàng hơn failure, đánh dấu các task hoặc DAG đang có xu hướng chậm trước khi chúng trở thành trễ hẳn.
- Xu hướng thời lượng task — UI của webserver (hoặc metric xuất ra Prometheus/Datadog) cho thấy một task từng mất 5 phút giờ mất 45 phút, thường là dấu hiệu báo trước một failure hoặc timeout hẳn.
Đâ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
- Thiết kế mọi task idempotent trước khi lo về retry. Retry và backfill chỉ an toàn khi đặt trên nền task idempotent; cấu hình
retries=3cho một bước load không idempotent là một bug làm hỏng dữ liệu đang chờ xảy ra, không phải một tính năng bền vững. - Đặt
catchup=Falsetường minh trừ khi backfill-khi-deploy thực sự được dự định. Đây là một trong những sự cố “vì sao 90 DAG run vừa chạy” phổ biến nhất với các team mới dùng Airflow. - Giữ cho file DAG rẻ khi parse. Scheduler parse lại mọi file DAG theo một chu kỳ cố định; thực hiện truy vấn database, gọi API, hoặc tính toán nặng lúc định nghĩa DAG (bên ngoài các hàm task) làm chậm toàn bộ scheduler, không chỉ một DAG.
- Ưu tiên các task nhỏ, đơn mục đích thay vì một task khổng lồ. Một DAG với
extract,transform, vàloadlà các task riêng biệt cho phép bạn retry (và quan sát) đúng bước bị fail, thay vì chạy lại toàn bộ pipeline từ đầu. - Dùng sensor một cách tiết chế và với
mode="reschedule". Một sensor ở modepokegiữ một worker slot suốt thời gian chờ, điều này có thể âm thầm làm cạn kiệt năng lực worker khi số DAG đang chờ tăng lên. - Đặt
max_active_runsmột cách có chủ đích. Không có nó, một lần chạy chậm có thể chồng lấn với lần chạy theo lịch tiếp theo, và hai lần chạy cùng ghi vào một partition đồng thời là nguồn phổ biến gây hỏng dữ liệu tinh vi. - Truyền tham chiếu dữ liệu, không phải payload lớn, qua XCom (hoặc tương đương của Prefect/Dagster). XCom dành cho metadata nhỏ (số dòng, đường dẫn file, cờ trạng thái) — đẩy cả một dataframe đầy đủ qua nó sẽ làm phình metadata database và làm chậm scheduler.
- Version-control DAG và deploy chúng qua CI, như bất kỳ code nào khác. Đối xử với một thay đổi DAG với cùng mức độ nghiêm ngặt review như một thay đổi code ứng dụng — một DAG tồi có thể âm thầm dừng một pipeline hoặc nhân đôi dữ liệu dễ dàng như một deploy ứng dụng tồi. Xem ./17-cicd-for-data-engineering.md.
- Để dbt sở hữu DAG tầng transformation; để Airflow (hoặc orchestrator của bạn) sở hữu DAG tầng pipeline. Chống lại sự tách biệt này — ví dụ, tái triển khai dependency graph model của dbt thành các task Airflow riêng lẻ cho từng model — thường thêm độ phức tạp vận hành mà không mang lại lợi ích tương xứng, trừ khi bạn cần cụ thể độ chi tiết retry theo từng model mà các công cụ như
astronomer-cosmoscung cấp gọn gàng hơn. - Cảnh báo khi SLA bị miss, không chỉ khi fail. Một pipeline “thành công” nhưng mất thời gian gấp ba lần bình thường là một cảnh báo sớm sẽ trở thành failure nếu không được xử lý.
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:
| Question | Plain cron | Orchestrator |
|---|---|---|
| What ran, when, and did it succeed? | Grep log files by hand | Web UI with per-run history |
| Retry only the step that failed? | Re-run the whole chain | Retry just the failed task |
| Task B needs task A’s output, not just its exit code | Not modeled | Explicit dependency + data handoff (XCom, artifacts) |
| Run two independent branches in parallel | Manual backgrounding (&), fragile | Parallel task execution built in |
| Backfill for 30 days of missed history | Write a loop manually | catchup=True / backfill command |
| Alert on-call when the 2am job doesn’t finish by 4am | Custom monitoring script | Built-in SLA / alerting hooks |
| See the whole pipeline’s shape at a glance | Read shell script | DAG 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):
- Directed: each edge has a direction. An edge from task A to task B means “A must complete before B starts” — not the reverse.
- Acyclic: there are no cycles. You cannot have A depend on B while B also (transitively) depends on A. A task can never depend on its own output.
- Graph: nodes are tasks, edges are dependencies.
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)
│ │ │ │ │
* * * * *
| Expression | Meaning |
|---|---|
0 2 * * * | Every day at 02:00 |
*/15 * * * * | Every 15 minutes |
0 0 * * 0 | Every Sunday at midnight |
0 9-17 * * 1-5 | Every 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.”
catchup: if a DAG’sstart_dateis in the past andcatchup=True(Airflow’s historical default), the scheduler will automatically create and run one DAG run for every missed interval betweenstart_dateand now, in order. Deploy a daily DAG with astart_datethree months back, and Airflow will kick off ~90 backfill runs the moment it’s turned on — often not what you want for a brand-new pipeline that only cares about data going forward. Most teams explicitly setcatchup=Falseunless historical backfill is actually intended.- Backfill: the deliberate, usually manual, act of running a DAG for a range of past dates — typically because logic changed and historical outputs need to be recomputed, or because a new pipeline needs to populate history. Airflow supports this via
airflow dags backfill -s 2026-01-01 -e 2026-01-31 my_dag, which is really just catchup triggered on demand for a bounded range.
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:
| Component | Role |
|---|---|
| Scheduler | The heart of Airflow. Parses DAG files, evaluates schedules, and decides which task instances are ready to run, queuing them for execution. |
| Executor | Determines how and where tasks actually run — in-process (SequentialExecutor, dev only), locally in parallel processes (LocalExecutor), or distributed across a worker fleet (CeleryExecutor, KubernetesExecutor). |
| Workers | The processes that actually execute task logic (relevant for Celery/Kubernetes executors — each task can run in its own worker/pod). |
| Webserver | Serves the UI: DAG graph/grid views, logs, task duration trends, manual trigger/retry controls. |
| Metadata database | Postgres/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:
- The
>>operator sets dependencies:a >> bmeans “b runs after a.” You can also writea.set_downstream(b), or build fan-out/fan-in shapes with lists:extract >> [transform_a, transform_b] >> merge. - Operators vs. the DAG:
PythonOperator,BashOperator, andFileSensorare all operators — each one is a template for a specific kind of task. The DAG object only wires them together; it doesn’t know or care what aPythonOperatoractually executes internally. This is the “DAG defines structure, tasks execute the work” split mentioned earlier — swappingBashOperatorfor aKubernetesPodOperatorchanges where and how the load step runs without touching the dependency graph at all. - Sensors (
FileSensorhere) are a special operator category that waits for a condition — a file to appear, a partition to exist, an external DAG to finish — rather than doing work directly.mode="reschedule"releases the worker slot between checks instead of holding it the whole time, which matters at scale (apokemode sensor that blocks for an hour ties up a worker for that whole hour). retries+retry_exponential_backoffimplement automatic retry with backoff at the task level — see the Idempotency and Retries section below for why this is safe here.catchup=Falseandmax_active_runs=1are two of the most commonly forgotten settings that cause real incidents: forgetting the first backfills unexpectedly on deploy; forgetting the second lets a slow run overlap with the next scheduled run and corrupt shared state.
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
| Airflow | Prefect | Dagster | Luigi | |
|---|---|---|---|---|
| Primary abstraction | DAG of tasks | Flow of Python tasks | Graph of software-defined assets | Task with requires()/output() |
| Language | Python (DAG-as-code) | Python (plain functions + decorators) | Python (asset/op decorators) | Python |
| Execution model | Scheduler + executor + workers (Celery/Kubernetes/Local) | Hybrid: cloud/server control plane, execution anywhere via agents | Dagster daemon + configurable executors (in-process, multiprocess, Celery, K8s) | No built-in scheduler; usually cron-driven, central scheduler process for concurrency limits |
| Dynamic workflows | Historically rigid; dynamic task mapping added in 2.3+ | Native, first-class | Supported via dynamic outputs | Limited |
| Idempotency model | Convention (you design it) | Convention (you design it) | Convention, reinforced by asset materialization tracking | Built-in via output() target existence check |
| Maturity / ecosystem | Very mature, huge plugin ecosystem (providers for every cloud, DB, SaaS tool) | Mature, smaller but fast-growing ecosystem | Mature, strong dbt integration, smaller ecosystem than Airflow | Mature but largely legacy; low ongoing development |
| Local dev experience | Historically clunky (needs scheduler + webserver + DB running) | Lightweight; a flow is just a Python script | Good; asset graph visible without a full deployment | Simple but minimal tooling |
| Best fit | Broad, heterogeneous pipelines across many systems; teams wanting the largest ecosystem and community | Teams wanting Pythonic, dynamic workflows and less orchestration boilerplate | Teams centered on data assets/quality, especially alongside dbt | Legacy 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:
- Loads should be
DELETE + INSERTorMERGE/upsert on a partition key, never a blindINSERT/append. In the example DAG,load_orders_to_warehouseruns in--mode upsertagainst the{{ ds }}partition specifically so a retry after a partial failure doesn’t create duplicate rows. - Extracts that write files should overwrite the target path deterministically (
orders_{ds}.parquet, notorders_{run_timestamp}.parquet) so a retry replaces rather than duplicates staged data. - Side effects with no natural “undo” (sending an email, calling a non-idempotent third-party API, charging a payment) need explicit dedup logic — an idempotency key, a “have I already sent this” check — because the orchestrator retrying the task will call that side effect again verbatim.
- Retries should be reserved for failures that are plausibly transient. A task that fails because of a bug in the transformation logic will fail identically on every retry, burning the retry budget and delaying the failure alert — this is what exponential backoff partially mitigates (spend less wall-clock time discovering a retry won’t help) but doesn’t fully solve; some teams explicitly branch retry behavior by exception type (retry on
ConnectionError, fail fast on a data validation error).
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:
- Failure alerting — Slack or PagerDuty notification the moment a task exhausts its retries and fails, ideally with a direct link to the failed task’s logs.
- SLA miss alerting — a softer signal than failure, flagging tasks or DAGs that are trending slow before they become outright late.
- Task duration trends — the webserver’s UI (or metrics exported to Prometheus/Datadog) showing whether a task that used to take 5 minutes now takes 45, which usually precedes an outright failure or timeout.
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
- Design every task to be idempotent before worrying about retries. Retries and backfills are only safe on top of idempotent tasks; configuring
retries=3on a non-idempotent load is a data-corruption bug waiting to happen, not a resilience feature. - Set
catchup=Falseexplicitly unless backfill-on-deploy is genuinely intended. This is one of the most common “why did 90 DAG runs just fire” incidents for teams new to Airflow. - Keep DAG files cheap to parse. The scheduler re-parses every DAG file on a fixed interval; doing database queries, API calls, or heavy computation at DAG definition time (outside of task functions) slows down the whole scheduler, not just one DAG.
- Prefer small, single-purpose tasks over one giant task. A DAG with
extract,transform, andloadas separate tasks lets you retry (and observe) just the step that failed, instead of re-running the whole pipeline from scratch. - Use sensors sparingly and with
mode="reschedule". Apoke-mode sensor holds a worker slot for its entire wait, which can quietly exhaust worker capacity as the number of waiting DAGs grows. - Set
max_active_runsdeliberately. Without it, a slow run can overlap with the next scheduled run, and two runs writing to the same partition concurrently is a common source of subtle data corruption. - Pass data references, not large payloads, through XCom (or Prefect/Dagster’s equivalent). XCom is meant for small metadata (row counts, file paths, status flags) — pushing a full dataframe through it will bloat the metadata database and slow the scheduler.
- Version-control DAGs and deploy them through CI, like any other code. Treat a DAG change with the same review rigor as an application code change — a bad DAG can silently stop a pipeline or duplicate data just as easily as a bad application deploy. See ./17-cicd-for-data-engineering.md.
- Let dbt own the transformation-layer DAG; let Airflow (or your orchestrator) own the pipeline-level DAG. Fighting this separation — e.g., re-implementing dbt’s model dependency graph as individual Airflow tasks for every model — usually adds operational complexity without a proportional benefit, unless you specifically need per-model retry granularity that tools like
astronomer-cosmosprovide more cleanly. - Alert on SLA misses, not just failures. A pipeline that “succeeds” but takes three times longer than usual is an early warning that will become a failure soon if left unaddressed.