вынесли sql из dag в папку sql
This commit is contained in:
@@ -34,6 +34,22 @@
|
|||||||
- Форматирование: `black` (88 cols) и `isort`. Если не уверены — запустите `make fmt`.
|
- Форматирование: `black` (88 cols) и `isort`. Если не уверены — запустите `make fmt`.
|
||||||
- Язык: комментарии, docstring и документацию — на русском; имена идентификаторов — на английском.
|
- Язык: комментарии, 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`.
|
- Тесты лежат в `tests/` (pytest). Запуск: `make test`.
|
||||||
- Есть юнит‑тесты для `helpers/greenplum.py` и smoke‑тесты DAG‑структуры (`tests/test_dags_smoke.py`).
|
- Есть юнит‑тесты для `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 режиме (для обучения)
|
- **Greenplum** запущен в single-node режиме (для обучения)
|
||||||
|
|||||||
@@ -4,19 +4,17 @@ from __future__ import annotations
|
|||||||
Учебный DAG для менти: показывает, как устроен поток
|
Учебный DAG для менти: показывает, как устроен поток
|
||||||
от источника bookings-db до слоя stg в Greenplum.
|
от источника bookings-db до слоя stg в Greenplum.
|
||||||
|
|
||||||
Важно: этот файл специально должен быть хорошо задокументирован —
|
В этом примере мы сознательно используем:
|
||||||
docstring и комментарии помогают студенту понять, «зачем» каждая задача,
|
- Airflow Connections для подключения к БД;
|
||||||
а не только «что именно она делает».
|
- PostgresOperator, который берёт SQL-скрипты с диска;
|
||||||
|
чтобы студент увидел «канонический» способ работы с SQL в DAG.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from airflow import DAG
|
from airflow import DAG
|
||||||
from airflow.operators.python import PythonOperator
|
from airflow.operators.python import PythonOperator
|
||||||
|
from airflow.providers.postgres.operators.postgres import PostgresOperator
|
||||||
from helpers.greenplum import get_gp_conn
|
|
||||||
|
|
||||||
|
|
||||||
default_args = {
|
default_args = {
|
||||||
@@ -26,341 +24,21 @@ default_args = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
BOOKINGS_CONN_ID = os.getenv("BOOKINGS_CONN_ID", "bookings_db")
|
BOOKINGS_CONN_ID = "bookings_db"
|
||||||
|
GREENPLUM_CONN_ID = "greenplum_conn"
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _finish_summary() -> None:
|
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(
|
with DAG(
|
||||||
@@ -372,41 +50,40 @@ with DAG(
|
|||||||
tags=["demo", "bookings", "greenplum", "stg"],
|
tags=["demo", "bookings", "greenplum", "stg"],
|
||||||
description="Учебный DAG: загрузка из bookings-db в stg.bookings (Greenplum)",
|
description="Учебный DAG: загрузка из bookings-db в stg.bookings (Greenplum)",
|
||||||
) as dag:
|
) as dag:
|
||||||
generate_bookings_day = PythonOperator(
|
# 1. Генерируем один учебный день в демо-БД bookings (идемпотентно)
|
||||||
|
generate_bookings_day = PostgresOperator(
|
||||||
task_id="generate_bookings_day",
|
task_id="generate_bookings_day",
|
||||||
python_callable=_generate_bookings_day,
|
postgres_conn_id=BOOKINGS_CONN_ID,
|
||||||
op_kwargs={"load_date": "{{ ds }}"},
|
sql="/sql/src/bookings_generate_day_if_missing.sql",
|
||||||
|
params={"load_date": "{{ ds }}"},
|
||||||
)
|
)
|
||||||
|
|
||||||
get_last_loaded_ts = PythonOperator(
|
# 2. Загружаем инкремент из stg.bookings_ext в stg.bookings
|
||||||
task_id="get_last_loaded_ts_from_gp",
|
load_bookings_to_stg = PostgresOperator(
|
||||||
python_callable=_get_last_loaded_ts_from_gp,
|
task_id="load_bookings_to_stg",
|
||||||
)
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
|
sql="/sql/stg/bookings_load.sql",
|
||||||
extract_and_load_increment = PythonOperator(
|
params={
|
||||||
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') }}",
|
|
||||||
"load_date": "{{ ds }}",
|
"load_date": "{{ ds }}",
|
||||||
"batch_id": "{{ ds_nodash }}",
|
"batch_id": "{{ ds_nodash }}",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
check_row_counts = PythonOperator(
|
# 3. Проверяем количество строк между источником и stg.bookings
|
||||||
|
check_row_counts = PostgresOperator(
|
||||||
task_id="check_row_counts",
|
task_id="check_row_counts",
|
||||||
python_callable=_check_row_counts,
|
postgres_conn_id=GREENPLUM_CONN_ID,
|
||||||
op_kwargs={
|
sql="/sql/stg/bookings_dq.sql",
|
||||||
|
params={
|
||||||
"load_date": "{{ ds }}",
|
"load_date": "{{ ds }}",
|
||||||
"last_loaded_ts": "{{ ti.xcom_pull(task_ids='get_last_loaded_ts_from_gp') }}",
|
|
||||||
"batch_id": "{{ ds_nodash }}",
|
"batch_id": "{{ ds_nodash }}",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 4. Финальный лог/сводка
|
||||||
finish_summary = PythonOperator(
|
finish_summary = PythonOperator(
|
||||||
task_id="finish_summary",
|
task_id="finish_summary",
|
||||||
python_callable=_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:
|
volumes:
|
||||||
- ./airflow/dags:/opt/airflow/dags
|
- ./airflow/dags:/opt/airflow/dags
|
||||||
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
||||||
|
- ./sql:/sql:ro
|
||||||
- airflow_data:/opt/airflow/data
|
- airflow_data:/opt/airflow/data
|
||||||
depends_on:
|
depends_on:
|
||||||
pgmeta:
|
pgmeta:
|
||||||
@@ -106,6 +107,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./airflow/dags:/opt/airflow/dags
|
- ./airflow/dags:/opt/airflow/dags
|
||||||
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
||||||
|
- ./sql:/sql:ro
|
||||||
- airflow_data:/opt/airflow/data
|
- airflow_data:/opt/airflow/data
|
||||||
depends_on:
|
depends_on:
|
||||||
pgmeta:
|
pgmeta:
|
||||||
@@ -126,6 +128,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./airflow/dags:/opt/airflow/dags
|
- ./airflow/dags:/opt/airflow/dags
|
||||||
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
||||||
|
- ./sql:/sql:ro
|
||||||
- airflow_data:/opt/airflow/data
|
- airflow_data:/opt/airflow/data
|
||||||
command: >
|
command: >
|
||||||
bash -lc "
|
bash -lc "
|
||||||
|
|||||||
@@ -86,41 +86,45 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre
|
|||||||
|
|
||||||
### 4.2. DAG для ежедневной загрузки
|
### 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`;
|
- `bookings_db_conn_id` — Airflow connection к `bookings-db` (в коде DAG — `BOOKINGS_CONN_ID = "bookings_db"`);
|
||||||
- `greenplum_conn_id` — Airflow connection к Greenplum.
|
- `greenplum_conn_id` — Airflow connection к Greenplum (`GREENPLUM_CONN_ID = "greenplum_conn"`).
|
||||||
|
|
||||||
Предлагаемая последовательность задач:
|
Последовательность задач (упрощённая, но отражающая те же шаги):
|
||||||
|
|
||||||
1. `generate_bookings_day`
|
1. `generate_bookings_day`
|
||||||
- при необходимости создаёт таблицу `bookings.bookings` в `bookings-db` (одноразово);
|
- PostgresOperator к `bookings-db`;
|
||||||
- **идемпотентность**: проверяет, есть ли данные за `load_date` в `bookings.bookings`;
|
- выполняет скрипт `/sql/bookings/generate_day_if_missing.sql`;
|
||||||
- если нет — генерирует данные за один учебный день (можно опираться на `make bookings-generate-day` и логику из `docs/internal/bookings_tz.md`);
|
- скрипт сам идемпотентно проверяет, есть ли данные за `load_date` в `bookings.bookings`:
|
||||||
- если да — ничего не делает, логирует «данные за этот день уже есть» и считается успешно выполненной.
|
- если день уже есть — выдаёт NOTICE и ничего не делает;
|
||||||
2. `get_last_loaded_ts_from_gp`
|
- если нет — вызывает генератор демобазы (логика как в `bookings/generate_next_day.sql`).
|
||||||
- читает `max(src_created_at_ts)` из `stg.bookings`;
|
2. `load_bookings_to_stg`
|
||||||
- если данных нет — возвращает `None` и режим загрузки `full`;
|
- PostgresOperator к Greenplum;
|
||||||
- результат (последняя метка и режим) передаёт через XCom.
|
- выполняет скрипт `/sql/stg/bookings_load.sql`;
|
||||||
3. `extract_and_load_increment_via_pxf`
|
- внутри SQL считается `max(src_created_at_ts)` по «старым» батчам и по нему строится окно инкремента:
|
||||||
- делает `INSERT INTO stg.bookings (...) SELECT ... FROM stg.bookings_ext WHERE src_created_at_ts > :last_ts AND src_created_at_ts <= :window_end`;
|
- первая загрузка (full) — берём все строки из `stg.bookings_ext`;
|
||||||
- в режиме `full` подставляет «минимальную» дату (или просто не фильтрует);
|
- последующие загрузки — берём только записи, где `book_date` больше предыдущего максимума и не позже конца учебного дня;
|
||||||
- заполняет `src_created_at_ts`, `load_dttm`, `batch_id`.
|
- при вставке заполняются тех.колонки `src_created_at_ts`, `load_dttm`, `batch_id`.
|
||||||
4. `check_row_counts`
|
3. `check_row_counts`
|
||||||
- сравнивает количество строк, прочитанных из `stg.bookings_ext`, и количеством реально вставленных в `stg.bookings`;
|
- PostgresOperator к Greenplum;
|
||||||
- при расхождении падает с понятным сообщением «что посмотреть/проверить».
|
- выполняет скрипт `/sql/stg/bookings_dq.sql`;
|
||||||
5. (опционально) `finish_summary`
|
- скрипт заново считает окно инкремента по тем же правилам, что и загрузка, и сравнивает:
|
||||||
- логирует итог: режим загрузки (full/delta), интервал `src_created_at_ts`, количество строк.
|
- количество строк в `stg.bookings_ext` за окно,
|
||||||
|
- количество строк в `stg.bookings` для текущего `batch_id`;
|
||||||
|
- при расхождении выполняет `RAISE EXCEPTION` с понятным текстом ошибки.
|
||||||
|
4. `finish_summary`
|
||||||
|
- PythonOperator, который логирует итог выполнения DAG и напоминает, где смотреть детальные логи.
|
||||||
|
|
||||||
Эта последовательность служит учебным примером для менти: от генерации «операционных» данных до инкремента в сырой слой DWH.
|
Таким образом, вся бизнес‑логика инкремента и проверок живёт в SQL‑скриптах, а DAG отвечает за оркестрацию и подключение к нужным БД. Для менти это хороший пример разделения ответственности между SQL и Python.
|
||||||
|
|
||||||
## 5. Связь с остальными документами
|
## 5. Связь с остальными документами
|
||||||
|
|
||||||
- `docs/internal/bookings_tz.md` — как готовится и генерируется источник `bookings-db`.
|
- `docs/internal/bookings_tz.md` — как готовится и генерируется источник `bookings-db`.
|
||||||
- `docs/internal/pxf_bookings.md` — детали настройки PXF и внешней таблицы для чтения из `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` как входной слой.
|
Дальнейшая модель DWH (слои ODS/DDS/DM, факт/измерения, SCD) должна быть спроектирована студентом по статье о моделировании данных, используя `stg.bookings` как входной слой.
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ _Внутренний файл, чтобы не забыть договорён
|
|||||||
- Стенд поднят: `make up`.
|
- Стенд поднят: `make up`.
|
||||||
- Демо‑БД bookings инициализирована: `make bookings-init`.
|
- Демо‑БД bookings инициализирована: `make bookings-init`.
|
||||||
- В Greenplum применён DDL (созданы схема `stg` и таблицы `stg.bookings_ext` / `stg.bookings`):
|
- В 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 есть коннекты:
|
- В Airflow есть коннекты:
|
||||||
- `greenplum_conn` (по умолчанию уже используется в helpers/greenplum.py);
|
- `greenplum_conn` (по умолчанию уже используется в helpers/greenplum.py);
|
||||||
- `bookings_db` (Postgres к сервису `bookings-db`, если не хочется полагаться на ENV).
|
- `bookings_db` (Postgres к сервису `bookings-db`, если не хочется полагаться на ENV).
|
||||||
@@ -29,23 +29,19 @@ _Внутренний файл, чтобы не забыть договорён
|
|||||||
## 3. Последовательность задач в DAG
|
## 3. Последовательность задач в DAG
|
||||||
|
|
||||||
- `generate_bookings_day`:
|
- `generate_bookings_day`:
|
||||||
- проверяет наличие таблицы `bookings.bookings`;
|
- PostgresOperator к `bookings-db`;
|
||||||
- смотрит, есть ли строки за `load_date` (`book_date::date = load_date`);
|
- выполняет SQL `/sql/src/bookings_generate_day_if_missing.sql`;
|
||||||
- если день уже сгенерирован — ничего не делает (идемпотентность), просто логирует это;
|
- скрипт сам проверяет, есть ли строки за `load_date` (`book_date::date = load_date`);
|
||||||
- если нет — запускает генератор через DO‑блок (логика как в `bookings/generate_next_day.sql`).
|
- если день уже сгенерирован — ничего не делает (идемпотентность), только пишет NOTICE.
|
||||||
- `get_last_loaded_ts_from_gp`:
|
- `load_bookings_to_stg`:
|
||||||
- проверяет, что есть таблица `stg.bookings`;
|
- PostgresOperator к Greenplum;
|
||||||
- берёт `max(src_created_at_ts)` как последнюю загруженную метку;
|
- выполняет SQL `/sql/stg/bookings_load.sql`;
|
||||||
- если NULL — значит в STG ещё нет данных, дальше идём в режим `full`.
|
- считает «старый» максимум `src_created_at_ts` (по предыдущим батчам) и грузит только новые строки из `stg.bookings_ext`, заполняя `src_created_at_ts`, `load_dttm`, `batch_id={{ ds_nodash }}`.
|
||||||
- `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 }}`.
|
|
||||||
- `check_row_counts`:
|
- `check_row_counts`:
|
||||||
- пересчитывает количество строк в источнике (`stg.bookings_ext`) за текущее окно (full/delta);
|
- PostgresOperator к Greenplum;
|
||||||
- сравнивает с количеством строк в `stg.bookings` для текущего `batch_id`;
|
- выполняет SQL `/sql/stg/bookings_dq.sql`;
|
||||||
- если не сходится — падает с понятным сообщением и подсказкой «куда смотреть».
|
- за то же окно инкремента считает количество строк в источнике и в `stg.bookings` (по текущему `batch_id`);
|
||||||
|
- при расхождении делает `RAISE EXCEPTION` с понятным текстом ошибки.
|
||||||
- `finish_summary`:
|
- `finish_summary`:
|
||||||
- логирует итог выполнения DAG за одно срабатывание.
|
- логирует итог выполнения DAG за одно срабатывание.
|
||||||
|
|
||||||
@@ -78,4 +74,3 @@ _Внутренний файл, чтобы не забыть договорён
|
|||||||
- В будущем всё это нужно будет собрать в одну понятную историю для студента:
|
- В будущем всё это нужно будет собрать в одну понятную историю для студента:
|
||||||
- отдельный раздел «Учебный пример: bookings → stg → dwh»;
|
- отдельный раздел «Учебный пример: bookings → stg → dwh»;
|
||||||
- скриншоты DAG, примеры запросов и типичные ошибки.
|
- скриншоты DAG, примеры запросов и типичные ошибки.
|
||||||
|
|
||||||
|
|||||||
+3
-24
@@ -22,27 +22,6 @@ CREATE EXTERNAL TABLE public.ext_bookings_bookings (
|
|||||||
LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db')
|
LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db')
|
||||||
FORMAT 'CUSTOM' (formatter='pxfwritable_import');
|
FORMAT 'CUSTOM' (formatter='pxfwritable_import');
|
||||||
|
|
||||||
-- Схема stg для сырого слоя DWH.
|
-- DDL для слоя stg по таблице bookings вынесен в отдельный файл.
|
||||||
CREATE SCHEMA IF NOT EXISTS stg;
|
-- Здесь подключаем его через psql \i, чтобы сохранить единый входной скрипт.
|
||||||
|
\i stg/bookings_ddl.sql
|
||||||
-- Внешняя таблица в схеме 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,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