- Зачем: - Необходим пример загрузки данных из CSV в Greenplum через Airflow - Добавлены проверки качества данных для валидации загруженных данных - Что: - создан DAG csv_to_greenplum для генерации и загрузки CSV в таблицу orders - создан DAG csv_to_greenplum_dq для проверок качества данных - создан DAG ddl_greenplum_base для применения DDL таблицы orders - добавлен модуль helpers/greenplum с функциями подключения и валидации - добавлен SQL-скрипт sql/base/orders_ddl.sql для создания таблицы - добавлены unit-тесты test_greenplum_helpers.py - Проверка: - запуск DAG csv_to_greenplum в Airflow UI - запуск тестов pytest tests/test_greenplum_helpers.py
168 lines
5.7 KiB
Python
168 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import random
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import List
|
|
|
|
import pandas as pd
|
|
from airflow.operators.python import PythonOperator
|
|
from helpers.greenplum import get_gp_conn
|
|
|
|
from airflow import DAG
|
|
|
|
CSV_DIR = Path(os.getenv("CSV_DIR", "/opt/airflow/data"))
|
|
CSV_ROWS = int(os.getenv("CSV_ROWS", "1000"))
|
|
|
|
|
|
def _create_table() -> None:
|
|
"""Создаёт таблицу public.orders, если она ещё не существует."""
|
|
ddl = """
|
|
CREATE TABLE IF NOT EXISTS public.orders (
|
|
order_id BIGINT,
|
|
order_ts TIMESTAMP NOT NULL,
|
|
customer_id BIGINT NOT NULL,
|
|
amount NUMERIC(12,2) NOT NULL
|
|
)
|
|
WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1)
|
|
DISTRIBUTED BY (order_id);
|
|
"""
|
|
with get_gp_conn() as conn, conn.cursor() as cur:
|
|
cur.execute(ddl)
|
|
conn.commit()
|
|
|
|
|
|
def _generate_csv(rows: int, csv_dir: Path) -> str:
|
|
"""Генерирует CSV c заказами с помощью pandas и сохраняет на диск."""
|
|
csv_dir.mkdir(parents=True, exist_ok=True)
|
|
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
|
csv_path = csv_dir / f"orders_{timestamp}.csv"
|
|
|
|
# Генерируем данные в pandas-стиле
|
|
base_order_id = int(datetime.now(UTC).timestamp() * 1_000)
|
|
|
|
# Создаём DataFrame с использованием pandas методов
|
|
df = pd.DataFrame(
|
|
{
|
|
# Уникальные order_id начиная с базового значения
|
|
"order_id": pd.Series(
|
|
range(base_order_id, base_order_id + rows), dtype="int64"
|
|
),
|
|
# Временные метки с интервалом в 1 секунду в обратном порядке
|
|
"order_ts": pd.date_range(
|
|
end=datetime.now(UTC), periods=rows, freq="1S"
|
|
).sort_values(ascending=False),
|
|
# Случайные customer_id от 1 до 1000
|
|
"customer_id": pd.Series(
|
|
random.choices(range(1, 1001), k=rows), dtype="int64"
|
|
),
|
|
# Случайные суммы от 10 до 500 с округлением до 2 знаков
|
|
"amount": pd.Series(
|
|
[round(random.uniform(10, 500), 2) for _ in range(rows)],
|
|
dtype="float64",
|
|
),
|
|
}
|
|
)
|
|
|
|
# Сохраняем CSV без индекса
|
|
df.to_csv(csv_path, index=False)
|
|
logging.info("CSV сохранён: %s (строк: %s)", csv_path, len(df))
|
|
return str(csv_path)
|
|
|
|
|
|
def _preview_csv(csv_path: str, sample_rows: int = 5) -> None:
|
|
"""Отображает предпросмотр CSV через pandas (head и describe)."""
|
|
df = pd.read_csv(csv_path)
|
|
df["order_ts"] = pd.to_datetime(df["order_ts"], errors="coerce")
|
|
logging.info(
|
|
"Первые %s строк:\n%s", sample_rows, df.head(sample_rows).to_string(index=False)
|
|
)
|
|
numeric_summary = df.describe(include="number")
|
|
logging.info("Числовая статистика:\n%s", numeric_summary.to_string())
|
|
if df["order_ts"].notna().any():
|
|
logging.info(
|
|
"Диапазон order_ts: %s → %s",
|
|
df["order_ts"].min().isoformat(),
|
|
df["order_ts"].max().isoformat(),
|
|
)
|
|
|
|
|
|
def _load_csv(csv_path: str) -> None:
|
|
"""Загружает CSV в Greenplum через временную таблицу и anti-join."""
|
|
csv_file = Path(csv_path)
|
|
if not csv_file.exists():
|
|
raise FileNotFoundError(f"CSV не найден: {csv_file}")
|
|
|
|
with (
|
|
get_gp_conn() as conn,
|
|
conn.cursor() as cur,
|
|
csv_file.open("r", encoding="utf-8") as f,
|
|
):
|
|
cur.execute(
|
|
"CREATE TEMP TABLE tmp_orders (LIKE public.orders INCLUDING DEFAULTS) ON COMMIT DROP;"
|
|
)
|
|
cur.copy_expert(
|
|
"COPY tmp_orders (order_id, order_ts, customer_id, amount) FROM STDIN WITH CSV HEADER",
|
|
f,
|
|
)
|
|
|
|
cur.execute("SELECT COUNT(*) FROM tmp_orders")
|
|
tmp_rows = cur.fetchone()[0]
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO public.orders(order_id, order_ts, customer_id, amount)
|
|
SELECT t.order_id, t.order_ts, t.customer_id, t.amount
|
|
FROM tmp_orders t
|
|
LEFT JOIN public.orders o ON o.order_id = t.order_id
|
|
WHERE o.order_id IS NULL
|
|
"""
|
|
)
|
|
inserted = cur.rowcount if cur.rowcount != -1 else 0
|
|
conn.commit()
|
|
|
|
logging.info("Загружено строк: %s (прочитано из CSV: %s)", inserted, tmp_rows)
|
|
|
|
|
|
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
|
|
|
|
with DAG(
|
|
dag_id="csv_to_greenplum",
|
|
start_date=datetime(2017, 1, 1),
|
|
schedule=None,
|
|
catchup=False,
|
|
default_args=default_args,
|
|
tags=["demo", "greenplum", "csv"],
|
|
) as dag:
|
|
create_table = PythonOperator(
|
|
task_id="create_orders_table",
|
|
python_callable=_create_table,
|
|
)
|
|
|
|
generate_csv = PythonOperator(
|
|
task_id="generate_csv",
|
|
python_callable=_generate_csv,
|
|
op_kwargs={"rows": CSV_ROWS, "csv_dir": CSV_DIR},
|
|
)
|
|
|
|
preview_csv = PythonOperator(
|
|
task_id="preview_csv",
|
|
python_callable=_preview_csv,
|
|
op_kwargs={
|
|
"csv_path": "{{ ti.xcom_pull(task_ids='generate_csv') }}",
|
|
"sample_rows": 5,
|
|
},
|
|
)
|
|
|
|
load_csv = PythonOperator(
|
|
task_id="load_csv_to_greenplum",
|
|
python_callable=_load_csv,
|
|
op_kwargs={
|
|
"csv_path": "{{ ti.xcom_pull(task_ids='generate_csv') }}",
|
|
},
|
|
)
|
|
|
|
create_table >> generate_csv >> preview_csv >> load_csv
|