CCCPaste

Modifikasi ELT Pipeline

"""
ELT IoT Pipeline: Extract/Load raw data, Transform in SQL
This pipeline demonstrates the ELT pattern where raw data is loaded first,
then transformation happens in the data warehouse using SQL.
"""
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow.providers.postgres.hooks.postgres import PostgresHook
from datetime import datetime, timedelta
import pandas as pd
import os

# Configuration
DATA_DIR = "/opt/airflow/data"
POSTGRES_CONN_ID = "postgres_default"

def load_sensors_to_staging(**context):
    """
    Load raw sensors data directly to staging table
    """
    print("🔄 Loading sensors data to staging...")
    
    # Read CSV file
    sensors_path = os.path.join(DATA_DIR, "sensors.csv")
    sensors_df = pd.read_csv(sensors_path)
    
    print(f"📊 Loading {len(sensors_df)} sensor records")
    
    # Get PostgreSQL connection and load data
    postgres_hook = PostgresHook(postgres_conn_id=POSTGRES_CONN_ID)
    engine = postgres_hook.get_sqlalchemy_engine()
    
    # Load raw data to staging table
    sensors_df.to_sql(
        'raw_sensors', 
        engine, 
        if_exists='replace', 
        index=False,
        method='multi'
    )
    
    print("✅ Sensors data loaded to raw_sensors staging table!")

def load_readings_to_staging(**context):
    """
    Load raw readings data directly to staging table
    """
    print("🔄 Loading readings data to staging...")
    
    # Read CSV file
    readings_path = os.path.join(DATA_DIR, "readings.csv")
    readings_df = pd.read_csv(readings_path)
    
    print(f"📊 Loading {len(readings_df)} reading records")
    
    # Get PostgreSQL connection and load data
    postgres_hook = PostgresHook(postgres_conn_id=POSTGRES_CONN_ID)
    engine = postgres_hook.get_sqlalchemy_engine()
    
    # Load raw data to staging table
    readings_df.to_sql(
        'raw_readings', 
        engine, 
        if_exists='replace', 
        index=False,
        method='multi'
    )
    
    print("✅ Readings data loaded to raw_readings staging table!")

def load_weather_to_staging(**context):
    print("🔄 Loading weather data to staging...")
    weather_path = os.path.join(DATA_DIR, "weather.csv")
    weather_df = pd.read_csv(weather_path)
    
    # ✅ Convert to datetime
    weather_df['date'] = pd.to_datetime(weather_df['date'])
    
    print(f"📊 Loading {len(weather_df)} weather records")

    postgres_hook = PostgresHook(postgres_conn_id=POSTGRES_CONN_ID)
    engine = postgres_hook.get_sqlalchemy_engine()

    weather_df.to_sql(
        'raw_weather',
        engine,
        if_exists='replace',
        index=False,
        method='multi'
    )
    print("✅ Weather data loaded to raw_weather staging table!")

# Default arguments
default_args = {
    'owner': 'data-engineer',
    'depends_on_past': False,
    'start_date': datetime(2025, 9, 24),
    'email_on_failure': False,
    'email_on_retry': False,
    'retries': 1,
    'retry_delay': timedelta(minutes=5),
}

# Create DAG
with DAG(
    dag_id='elt_iot_pipeline',
    default_args=default_args,
    description='IoT ELT Pipeline - Transform in Database',
    schedule_interval=None,  # Manual trigger
    catchup=False,
    tags=['iot', 'elt', 'demo'],
) as dag:

    # Create staging tables
    create_staging_tables = PostgresOperator(
        task_id='create_staging_tables',
        postgres_conn_id=POSTGRES_CONN_ID,
        sql="""
        -- Drop existing staging tables
        DROP TABLE IF EXISTS raw_sensors CASCADE;
        DROP TABLE IF EXISTS raw_readings CASCADE;
        
        -- Create sensors staging table
        CREATE TABLE raw_sensors (
            sensor_id INTEGER,
            location VARCHAR(100)
        );
        
        -- Create readings staging table  
        CREATE TABLE raw_readings (
            reading_id INTEGER,
            sensor_id INTEGER,
            temperature DECIMAL(5,2),
            timestamp TIMESTAMP
        );

        -- Create weather staging table
        CREATE TABLE IF NOT EXISTS raw_weather (
            date DATE,
            temperature FLOAT,
            humidity INTEGER,
            wind_speed FLOAT
        );


        """,
    )

    # Load tasks - Extract and Load in one step
    load_sensors_task = PythonOperator(
        task_id='load_sensors',
        python_callable=load_sensors_to_staging,
        provide_context=True,
    )

    load_readings_task = PythonOperator(
        task_id='load_readings',
        python_callable=load_readings_to_staging,
        provide_context=True,
    )

    load_weather_task = PythonOperator(
        task_id='load_weather',
        python_callable=load_weather_to_staging,
        provide_context=True,
    )

    # Transform in SQL - This is where ELT differs from ETL
    transform_in_sql = PostgresOperator(
        task_id='transform_in_sql',
        postgres_conn_id=POSTGRES_CONN_ID,
        sql="""
        -- Create partitioned summary table if not exists
        DO $$
        BEGIN
            IF NOT EXISTS (
                SELECT FROM pg_tables WHERE tablename = 'daily_sensor_summary_elt'
            ) THEN
                CREATE TABLE daily_sensor_summary_elt (
                    location VARCHAR(100),
                    date DATE,
                    avg_temp DECIMAL(5,2),
                    min_temp DECIMAL(5,2),
                    max_temp DECIMAL(5,2),
                    reading_count INTEGER,
                    humidity INTEGER,
                    wind_speed FLOAT,
                    moving_avg_temp DECIMAL(5,2),
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                ) PARTITION BY RANGE (date);
            END IF;
        END $$;

        -- Create temporary table to hold new data
        DROP TABLE IF EXISTS daily_sensor_summary_elt_temp;
        CREATE TEMP TABLE daily_sensor_summary_elt_temp AS
        WITH base AS (
            SELECT
                s.location,
                DATE(r.timestamp) AS date,
                r.temperature,
                w.humidity,
                w.wind_speed
            FROM raw_readings r
            JOIN raw_sensors s ON r.sensor_id = s.sensor_id
            LEFT JOIN raw_weather w ON DATE(r.timestamp) = w.date
            WHERE DATE(r.timestamp) > (
                SELECT COALESCE(MAX(date), '1900-01-01') FROM daily_sensor_summary_elt
            )
        ),
        aggregated AS (
            SELECT
                location,
                date,
                ROUND(AVG(temperature)::numeric, 2) AS avg_temp,
                ROUND(MIN(temperature)::numeric, 2) AS min_temp,
                ROUND(MAX(temperature)::numeric, 2) AS max_temp,
                COUNT(*) AS reading_count,
                MAX(humidity) AS humidity,
                MAX(wind_speed) AS wind_speed
            FROM base
            GROUP BY location, date
        ),
        moving_avg AS (
            SELECT *,
                ROUND(AVG(avg_temp) OVER (
                    PARTITION BY location ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
                )::numeric, 2) AS moving_avg_temp
            FROM aggregated
        ),
        validated AS (
            SELECT *
            FROM moving_avg
            WHERE avg_temp BETWEEN -10 AND 50 AND humidity BETWEEN 0 AND 100
        )
        SELECT * FROM validated;

        -- Insert new data into partitioned table
        INSERT INTO daily_sensor_summary_elt (
            location, date, avg_temp, min_temp, max_temp,
            reading_count, humidity, wind_speed, moving_avg_temp, created_at
        )
        SELECT 
            location, date, avg_temp, min_temp, max_temp,
            reading_count, humidity, wind_speed, moving_avg_temp, CURRENT_TIMESTAMP
        FROM daily_sensor_summary_elt_temp;

        -- Add indexes for performance (only once)
        DO $$
        BEGIN
            IF NOT EXISTS (
                SELECT FROM pg_indexes WHERE tablename = 'daily_sensor_summary_elt' AND indexname = 'idx_daily_summary_elt_location'
            ) THEN
                CREATE INDEX idx_daily_summary_elt_location ON daily_sensor_summary_elt(location);
            END IF;
            IF NOT EXISTS (
                SELECT FROM pg_indexes WHERE tablename = 'daily_sensor_summary_elt' AND indexname = 'idx_daily_summary_elt_date'
            ) THEN
                CREATE INDEX idx_daily_summary_elt_date ON daily_sensor_summary_elt(date);
            END IF;
        END $$;
        """,
    )

    alert_extreme_temp = PostgresOperator(
        task_id='alert_extreme_temperature',
        postgres_conn_id=POSTGRES_CONN_ID,
        sql="""
        SELECT location, date, avg_temp
        FROM daily_sensor_summary_elt
        WHERE avg_temp > 40;
        """,
    )

    # Data quality checks
    quality_checks = PostgresOperator(
        task_id='data_quality_checks',
        postgres_conn_id=POSTGRES_CONN_ID,
        sql="""
        -- Check for data completeness
        DO $$
        DECLARE
            staging_sensors_count INTEGER;
            staging_readings_count INTEGER;
            summary_count INTEGER;
        BEGIN
            SELECT COUNT(*) INTO staging_sensors_count FROM raw_sensors;
            SELECT COUNT(*) INTO staging_readings_count FROM raw_readings;
            SELECT COUNT(*) INTO summary_count FROM daily_sensor_summary_elt;
            
            RAISE NOTICE 'Data Quality Report:';
            RAISE NOTICE '- Raw sensors: % records', staging_sensors_count;
            RAISE NOTICE '- Raw readings: % records', staging_readings_count;
            RAISE NOTICE '- Daily summaries: % records', summary_count;
            
            -- Basic validation
            IF staging_sensors_count = 0 THEN
                RAISE EXCEPTION 'No sensor data found in staging';
            END IF;
            
            IF staging_readings_count = 0 THEN
                RAISE EXCEPTION 'No readings data found in staging';
            END IF;
            
            IF summary_count = 0 THEN
                RAISE EXCEPTION 'No summary data generated';
            END IF;
            
            RAISE NOTICE 'All data quality checks passed!';
        END $$;
        """,
    )

    # Verify final results
    verify_results = PostgresOperator(
        task_id='verify_elt_results',
        postgres_conn_id=POSTGRES_CONN_ID,
        sql="""
        SELECT 
            'ELT Pipeline Results' as pipeline,
            COUNT(*) as total_records,
            COUNT(DISTINCT location) as locations,
            COUNT(DISTINCT date) as dates,
            ROUND(AVG(avg_temp)::numeric, 2) as overall_avg_temp,
            MIN(date) as earliest_date,
            MAX(date) as latest_date
        FROM daily_sensor_summary_elt;
        """,
        autocommit=True,
    )

    # Task dependencies
    create_staging_tables >> [load_sensors_task, load_readings_task, load_weather_task] >> transform_in_sql >> alert_extreme_temp >> quality_checks >> verify_results