вынесли sql из dag в папку sql
This commit is contained in:
@@ -34,6 +34,22 @@
|
||||
- Форматирование: `black` (88 cols) и `isort`. Если не уверены — запустите `make fmt`.
|
||||
- Язык: комментарии, docstring и документацию — на русском; имена идентификаторов — на английском.
|
||||
|
||||
### Структура и нейминг SQL (слои DWH)
|
||||
- В каталоге `sql/` придерживаемся слоёв DWH:
|
||||
- `sql/src/` — скрипты, работающие с исходными системами (например, `bookings_generate_day_if_missing.sql`);
|
||||
- `sql/stg/` — скрипты для стейджинга (`bookings_ddl.sql`, `bookings_load.sql`, `bookings_dq.sql`);
|
||||
- в будущем можно добавить `sql/ods/`, `sql/dds/`, `sql/dm/` по мере роста стенда.
|
||||
- Именование файлов: `{объект}_{роль}.sql`, где:
|
||||
- `объект` — логическое имя сущности (`bookings`, `orders`, и т.п.);
|
||||
- `роль` — `ddl` (создание/изменение объектов), `load` (загрузка/инкремент), `dq` (проверки качества данных) и т.п.
|
||||
- Общие DDL-скрипты (например, `sql/ddl_gp.sql`) могут подключать файловые DDL через `\i`, но сами определения таблиц живут рядом с объектом (`sql/stg/bookings_ddl.sql` и т.п.).
|
||||
|
||||
### Airflow + SQL
|
||||
- В учебных DAG’ах, где основная логика — в SQL, по умолчанию используем `PostgresOperator` + Airflow Connections:
|
||||
- DAG оркестрирует шаги и подключение к БД;
|
||||
- SQL-скрипты лежат в `sql/...` и подключаются по пути (`sql='sql/stg/bookings_load.sql'`).
|
||||
- Сложную ручную работу с подключениями (`psycopg2`, ENV-фоллбеки) используем только там, где реально много Python-логики и это помогает учебной цели.
|
||||
|
||||
## Тестирование
|
||||
- Тесты лежат в `tests/` (pytest). Запуск: `make test`.
|
||||
- Есть юнит‑тесты для `helpers/greenplum.py` и smoke‑тесты DAG‑структуры (`tests/test_dags_smoke.py`).
|
||||
|
||||
@@ -239,6 +239,30 @@ docker compose -f docker-compose.yml exec bookings-db bash -lc 'PGPASSWORD="$POS
|
||||
- Объем загруженных данных
|
||||
- Отсутствие дубликатов записей
|
||||
|
||||
### Пример DAG с SQL-скриптами (bookings → stg)
|
||||
|
||||
В репозитории есть учебный DAG `bookings_to_gp_stage`, который показывает «канонический» способ работы с SQL в Airflow:
|
||||
|
||||
- подключение к БД через Airflow Connections (`bookings_db`, `greenplum_conn`);
|
||||
- бизнес-логика инкрементальной загрузки и DQ вынесена в SQL-файлы в каталоге `sql/`:
|
||||
- `sql/src/bookings_generate_day_if_missing.sql` — генерация учебного дня в демо-БД bookings (идемпотентно);
|
||||
- `sql/stg/bookings_ddl.sql` — DDL для схемы `stg` и таблиц `stg.bookings_ext` / `stg.bookings`;
|
||||
- `sql/stg/bookings_load.sql` — загрузка инкремента из `stg.bookings_ext` в `stg.bookings`;
|
||||
- `sql/stg/bookings_dq.sql` — проверка количества строк между источником и stg.
|
||||
|
||||
Фрагмент DAG:
|
||||
|
||||
```python
|
||||
load_bookings_to_stg = PostgresOperator(
|
||||
task_id="load_bookings_to_stg",
|
||||
postgres_conn_id="greenplum_conn",
|
||||
sql="/sql/stg/bookings_load.sql",
|
||||
params={"load_date": "{{ ds }}", "batch_id": "{{ ds_nodash }}"},
|
||||
)
|
||||
```
|
||||
|
||||
Такой подход помогает держать оркестрацию (DAG) и SQL-логику в отдельных файлах и легче сравнивать её с теорией из статьи про моделирование DWH.
|
||||
|
||||
### Ограничения учебного стенда
|
||||
|
||||
- **Greenplum** запущен в single-node режиме (для обучения)
|
||||
|
||||
@@ -4,19 +4,17 @@ from __future__ import annotations
|
||||
Учебный DAG для менти: показывает, как устроен поток
|
||||
от источника bookings-db до слоя stg в Greenplum.
|
||||
|
||||
Важно: этот файл специально должен быть хорошо задокументирован —
|
||||
docstring и комментарии помогают студенту понять, «зачем» каждая задача,
|
||||
а не только «что именно она делает».
|
||||
В этом примере мы сознательно используем:
|
||||
- Airflow Connections для подключения к БД;
|
||||
- PostgresOperator, который берёт SQL-скрипты с диска;
|
||||
чтобы студент увидел «канонический» способ работы с SQL в DAG.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from airflow import DAG
|
||||
from airflow.operators.python import PythonOperator
|
||||
|
||||
from helpers.greenplum import get_gp_conn
|
||||
from airflow.providers.postgres.operators.postgres import PostgresOperator
|
||||
|
||||
|
||||
default_args = {
|
||||
@@ -26,341 +24,21 @@ default_args = {
|
||||
}
|
||||
|
||||
|
||||
BOOKINGS_CONN_ID = os.getenv("BOOKINGS_CONN_ID", "bookings_db")
|
||||
|
||||
|
||||
def _get_bookings_conn():
|
||||
"""
|
||||
Возвращает подключение к демо-БД bookings.
|
||||
|
||||
Приоритет:
|
||||
1. Airflow Connection с ID из BOOKINGS_CONN_ID (по умолчанию: bookings_db)
|
||||
2. Прямое подключение по переменным окружения (фоллбек)
|
||||
"""
|
||||
try:
|
||||
from airflow.providers.postgres.hooks.postgres import PostgresHook
|
||||
|
||||
hook = PostgresHook(postgres_conn_id=BOOKINGS_CONN_ID)
|
||||
conn = hook.get_conn()
|
||||
logging.info(
|
||||
"✅ Подключение к bookings-db через Airflow Connection '%s' успешно",
|
||||
BOOKINGS_CONN_ID,
|
||||
)
|
||||
return conn
|
||||
except Exception as exc: # pragma: no cover - фоллбек для нестандартных окружений
|
||||
logging.warning(
|
||||
"⚠️ Не удалось подключиться к bookings-db через Airflow Connection '%s': %s",
|
||||
BOOKINGS_CONN_ID,
|
||||
exc,
|
||||
)
|
||||
logging.info("🔄 Пробуем прямое подключение по переменным окружения")
|
||||
|
||||
import psycopg2
|
||||
|
||||
conn_params = {
|
||||
# Внутри Docker-сети bookings-db доступен по имени сервиса и порту 5432
|
||||
"host": os.getenv("BOOKINGS_DB_HOST", "bookings-db"),
|
||||
"port": int(os.getenv("BOOKINGS_DB_PORT_INTERNAL", "5432")),
|
||||
"dbname": os.getenv("BOOKINGS_DB_NAME", "demo"),
|
||||
"user": os.getenv("BOOKINGS_DB_USER", "bookings"),
|
||||
"password": os.getenv("BOOKINGS_DB_PASSWORD", "bookings"),
|
||||
}
|
||||
logging.info(
|
||||
"🔗 Подключение к bookings-db по ENV: %s:%s/%s",
|
||||
conn_params["host"],
|
||||
conn_params["port"],
|
||||
conn_params["dbname"],
|
||||
)
|
||||
return psycopg2.connect(**conn_params)
|
||||
|
||||
|
||||
def _generate_bookings_day(load_date: str) -> None:
|
||||
"""
|
||||
Готовит данные за указанный день в bookings-db.
|
||||
|
||||
Идея:
|
||||
- если за load_date уже есть строки в bookings.bookings → ничего не делаем;
|
||||
- если нет → запускаем генератор (аналог make bookings-generate-day).
|
||||
"""
|
||||
logging.info("Запускаем подготовку данных в bookings-db за дату %s", load_date)
|
||||
|
||||
with _get_bookings_conn() as conn, conn.cursor() as cur:
|
||||
# 1. Проверяем, что демо-БД установлена (таблица bookings.bookings существует)
|
||||
logging.info("Проверяем наличие таблицы bookings.bookings...")
|
||||
cur.execute("SELECT to_regclass('bookings.bookings')")
|
||||
table_regclass = cur.fetchone()[0]
|
||||
if table_regclass is None:
|
||||
raise ValueError(
|
||||
"❌ Таблица bookings.bookings не найдена. "
|
||||
"Сначала выполните make bookings-init, чтобы подготовить демо-БД."
|
||||
)
|
||||
|
||||
# 2. Проверяем, есть ли уже данные за нужный день
|
||||
logging.info(
|
||||
"Проверяем, есть ли данные за %s в bookings.bookings...", load_date
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM bookings.bookings
|
||||
WHERE book_date::date = %s::date
|
||||
)
|
||||
""",
|
||||
(load_date,),
|
||||
)
|
||||
has_day = bool(cur.fetchone()[0])
|
||||
|
||||
if has_day:
|
||||
logging.info(
|
||||
"Данные за %s уже есть в bookings.bookings — "
|
||||
"генерация не требуется (идемпотентность).",
|
||||
load_date,
|
||||
)
|
||||
return
|
||||
|
||||
logging.info(
|
||||
"Данных за %s нет — запускаем генератор демобазы "
|
||||
"(аналог make bookings-generate-day)...",
|
||||
load_date,
|
||||
)
|
||||
|
||||
# 3. Запускаем генерацию следующего дня через тот же DO-блок,
|
||||
# который используется в скрипте bookings/generate_next_day.sql.
|
||||
# Это гарантирует, что логика совпадает с CLI-сценарием.
|
||||
cur.execute(
|
||||
"""
|
||||
DO $$
|
||||
DECLARE
|
||||
v_max_book_date timestamptz;
|
||||
v_start_date timestamptz;
|
||||
v_end_date timestamptz;
|
||||
v_jobs integer := COALESCE(current_setting('bookings.jobs', true), '1')::integer;
|
||||
v_init_days integer := COALESCE(current_setting('bookings.init_days', true), '1')::integer;
|
||||
v_start_cfg text := COALESCE(current_setting('bookings.start_date', true), '2017-01-01');
|
||||
BEGIN
|
||||
-- Проверяем, что демобаза установлена
|
||||
IF to_regclass('bookings.bookings') IS NULL THEN
|
||||
RAISE EXCEPTION 'Таблица bookings.bookings не найдена. Сначала выполните make bookings-init.';
|
||||
END IF;
|
||||
|
||||
-- Ищем последнюю сгенерированную дату
|
||||
SELECT max(book_date) INTO v_max_book_date FROM bookings.bookings;
|
||||
|
||||
IF v_max_book_date IS NULL THEN
|
||||
-- База пустая: берём стартовую дату из конфигурации (или дефолтную)
|
||||
v_start_date := date_trunc('day', v_start_cfg::timestamptz);
|
||||
ELSE
|
||||
-- Продолжаем с дня, следующего за максимальной датой
|
||||
v_start_date := date_trunc('day', v_max_book_date) + interval '1 day';
|
||||
END IF;
|
||||
|
||||
-- Первая генерация вызывает generate(), последующие — continue()
|
||||
IF v_max_book_date IS NULL THEN
|
||||
v_end_date := v_start_date + (v_init_days || ' days')::interval;
|
||||
CALL generate(v_start_date, v_end_date, v_jobs);
|
||||
ELSE
|
||||
v_end_date := v_start_date + interval '1 day';
|
||||
CALL continue(v_end_date, v_jobs);
|
||||
END IF;
|
||||
|
||||
-- Ждём завершения фоновых джобов генератора, чтобы данные успели записаться
|
||||
WHILE busy() LOOP
|
||||
PERFORM pg_sleep(1);
|
||||
END LOOP;
|
||||
PERFORM dblink_disconnect(unnest(dblink_get_connections()));
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
logging.info("Генерация данных за %s в bookings-db завершена.", load_date)
|
||||
|
||||
|
||||
def _get_last_loaded_ts_from_gp() -> str | None:
|
||||
"""
|
||||
Возвращает максимальное значение src_created_at_ts из stg.bookings.
|
||||
|
||||
Если данных ещё нет, возвращает None — это будет означать
|
||||
режим полной загрузки (full).
|
||||
"""
|
||||
with get_gp_conn() as conn, conn.cursor() as cur:
|
||||
logging.info("Проверяем наличие таблицы stg.bookings в Greenplum...")
|
||||
cur.execute("SELECT to_regclass('stg.bookings')")
|
||||
table_regclass = cur.fetchone()[0]
|
||||
if table_regclass is None:
|
||||
raise ValueError(
|
||||
"❌ Таблица stg.bookings не найдена. "
|
||||
"Убедитесь, что выполнен DDL для схемы stg (например, make ddl-gp)."
|
||||
)
|
||||
|
||||
logging.info(
|
||||
"Читаем максимальное значение src_created_at_ts из stg.bookings..."
|
||||
)
|
||||
cur.execute("SELECT max(src_created_at_ts) FROM stg.bookings")
|
||||
row = cur.fetchone()
|
||||
last_ts = row[0]
|
||||
|
||||
if last_ts is None:
|
||||
logging.info(
|
||||
"В stg.bookings пока нет данных — будет выполнена полная загрузка (full)."
|
||||
)
|
||||
return None
|
||||
|
||||
logging.info(
|
||||
"Последний загруженный src_created_at_ts в stg.bookings: %s", last_ts
|
||||
)
|
||||
# Возвращаем строку, чтобы её было проще использовать в шаблонах и XCom
|
||||
return last_ts.isoformat()
|
||||
|
||||
|
||||
def _extract_and_load_increment_via_pxf(
|
||||
last_loaded_ts: str | None,
|
||||
load_date: str,
|
||||
batch_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Читает дельту из stg.bookings_ext и вставляет её в stg.bookings.
|
||||
|
||||
Логика:
|
||||
- если last_loaded_ts is None → первая загрузка (full),
|
||||
берём все данные из источника;
|
||||
- иначе берём только записи, где src_created_at_ts > last_loaded_ts
|
||||
и не позже конца учебного дня.
|
||||
"""
|
||||
logging.info(
|
||||
"Загрузка инкремента через PXF: last_loaded_ts=%s, load_date=%s, batch_id=%s",
|
||||
last_loaded_ts,
|
||||
load_date,
|
||||
batch_id,
|
||||
)
|
||||
|
||||
with get_gp_conn() as conn, conn.cursor() as cur:
|
||||
if last_loaded_ts is None:
|
||||
# Полная загрузка: переносим все строки из внешней таблицы.
|
||||
logging.info("Режим загрузки: full (первичная загрузка данных).")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO stg.bookings (
|
||||
book_ref,
|
||||
book_date,
|
||||
total_amount,
|
||||
src_created_at_ts,
|
||||
load_dttm,
|
||||
batch_id
|
||||
)
|
||||
SELECT
|
||||
book_ref::text,
|
||||
book_date::text,
|
||||
total_amount::text,
|
||||
book_date::timestamp,
|
||||
now(),
|
||||
%s
|
||||
FROM stg.bookings_ext
|
||||
""",
|
||||
(batch_id,),
|
||||
)
|
||||
else:
|
||||
# Инкрементальная загрузка: берём только «новые» строки по окну времени.
|
||||
logging.info("Режим загрузки: delta (инкрементальная загрузка).")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO stg.bookings (
|
||||
book_ref,
|
||||
book_date,
|
||||
total_amount,
|
||||
src_created_at_ts,
|
||||
load_dttm,
|
||||
batch_id
|
||||
)
|
||||
SELECT
|
||||
book_ref::text,
|
||||
book_date::text,
|
||||
total_amount::text,
|
||||
book_date::timestamp,
|
||||
now(),
|
||||
%s
|
||||
FROM stg.bookings_ext
|
||||
WHERE book_date > %s::timestamp
|
||||
AND book_date <= (%s::date + INTERVAL '1 day')
|
||||
""",
|
||||
(batch_id, last_loaded_ts, load_date),
|
||||
)
|
||||
|
||||
inserted = cur.rowcount if cur.rowcount not in (None, -1) else None
|
||||
conn.commit()
|
||||
|
||||
logging.info("Вставлено строк в stg.bookings: %s", inserted)
|
||||
|
||||
|
||||
def _check_row_counts(
|
||||
load_date: str,
|
||||
last_loaded_ts: str | None,
|
||||
batch_id: str,
|
||||
) -> None:
|
||||
"""
|
||||
Проверяет, что количество строк из источника и в stg.bookings совпадает.
|
||||
|
||||
Для наглядности считаем:
|
||||
- количество строк в stg.bookings_ext за текущее окно;
|
||||
- количество строк в stg.bookings с текущим batch_id.
|
||||
"""
|
||||
logging.info(
|
||||
"Проверка количества строк за %s (last_loaded_ts=%s, batch_id=%s)",
|
||||
load_date,
|
||||
last_loaded_ts,
|
||||
batch_id,
|
||||
)
|
||||
|
||||
# Здесь мы специально не вытаскиваем rowcount из предыдущей задачи,
|
||||
# а пересчитываем окно, чтобы показать связь DQ‑логики с бизнес-правилами.
|
||||
with get_gp_conn() as conn, conn.cursor() as cur:
|
||||
if last_loaded_ts is None:
|
||||
# full: считаем все строки во внешней таблице
|
||||
cur.execute("SELECT COUNT(*) FROM stg.bookings_ext")
|
||||
src_count = cur.fetchone()[0]
|
||||
else:
|
||||
# delta: считаем строки только за текущий интервал
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM stg.bookings_ext
|
||||
WHERE book_date > %s::timestamp
|
||||
AND book_date <= (%s::date + INTERVAL '1 day')
|
||||
""",
|
||||
(last_loaded_ts, load_date),
|
||||
)
|
||||
src_count = cur.fetchone()[0]
|
||||
|
||||
# Считаем количество строк, реально вставленных в stg.bookings в этом запуске
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM stg.bookings
|
||||
WHERE batch_id = %s
|
||||
""",
|
||||
(batch_id,),
|
||||
)
|
||||
stg_count = cur.fetchone()[0]
|
||||
|
||||
if src_count != stg_count:
|
||||
raise ValueError(
|
||||
"❌ Несовпадение количества строк при загрузке bookings: "
|
||||
f"источник={src_count}, stg={stg_count}. "
|
||||
"Проверьте логи задач extract_and_load_increment_via_pxf "
|
||||
"и корректность окна инкремента."
|
||||
)
|
||||
|
||||
logging.info(
|
||||
"✅ Проверка количества строк пройдена: источник=%s, stg=%s",
|
||||
src_count,
|
||||
stg_count,
|
||||
)
|
||||
BOOKINGS_CONN_ID = "bookings_db"
|
||||
GREENPLUM_CONN_ID = "greenplum_conn"
|
||||
|
||||
|
||||
def _finish_summary() -> None:
|
||||
"""Логирует краткий итог выполнения DAG за один запуск."""
|
||||
logging.info("DAG bookings_to_gp_stage завершён.")
|
||||
"""
|
||||
Логирует краткий итог выполнения DAG за один запуск.
|
||||
|
||||
Здесь можно добавить дополнительную агрегацию/логирование,
|
||||
но для учебного примера достаточно простого сообщения.
|
||||
"""
|
||||
from logging import getLogger
|
||||
|
||||
log = getLogger(__name__)
|
||||
log.info("DAG bookings_to_gp_stage завершён. Подробности смотрите в логах задач.")
|
||||
|
||||
|
||||
with DAG(
|
||||
@@ -372,41 +50,40 @@ with DAG(
|
||||
tags=["demo", "bookings", "greenplum", "stg"],
|
||||
description="Учебный DAG: загрузка из bookings-db в stg.bookings (Greenplum)",
|
||||
) as dag:
|
||||
generate_bookings_day = PythonOperator(
|
||||
# 1. Генерируем один учебный день в демо-БД bookings (идемпотентно)
|
||||
generate_bookings_day = PostgresOperator(
|
||||
task_id="generate_bookings_day",
|
||||
python_callable=_generate_bookings_day,
|
||||
op_kwargs={"load_date": "{{ ds }}"},
|
||||
postgres_conn_id=BOOKINGS_CONN_ID,
|
||||
sql="/sql/src/bookings_generate_day_if_missing.sql",
|
||||
params={"load_date": "{{ ds }}"},
|
||||
)
|
||||
|
||||
get_last_loaded_ts = PythonOperator(
|
||||
task_id="get_last_loaded_ts_from_gp",
|
||||
python_callable=_get_last_loaded_ts_from_gp,
|
||||
)
|
||||
|
||||
extract_and_load_increment = PythonOperator(
|
||||
task_id="extract_and_load_increment_via_pxf",
|
||||
python_callable=_extract_and_load_increment_via_pxf,
|
||||
op_kwargs={
|
||||
"last_loaded_ts": "{{ ti.xcom_pull(task_ids='get_last_loaded_ts_from_gp') }}",
|
||||
# 2. Загружаем инкремент из stg.bookings_ext в stg.bookings
|
||||
load_bookings_to_stg = PostgresOperator(
|
||||
task_id="load_bookings_to_stg",
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="/sql/stg/bookings_load.sql",
|
||||
params={
|
||||
"load_date": "{{ ds }}",
|
||||
"batch_id": "{{ ds_nodash }}",
|
||||
},
|
||||
)
|
||||
|
||||
check_row_counts = PythonOperator(
|
||||
# 3. Проверяем количество строк между источником и stg.bookings
|
||||
check_row_counts = PostgresOperator(
|
||||
task_id="check_row_counts",
|
||||
python_callable=_check_row_counts,
|
||||
op_kwargs={
|
||||
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||
sql="/sql/stg/bookings_dq.sql",
|
||||
params={
|
||||
"load_date": "{{ ds }}",
|
||||
"last_loaded_ts": "{{ ti.xcom_pull(task_ids='get_last_loaded_ts_from_gp') }}",
|
||||
"batch_id": "{{ ds_nodash }}",
|
||||
},
|
||||
)
|
||||
|
||||
# 4. Финальный лог/сводка
|
||||
finish_summary = PythonOperator(
|
||||
task_id="finish_summary",
|
||||
python_callable=_finish_summary,
|
||||
)
|
||||
|
||||
generate_bookings_day >> get_last_loaded_ts >> extract_and_load_increment >> check_row_counts >> finish_summary
|
||||
|
||||
generate_bookings_day >> load_bookings_to_stg >> check_row_counts >> finish_summary
|
||||
|
||||
@@ -84,6 +84,7 @@ services:
|
||||
volumes:
|
||||
- ./airflow/dags:/opt/airflow/dags
|
||||
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
||||
- ./sql:/sql:ro
|
||||
- airflow_data:/opt/airflow/data
|
||||
depends_on:
|
||||
pgmeta:
|
||||
@@ -106,6 +107,7 @@ services:
|
||||
volumes:
|
||||
- ./airflow/dags:/opt/airflow/dags
|
||||
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
||||
- ./sql:/sql:ro
|
||||
- airflow_data:/opt/airflow/data
|
||||
depends_on:
|
||||
pgmeta:
|
||||
@@ -126,6 +128,7 @@ services:
|
||||
volumes:
|
||||
- ./airflow/dags:/opt/airflow/dags
|
||||
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
||||
- ./sql:/sql:ro
|
||||
- airflow_data:/opt/airflow/data
|
||||
command: >
|
||||
bash -lc "
|
||||
|
||||
@@ -86,41 +86,45 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre
|
||||
|
||||
### 4.2. DAG для ежедневной загрузки
|
||||
|
||||
- `dag_id`: `bookings_to_gp_stage` (рабочее имя).
|
||||
- `dag_id`: `bookings_to_gp_stage`.
|
||||
- Основные параметры:
|
||||
- `load_date` (по умолчанию `{{ ds }}`) — учебный день, за который генерим и грузим данные.
|
||||
- `load_date` (по умолчанию `{{ ds }}`) — учебный день, за который генерим и грузим данные;
|
||||
- подключения:
|
||||
- `bookings_db_conn_id` — Airflow connection к `bookings-db`;
|
||||
- `greenplum_conn_id` — Airflow connection к Greenplum.
|
||||
- `bookings_db_conn_id` — Airflow connection к `bookings-db` (в коде DAG — `BOOKINGS_CONN_ID = "bookings_db"`);
|
||||
- `greenplum_conn_id` — Airflow connection к Greenplum (`GREENPLUM_CONN_ID = "greenplum_conn"`).
|
||||
|
||||
Предлагаемая последовательность задач:
|
||||
Последовательность задач (упрощённая, но отражающая те же шаги):
|
||||
|
||||
1. `generate_bookings_day`
|
||||
- при необходимости создаёт таблицу `bookings.bookings` в `bookings-db` (одноразово);
|
||||
- **идемпотентность**: проверяет, есть ли данные за `load_date` в `bookings.bookings`;
|
||||
- если нет — генерирует данные за один учебный день (можно опираться на `make bookings-generate-day` и логику из `docs/internal/bookings_tz.md`);
|
||||
- если да — ничего не делает, логирует «данные за этот день уже есть» и считается успешно выполненной.
|
||||
2. `get_last_loaded_ts_from_gp`
|
||||
- читает `max(src_created_at_ts)` из `stg.bookings`;
|
||||
- если данных нет — возвращает `None` и режим загрузки `full`;
|
||||
- результат (последняя метка и режим) передаёт через XCom.
|
||||
3. `extract_and_load_increment_via_pxf`
|
||||
- делает `INSERT INTO stg.bookings (...) SELECT ... FROM stg.bookings_ext WHERE src_created_at_ts > :last_ts AND src_created_at_ts <= :window_end`;
|
||||
- в режиме `full` подставляет «минимальную» дату (или просто не фильтрует);
|
||||
- заполняет `src_created_at_ts`, `load_dttm`, `batch_id`.
|
||||
4. `check_row_counts`
|
||||
- сравнивает количество строк, прочитанных из `stg.bookings_ext`, и количеством реально вставленных в `stg.bookings`;
|
||||
- при расхождении падает с понятным сообщением «что посмотреть/проверить».
|
||||
5. (опционально) `finish_summary`
|
||||
- логирует итог: режим загрузки (full/delta), интервал `src_created_at_ts`, количество строк.
|
||||
- PostgresOperator к `bookings-db`;
|
||||
- выполняет скрипт `/sql/bookings/generate_day_if_missing.sql`;
|
||||
- скрипт сам идемпотентно проверяет, есть ли данные за `load_date` в `bookings.bookings`:
|
||||
- если день уже есть — выдаёт NOTICE и ничего не делает;
|
||||
- если нет — вызывает генератор демобазы (логика как в `bookings/generate_next_day.sql`).
|
||||
2. `load_bookings_to_stg`
|
||||
- PostgresOperator к Greenplum;
|
||||
- выполняет скрипт `/sql/stg/bookings_load.sql`;
|
||||
- внутри SQL считается `max(src_created_at_ts)` по «старым» батчам и по нему строится окно инкремента:
|
||||
- первая загрузка (full) — берём все строки из `stg.bookings_ext`;
|
||||
- последующие загрузки — берём только записи, где `book_date` больше предыдущего максимума и не позже конца учебного дня;
|
||||
- при вставке заполняются тех.колонки `src_created_at_ts`, `load_dttm`, `batch_id`.
|
||||
3. `check_row_counts`
|
||||
- PostgresOperator к Greenplum;
|
||||
- выполняет скрипт `/sql/stg/bookings_dq.sql`;
|
||||
- скрипт заново считает окно инкремента по тем же правилам, что и загрузка, и сравнивает:
|
||||
- количество строк в `stg.bookings_ext` за окно,
|
||||
- количество строк в `stg.bookings` для текущего `batch_id`;
|
||||
- при расхождении выполняет `RAISE EXCEPTION` с понятным текстом ошибки.
|
||||
4. `finish_summary`
|
||||
- PythonOperator, который логирует итог выполнения DAG и напоминает, где смотреть детальные логи.
|
||||
|
||||
Эта последовательность служит учебным примером для менти: от генерации «операционных» данных до инкремента в сырой слой DWH.
|
||||
Таким образом, вся бизнес‑логика инкремента и проверок живёт в SQL‑скриптах, а DAG отвечает за оркестрацию и подключение к нужным БД. Для менти это хороший пример разделения ответственности между SQL и Python.
|
||||
|
||||
## 5. Связь с остальными документами
|
||||
|
||||
- `docs/internal/bookings_tz.md` — как готовится и генерируется источник `bookings-db`.
|
||||
- `docs/internal/pxf_bookings.md` — детали настройки PXF и внешней таблицы для чтения из `bookings-db`.
|
||||
- `sql/ddl_gp.sql` — итоговый DDL для схемы `stg` и таблиц `stg.bookings_ext` / `stg.bookings` (применяется через `make ddl-gp`).
|
||||
- `sql/stg/bookings_ddl.sql` — DDL для схемы `stg` и таблиц `stg.bookings_ext` / `stg.bookings` (подключается из `sql/ddl_gp.sql` и применяется через `make ddl-gp`).
|
||||
|
||||
Дальнейшая модель DWH (слои ODS/DDS/DM, факт/измерения, SCD) должна быть спроектирована студентом по статье о моделировании данных, используя `stg.bookings` как входной слой.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ _Внутренний файл, чтобы не забыть договорён
|
||||
- Стенд поднят: `make up`.
|
||||
- Демо‑БД bookings инициализирована: `make bookings-init`.
|
||||
- В Greenplum применён DDL (созданы схема `stg` и таблицы `stg.bookings_ext` / `stg.bookings`):
|
||||
- `make ddl-gp` (использует `sql/ddl_gp.sql`).
|
||||
- `make ddl-gp` (использует `sql/ddl_gp.sql`, который подтягивает `sql/stg/bookings_ddl.sql`).
|
||||
- В Airflow есть коннекты:
|
||||
- `greenplum_conn` (по умолчанию уже используется в helpers/greenplum.py);
|
||||
- `bookings_db` (Postgres к сервису `bookings-db`, если не хочется полагаться на ENV).
|
||||
@@ -29,23 +29,19 @@ _Внутренний файл, чтобы не забыть договорён
|
||||
## 3. Последовательность задач в DAG
|
||||
|
||||
- `generate_bookings_day`:
|
||||
- проверяет наличие таблицы `bookings.bookings`;
|
||||
- смотрит, есть ли строки за `load_date` (`book_date::date = load_date`);
|
||||
- если день уже сгенерирован — ничего не делает (идемпотентность), просто логирует это;
|
||||
- если нет — запускает генератор через DO‑блок (логика как в `bookings/generate_next_day.sql`).
|
||||
- `get_last_loaded_ts_from_gp`:
|
||||
- проверяет, что есть таблица `stg.bookings`;
|
||||
- берёт `max(src_created_at_ts)` как последнюю загруженную метку;
|
||||
- если NULL — значит в STG ещё нет данных, дальше идём в режим `full`.
|
||||
- `extract_and_load_increment_via_pxf`:
|
||||
- читает данные из `stg.bookings_ext` и вставляет в `stg.bookings`;
|
||||
- при `last_loaded_ts is None` делает полную загрузку (full) — все строки;
|
||||
- при delta берёт только строки с `book_date > last_loaded_ts` и `book_date <= load_date + 1 day`;
|
||||
- приводит бизнес‑поля к `TEXT`, заполняет `src_created_at_ts`, `load_dttm`, `batch_id={{ ds_nodash }}`.
|
||||
- PostgresOperator к `bookings-db`;
|
||||
- выполняет SQL `/sql/src/bookings_generate_day_if_missing.sql`;
|
||||
- скрипт сам проверяет, есть ли строки за `load_date` (`book_date::date = load_date`);
|
||||
- если день уже сгенерирован — ничего не делает (идемпотентность), только пишет NOTICE.
|
||||
- `load_bookings_to_stg`:
|
||||
- PostgresOperator к Greenplum;
|
||||
- выполняет SQL `/sql/stg/bookings_load.sql`;
|
||||
- считает «старый» максимум `src_created_at_ts` (по предыдущим батчам) и грузит только новые строки из `stg.bookings_ext`, заполняя `src_created_at_ts`, `load_dttm`, `batch_id={{ ds_nodash }}`.
|
||||
- `check_row_counts`:
|
||||
- пересчитывает количество строк в источнике (`stg.bookings_ext`) за текущее окно (full/delta);
|
||||
- сравнивает с количеством строк в `stg.bookings` для текущего `batch_id`;
|
||||
- если не сходится — падает с понятным сообщением и подсказкой «куда смотреть».
|
||||
- PostgresOperator к Greenplum;
|
||||
- выполняет SQL `/sql/stg/bookings_dq.sql`;
|
||||
- за то же окно инкремента считает количество строк в источнике и в `stg.bookings` (по текущему `batch_id`);
|
||||
- при расхождении делает `RAISE EXCEPTION` с понятным текстом ошибки.
|
||||
- `finish_summary`:
|
||||
- логирует итог выполнения DAG за одно срабатывание.
|
||||
|
||||
@@ -78,4 +74,3 @@ _Внутренний файл, чтобы не забыть договорён
|
||||
- В будущем всё это нужно будет собрать в одну понятную историю для студента:
|
||||
- отдельный раздел «Учебный пример: bookings → stg → dwh»;
|
||||
- скриншоты DAG, примеры запросов и типичные ошибки.
|
||||
|
||||
|
||||
+3
-24
@@ -22,27 +22,6 @@ CREATE EXTERNAL TABLE public.ext_bookings_bookings (
|
||||
LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db')
|
||||
FORMAT 'CUSTOM' (formatter='pxfwritable_import');
|
||||
|
||||
-- Схема stg для сырого слоя DWH.
|
||||
CREATE SCHEMA IF NOT EXISTS stg;
|
||||
|
||||
-- Внешняя таблица в схеме stg для чтения данных из bookings.bookings через PXF.
|
||||
DROP EXTERNAL TABLE IF EXISTS stg.bookings_ext;
|
||||
CREATE EXTERNAL TABLE stg.bookings_ext (
|
||||
book_ref CHAR(6),
|
||||
book_date TIMESTAMP,
|
||||
total_amount NUMERIC(10,2)
|
||||
)
|
||||
LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db')
|
||||
FORMAT 'CUSTOM' (formatter='pxfwritable_import');
|
||||
|
||||
-- Внутренняя таблица stg.bookings — сырой слой, все бизнес-колонки как TEXT.
|
||||
CREATE TABLE IF NOT EXISTS stg.bookings (
|
||||
book_ref TEXT,
|
||||
book_date TEXT,
|
||||
total_amount TEXT,
|
||||
src_created_at_ts TIMESTAMP,
|
||||
load_dttm TIMESTAMP NOT NULL DEFAULT now(),
|
||||
batch_id TEXT NOT NULL
|
||||
)
|
||||
WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1)
|
||||
DISTRIBUTED BY (book_ref);
|
||||
-- DDL для слоя stg по таблице bookings вынесен в отдельный файл.
|
||||
-- Здесь подключаем его через psql \i, чтобы сохранить единый входной скрипт.
|
||||
\i stg/bookings_ddl.sql
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Генерация учебного дня в демо-БД bookings.
|
||||
-- Если данные за указанный день уже существуют, генерация не выполняется.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_has_day boolean;
|
||||
v_load_date date := {{ params.load_date }}::date;
|
||||
v_max_book_date timestamptz;
|
||||
v_start_date timestamptz;
|
||||
v_end_date timestamptz;
|
||||
v_jobs integer := COALESCE(current_setting('bookings.jobs', true), '1')::integer;
|
||||
v_init_days integer := COALESCE(current_setting('bookings.init_days', true), '1')::integer;
|
||||
v_start_cfg text := COALESCE(current_setting('bookings.start_date', true), '2017-01-01');
|
||||
BEGIN
|
||||
-- Проверяем, что демобаза установлена
|
||||
IF to_regclass('bookings.bookings') IS NULL THEN
|
||||
RAISE EXCEPTION 'Таблица bookings.bookings не найдена. Сначала выполните make bookings-init.';
|
||||
END IF;
|
||||
|
||||
-- Проверяем, есть ли уже данные за нужный день
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM bookings.bookings
|
||||
WHERE book_date::date = v_load_date
|
||||
)
|
||||
INTO v_has_day;
|
||||
|
||||
IF v_has_day THEN
|
||||
RAISE NOTICE 'Данные за % уже есть в bookings.bookings — генерация не требуется.', v_load_date;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Ищем последнюю сгенерированную дату
|
||||
SELECT max(book_date) INTO v_max_book_date FROM bookings.bookings;
|
||||
|
||||
IF v_max_book_date IS NULL THEN
|
||||
-- База пустая: берём стартовую дату из конфигурации (или дефолтную)
|
||||
v_start_date := date_trunc('day', v_start_cfg::timestamptz);
|
||||
ELSE
|
||||
-- Продолжаем с дня, следующего за максимальной датой
|
||||
v_start_date := date_trunc('day', v_max_book_date) + interval '1 day';
|
||||
END IF;
|
||||
|
||||
-- Первая генерация вызывает generate(), последующие — continue()
|
||||
IF v_max_book_date IS NULL THEN
|
||||
v_end_date := v_start_date + (v_init_days || ' days')::interval;
|
||||
CALL generate(v_start_date, v_end_date, v_jobs);
|
||||
ELSE
|
||||
v_end_date := v_start_date + interval '1 day';
|
||||
CALL continue(v_end_date, v_jobs);
|
||||
END IF;
|
||||
|
||||
-- Ждём завершения фоновых джобов генератора, чтобы данные успели записаться
|
||||
WHILE busy() LOOP
|
||||
PERFORM pg_sleep(1);
|
||||
END LOOP;
|
||||
PERFORM dblink_disconnect(unnest(dblink_get_connections()));
|
||||
END $$;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- DDL для слоя STG по таблице bookings.
|
||||
-- Используется как из общего скрипта ddl_gp.sql (через \i),
|
||||
-- так и может выполняться отдельно при изменении схемы.
|
||||
|
||||
-- Схема stg для сырого слоя DWH.
|
||||
CREATE SCHEMA IF NOT EXISTS stg;
|
||||
|
||||
-- Внешняя таблица в схеме stg для чтения данных из bookings.bookings через PXF.
|
||||
DROP EXTERNAL TABLE IF EXISTS stg.bookings_ext;
|
||||
CREATE EXTERNAL TABLE stg.bookings_ext (
|
||||
book_ref CHAR(6),
|
||||
book_date TIMESTAMP,
|
||||
total_amount NUMERIC(10,2)
|
||||
)
|
||||
LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db')
|
||||
FORMAT 'CUSTOM' (formatter='pxfwritable_import');
|
||||
|
||||
-- Внутренняя таблица stg.bookings — сырой слой, все бизнес-колонки как TEXT.
|
||||
CREATE TABLE IF NOT EXISTS stg.bookings (
|
||||
book_ref TEXT,
|
||||
book_date TEXT,
|
||||
total_amount TEXT,
|
||||
src_created_at_ts TIMESTAMP,
|
||||
load_dttm TIMESTAMP NOT NULL DEFAULT now(),
|
||||
batch_id TEXT NOT NULL
|
||||
)
|
||||
WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1)
|
||||
DISTRIBUTED BY (book_ref);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Проверка количества строк между источником stg.bookings_ext и стейджем stg.bookings.
|
||||
-- Считаем строки за то же окно инкремента, что и при загрузке,
|
||||
-- используя batch_id и "старый" максимум src_created_at_ts.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_load_date date := {{ params.load_date }}::date;
|
||||
v_batch_id text := {{ params.batch_id | tojson }}::text;
|
||||
v_prev_ts timestamp;
|
||||
v_src_count bigint;
|
||||
v_stg_count bigint;
|
||||
BEGIN
|
||||
-- Опорная метка: максимум src_created_at_ts среди предыдущих батчей
|
||||
SELECT max(src_created_at_ts)
|
||||
INTO v_prev_ts
|
||||
FROM stg.bookings
|
||||
WHERE batch_id <> v_batch_id
|
||||
OR batch_id IS NULL;
|
||||
|
||||
IF v_prev_ts IS NULL THEN
|
||||
-- Полная загрузка: считаем все строки во внешней таблице
|
||||
SELECT COUNT(*) INTO v_src_count FROM stg.bookings_ext;
|
||||
ELSE
|
||||
-- Инкремент: считаем только строки за текущее окно
|
||||
SELECT COUNT(*)
|
||||
INTO v_src_count
|
||||
FROM stg.bookings_ext
|
||||
WHERE book_date > v_prev_ts
|
||||
AND book_date <= (v_load_date + INTERVAL '1 day');
|
||||
END IF;
|
||||
|
||||
-- Считаем строки, реально вставленные в stg.bookings в этом батче
|
||||
SELECT COUNT(*)
|
||||
INTO v_stg_count
|
||||
FROM stg.bookings
|
||||
WHERE batch_id = v_batch_id;
|
||||
|
||||
IF v_src_count <> v_stg_count THEN
|
||||
RAISE EXCEPTION
|
||||
'Несовпадение количества строк при загрузке bookings: источник=%, stg=%. Проверьте окно инкремента и логи задач загрузки.',
|
||||
v_src_count,
|
||||
v_stg_count;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE
|
||||
'Проверка количества строк пройдена: источник=%, stg=%',
|
||||
v_src_count,
|
||||
v_stg_count;
|
||||
END $$;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Загрузка инкремента из stg.bookings_ext в stg.bookings.
|
||||
-- Окно инкремента определяется по src_created_at_ts:
|
||||
-- берем строки, где book_date больше максимального src_created_at_ts
|
||||
-- среди "старых" батчей и не позже конца учебного дня.
|
||||
|
||||
INSERT INTO stg.bookings (
|
||||
book_ref,
|
||||
book_date,
|
||||
total_amount,
|
||||
src_created_at_ts,
|
||||
load_dttm,
|
||||
batch_id
|
||||
)
|
||||
SELECT
|
||||
book_ref::text,
|
||||
book_date::text,
|
||||
total_amount::text,
|
||||
book_date::timestamp,
|
||||
now(),
|
||||
{{ params.batch_id | tojson }}::text
|
||||
FROM stg.bookings_ext
|
||||
WHERE book_date > COALESCE(
|
||||
(
|
||||
SELECT max(src_created_at_ts)
|
||||
FROM stg.bookings
|
||||
WHERE batch_id <> {{ params.batch_id | tojson }}::text
|
||||
OR batch_id IS NULL
|
||||
),
|
||||
TIMESTAMP '1900-01-01 00:00:00'
|
||||
)
|
||||
AND book_date <= ({{ params.load_date }}::date + INTERVAL '1 day');
|
||||
|
||||
Reference in New Issue
Block a user