data-engineering// idempotent-pipelines-airflow-openlineage

طراحی پایپلاینهای دادهای بازگشتپذیر (Idempotent) و رهگیری متادیتا با Airflow و OpenLineage

اصول مهندسی داده تابعی (Functional Data Engineering)، پیادهسازی الگوی Write-Audit-Publish (WAP) برای تعویض اتمیک پارتیشنها و ردیابی خودکار گراف تبار داده با OpenLineage.

احسان شهبازی·۱۴۰۴/۰۸/۰۳·۱۳ دقیقه مطالعه
// SBU Data Science Lab

مفهوم Idempotency در مهندسی داده: چرا Retry نباید داده تکراری بسازد؟

در ریاضیات و علوم کامپیوتر، یک عملیات زمانی Idempotent (بازگشتپذیر / همتوان) نامیده میشود که اجرای یکباره یا صدباره آن با ورودی یکسان، دقیقاً یک وضعیت خروجی واحد در دیتابیس ایجاد کند: f(f(x))=f(x)f(f(x)) = f(x). در دنیای واقعی، پایپلاینهای داده به دلایل متعددی مانند قطع موقت شبکه، تغییر منطق کسبوکار و نیاز به Backfill کردن دادههای ۶ ماه گذشته، بارها مجدداً اجرا میشوند.

دو ضدالگوی کشنده که خاصیت Idempotency را در Apache Airflow نابود میکنند عبارتاند از:

  • استفاده از ساعت سیستم (`datetime.now()` یا `CURRENT_DATE`): اگر تسکی که قرار بوده دادههای دیروز را پردازش کند با خطا مواجه شود و فردا صبح مجدداً اجرا (Retry) گردد، استفاده از NOW() باعث میشود بازه زمانی اشتباهی خوانده شود. در Airflow همیشه باید از متغیرهای منطقی data_interval_start و data_interval_end استفاده کرد.
  • استفاده از `INSERT INTO` بدون پاکسازی یا جایگزینی پارتیشن: اگر تسک در میانه درج یا پس از آن کرش کند، اجرای مجدد باعث دوبرابر شدن رکوردها (Double Counting) در گزارشهای مالی خواهد شد.

الگوی طلایی Write-Audit-Publish (WAP) و تعویض اتمیک پارتیشن

صرفاً اجرای DELETE FROM table WHERE dt = '2025-10-25' و سپس INSERT کردن دادههای جدید، در سیستمهای تحلیلی پرترافیک مشکلساز است؛ زیرا در فاصله زمانی بین حذف و اتمام درج جدید (یا در صورت شکست تسک در مرحله درج)، کاربران داشبوردها با جدول خالی یا ناقص روبهرو میشوند.

الگوی Write-Audit-Publish (WAP) این چالش را در سه گام ایزوله حل میکند:

  • ۱. مرحله Write (نوشتن در محیط ایزوله Staging): دادههای بازه زمانی منطقی در یک جدول موقت (Staging Table) یا شاخه مجزا با ساختار پارتیشن مشابه نوشته میشوند.
  • ۲. مرحله Audit (ممیزی کیفیت داده): تستهای سختگیرانه کیفیت داده (بررسی نبود مقدار NULL در کلیدها، بررسی تعداد کل ردیفها و تطابق جمع مبالغ) روی جدول Staging اجرا میشوند. اگر حتی یک تست شکست بخورد، پایپلاین متوقف شده و جدول اصلی دستنخورده باقی میماند.
  • ۳. مرحله Publish (تعویض اتمیک پارتیشن): با اجرای یک دستور متادیتایی سریع مانند ALTER TABLE prod ATTACH/REPLACE PARTITION (در Postgres یا ClickHouse)، پارتیشن جدید در کسری از میلیثانیه جایگزین پارتیشن قبلی میشود.

مشاهدهپذیری و گراف تبار داده (Data Lineage) با OpenLineage

وقتی در یک سازمان صدها DAG به یکدیگر وابستهاند، تغییر نام یک ستون در جدول خام میتواند ۱۰ داشبورد مدیریتی را در لایه BI از کار بیندازد. استاندارد متنباز OpenLineage (در کنار سرور متادیتای Marquez) به صورت خودکار به تسکهای Airflow متصل شده و رویدادهای استاندارد زمان اجرا، اسکیمای ورودی/خروجی، معیارهای کیفیت داده و گراف وابستگی در سطح ستون (Column-Level Lineage) را ثبت میکند.

پیادهسازی DAG بازگشتپذیر با الگوی WAP و متادیتای OpenLineage

کد زیر یک پایپلاین کامل Airflow مبتنی بر TaskFlow API است که بازه زمانی منطقی را دریافت کرده، الگوی سه مرحلهای Write-Audit-Publish را همراه با تعویض اتمیک پارتیشن در PostgreSQL اجرا میکند و دیتاستهای ورودی و خروجی را برای ردیابی در OpenLineage ثبت مینماید:

idempotent_wap_dag.py
python
from datetime import datetime, timedelta
from airflow.decorators import dag, task
from airflow.providers.postgres.hooks.postgres import PostgresHook
from airflow.exceptions import AirflowFailException

DEFAULT_ARGS = {
    'owner': 'data-platform',
    'retries': 3,
    'retry_delay': timedelta(minutes=5),
}

@dag(
    dag_id='fin_daily_settlement_wap',
    schedule='@daily',
    start_date=datetime(2025, 1, 1),
    catchup=True,
    max_active_runs=3,
    default_args=DEFAULT_ARGS,
    tags=['finance', 'idempotent', 'wap']
)
def financial_settlement_pipeline():

    @task
    def stage_partition(data_interval_start=None, data_interval_end=None) -> str:
        dt_str = data_interval_start.strftime('%Y_%m_%d')
        staging_table = f'staging.settlements_{dt_str}'
        hook = PostgresHook(postgres_conn_id='warehouse_postgres')

        sql = f"""
        DROP TABLE IF EXISTS {staging_table};
        CREATE TABLE {staging_table} (LIKE prod.daily_settlements INCLUDING ALL);
        INSERT INTO {staging_table}
        SELECT
            DATE('{data_interval_start.strftime('%Y-%m-%d')}') AS settlement_date,
            merchant_id,
            COUNT(tx_id) AS tx_count,
            SUM(amount_cents) AS total_amount_cents
        FROM raw.payment_transactions
        WHERE created_at >= '{data_interval_start.isoformat()}'
          AND created_at < '{data_interval_end.isoformat()}'
          AND status = 'CAPTURED'
        GROUP BY 1, 2;
        """
        hook.run(sql)
        return staging_table

    @task
    def audit_staging_data(staging_table: str) -> str:
        hook = PostgresHook(postgres_conn_id='warehouse_postgres')
        row_count, negative_totals = hook.get_first(f"""
            SELECT
                COUNT(*),
                COALESCE(SUM(CASE WHEN total_amount_cents < 0 THEN 1 ELSE 0 END), 0)
            FROM {staging_table};
        """)
        if row_count == 0:
            raise AirflowFailException(f'Data Quality Audit Failed: {staging_table} is empty!')
        if negative_totals > 0:
            raise AirflowFailException(f'Audit Failed: Found {negative_totals} negative settlement rows!')
        return staging_table

    @task
    def publish_atomic_swap(staging_table: str, data_interval_start=None, data_interval_end=None) -> None:
        dt_str = data_interval_start.strftime('%Y_%m_%d')
        start_date = data_interval_start.strftime('%Y-%m-%d')
        end_date = data_interval_end.strftime('%Y-%m-%d')
        target_partition = f'prod.daily_settlements_p_{dt_str}'
        hook = PostgresHook(postgres_conn_id='warehouse_postgres')

        # Atomic metadata swap inside a single transaction block
        swap_sql = f"""
        BEGIN;
        CREATE TABLE IF NOT EXISTS {target_partition}
            PARTITION OF prod.daily_settlements
            FOR VALUES FROM ('{start_date}') TO ('{end_date}');
        ALTER TABLE prod.daily_settlements DETACH PARTITION {target_partition};
        DROP TABLE {target_partition};
        ALTER TABLE {staging_table} RENAME TO daily_settlements_p_{dt_str};
        ALTER TABLE prod.daily_settlements.daily_settlements_p_{dt_str} SET SCHEMA prod;
        ALTER TABLE prod.daily_settlements ATTACH PARTITION prod.daily_settlements_p_{dt_str}
            FOR VALUES FROM ('{start_date}') TO ('{end_date}');
        COMMIT;
        """
        hook.run(swap_sql)

    staged = stage_partition()
    audited = audit_staging_data(staged)
    publish_atomic_swap(audited)

dag_instance = financial_settlement_pipeline()

نکته کلیدی مهندسی: برای فعالسازی خودکار OpenLineage روی کل کلاستر Airflow بدون دستکاری کد DAGها، پکیج apache-airflow-providers-openlineage را نصب کرده و متغیر محیطی AIRFLOW__OPENLINEAGE__TRANSPORT را روی آدرس سرور Marquez یا DataHub خود تنظیم کنید؛ مفسر SQL داخلی این پکیج به صورت خودکار کوئریهای Postgres، BigQuery و Snowflake را پارس کرده و گراف Lineage سطح ستون را استخراج میکند.