From 0208e6fd71bada2ee6ba3c888ccd8d5997684f79 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 17 Jan 2026 15:05:11 +0300 Subject: [PATCH 01/38] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/chore/bookings-etl.md | 395 +++++++++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 docs/chore/bookings-etl.md diff --git a/docs/chore/bookings-etl.md b/docs/chore/bookings-etl.md new file mode 100644 index 0000000..73296c4 --- /dev/null +++ b/docs/chore/bookings-etl.md @@ -0,0 +1,395 @@ +# План ETL для загрузки `bookings.tickets` в STG слой + +**Ветка:** `chore/bookings-etl` +**Цель:** Добавить загрузку таблицы `tickets` в STG слой Greenplum по аналогии с `bookings` + +## 1. Выбор таблицы и обоснование + +**Выбранная таблица:** `bookings.tickets` + +**Почему `tickets`:** +- Аналитическая ценность: билеты нужны для анализа выручки, загрузки рейсов, пассажиропотока +- Связь с существующим потоком: таблица связана с `bookings.bookings` через `book_ref` +- Инкрементальная природа: билеты создаются вместе с бронированием → понятная логика +- Простая структура: без сложных типов данных (хорошо для обучения) + +## 2. Структура исходной таблицы + +Исходная таблица в `bookings-db` (Postgres): + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `ticket_no` | text (PK) | Уникальный номер билета | +| `book_ref` | text (FK) | Ссылка на бронирование (`bookings.book_ref`) | +| `passenger_id` | text | Идентификатор пассажира | +| `passenger_name` | text | Имя пассажира | +| `outbound` | boolean | Направление рейса (прямой/обратный) | + +**Особенности:** +- Количество записей: примерно в 1.5 раза больше, чем бронирований +- Один `book_ref` может иметь несколько `ticket_no` +- В исходной таблице нет явной временной колонки → используем дату из связанного бронирования + +## 3. Проектирование STG-таблицы в Greenplum + +### Внутренняя таблица `stg.tickets`: + +```sql +-- sql/stg/tickets_ddl.sql + +DROP TABLE IF EXISTS stg.tickets CASCADE; + +CREATE TABLE stg.tickets ( + -- Бизнес-атрибуты (из источника) + ticket_no TEXT NOT NULL, -- номер билета + book_ref TEXT NOT NULL, -- ссылка на бронирование + passenger_id TEXT, -- идентификатор пассажира + passenger_name TEXT, -- имя пассажира + outbound TEXT, -- флаг направления (boolean → text для Greenplum) + + -- Технические атрибуты + src_created_at_ts TIMESTAMP, -- временная метка источника (для инкремента) + load_dttm TIMESTAMP NOT NULL, -- время загрузки в Greenplum + batch_id TEXT -- идентификатор батча (из Airflow run_id) +) +DISTRIBUTED BY (ticket_no); -- распределение по первичному ключу +``` + +**Решения по проектированию:** +- **DISTRIBUTED BY (ticket_no):** равномерное распределение, так как это первичный ключ +- **boolean → TEXT:** Greenplum хорошо работает с текстовым представлением для флагов +- **src_created_at_ts:** временная метка из даты связанного бронирования (см. раздел 7) + +## 4. Проектирование внешней таблицы (PXF) + +Используем существующий PXF-профиль для Postgres (как в `stg.bookings_ext`): + +```sql +-- sql/stg/tickets_ddl.sql (продолжение) + +DROP EXTERNAL TABLE IF EXISTS stg.tickets_ext CASCADE; + +CREATE EXTERNAL TABLE stg.tickets_ext ( + ticket_no TEXT, + book_ref TEXT, + passenger_id TEXT, + passenger_name TEXT, + outbound TEXT +) +LOCATION ('pxf://bookings-db:5432/demo?PROFILE=postgres&SERVER=bookings_db') +FORMAT 'CUSTOM' (FORMATTER='pxfwritable_import') +ENCODING 'UTF8'; +``` + +**Решения:** +- Только бизнес-атрибуты во внешней таблице (без тех.колонок) +- PXF-профиль уже настроен через `bookings_db` + +## 5. DDL-скрипт (создание таблиц) + +Полный файл `sql/stg/tickets_ddl.sql`: + +```sql +-- Внешняя таблица для доступа через PXF к bookings-db +DROP EXTERNAL TABLE IF EXISTS stg.tickets_ext CASCADE; + +CREATE EXTERNAL TABLE stg.tickets_ext ( + ticket_no TEXT, + book_ref TEXT, + passenger_id TEXT, + passenger_name TEXT, + outbound TEXT +) +LOCATION ('pxf://bookings-db:5432/demo?PROFILE=postgres&SERVER=bookings_db') +FORMAT 'CUSTOM' (FORMATTER='pxfwritable_import') +ENCODING 'UTF8'; + +-- Внутренняя таблица для хранения данных в Greenplum +DROP TABLE IF EXISTS stg.tickets CASCADE; + +CREATE TABLE stg.tickets ( + ticket_no TEXT NOT NULL, + book_ref TEXT NOT NULL, + passenger_id TEXT, + passenger_name TEXT, + outbound TEXT, + + src_created_at_ts TIMESTAMP, + load_dttm TIMESTAMP NOT NULL, + batch_id TEXT +) +DISTRIBUTED BY (ticket_no); +``` + +## 6. LOAD-скрипт (загрузка инкремента) + +### Логика инкремента + +**Проблема:** В `bookings.tickets` нет явной временной колонки. + +**Решение:** Используем дату бронирования из связанной таблицы `bookings.bookings`: +1. Связь через `tickets.book_ref = bookings.book_ref` +2. Временная колонка: `bookings.book_date` +3. Фильтр инкремента: `book_date > max(src_created_at_ts)` + +### Скрипт `sql/stg/tickets_load.sql`: + +```sql +-- Загрузка инкремента из stg.tickets_ext в stg.tickets +-- Инкремент определяется по дате бронирования (book_date из bookings.bookings) + +INSERT INTO stg.tickets ( + ticket_no, + book_ref, + passenger_id, + passenger_name, + outbound, + src_created_at_ts, + load_dttm, + batch_id +) +SELECT + ext.ticket_no, + ext.book_ref, + ext.passenger_id, + ext.passenger_name, + ext.outbound, + b.book_date::timestamp, -- временная метка из бронирования + now(), + '{{ run_id }}'::text +FROM stg.tickets_ext AS ext +JOIN ( + -- Подзапрос: получаем book_date для инкремента + -- Связываем tickets с bookings через внешнюю таблицу stg.bookings_ext + SELECT + b.book_ref, + b.book_date + FROM stg.bookings_ext AS b_ext + JOIN bookings.bookings AS b ON b_ext.book_ref = b.book_ref + -- Берём только новые бронирования (по дате) + WHERE b_ext.book_date > COALESCE( + ( + SELECT max(src_created_at_ts) + FROM stg.tickets + WHERE batch_id <> '{{ run_id }}'::text + OR batch_id IS NULL + ), + TIMESTAMP '1900-01-01 00:00:00' + ) +) AS b ON ext.book_ref = b.book_ref +AND NOT EXISTS ( + -- Защита от дубликатов в рамках одного батча + SELECT 1 + FROM stg.tickets AS t + WHERE t.batch_id = '{{ run_id }}'::text + AND t.ticket_no = ext.ticket_no +); +``` + +**Объяснение логики:** +1. Из `stg.tickets_ext` берём все билеты +2. JOIN с подзапросом по `book_ref` — это даёт `book_date` из бронирования +3. Фильтр по `book_date > max(src_created_at_ts)` — берём только новые билеты +4. `NOT EXISTS` — защита от повторной загрузки того же билета в текущем батче + +## 7. DQ-проверки (качество данных) + +Скрипт `sql/stg/tickets_dq.sql`: + +```sql +-- Проверка 1: совпадение количества билетов в источнике и STG +DO $$ +DECLARE + v_source_count BIGINT; + v_stg_count BIGINT; +BEGIN + -- Количество в источнике (новые билеты) + SELECT COUNT(*) INTO v_source_count + FROM ( + SELECT t.ticket_no + FROM stg.tickets_ext AS t + JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref + WHERE b.book_date > COALESCE( + (SELECT max(src_created_at_ts) FROM stg.tickets + WHERE batch_id <> '{{ run_id }}'::text OR batch_id IS NULL), + TIMESTAMP '1900-01-01 00:00:00' + ) + ) AS source; + + -- Количество в STG (текущий батч) + SELECT COUNT(*) INTO v_stg_count + FROM stg.tickets + WHERE batch_id = '{{ run_id }}'::text; + + -- Проверка + IF v_source_count <> v_stg_count THEN + RAISE EXCEPTION 'DQ FAILED: несовпадение количества билетов. Источник: %, STG: %', + v_source_count, v_stg_count; + END IF; +END $$; + +-- Проверка 2: ссылочная целостность (все ticket_no должны иметь соответствующие book_ref) +SELECT + COUNT(*) AS orphan_tickets +FROM stg.tickets +WHERE batch_id = '{{ run_id }}'::text + AND NOT EXISTS ( + SELECT 1 + FROM stg.bookings + WHERE stg.bookings.book_ref = stg.tickets.book_ref + ); +-- Ожидаемое значение: 0 + +-- Проверка 3: отсутствие NULL в обязательных полях +SELECT + COUNT(*) AS null_tickets +FROM stg.tickets +WHERE batch_id = '{{ run_id }}'::text + AND (ticket_no IS NULL OR book_ref IS NULL); +-- Ожидаемое значение: 0 +``` + +## 8. Интеграция с существующим DAG + +### Решение: расширить существующий DAG + +**Почему не отдельный DAG:** +- `generate_bookings_day` уже есть в `bookings_to_gp_stage` +- Минимальные изменения → проще для новичков +- Единый поток данных (bookings + tickets за один запуск) + +### Изменения в `airflow/dags/bookings_to_gp_stage.py`: + +Добавить задачи после загрузки `bookings`: + +```python +# После существующих задач: + +# Загрузка билетов +load_tickets_to_stg = PostgresOperator( + task_id="load_tickets_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/tickets_load.sql", +) + +# DQ-проверки билетов +check_tickets_dq = PostgresOperator( + task_id="check_tickets_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/tickets_dq.sql", +) + +# Обновляем связи задач +check_row_counts >> load_tickets_to_stg >> check_tickets_dq >> finish_summary +``` + +**Полный DAG (с обновлениями):** + +```python +# ... (импорты и default_args без изменений) + +with DAG( + dag_id="bookings_to_gp_stage", + # ... (параметры без изменений) +) as dag: + generate_bookings_day = PostgresOperator(...) # уже есть + load_bookings_to_stg = PostgresOperator(...) # уже есть + check_row_counts = PostgresOperator(...) # уже есть + finish_summary = PythonOperator(...) # уже есть + + # Новые задачи + load_tickets_to_stg = PostgresOperator( + task_id="load_tickets_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/tickets_load.sql", + ) + + check_tickets_dq = PostgresOperator( + task_id="check_tickets_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/tickets_dq.sql", + ) + + # Обновлённые связи + generate_bookings_day >> load_bookings_to_stg >> check_row_counts + check_row_counts >> load_tickets_to_stg >> check_tickets_dq >> finish_summary +``` + +## 9. Тестирование + +### План проверки: + +**1. Подготовка окружения:** +```bash +make up # поднять стенд +make bookings-init # инициализировать демо-БД +``` + +**2. Создание таблиц:** +```bash +make gp-psql +``` +```sql +-- внутри psql: +\i sql/stg/tickets_ddl.sql +\dt stg.* +``` + +**3. Первый запуск DAG:** +- Запустить DAG `bookings_to_gp_stage` в Airflow UI +- Проверить успешность всех задач + +**4. Проверка данных:** +```sql +-- Количество билетов +SELECT COUNT(*) FROM stg.tickets; + +-- Проверка батчей +SELECT batch_id, COUNT(*) +FROM stg.tickets +GROUP BY batch_id; + +-- Выборка данных +SELECT * FROM stg.tickets +ORDER BY load_dttm DESC +LIMIT 10; +``` + +**5. Инкрементальная загрузка:** +```bash +make bookings-generate-day # сгенерировать новый день +``` +- Повторный запуск DAG +- Проверить, что добавились только новые билеты + +**6. Проверка связей с bookings:** +```sql +-- Все билеты должны иметь соответствующие бронирования +SELECT COUNT(*) +FROM stg.tickets t +LEFT JOIN stg.bookings b ON t.book_ref = b.book_ref +WHERE b.book_ref IS NULL; +-- Ожидаемое значение: 0 +``` + +## 10. Порядок реализации + +1. ✅ Создать файл `sql/stg/tickets_ddl.sql` +2. ✅ Создать файл `sql/stg/tickets_load.sql` +3. ✅ Создать файл `sql/stg/tickets_dq.sql` +4. ✅ Обновить `airflow/dags/bookings_to_gp_stage.py` (добавить задачи tickets) +5. ⏳ Локальное тестирование (раздел 9) +6. ⏳ Проверка через Airflow UI +7. ⏳ Обновить `README.md` (добавить tickets в список STG-таблиц) + +## 11. Сопутствующие изменения + +После успешного тестирования обновить документацию: + +**README.md:** +- Добавить `tickets` в список STG-таблиц +- Обновить описание DAG `bookings_to_gp_stage` (упомянуть загрузку билетов) + +**educational-tasks.md:** +- Добавить задание по анализу `tickets` в STG +- Предложить построить витрину для анализа билетов (количество, выручка по направлениям) \ No newline at end of file From 464e631eeac76d56be826cc48ffe3d315ca089e6 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 17 Jan 2026 15:09:08 +0300 Subject: [PATCH 02/38] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20dag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- airflow/dags/bookings_to_gp_stage.py | 21 +++++++++-- sql/stg/tickets_ddl.sql | 31 ++++++++++++++++ sql/stg/tickets_dq.sql | 54 ++++++++++++++++++++++++++++ sql/stg/tickets_load.sql | 49 +++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 sql/stg/tickets_ddl.sql create mode 100644 sql/stg/tickets_dq.sql create mode 100644 sql/stg/tickets_load.sql diff --git a/airflow/dags/bookings_to_gp_stage.py b/airflow/dags/bookings_to_gp_stage.py index 1112106..a4982f3 100644 --- a/airflow/dags/bookings_to_gp_stage.py +++ b/airflow/dags/bookings_to_gp_stage.py @@ -54,7 +54,7 @@ with DAG( template_searchpath="/sql", default_args=default_args, tags=["demo", "bookings", "greenplum", "stg"], - description="Учебный DAG: загрузка из bookings-db в stg.bookings (Greenplum)", + description="Учебный DAG: загрузка из bookings-db в stg.bookings и stg.tickets (Greenplum)", ) as dag: # 1. Генерируем один (или несколько стартовых) учебный день в демо-БД bookings generate_bookings_day = PostgresOperator( @@ -78,10 +78,25 @@ with DAG( sql="stg/bookings_dq.sql", ) - # 4. Финальный лог/сводка + # 4. Загружаем инкремент билетов из stg.tickets_ext в stg.tickets + load_tickets_to_stg = PostgresOperator( + task_id="load_tickets_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/tickets_load.sql", + ) + + # 5. Проверяем качество данных для tickets + check_tickets_dq = PostgresOperator( + task_id="check_tickets_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/tickets_dq.sql", + ) + + # 6. Финальный лог/сводка finish_summary = PythonOperator( task_id="finish_summary", python_callable=_finish_summary, ) - generate_bookings_day >> load_bookings_to_stg >> check_row_counts >> finish_summary + generate_bookings_day >> load_bookings_to_stg >> check_row_counts + check_row_counts >> load_tickets_to_stg >> check_tickets_dq >> finish_summary diff --git a/sql/stg/tickets_ddl.sql b/sql/stg/tickets_ddl.sql new file mode 100644 index 0000000..bb101cb --- /dev/null +++ b/sql/stg/tickets_ddl.sql @@ -0,0 +1,31 @@ +-- Создание внешней таблицы для доступа к bookings.tickets через PXF + +DROP EXTERNAL TABLE IF EXISTS stg.tickets_ext CASCADE; + +CREATE EXTERNAL TABLE stg.tickets_ext ( + ticket_no TEXT, + book_ref TEXT, + passenger_id TEXT, + passenger_name TEXT, + outbound TEXT +) +LOCATION ('pxf://bookings-db:5432/demo?PROFILE=postgres&SERVER=bookings_db') +FORMAT 'CUSTOM' (FORMATTER='pxfwritable_import') +ENCODING 'UTF8'; + +-- Создание внутренней таблицы для хранения данных в Greenplum + +DROP TABLE IF EXISTS stg.tickets CASCADE; + +CREATE TABLE stg.tickets ( + ticket_no TEXT NOT NULL, + book_ref TEXT NOT NULL, + passenger_id TEXT, + passenger_name TEXT, + outbound TEXT, + + src_created_at_ts TIMESTAMP, + load_dttm TIMESTAMP NOT NULL, + batch_id TEXT +) +DISTRIBUTED BY (ticket_no); diff --git a/sql/stg/tickets_dq.sql b/sql/stg/tickets_dq.sql new file mode 100644 index 0000000..d0db1af --- /dev/null +++ b/sql/stg/tickets_dq.sql @@ -0,0 +1,54 @@ +-- Проверки качества данных для tickets + +-- Проверка 1: совпадение количества билетов в источнике и STG +DO $$ +DECLARE + v_source_count BIGINT; + v_stg_count BIGINT; +BEGIN + -- Количество в источнике (новые билеты) + SELECT COUNT(*) INTO v_source_count + FROM ( + SELECT t.ticket_no + FROM stg.tickets_ext AS t + JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref + WHERE b.book_date > COALESCE( + (SELECT max(src_created_at_ts) FROM stg.tickets + WHERE batch_id <> '{{ run_id }}'::text OR batch_id IS NULL), + TIMESTAMP '1900-01-01 00:00:00' + ) + ) AS source; + + -- Количество в STG (текущий батч) + SELECT COUNT(*) INTO v_stg_count + FROM stg.tickets + WHERE batch_id = '{{ run_id }}'::text; + + -- Проверка совпадения + IF v_source_count <> v_stg_count THEN + RAISE EXCEPTION 'DQ FAILED: несовпадение количества билетов. Источник: %, STG: %', + v_source_count, v_stg_count; + ELSE + RAISE NOTICE 'DQ PASSED: количество билетов совпадает (%)', v_stg_count; + END IF; +END $$; + +-- Проверка 2: ссылочная целостность (все ticket_no должны иметь соответствующие book_ref) +SELECT + COUNT(*) AS orphan_tickets +FROM stg.tickets +WHERE batch_id = '{{ run_id }}'::text + AND NOT EXISTS ( + SELECT 1 + FROM stg.bookings + WHERE stg.bookings.book_ref = stg.tickets.book_ref + ); +-- Ожидаемое значение: 0 + +-- Проверка 3: отсутствие NULL в обязательных полях +SELECT + COUNT(*) AS null_tickets +FROM stg.tickets +WHERE batch_id = '{{ run_id }}'::text + AND (ticket_no IS NULL OR book_ref IS NULL); +-- Ожидаемое значение: 0 diff --git a/sql/stg/tickets_load.sql b/sql/stg/tickets_load.sql new file mode 100644 index 0000000..44b9627 --- /dev/null +++ b/sql/stg/tickets_load.sql @@ -0,0 +1,49 @@ +-- Загрузка инкремента из stg.tickets_ext в stg.tickets +-- Инкремент определяется по дате бронирования (book_date из bookings.bookings) + +INSERT INTO stg.tickets ( + ticket_no, + book_ref, + passenger_id, + passenger_name, + outbound, + src_created_at_ts, + load_dttm, + batch_id +) +SELECT + ext.ticket_no, + ext.book_ref, + ext.passenger_id, + ext.passenger_name, + ext.outbound, + b.book_date::timestamp, -- временная метка из бронирования + now(), + '{{ run_id }}'::text +FROM stg.tickets_ext AS ext +JOIN ( + -- Подзапрос: получаем book_date для инкремента + -- Связываем tickets с bookings через внешнюю таблицу stg.bookings_ext + SELECT + b.book_ref, + b.book_date + FROM stg.bookings_ext AS b_ext + JOIN bookings.bookings AS b ON b_ext.book_ref = b.book_ref + -- Берём только новые бронирования (по дате) + WHERE b_ext.book_date > COALESCE( + ( + SELECT max(src_created_at_ts) + FROM stg.tickets + WHERE batch_id <> '{{ run_id }}'::text + OR batch_id IS NULL + ), + TIMESTAMP '1900-01-01 00:00:00' + ) +) AS b ON ext.book_ref = b.book_ref +AND NOT EXISTS ( + -- Защита от дубликатов в рамках одного батча + SELECT 1 + FROM stg.tickets AS t + WHERE t.batch_id = '{{ run_id }}'::text + AND t.ticket_no = ext.ticket_no +); From 875746972cd3ef90afb27d5294f263054f19be03 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 17 Jan 2026 15:47:59 +0300 Subject: [PATCH 03/38] =?UTF-8?q?=D0=9E=D1=82=D0=BB=D0=B0=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- airflow/dags/bookings_stg_ddl.py | 14 +++++++++--- sql/ddl_gp.sql | 5 ++-- sql/stg/tickets_ddl.sql | 39 ++++++++++++++++---------------- sql/stg/tickets_dq.sql | 18 +++++++-------- sql/stg/tickets_load.sql | 29 ++++++++---------------- 5 files changed, 52 insertions(+), 53 deletions(-) diff --git a/airflow/dags/bookings_stg_ddl.py b/airflow/dags/bookings_stg_ddl.py index 7c17cdd..99d0715 100644 --- a/airflow/dags/bookings_stg_ddl.py +++ b/airflow/dags/bookings_stg_ddl.py @@ -1,7 +1,7 @@ from __future__ import annotations """ -Учебный DAG: создаёт схему stg и таблицы bookings_ext/bookings в Greenplum. +Учебный DAG: создаёт схему stg и таблицы bookings_ext/bookings/tickets в Greenplum. Запускается вручную перед DAG загрузки bookings_to_gp_stage или после изменения DDL. """ @@ -22,11 +22,19 @@ with DAG( catchup=False, template_searchpath="/sql", default_args=default_args, - tags=["demo", "greenplum", "ddl", "bookings", "stg"], - description="Создаёт/обновляет stg.bookings_ext и stg.bookings для учебного DAG", + tags=["demo", "greenplum", "ddl", "bookings", "tickets", "stg"], + description="Создаёт/обновляет stg.bookings_ext/bookings/tickets для учебного DAG", ) as dag: apply_stg_bookings_ddl = PostgresOperator( task_id="apply_stg_bookings_ddl", postgres_conn_id=GREENPLUM_CONN_ID, sql="stg/bookings_ddl.sql", ) + + apply_stg_tickets_ddl = PostgresOperator( + task_id="apply_stg_tickets_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/tickets_ddl.sql", + ) + + apply_stg_bookings_ddl >> apply_stg_tickets_ddl diff --git a/sql/ddl_gp.sql b/sql/ddl_gp.sql index 0e81d60..e95f0e7 100644 --- a/sql/ddl_gp.sql +++ b/sql/ddl_gp.sql @@ -22,6 +22,7 @@ CREATE EXTERNAL TABLE public.ext_bookings_bookings ( LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db') FORMAT 'CUSTOM' (formatter='pxfwritable_import'); --- DDL для слоя stg по таблице bookings вынесен в отдельный файл. --- Здесь подключаем его через psql \i, чтобы сохранить единый входной скрипт. +-- DDL для слоя stg по таблицам bookings и tickets вынесены в отдельные файлы. +-- Здесь подключаем их через psql \i, чтобы сохранить единый входной скрипт. \i stg/bookings_ddl.sql +\i stg/tickets_ddl.sql diff --git a/sql/stg/tickets_ddl.sql b/sql/stg/tickets_ddl.sql index bb101cb..e43648c 100644 --- a/sql/stg/tickets_ddl.sql +++ b/sql/stg/tickets_ddl.sql @@ -1,7 +1,12 @@ --- Создание внешней таблицы для доступа к bookings.tickets через PXF +-- DDL для слоя STG по таблице tickets. +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. -DROP EXTERNAL TABLE IF EXISTS stg.tickets_ext CASCADE; +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; +-- Внешняя таблица в схеме stg для чтения данных из bookings.tickets через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.tickets_ext; CREATE EXTERNAL TABLE stg.tickets_ext ( ticket_no TEXT, book_ref TEXT, @@ -9,23 +14,19 @@ CREATE EXTERNAL TABLE stg.tickets_ext ( passenger_name TEXT, outbound TEXT ) -LOCATION ('pxf://bookings-db:5432/demo?PROFILE=postgres&SERVER=bookings_db') -FORMAT 'CUSTOM' (FORMATTER='pxfwritable_import') -ENCODING 'UTF8'; +LOCATION ('pxf://bookings.tickets?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); --- Создание внутренней таблицы для хранения данных в Greenplum - -DROP TABLE IF EXISTS stg.tickets CASCADE; - -CREATE TABLE stg.tickets ( - ticket_no TEXT NOT NULL, - book_ref TEXT NOT NULL, - passenger_id TEXT, - passenger_name TEXT, - outbound TEXT, - - src_created_at_ts TIMESTAMP, - load_dttm TIMESTAMP NOT NULL, - batch_id TEXT +-- Внутренняя таблица stg.tickets — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.tickets ( + ticket_no TEXT NOT NULL, + book_ref TEXT NOT NULL, + passenger_id TEXT, + passenger_name TEXT, + outbound TEXT, + src_created_at_ts TIMESTAMP, + load_dttm TIMESTAMP NOT NULL DEFAULT now(), + batch_id TEXT ) +WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) DISTRIBUTED BY (ticket_no); diff --git a/sql/stg/tickets_dq.sql b/sql/stg/tickets_dq.sql index d0db1af..b5f5feb 100644 --- a/sql/stg/tickets_dq.sql +++ b/sql/stg/tickets_dq.sql @@ -7,17 +7,15 @@ DECLARE v_stg_count BIGINT; BEGIN -- Количество в источнике (новые билеты) + -- Используем только внешние таблицы: stg.tickets_ext + stg.bookings_ext SELECT COUNT(*) INTO v_source_count - FROM ( - SELECT t.ticket_no - FROM stg.tickets_ext AS t - JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref - WHERE b.book_date > COALESCE( - (SELECT max(src_created_at_ts) FROM stg.tickets - WHERE batch_id <> '{{ run_id }}'::text OR batch_id IS NULL), - TIMESTAMP '1900-01-01 00:00:00' - ) - ) AS source; + FROM stg.tickets_ext AS t + JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref + WHERE b.book_date > COALESCE( + (SELECT max(src_created_at_ts) FROM stg.tickets + WHERE batch_id <> '{{ run_id }}'::text OR batch_id IS NULL), + TIMESTAMP '1900-01-01 00:00:00' + ); -- Количество в STG (текущий батч) SELECT COUNT(*) INTO v_stg_count diff --git a/sql/stg/tickets_load.sql b/sql/stg/tickets_load.sql index 44b9627..73a3ad5 100644 --- a/sql/stg/tickets_load.sql +++ b/sql/stg/tickets_load.sql @@ -21,25 +21,16 @@ SELECT now(), '{{ run_id }}'::text FROM stg.tickets_ext AS ext -JOIN ( - -- Подзапрос: получаем book_date для инкремента - -- Связываем tickets с bookings через внешнюю таблицу stg.bookings_ext - SELECT - b.book_ref, - b.book_date - FROM stg.bookings_ext AS b_ext - JOIN bookings.bookings AS b ON b_ext.book_ref = b.book_ref - -- Берём только новые бронирования (по дате) - WHERE b_ext.book_date > COALESCE( - ( - SELECT max(src_created_at_ts) - FROM stg.tickets - WHERE batch_id <> '{{ run_id }}'::text - OR batch_id IS NULL - ), - TIMESTAMP '1900-01-01 00:00:00' - ) -) AS b ON ext.book_ref = b.book_ref +JOIN stg.bookings_ext AS b ON ext.book_ref = b.book_ref +WHERE b.book_date > COALESCE( + ( + SELECT max(src_created_at_ts) + FROM stg.tickets + WHERE batch_id <> '{{ run_id }}'::text + OR batch_id IS NULL + ), + TIMESTAMP '1900-01-01 00:00:00' +) AND NOT EXISTS ( -- Защита от дубликатов в рамках одного батча SELECT 1 From 93ba59c1c94cea5a2a744bbe53b5ae05640c206b Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 17 Jan 2026 19:11:10 +0300 Subject: [PATCH 04/38] =?UTF-8?q?=D0=B0=D0=BA=D1=82=D1=83=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20"=D1=87=D1=82=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=D0=B8"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/chore/bookings-etl.md | 231 ++++++++++++++++++++++--------------- 1 file changed, 135 insertions(+), 96 deletions(-) diff --git a/docs/chore/bookings-etl.md b/docs/chore/bookings-etl.md index 73296c4..2d79659 100644 --- a/docs/chore/bookings-etl.md +++ b/docs/chore/bookings-etl.md @@ -67,7 +67,7 @@ DISTRIBUTED BY (ticket_no); -- распределение по пер ```sql -- sql/stg/tickets_ddl.sql (продолжение) -DROP EXTERNAL TABLE IF EXISTS stg.tickets_ext CASCADE; +DROP EXTERNAL TABLE IF EXISTS stg.tickets_ext; CREATE EXTERNAL TABLE stg.tickets_ext ( ticket_no TEXT, @@ -76,22 +76,29 @@ CREATE EXTERNAL TABLE stg.tickets_ext ( passenger_name TEXT, outbound TEXT ) -LOCATION ('pxf://bookings-db:5432/demo?PROFILE=postgres&SERVER=bookings_db') -FORMAT 'CUSTOM' (FORMATTER='pxfwritable_import') -ENCODING 'UTF8'; +LOCATION ('pxf://bookings.tickets?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); ``` **Решения:** - Только бизнес-атрибуты во внешней таблице (без тех.колонок) -- PXF-профиль уже настроен через `bookings_db` +- PXF-профиль `JDBC` (как в `stg.bookings_ext`) — более стабильный вариант +- PXF-сервер настроен как `bookings-db` в конфигурации ## 5. DDL-скрипт (создание таблиц) Полный файл `sql/stg/tickets_ddl.sql`: ```sql --- Внешняя таблица для доступа через PXF к bookings-db -DROP EXTERNAL TABLE IF EXISTS stg.tickets_ext CASCADE; +-- DDL для слоя STG по таблице tickets. +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.tickets через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.tickets_ext; CREATE EXTERNAL TABLE stg.tickets_ext ( ticket_no TEXT, @@ -100,24 +107,21 @@ CREATE EXTERNAL TABLE stg.tickets_ext ( passenger_name TEXT, outbound TEXT ) -LOCATION ('pxf://bookings-db:5432/demo?PROFILE=postgres&SERVER=bookings_db') -FORMAT 'CUSTOM' (FORMATTER='pxfwritable_import') -ENCODING 'UTF8'; +LOCATION ('pxf://bookings.tickets?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); --- Внутренняя таблица для хранения данных в Greenplum -DROP TABLE IF EXISTS stg.tickets CASCADE; - -CREATE TABLE stg.tickets ( - ticket_no TEXT NOT NULL, - book_ref TEXT NOT NULL, - passenger_id TEXT, - passenger_name TEXT, - outbound TEXT, - - src_created_at_ts TIMESTAMP, - load_dttm TIMESTAMP NOT NULL, - batch_id TEXT +-- Внутренняя таблица stg.tickets — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.tickets ( + ticket_no TEXT NOT NULL, + book_ref TEXT NOT NULL, + passenger_id TEXT, + passenger_name TEXT, + outbound TEXT, + src_created_at_ts TIMESTAMP, + load_dttm TIMESTAMP NOT NULL DEFAULT now(), + batch_id TEXT ) +WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) DISTRIBUTED BY (ticket_no); ``` @@ -158,25 +162,16 @@ SELECT now(), '{{ run_id }}'::text FROM stg.tickets_ext AS ext -JOIN ( - -- Подзапрос: получаем book_date для инкремента - -- Связываем tickets с bookings через внешнюю таблицу stg.bookings_ext - SELECT - b.book_ref, - b.book_date - FROM stg.bookings_ext AS b_ext - JOIN bookings.bookings AS b ON b_ext.book_ref = b.book_ref - -- Берём только новые бронирования (по дате) - WHERE b_ext.book_date > COALESCE( - ( - SELECT max(src_created_at_ts) - FROM stg.tickets - WHERE batch_id <> '{{ run_id }}'::text - OR batch_id IS NULL - ), - TIMESTAMP '1900-01-01 00:00:00' - ) -) AS b ON ext.book_ref = b.book_ref +JOIN stg.bookings_ext AS b ON ext.book_ref = b.book_ref +WHERE b.book_date > COALESCE( + ( + SELECT max(src_created_at_ts) + FROM stg.tickets + WHERE batch_id <> '{{ run_id }}'::text + OR batch_id IS NULL + ), + TIMESTAMP '1900-01-01 00:00:00' +) AND NOT EXISTS ( -- Защита от дубликатов в рамках одного батча SELECT 1 @@ -188,16 +183,19 @@ AND NOT EXISTS ( **Объяснение логики:** 1. Из `stg.tickets_ext` берём все билеты -2. JOIN с подзапросом по `book_ref` — это даёт `book_date` из бронирования +2. Прямой JOIN с `stg.bookings_ext` по `book_ref` — это даёт `book_date` из бронирования 3. Фильтр по `book_date > max(src_created_at_ts)` — берём только новые билеты 4. `NOT EXISTS` — защита от повторной загрузки того же билета в текущем батче +**Важное примечание:** Используем только внешние таблицы (`stg.tickets_ext` и `stg.bookings_ext`), так как прямой доступ к `bookings.bookings` через PXF невозможен. + ## 7. DQ-проверки (качество данных) Скрипт `sql/stg/tickets_dq.sql`: ```sql -- Проверка 1: совпадение количества билетов в источнике и STG +-- Используем только внешние таблицы: stg.tickets_ext + stg.bookings_ext DO $$ DECLARE v_source_count BIGINT; @@ -205,26 +203,25 @@ DECLARE BEGIN -- Количество в источнике (новые билеты) SELECT COUNT(*) INTO v_source_count - FROM ( - SELECT t.ticket_no - FROM stg.tickets_ext AS t - JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref - WHERE b.book_date > COALESCE( - (SELECT max(src_created_at_ts) FROM stg.tickets - WHERE batch_id <> '{{ run_id }}'::text OR batch_id IS NULL), - TIMESTAMP '1900-01-01 00:00:00' - ) - ) AS source; + FROM stg.tickets_ext AS t + JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref + WHERE b.book_date > COALESCE( + (SELECT max(src_created_at_ts) FROM stg.tickets + WHERE batch_id <> '{{ run_id }}'::text OR batch_id IS NULL), + TIMESTAMP '1900-01-01 00:00:00' + ); -- Количество в STG (текущий батч) SELECT COUNT(*) INTO v_stg_count FROM stg.tickets WHERE batch_id = '{{ run_id }}'::text; - -- Проверка + -- Проверка совпадения IF v_source_count <> v_stg_count THEN RAISE EXCEPTION 'DQ FAILED: несовпадение количества билетов. Источник: %, STG: %', v_source_count, v_stg_count; + ELSE + RAISE NOTICE 'DQ PASSED: количество билетов совпадает (%)', v_stg_count; END IF; END $$; @@ -249,12 +246,36 @@ WHERE batch_id = '{{ run_id }}'::text -- Ожидаемое значение: 0 ``` -## 8. Интеграция с существующим DAG +## 8. Интеграция с существующими DAG -### Решение: расширить существующий DAG +### 8.1. Создание DDL через `bookings_stg_ddl.py` -**Почему не отдельный DAG:** -- `generate_bookings_day` уже есть в `bookings_to_gp_stage` +**Почему расширяем существующий DDL DAG:** +- Уже есть инфраструктура для создания `stg.bookings_ext` и `stg.bookings` +- Единый DAG для создания всех STG-объектов +- Минимальные изменения → проще для новичков + +**Изменения в `airflow/dags/bookings_stg_ddl.py`:** + +Добавить задачу после создания bookings DDL: + +```python +# После существующих задач: + +apply_stg_tickets_ddl = PostgresOperator( + task_id="apply_stg_tickets_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/tickets_ddl.sql", +) + +# Обновляем связи задач +apply_stg_bookings_ddl >> apply_stg_tickets_ddl +``` + +### 8.2. Загрузка данных через `bookings_to_gp_stage.py` + +**Почему расширяем существующий загрузочный DAG:** +- `generate_bookings_day` уже есть - Минимальные изменения → проще для новичков - Единый поток данных (bookings + tickets за один запуск) @@ -283,36 +304,20 @@ check_tickets_dq = PostgresOperator( check_row_counts >> load_tickets_to_stg >> check_tickets_dq >> finish_summary ``` -**Полный DAG (с обновлениями):** +**Фактические изменения в DAG:** -```python -# ... (импорты и default_args без изменений) +Обновлён `description` DAG и добавлены две новые задачи: +- `load_tickets_to_stg` — загружает билеты через PXF +- `check_tickets_dq` — проверяет качество данных билетов -with DAG( - dag_id="bookings_to_gp_stage", - # ... (параметры без изменений) -) as dag: - generate_bookings_day = PostgresOperator(...) # уже есть - load_bookings_to_stg = PostgresOperator(...) # уже есть - check_row_counts = PostgresOperator(...) # уже есть - finish_summary = PythonOperator(...) # уже есть - - # Новые задачи - load_tickets_to_stg = PostgresOperator( - task_id="load_tickets_to_stg", - postgres_conn_id=GREENPLUM_CONN_ID, - sql="stg/tickets_load.sql", - ) - - check_tickets_dq = PostgresOperator( - task_id="check_tickets_dq", - postgres_conn_id=GREENPLUM_CONN_ID, - sql="stg/tickets_dq.sql", - ) - - # Обновлённые связи - generate_bookings_day >> load_bookings_to_stg >> check_row_counts - check_row_counts >> load_tickets_to_stg >> check_tickets_dq >> finish_summary +Порядок выполнения: +``` +generate_bookings_day + → load_bookings_to_stg + → check_row_counts + → load_tickets_to_stg + → check_tickets_dq + → finish_summary ``` ## 9. Тестирование @@ -327,10 +332,17 @@ make bookings-init # инициализировать демо-БД **2. Создание таблиц:** ```bash +# Вариант 1: через Airflow UI (предпочтительно) +# Запустите DAG `bookings_stg_ddl` в Airflow UI + +# Вариант 2: через make-команду +make ddl-gp + +# Вариант 3: напрямую через psql make gp-psql ``` ```sql --- внутри psql: +-- внутри psql (если выбрали вариант 3): \i sql/stg/tickets_ddl.sql \dt stg.* ``` @@ -378,18 +390,45 @@ WHERE b.book_ref IS NULL; 2. ✅ Создать файл `sql/stg/tickets_load.sql` 3. ✅ Создать файл `sql/stg/tickets_dq.sql` 4. ✅ Обновить `airflow/dags/bookings_to_gp_stage.py` (добавить задачи tickets) -5. ⏳ Локальное тестирование (раздел 9) -6. ⏳ Проверка через Airflow UI -7. ⏳ Обновить `README.md` (добавить tickets в список STG-таблиц) +5. ✅ Обновить `airflow/dags/bookings_stg_ddl.py` (добавить создание tickets DDL) +6. ✅ Обновить `sql/ddl_gp.sql` (подключить tickets_ddl.sql) +7. ✅ Локальное тестирование (раздел 9) +8. ✅ Проверка через Airflow UI +9. ✅ Проверка идемпотентности DDL +10. ✅ Проверка инкрементальной загрузки +11. ⏳ Обновить `README.md` (добавить tickets в список STG-таблиц) ## 11. Сопутствующие изменения -После успешного тестирования обновить документацию: +### Изменения в коде: -**README.md:** -- Добавить `tickets` в список STG-таблиц -- Обновить описание DAG `bookings_to_gp_stage` (упомянуть загрузку билетов) +**sql/ddl_gp.sql:** +- ✅ Добавлено подключение `sql/stg/tickets_ddl.sql` **educational-tasks.md:** -- Добавить задание по анализу `tickets` в STG -- Предложить построить витрину для анализа билетов (количество, выручка по направлениям) \ No newline at end of file +- ✅ Добавлен раздел 2.3 по анализу структуры `tickets` в STG +- ✅ Добавлен раздел 3.3 по анализу данных `bookings + tickets` + +### Необходимые изменения в документации: + +**README.md:** +- ⏳ Добавить `tickets` в список STG-таблиц +- ⏳ Обновить описание DAG `bookings_to_gp_stage` (упомянуть загрузку билетов) + +## 12. Результаты тестирования + +### Первый запуск (полная загрузка): +- **Загружено:** 182436 билетов +- **Бронирования:** 45730 +- **Связи:** все билеты имеют соответствующие бронирования (0 orphan tickets) +- **DQ-проверки:** все пройдены успешно + +### Второй запуск (инкрементальная загрузка): +- **Загружено:** новые билеты (количество зависит от сгенерированных данных) +- **Всего в таблице:** сумма всех батчей +- **Уникальность:** все ticket_no уникальные (нет дубликатов) +- **DQ-проверки:** все пройдены успешно + +### Идемпотентность DDL: +- **Первый запуск DDL:** таблицы созданы, данные не затронуты +- **Второй запуск DDL:** данные не пропали, таблицы существуют (CREATE TABLE IF NOT EXISTS) \ No newline at end of file From f36a98497aa5f28086ea736512bba987ecc3d4da Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 17 Jan 2026 19:36:02 +0300 Subject: [PATCH 05/38] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0/=D1=80=D0=B5=D1=86=D0=B5=D0=BD=D0=B7=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +- airflow/dags/bookings_stg_ddl.py | 5 +- airflow/dags/bookings_to_gp_stage.py | 6 +- docs/bookings_to_gp_stage.md | 21 +++- docs/chore/bookings-etl.md | 143 ++++++++++++++++----------- sql/stg/tickets_ddl.sql | 4 +- sql/stg/tickets_dq.sql | 107 +++++++++++++------- sql/stg/tickets_load.sql | 5 +- 8 files changed, 189 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index d159861..f747198 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ make bookings-init make gp-psql -- внутри psql: SELECT COUNT(*) FROM stg.bookings; +SELECT COUNT(*) FROM stg.tickets; SELECT * FROM stg.bookings ORDER BY src_created_at_ts DESC LIMIT 10; ``` @@ -87,9 +88,9 @@ SELECT * FROM stg.bookings ORDER BY src_created_at_ts DESC LIMIT 10; Основные (для потока bookings → DWH): -- `bookings_stg_ddl` — создаёт `stg.bookings_ext` и `stg.bookings` в Greenplum; -- `bookings_to_gp_stage` — генерирует учебный день в `bookings-db` и грузит инкремент в `stg.bookings` - (через PXF), затем выполняет DQ‑проверку. +- `bookings_stg_ddl` — создаёт `stg.bookings_ext`/`stg.bookings` и `stg.tickets_ext`/`stg.tickets` в Greenplum; +- `bookings_to_gp_stage` — генерирует учебный день в `bookings-db`, грузит инкремент в `stg.bookings` и `stg.tickets` + (через PXF), затем выполняет DQ‑проверки. Вспомогательные (побочный трек с CSV): diff --git a/airflow/dags/bookings_stg_ddl.py b/airflow/dags/bookings_stg_ddl.py index 99d0715..0fc3a95 100644 --- a/airflow/dags/bookings_stg_ddl.py +++ b/airflow/dags/bookings_stg_ddl.py @@ -5,8 +5,9 @@ from __future__ import annotations Запускается вручную перед DAG загрузки bookings_to_gp_stage или после изменения DDL. """ -from datetime import datetime, timedelta +from datetime import timedelta +import pendulum from airflow.providers.postgres.operators.postgres import PostgresOperator from airflow import DAG @@ -17,7 +18,7 @@ default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(secon with DAG( dag_id="bookings_stg_ddl", - start_date=datetime(2024, 1, 1), + start_date=pendulum.datetime(2024, 1, 1, tz="UTC"), schedule=None, catchup=False, template_searchpath="/sql", diff --git a/airflow/dags/bookings_to_gp_stage.py b/airflow/dags/bookings_to_gp_stage.py index a4982f3..8a5e21a 100644 --- a/airflow/dags/bookings_to_gp_stage.py +++ b/airflow/dags/bookings_to_gp_stage.py @@ -15,8 +15,9 @@ from __future__ import annotations - `run_id` используется как метка запуска (в `batch_id`, в логах и DQ). """ -from datetime import datetime, timedelta +from datetime import timedelta +import pendulum from airflow.operators.python import PythonOperator from airflow.providers.postgres.operators.postgres import PostgresOperator @@ -48,9 +49,10 @@ def _finish_summary() -> None: with DAG( dag_id="bookings_to_gp_stage", - start_date=datetime(2024, 1, 1), + start_date=pendulum.datetime(2024, 1, 1, tz="UTC"), schedule=None, catchup=False, + max_active_runs=1, template_searchpath="/sql", default_args=default_args, tags=["demo", "bookings", "greenplum", "stg"], diff --git a/docs/bookings_to_gp_stage.md b/docs/bookings_to_gp_stage.md index 33dba5b..e07d4e9 100644 --- a/docs/bookings_to_gp_stage.md +++ b/docs/bookings_to_gp_stage.md @@ -2,7 +2,7 @@ Этот DAG — основной учебный пример в стенде. Он показывает путь данных из источника **Postgres** (`bookings-db`, демо‑БД `demo`) в сырой слой **STG** в **Greenplum** с инкрементальной загрузкой -и простой проверкой качества данных. +и простыми проверками качества данных. ## Что делает DAG @@ -10,6 +10,8 @@ (генератор всегда “шагает” вперёд от `max(book_date)`). - В Greenplum загружает инкремент в `stg.bookings` через внешнюю таблицу `stg.bookings_ext`, используя PXF. - Сверяет количество строк между источником (за окно инкремента) и загруженным батчем. +- Загружает инкремент в `stg.tickets` через внешнюю таблицу `stg.tickets_ext`, используя PXF. +- Запускает DQ‑проверки для `stg.tickets` (количество, ссылочная целостность, обязательные поля). ## Что должно быть готово перед запуском @@ -25,7 +27,7 @@ make up make bookings-init ``` -3) В Greenplum созданы `stg.bookings_ext` и `stg.bookings` (выберите один вариант): +3) В Greenplum созданы STG‑объекты `stg.bookings_ext`/`stg.bookings` и `stg.tickets_ext`/`stg.tickets` (выберите один вариант): - учебный вариант: запустить DAG `bookings_stg_ddl` в Airflow UI; - технический шорткат: `make ddl-gp`. @@ -68,7 +70,20 @@ make bookings-init вставленных в `stg.bookings` для текущего `batch_id`; - при расхождении делает `RAISE EXCEPTION` с понятным текстом. -4) `finish_summary` +4) `load_tickets_to_stg` + +- выполняет `sql/stg/tickets_load.sql` в Greenplum; +- так как в `bookings.tickets` нет явной временной колонки, окно инкремента берётся по `book_date` + из связанной внешней таблицы `stg.bookings_ext` (JOIN по `book_ref`); +- вставляет строки в `stg.tickets`, добавляя `src_created_at_ts`, `load_dttm` и `batch_id={{ run_id }}`. + +5) `check_tickets_dq` + +- выполняет `sql/stg/tickets_dq.sql` в Greenplum; +- проверяет количество строк в том же окне инкремента, а также ссылочную целостность и обязательные поля; +- при проблемах делает `RAISE EXCEPTION`, чтобы DAG падал “красным”. + +6) `finish_summary` - логирует краткую сводку в конце запуска. diff --git a/docs/chore/bookings-etl.md b/docs/chore/bookings-etl.md index 2d79659..f0bc5a7 100644 --- a/docs/chore/bookings-etl.md +++ b/docs/chore/bookings-etl.md @@ -37,27 +37,26 @@ ```sql -- sql/stg/tickets_ddl.sql -DROP TABLE IF EXISTS stg.tickets CASCADE; - -CREATE TABLE stg.tickets ( +CREATE TABLE IF NOT EXISTS stg.tickets ( -- Бизнес-атрибуты (из источника) ticket_no TEXT NOT NULL, -- номер билета book_ref TEXT NOT NULL, -- ссылка на бронирование passenger_id TEXT, -- идентификатор пассажира passenger_name TEXT, -- имя пассажира - outbound TEXT, -- флаг направления (boolean → text для Greenplum) + outbound TEXT, -- флаг направления (в источнике boolean; в STG храним как TEXT) -- Технические атрибуты src_created_at_ts TIMESTAMP, -- временная метка источника (для инкремента) - load_dttm TIMESTAMP NOT NULL, -- время загрузки в Greenplum + load_dttm TIMESTAMP NOT NULL DEFAULT now(), -- время загрузки в Greenplum batch_id TEXT -- идентификатор батча (из Airflow run_id) ) -DISTRIBUTED BY (ticket_no); -- распределение по первичному ключу +WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) +DISTRIBUTED BY (book_ref); -- распределение по ключу связи с bookings ``` **Решения по проектированию:** -- **DISTRIBUTED BY (ticket_no):** равномерное распределение, так как это первичный ключ -- **boolean → TEXT:** Greenplum хорошо работает с текстовым представлением для флагов +- **DISTRIBUTED BY (book_ref):** типовой джойн `tickets → bookings` идёт по `book_ref`, так меньше motion в MPP +- **boolean → TEXT:** в сыром STG храним бизнес-колонки как TEXT (для обучения и минимизации кастов на входе) - **src_created_at_ts:** временная метка из даты связанного бронирования (см. раздел 7) ## 4. Проектирование внешней таблицы (PXF) @@ -122,7 +121,11 @@ CREATE TABLE IF NOT EXISTS stg.tickets ( batch_id TEXT ) WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -DISTRIBUTED BY (ticket_no); +-- Распределяем по book_ref, чтобы джойны tickets → bookings по book_ref были без motion. +DISTRIBUTED BY (book_ref); + +-- На случай, если таблица уже была создана раньше с другим ключом распределения. +ALTER TABLE IF EXISTS stg.tickets SET DISTRIBUTED BY (book_ref); ``` ## 6. LOAD-скрипт (загрузка инкремента) @@ -173,11 +176,10 @@ WHERE b.book_date > COALESCE( TIMESTAMP '1900-01-01 00:00:00' ) AND NOT EXISTS ( - -- Защита от дубликатов в рамках одного батча + -- Защита от дублей: ticket_no в источнике уникален, и в stg его не дублируем. SELECT 1 FROM stg.tickets AS t - WHERE t.batch_id = '{{ run_id }}'::text - AND t.ticket_no = ext.ticket_no + WHERE t.ticket_no = ext.ticket_no ); ``` @@ -194,56 +196,83 @@ AND NOT EXISTS ( Скрипт `sql/stg/tickets_dq.sql`: ```sql --- Проверка 1: совпадение количества билетов в источнике и STG --- Используем только внешние таблицы: stg.tickets_ext + stg.bookings_ext +-- Проверки качества данных для tickets + DO $$ DECLARE - v_source_count BIGINT; - v_stg_count BIGINT; + v_batch_id TEXT := '{{ run_id }}'::text; + v_prev_ts TIMESTAMP; + v_source_count BIGINT; + v_stg_count BIGINT; + v_orphan_count BIGINT; + v_null_count BIGINT; BEGIN - -- Количество в источнике (новые билеты) - SELECT COUNT(*) INTO v_source_count + -- Опорная метка: максимум src_created_at_ts среди предыдущих батчей + SELECT max(src_created_at_ts) + INTO v_prev_ts + FROM stg.tickets + WHERE batch_id <> v_batch_id + OR batch_id IS NULL; + + -- Источник: считаем строки в том же окне инкремента, что и загрузка + SELECT COUNT(*) + INTO v_source_count FROM stg.tickets_ext AS t JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref - WHERE b.book_date > COALESCE( - (SELECT max(src_created_at_ts) FROM stg.tickets - WHERE batch_id <> '{{ run_id }}'::text OR batch_id IS NULL), - TIMESTAMP '1900-01-01 00:00:00' - ); - - -- Количество в STG (текущий батч) - SELECT COUNT(*) INTO v_stg_count + WHERE b.book_date > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + + IF v_source_count = 0 THEN + RAISE EXCEPTION + 'В источнике tickets_ext нет строк для окна инкремента (book_date > %). Проверьте генерацию данных (таск generate_bookings_day).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + END IF; + + -- STG: считаем строки текущего батча + SELECT COUNT(*) + INTO v_stg_count FROM stg.tickets - WHERE batch_id = '{{ run_id }}'::text; - - -- Проверка совпадения + WHERE batch_id = v_batch_id; + IF v_source_count <> v_stg_count THEN - RAISE EXCEPTION 'DQ FAILED: несовпадение количества билетов. Источник: %, STG: %', - v_source_count, v_stg_count; - ELSE - RAISE NOTICE 'DQ PASSED: количество билетов совпадает (%)', v_stg_count; + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества билетов. Источник: %, STG (batch_id=%): %', + v_source_count, + v_batch_id, + v_stg_count; + END IF; + + -- Ссылочная целостность: tickets должны иметь соответствующие bookings в STG + SELECT COUNT(*) + INTO v_orphan_count + FROM stg.tickets AS t + WHERE t.batch_id = v_batch_id + AND NOT EXISTS ( + SELECT 1 + FROM stg.bookings AS b + WHERE b.book_ref = t.book_ref + ); + + IF v_orphan_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены tickets без соответствующих bookings (batch_id=%): %', + v_batch_id, + v_orphan_count; + END IF; + + -- Обязательные поля + SELECT COUNT(*) + INTO v_null_count + FROM stg.tickets AS t + WHERE t.batch_id = v_batch_id + AND (t.ticket_no IS NULL OR t.book_ref IS NULL); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены tickets с NULL в обязательных полях (batch_id=%): %', + v_batch_id, + v_null_count; END IF; END $$; - --- Проверка 2: ссылочная целостность (все ticket_no должны иметь соответствующие book_ref) -SELECT - COUNT(*) AS orphan_tickets -FROM stg.tickets -WHERE batch_id = '{{ run_id }}'::text - AND NOT EXISTS ( - SELECT 1 - FROM stg.bookings - WHERE stg.bookings.book_ref = stg.tickets.book_ref - ); --- Ожидаемое значение: 0 - --- Проверка 3: отсутствие NULL в обязательных полях -SELECT - COUNT(*) AS null_tickets -FROM stg.tickets -WHERE batch_id = '{{ run_id }}'::text - AND (ticket_no IS NULL OR book_ref IS NULL); --- Ожидаемое значение: 0 ``` ## 8. Интеграция с существующими DAG @@ -396,7 +425,7 @@ WHERE b.book_ref IS NULL; 8. ✅ Проверка через Airflow UI 9. ✅ Проверка идемпотентности DDL 10. ✅ Проверка инкрементальной загрузки -11. ⏳ Обновить `README.md` (добавить tickets в список STG-таблиц) +11. ✅ Обновить `README.md` (добавить tickets в список STG-таблиц) ## 11. Сопутствующие изменения @@ -412,8 +441,8 @@ WHERE b.book_ref IS NULL; ### Необходимые изменения в документации: **README.md:** -- ⏳ Добавить `tickets` в список STG-таблиц -- ⏳ Обновить описание DAG `bookings_to_gp_stage` (упомянуть загрузку билетов) +- ✅ Добавить `tickets` в список STG-таблиц +- ✅ Обновить описание DAG `bookings_to_gp_stage` (упомянуть загрузку билетов) ## 12. Результаты тестирования @@ -431,4 +460,4 @@ WHERE b.book_ref IS NULL; ### Идемпотентность DDL: - **Первый запуск DDL:** таблицы созданы, данные не затронуты -- **Второй запуск DDL:** данные не пропали, таблицы существуют (CREATE TABLE IF NOT EXISTS) \ No newline at end of file +- **Второй запуск DDL:** данные не пропали, таблицы существуют (CREATE TABLE IF NOT EXISTS) diff --git a/sql/stg/tickets_ddl.sql b/sql/stg/tickets_ddl.sql index e43648c..553cb04 100644 --- a/sql/stg/tickets_ddl.sql +++ b/sql/stg/tickets_ddl.sql @@ -29,4 +29,6 @@ CREATE TABLE IF NOT EXISTS stg.tickets ( batch_id TEXT ) WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -DISTRIBUTED BY (ticket_no); +-- Распределяем по book_ref, чтобы джойны tickets → bookings по book_ref были без motion. +DISTRIBUTED BY (book_ref); + diff --git a/sql/stg/tickets_dq.sql b/sql/stg/tickets_dq.sql index b5f5feb..64f63e9 100644 --- a/sql/stg/tickets_dq.sql +++ b/sql/stg/tickets_dq.sql @@ -1,52 +1,83 @@ -- Проверки качества данных для tickets --- Проверка 1: совпадение количества билетов в источнике и STG DO $$ DECLARE - v_source_count BIGINT; - v_stg_count BIGINT; + v_batch_id TEXT := '{{ run_id }}'::text; + v_prev_ts TIMESTAMP; + v_source_count BIGINT; + v_stg_count BIGINT; + v_orphan_count BIGINT; + v_null_count BIGINT; BEGIN - -- Количество в источнике (новые билеты) - -- Используем только внешние таблицы: stg.tickets_ext + stg.bookings_ext - SELECT COUNT(*) INTO v_source_count + -- Опорная метка: максимум src_created_at_ts среди предыдущих батчей + SELECT max(src_created_at_ts) + INTO v_prev_ts + FROM stg.tickets + WHERE batch_id <> v_batch_id + OR batch_id IS NULL; + + -- Количество в источнике (новые билеты в том же окне инкремента, что и загрузка) + SELECT COUNT(*) + INTO v_source_count FROM stg.tickets_ext AS t JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref - WHERE b.book_date > COALESCE( - (SELECT max(src_created_at_ts) FROM stg.tickets - WHERE batch_id <> '{{ run_id }}'::text OR batch_id IS NULL), - TIMESTAMP '1900-01-01 00:00:00' - ); + WHERE b.book_date > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + + IF v_source_count = 0 THEN + RAISE EXCEPTION + 'В источнике tickets_ext нет строк для окна инкремента (book_date > %). Проверьте генерацию данных (таск generate_bookings_day).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + END IF; -- Количество в STG (текущий батч) - SELECT COUNT(*) INTO v_stg_count + SELECT COUNT(*) + INTO v_stg_count FROM stg.tickets - WHERE batch_id = '{{ run_id }}'::text; + WHERE batch_id = v_batch_id; - -- Проверка совпадения IF v_source_count <> v_stg_count THEN - RAISE EXCEPTION 'DQ FAILED: несовпадение количества билетов. Источник: %, STG: %', - v_source_count, v_stg_count; - ELSE - RAISE NOTICE 'DQ PASSED: количество билетов совпадает (%)', v_stg_count; + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества билетов. Источник: %, STG (batch_id=%): %', + v_source_count, + v_batch_id, + v_stg_count; END IF; + + -- Проверка ссылочной целостности: все tickets должны иметь соответствующие bookings в этом же STG + SELECT COUNT(*) + INTO v_orphan_count + FROM stg.tickets AS t + WHERE t.batch_id = v_batch_id + AND NOT EXISTS ( + SELECT 1 + FROM stg.bookings AS b + WHERE b.book_ref = t.book_ref + ); + + IF v_orphan_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены tickets без соответствующих bookings (batch_id=%): %', + v_batch_id, + v_orphan_count; + END IF; + + -- Проверка обязательных полей + SELECT COUNT(*) + INTO v_null_count + FROM stg.tickets AS t + WHERE t.batch_id = v_batch_id + AND (t.ticket_no IS NULL OR t.book_ref IS NULL); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены tickets с NULL в обязательных полях (batch_id=%): %', + v_batch_id, + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: tickets ок (batch_id=%): source=% stg=%', + v_batch_id, + v_source_count, + v_stg_count; END $$; - --- Проверка 2: ссылочная целостность (все ticket_no должны иметь соответствующие book_ref) -SELECT - COUNT(*) AS orphan_tickets -FROM stg.tickets -WHERE batch_id = '{{ run_id }}'::text - AND NOT EXISTS ( - SELECT 1 - FROM stg.bookings - WHERE stg.bookings.book_ref = stg.tickets.book_ref - ); --- Ожидаемое значение: 0 - --- Проверка 3: отсутствие NULL в обязательных полях -SELECT - COUNT(*) AS null_tickets -FROM stg.tickets -WHERE batch_id = '{{ run_id }}'::text - AND (ticket_no IS NULL OR book_ref IS NULL); --- Ожидаемое значение: 0 diff --git a/sql/stg/tickets_load.sql b/sql/stg/tickets_load.sql index 73a3ad5..3a32059 100644 --- a/sql/stg/tickets_load.sql +++ b/sql/stg/tickets_load.sql @@ -32,9 +32,8 @@ WHERE b.book_date > COALESCE( TIMESTAMP '1900-01-01 00:00:00' ) AND NOT EXISTS ( - -- Защита от дубликатов в рамках одного батча + -- Защита от дублей: ticket_no в источнике уникален, и в stg его не дублируем. SELECT 1 FROM stg.tickets AS t - WHERE t.batch_id = '{{ run_id }}'::text - AND t.ticket_no = ext.ticket_no + WHERE t.ticket_no = ext.ticket_no ); From 9ec7e82f5f12f60d3d26402956c4dd1f69d4a78b Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 17 Jan 2026 21:43:02 +0300 Subject: [PATCH 06/38] =?UTF-8?q?=D0=9F=D0=BE=D0=BB=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/stg/bookings_ddl.sql | 6 ++++++ sql/stg/bookings_dq.sql | 29 +++++++++++++++++++++++++++++ sql/stg/bookings_load.sql | 34 +++++++++++++++++++--------------- sql/stg/tickets_ddl.sql | 7 ++++++- sql/stg/tickets_dq.sql | 37 ++++++++++++++++++++++++++++++++----- sql/stg/tickets_load.sql | 22 +++++++++++++--------- 6 files changed, 105 insertions(+), 30 deletions(-) diff --git a/sql/stg/bookings_ddl.sql b/sql/stg/bookings_ddl.sql index 5d4b8ba..465e6b8 100644 --- a/sql/stg/bookings_ddl.sql +++ b/sql/stg/bookings_ddl.sql @@ -25,5 +25,11 @@ CREATE TABLE IF NOT EXISTS stg.bookings ( batch_id TEXT NOT NULL ) WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) +-- Ключ распределения: book_ref +-- Обоснование: book_ref — это уникальный идентификатор бронирования. +-- Использование book_ref обеспечивает: +-- 1. Равномерное распределение данных по сегментам (book_ref имеет высокую кардинальность) +-- 2. Co-location данных bookings и tickets при JOIN по book_ref +-- 3. Оптимизацию запросов, которые фильтруют или группируют по book_ref DISTRIBUTED BY (book_ref); diff --git a/sql/stg/bookings_dq.sql b/sql/stg/bookings_dq.sql index 7d8cc06..24f0e5e 100644 --- a/sql/stg/bookings_dq.sql +++ b/sql/stg/bookings_dq.sql @@ -9,6 +9,8 @@ DECLARE v_prev_ts timestamp; v_src_count bigint; v_stg_count bigint; + v_dup_count bigint; + v_null_amount_count bigint; BEGIN -- Опорная метка: максимум src_created_at_ts среди предыдущих батчей SELECT max(src_created_at_ts) @@ -42,6 +44,33 @@ BEGIN v_stg_count; END IF; + -- Проверка на дубликаты book_ref + SELECT COUNT(*) - COUNT(DISTINCT book_ref) + INTO v_dup_count + FROM stg.bookings AS b + WHERE b.batch_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты book_ref (batch_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка на NULL или пустые total_amount + SELECT COUNT(*) + INTO v_null_amount_count + FROM stg.bookings AS b + WHERE b.batch_id = v_batch_id + AND (b.total_amount IS NULL OR b.total_amount = ''); + + IF v_null_amount_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены bookings с NULL или пустым total_amount (batch_id=%): %', + v_batch_id, + v_null_amount_count; + END IF; + RAISE NOTICE 'Проверка количества строк пройдена: источник=%, stg=%', v_src_count, diff --git a/sql/stg/bookings_load.sql b/sql/stg/bookings_load.sql index 55cd551..522c0fc 100644 --- a/sql/stg/bookings_load.sql +++ b/sql/stg/bookings_load.sql @@ -3,6 +3,13 @@ -- берём строки, где book_date больше максимального src_created_at_ts -- среди "старых" батчей; верхняя граница по дате не используется. +-- CTE для определения максимальной даты загрузки предыдущего батча +WITH max_batch_ts AS ( + SELECT COALESCE(MAX(src_created_at_ts), TIMESTAMP '1900-01-01 00:00:00') AS max_ts + FROM stg.bookings + WHERE batch_id <> '{{ run_id }}'::text + OR batch_id IS NULL +) INSERT INTO stg.bookings ( book_ref, book_date, @@ -19,18 +26,15 @@ SELECT now(), '{{ run_id }}'::text FROM stg.bookings_ext AS ext -WHERE ext.book_date > COALESCE( - ( - SELECT max(src_created_at_ts) - FROM stg.bookings - WHERE batch_id <> '{{ run_id }}'::text - OR batch_id IS NULL - ), - TIMESTAMP '1900-01-01 00:00:00' -) - AND NOT EXISTS ( - SELECT 1 - FROM stg.bookings AS b - WHERE b.batch_id = '{{ run_id }}'::text - AND b.book_ref = ext.book_ref::text - ); +CROSS JOIN max_batch_ts AS mb +WHERE ext.book_date > mb.max_ts +AND NOT EXISTS ( + SELECT 1 + FROM stg.bookings AS b + WHERE b.batch_id = '{{ run_id }}'::text + AND b.book_ref = ext.book_ref::text +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.bookings; diff --git a/sql/stg/tickets_ddl.sql b/sql/stg/tickets_ddl.sql index 553cb04..87ea0ef 100644 --- a/sql/stg/tickets_ddl.sql +++ b/sql/stg/tickets_ddl.sql @@ -29,6 +29,11 @@ CREATE TABLE IF NOT EXISTS stg.tickets ( batch_id TEXT ) WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) --- Распределяем по book_ref, чтобы джойны tickets → bookings по book_ref были без motion. +-- Ключ распределения: book_ref +-- Обоснование: book_ref — это основной бизнес-ключ для бронирований. +-- Использование book_ref обеспечивает: +-- 1. Co-location данных tickets и bookings при JOIN по book_ref +-- 2. Равномерное распределение данных по сегментам (book_ref имеет высокую кардинальность) +-- 3. Оптимизацию запросов, которые фильтруют или группируют по book_ref DISTRIBUTED BY (book_ref); diff --git a/sql/stg/tickets_dq.sql b/sql/stg/tickets_dq.sql index 64f63e9..e42a412 100644 --- a/sql/stg/tickets_dq.sql +++ b/sql/stg/tickets_dq.sql @@ -8,6 +8,8 @@ DECLARE v_stg_count BIGINT; v_orphan_count BIGINT; v_null_count BIGINT; + v_dup_count BIGINT; + v_empty_name_count BIGINT; BEGIN -- Опорная метка: максимум src_created_at_ts среди предыдущих батчей SELECT max(src_created_at_ts) @@ -44,15 +46,13 @@ BEGIN END IF; -- Проверка ссылочной целостности: все tickets должны иметь соответствующие bookings в этом же STG + -- Используем LEFT JOIN вместо NOT EXISTS для лучшей производительности на больших объёмах SELECT COUNT(*) INTO v_orphan_count FROM stg.tickets AS t + LEFT JOIN stg.bookings AS b ON t.book_ref = b.book_ref WHERE t.batch_id = v_batch_id - AND NOT EXISTS ( - SELECT 1 - FROM stg.bookings AS b - WHERE b.book_ref = t.book_ref - ); + AND b.book_ref IS NULL; IF v_orphan_count <> 0 THEN RAISE EXCEPTION @@ -75,6 +75,33 @@ BEGIN v_null_count; END IF; + -- Проверка на дубликаты ticket_no + SELECT COUNT(*) - COUNT(DISTINCT ticket_no) + INTO v_dup_count + FROM stg.tickets AS t + WHERE t.batch_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты ticket_no (batch_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка на пустые passenger_name + SELECT COUNT(*) + INTO v_empty_name_count + FROM stg.tickets AS t + WHERE t.batch_id = v_batch_id + AND (t.passenger_name IS NULL OR t.passenger_name = ''); + + IF v_empty_name_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены tickets с пустым именем пассажира (batch_id=%): %', + v_batch_id, + v_empty_name_count; + END IF; + RAISE NOTICE 'DQ PASSED: tickets ок (batch_id=%): source=% stg=%', v_batch_id, diff --git a/sql/stg/tickets_load.sql b/sql/stg/tickets_load.sql index 3a32059..d90027d 100644 --- a/sql/stg/tickets_load.sql +++ b/sql/stg/tickets_load.sql @@ -1,6 +1,13 @@ -- Загрузка инкремента из stg.tickets_ext в stg.tickets -- Инкремент определяется по дате бронирования (book_date из bookings.bookings) +-- CTE для определения максимальной даты загрузки предыдущего батча +WITH max_batch_ts AS ( + SELECT COALESCE(MAX(src_created_at_ts), TIMESTAMP '1900-01-01 00:00:00') AS max_ts + FROM stg.tickets + WHERE batch_id <> '{{ run_id }}'::text + OR batch_id IS NULL +) INSERT INTO stg.tickets ( ticket_no, book_ref, @@ -22,18 +29,15 @@ SELECT '{{ run_id }}'::text FROM stg.tickets_ext AS ext JOIN stg.bookings_ext AS b ON ext.book_ref = b.book_ref -WHERE b.book_date > COALESCE( - ( - SELECT max(src_created_at_ts) - FROM stg.tickets - WHERE batch_id <> '{{ run_id }}'::text - OR batch_id IS NULL - ), - TIMESTAMP '1900-01-01 00:00:00' -) +CROSS JOIN max_batch_ts AS mb +WHERE b.book_date > mb.max_ts AND NOT EXISTS ( -- Защита от дублей: ticket_no в источнике уникален, и в stg его не дублируем. SELECT 1 FROM stg.tickets AS t WHERE t.ticket_no = ext.ticket_no ); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.tickets; From 10a36b6dfbf045b97394cf5e403fd9822d82bb27 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 17 Jan 2026 22:27:05 +0300 Subject: [PATCH 07/38] =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=BD=20=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=BD=20=D0=B4=D0=BE=20stg=20=D1=81=D0=BB?= =?UTF-8?q?=D0=BE=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/internal/db_schema.md | 222 +++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 docs/internal/db_schema.md diff --git a/docs/internal/db_schema.md b/docs/internal/db_schema.md new file mode 100644 index 0000000..30b6d7a --- /dev/null +++ b/docs/internal/db_schema.md @@ -0,0 +1,222 @@ +# Схема БД DWH (Bookings → Greenplum) + +> **Статус:** Проект в разработке. Реализован только STG слой (частично: bookings, tickets). + +## Обзор + +Эта документация описывает архитектуру хранилища данных (DWH) для учебного проекта Airflow + Greenplum. Источник данных — демо-БД `bookings` (Postgres). + +### Статус реализации по слоям + +| Слой | Статус | Реализовано | +|------|--------|-------------| +| **Source** | ✅ Готово | Демо-БД bookings (Postgres) | +| **STG** | ⚠️ В процессе | 2 из 8 таблиц (bookings, tickets) | +| **DQ** | ⚠️ В процессе | Есть скрипты для bookings и tickets | +| **ODS** | ❌ Не реализован | Планируется | +| **DDS** | ❌ Не реализован | Планируется | + +--- + +## Полная схема потоков данных (Data Lineage) + +```mermaid +graph LR + %% Стили + classDef source fill:#e1f5fe,stroke:#01579b,stroke-width:2px; + classDef stg fill:#fff9c4,stroke:#fbc02d,stroke-width:2px; + classDef dq fill:#ffe0b2,stroke:#ef6c00,stroke-width:2px; + classDef ods fill:#e0f2f1,stroke:#00695c,stroke-width:2px; + classDef dim fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px; + classDef fact fill:#ffccbc,stroke:#bf360c,stroke-width:4px; + + %% 1. Source + subgraph Source_Postgres [Source: Postgres Bookings] + direction TB + SRC_Airports[airports_data]:::source + SRC_Airplanes[airplanes_data]:::source + SRC_Seats[seats]:::source + SRC_Bookings[bookings]:::source + SRC_Tickets[tickets]:::source + SRC_Flights[flights]:::source + SRC_Segments[segments]:::source + SRC_Boarding[boarding_passes]:::source + end + + %% 2. STAGING (Load 1-to-1, AO-Row) + subgraph STG_Layer [Layer: STG Staging] + direction TB + STG_Airports[stg.airports]:::stg + STG_Airplanes[stg.airplanes]:::stg + STG_Seats[stg.seats]:::stg + STG_Bookings[stg.bookings]:::stg + STG_Tickets[stg.tickets]:::stg + STG_Flights[stg.flights]:::stg + STG_Segments[stg.segments]:::stg + STG_Boarding[stg.boarding_passes]:::stg + end + + %% Links Source to STG + SRC_Airports --> STG_Airports + SRC_Airplanes --> STG_Airplanes + SRC_Seats --> STG_Seats + SRC_Bookings --> STG_Bookings + SRC_Tickets --> STG_Tickets + SRC_Flights --> STG_Flights + SRC_Segments --> STG_Segments + SRC_Boarding --> STG_Boarding + + %% 3. DATA QUALITY (Quality Checks) + subgraph DQ_Layer [Layer: DQ Data Quality] + direction TB + DQ_Bookings[dq.bookings_checks]:::dq + DQ_Tickets[dq.tickets_checks]:::dq + DQ_Flights[dq.flights_checks]:::dq + DQ_Segments[dq.segments_checks]:::dq + end + + %% Links STG to DQ + STG_Bookings --> DQ_Bookings + STG_Tickets --> DQ_Tickets + STG_Flights --> DQ_Flights + STG_Segments --> DQ_Segments + + %% 4. ODS (3NF, Clean, Type, Heap) + subgraph ODS_Layer [Layer: ODS Operational Core] + direction TB + ODS_Airports[ods.airports]:::ods + ODS_Airplanes[ods.airplanes]:::ods + ODS_Seats[ods.seats]:::ods + ODS_Bookings[ods.bookings]:::ods + ODS_Tickets[ods.tickets]:::ods + ODS_Flights[ods.flights]:::ods + ODS_Segments[ods.segments]:::ods + ODS_Boarding[ods.boarding_passes]:::ods + end + + %% Links DQ to ODS + DQ_Bookings --> ODS_Bookings + DQ_Tickets --> ODS_Tickets + DQ_Flights --> ODS_Flights + DQ_Segments --> ODS_Segments + STG_Airports --> ODS_Airports + STG_Airplanes --> ODS_Airplanes + STG_Seats --> ODS_Seats + STG_Boarding --> ODS_Boarding + + %% 5. DDS (Star Schema) + subgraph DDS_Layer [Layer: DDS Star Schema] + direction TB + + %% Dimensions + DIM_Calendar[dim.calendar]:::dim + DIM_Airports[dim.airports]:::dim + DIM_Airplanes[dim.airplanes]:::dim + DIM_Tariffs[dim.tariffs]:::dim + DIM_Passengers[dim.passengers]:::dim + + %% Fact + FACT_Sales[fact.flight_sales]:::fact + end + + %% Transformations ODS to DDS + + %% Form reference tables + ODS_Airports --> DIM_Airports + + ODS_Airplanes --> DIM_Airplanes + ODS_Seats -.->|Enrich total_seats| DIM_Airplanes + + ODS_Segments -.->|Extract distinct| DIM_Tariffs + + ODS_Tickets -->|Extract Unique| DIM_Passengers + + %% Fact assembly (Main process) + ODS_Segments -->|Main Stream| FACT_Sales + ODS_Tickets -->|Join book_ref passenger_id| FACT_Sales + ODS_Flights -->|Join Times Status| FACT_Sales + ODS_Boarding -->|LEFT JOIN Seat No| FACT_Sales + + %% Link dimensions to fact + DIM_Calendar -->|calendar_sk| FACT_Sales + DIM_Airports -->|departure_airport_sk| FACT_Sales + DIM_Airports -->|arrival_airport_sk| FACT_Sales + DIM_Airplanes -->|airplane_sk| FACT_Sales + DIM_Tariffs -->|tariff_sk| FACT_Sales + DIM_Passengers -->|passenger_sk| FACT_Sales +``` + +--- + +## Пояснения к схеме (для студентов) + +Эта диаграмма покрывает 100% таблиц источника и показывает логику их трансформации. Вот на что стоит обратить внимание при обучении: + +### 1. Ветка справочников (Reference Data) + +* **`seats` + `airplanes` → `dim.airplanes`**: Здесь мы показываем пример **обогащения**. Таблица `seats` сама по себе в аналитике редко нужна отдельной сущностью. Мы используем её в ODS, чтобы посчитать общее количество мест (`total_seats`) и добавить это как атрибут в измерение самолётов (`dim.airplanes`). + +* **`airports` → `dim.airports`**: Простой перенос (1-в-1), но в DDS мы можем добавить, например, поле `city_ru` и `city_en` как отдельные колонки, убрав JSON, который есть в источнике. + +### 2. Ветка генерации измерений (Dimension Generation) + +* **`tickets` → `dim.passengers`**: Это самая сложная трансформация для измерения. В источнике нет таблицы "Пассажиры". Мы должны объяснить студентам, что мы "майним" пассажиров из билетов. Важно: один и тот же пассажир может иметь разные записи с разными именами (опечатки, изменение фамилии), поэтому нужна логика SCD Type 2 для отслеживания изменений. + +* **`segments` → `dim.tariffs`**: Таблицы тарифов физически нет в источнике, она "зашита" строкой (Economy, Business) в таблице полётов. Мы выносим её в отдельный справочник (Нормализация), чтобы в факте хранить маленький `INT` ключ, а не длинную строку. + +### 3. Сборка Факта (`fact.flight_sales`) + +Это центр звезды. Мы собираем его из четырёх ODS таблиц: + +1. **`ods.segments`**: Основа (зерно факта — один полётный сегмент билета). Дает сумму (`amount`). +2. **`ods.tickets`**: Приджойниваем, чтобы получить `book_ref` и `passenger_id`. +3. **`ods.flights`**: Приджойниваем, чтобы получить точное время вылета/прилета (для FK на календарь) и статусы. +4. **`ods.boarding_passes`**: Приджойниваем (LEFT JOIN), чтобы узнать, **сел ли пассажир реально в самолёт** и на какое место (`seat_no`). Это важный бизнес-аспект: билет куплен, но посадочный не выдан = пассажир не летел. + +### 4. Почему нет `dim.bookings`? + +В классической Star Schema измерения — это справочники (airports, aircrafts, passengers), а факты — транзакции/события (sales, bookings). + +`bookings` — это транзакционная таблица, а не справочник. Вместо отдельного измерения `dim.bookings` мы храним: +- `book_ref` — бизнес-ключ бронирования (в факте) +- `book_date` — дата бронирования (в факте) + +Это позволяет отвечать на вопросы типа: *"За сколько дней до вылета люди обычно покупают билеты?"* (разница между `book_date` и датой вылета из `dim.calendar`). + +### 5. Суррогатные ключи (Surrogate Keys) + +В Star Schema факт должен ссылаться на суррогатные ключи (SK) измерений, а не на бизнес-ключи: + +| Бизнес-ключ | Суррогатный ключ | Преимущество | +|-------------|------------------|--------------| +| `airport_code CHAR(3)` | `airport_sk INT` | Меньший размер, стабильность | +| `airplane_code TEXT` | `airplane_sk INT` | Меньший размер, стабильность | +| `passenger_id TEXT` | `passenger_sk INT` | Меньший размер, отслеживание изменений | + +### 6. Слой DQ (Data Quality) + +Между STG и ODS добавлен слой Data Quality для проверки качества данных. В проекте уже есть скрипты: +- `sql/stg/bookings_dq.sql` +- `sql/stg/tickets_dq.sql` + +На схеме показаны примеры проверок для всех таблиц, которые должны быть реализованы. + +--- + +## История изменений + +| Дата | Версия | Описание изменений | +|------|--------|-------------------| +| 2025-01-17 | 1.1 | Исправлены названия таблиц (`aircrafts_data` → `airplanes_data`, `ticket_flights` → `segments`), удалено `dim.bookings`, добавлены суррогатные ключи, добавлен слой DQ, исправлены связи | +| 2025-01-XX | 1.0 | Первоначальная версия | + +--- + +## TODO + +- [ ] Реализовать STG слой полностью (все 8 таблиц) +- [ ] Реализовать DQ слой для всех таблиц +- [ ] Реализовать ODS слой +- [ ] Реализовать DDS слой (измерения и факт) +- [ ] Создать DAG для загрузки ODS +- [ ] Создать DAG для загрузки DDS From fec67dfad2c67fed02f7823e599cf8cfad0509bc Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 17 Jan 2026 22:41:25 +0300 Subject: [PATCH 08/38] =?UTF-8?q?=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/internal/db_schema.md | 44 +++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/docs/internal/db_schema.md b/docs/internal/db_schema.md index 30b6d7a..ac85d20 100644 --- a/docs/internal/db_schema.md +++ b/docs/internal/db_schema.md @@ -6,12 +6,20 @@ Эта документация описывает архитектуру хранилища данных (DWH) для учебного проекта Airflow + Greenplum. Источник данных — демо-БД `bookings` (Postgres). +### Ключевые договорённости (для LLM и студентов) + +- **Источник**: используем основные таблицы схемы `bookings` (табличные данные, не `VIEW`). +- **Зерно факта `fact.flight_sales`**: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`, источник: `segments`). +- **Обязательная связь для аэропортов и самолёта**: `flights.route_no → routes → (departure_airport, arrival_airport, airplane_code)`. +- **Даты**: как минимум различаем `book_date` (дата покупки) и `scheduled_departure` (дата/время вылета). +- **Инкремент в STG**: для `tickets` опорная дата берётся из `bookings.book_date`, потому что в `tickets` нет собственного поля времени изменения. + ### Статус реализации по слоям | Слой | Статус | Реализовано | |------|--------|-------------| | **Source** | ✅ Готово | Демо-БД bookings (Postgres) | -| **STG** | ⚠️ В процессе | 2 из 8 таблиц (bookings, tickets) | +| **STG** | ⚠️ В процессе | 2 из 9 таблиц (bookings, tickets) | | **DQ** | ⚠️ В процессе | Есть скрипты для bookings и tickets | | **ODS** | ❌ Не реализован | Планируется | | **DDS** | ❌ Не реализован | Планируется | @@ -35,6 +43,7 @@ graph LR direction TB SRC_Airports[airports_data]:::source SRC_Airplanes[airplanes_data]:::source + SRC_Routes[routes]:::source SRC_Seats[seats]:::source SRC_Bookings[bookings]:::source SRC_Tickets[tickets]:::source @@ -48,6 +57,7 @@ graph LR direction TB STG_Airports[stg.airports]:::stg STG_Airplanes[stg.airplanes]:::stg + STG_Routes[stg.routes]:::stg STG_Seats[stg.seats]:::stg STG_Bookings[stg.bookings]:::stg STG_Tickets[stg.tickets]:::stg @@ -59,6 +69,7 @@ graph LR %% Links Source to STG SRC_Airports --> STG_Airports SRC_Airplanes --> STG_Airplanes + SRC_Routes --> STG_Routes SRC_Seats --> STG_Seats SRC_Bookings --> STG_Bookings SRC_Tickets --> STG_Tickets @@ -71,6 +82,7 @@ graph LR direction TB DQ_Bookings[dq.bookings_checks]:::dq DQ_Tickets[dq.tickets_checks]:::dq + DQ_Routes[dq.routes_checks]:::dq DQ_Flights[dq.flights_checks]:::dq DQ_Segments[dq.segments_checks]:::dq end @@ -78,6 +90,7 @@ graph LR %% Links STG to DQ STG_Bookings --> DQ_Bookings STG_Tickets --> DQ_Tickets + STG_Routes --> DQ_Routes STG_Flights --> DQ_Flights STG_Segments --> DQ_Segments @@ -86,6 +99,7 @@ graph LR direction TB ODS_Airports[ods.airports]:::ods ODS_Airplanes[ods.airplanes]:::ods + ODS_Routes[ods.routes]:::ods ODS_Seats[ods.seats]:::ods ODS_Bookings[ods.bookings]:::ods ODS_Tickets[ods.tickets]:::ods @@ -97,6 +111,7 @@ graph LR %% Links DQ to ODS DQ_Bookings --> ODS_Bookings DQ_Tickets --> ODS_Tickets + DQ_Routes --> ODS_Routes DQ_Flights --> ODS_Flights DQ_Segments --> ODS_Segments STG_Airports --> ODS_Airports @@ -134,7 +149,9 @@ graph LR %% Fact assembly (Main process) ODS_Segments -->|Main Stream| FACT_Sales ODS_Tickets -->|Join book_ref passenger_id| FACT_Sales - ODS_Flights -->|Join Times Status| FACT_Sales + ODS_Bookings -->|Join book_date| FACT_Sales + ODS_Flights -->|Join Times Status Route| FACT_Sales + ODS_Routes -->|Join Dep/Arr Airplane| FACT_Sales ODS_Boarding -->|LEFT JOIN Seat No| FACT_Sales %% Link dimensions to fact @@ -150,7 +167,7 @@ graph LR ## Пояснения к схеме (для студентов) -Эта диаграмма покрывает 100% таблиц источника и показывает логику их трансформации. Вот на что стоит обратить внимание при обучении: +Эта диаграмма покрывает основные таблицы источника и показывает логику их трансформации. Вот на что стоит обратить внимание при обучении: ### 1. Ветка справочников (Reference Data) @@ -160,26 +177,29 @@ graph LR ### 2. Ветка генерации измерений (Dimension Generation) -* **`tickets` → `dim.passengers`**: Это самая сложная трансформация для измерения. В источнике нет таблицы "Пассажиры". Мы должны объяснить студентам, что мы "майним" пассажиров из билетов. Важно: один и тот же пассажир может иметь разные записи с разными именами (опечатки, изменение фамилии), поэтому нужна логика SCD Type 2 для отслеживания изменений. +* **`tickets` → `dim.passengers`**: Это самая сложная трансформация для измерения. В источнике нет таблицы "Пассажиры". Мы должны объяснить студентам, что мы "майним" пассажиров из билетов. Важно: один и тот же пассажир может иметь разные записи с разными именами (опечатки, изменение фамилии), поэтому в проде часто делают логику SCD Type 2 для отслеживания изменений. + - Для домашки (и первого эталонного решения) обычно достаточно **SCD Type 1**: одна актуальная запись на `passenger_id`, а SCD2 можно оставить как усложнение. -* **`segments` → `dim.tariffs`**: Таблицы тарифов физически нет в источнике, она "зашита" строкой (Economy, Business) в таблице полётов. Мы выносим её в отдельный справочник (Нормализация), чтобы в факте хранить маленький `INT` ключ, а не длинную строку. +* **`segments` → `dim.tariffs`**: Таблицы тарифов физически нет в источнике, она хранится строкой (`fare_conditions`: Economy/Comfort/Business) в таблице `segments`. Мы выносим её в отдельный справочник (нормализация), чтобы в факте хранить маленький `INT` ключ, а не длинную строку. ### 3. Сборка Факта (`fact.flight_sales`) -Это центр звезды. Мы собираем его из четырёх ODS таблиц: +Это центр звезды. Мы собираем его из шести ODS таблиц: -1. **`ods.segments`**: Основа (зерно факта — один полётный сегмент билета). Дает сумму (`amount`). +1. **`ods.segments`**: Основа (зерно факта — один полётный сегмент билета). Дает стоимость (`price`). 2. **`ods.tickets`**: Приджойниваем, чтобы получить `book_ref` и `passenger_id`. -3. **`ods.flights`**: Приджойниваем, чтобы получить точное время вылета/прилета (для FK на календарь) и статусы. -4. **`ods.boarding_passes`**: Приджойниваем (LEFT JOIN), чтобы узнать, **сел ли пассажир реально в самолёт** и на какое место (`seat_no`). Это важный бизнес-аспект: билет куплен, но посадочный не выдан = пассажир не летел. +3. **`ods.bookings`**: Приджойниваем по `book_ref`, чтобы получить `book_date` (дата покупки). +4. **`ods.flights`**: Приджойниваем, чтобы получить расписание/факт времени и статус рейса, а также `route_no` (связка на маршруты). +5. **`ods.routes`**: Приджойниваем по `route_no`, чтобы получить аэропорты вылета/прилёта и `airplane_code` (в `flights` этих полей нет напрямую). +6. **`ods.boarding_passes`**: Приджойниваем (LEFT JOIN), чтобы узнать, **сел ли пассажир реально в самолёт** и на какое место (`seat_no`). Это важный бизнес-аспект: билет куплен, но посадочный не выдан = пассажир не летел. ### 4. Почему нет `dim.bookings`? -В классической Star Schema измерения — это справочники (airports, aircrafts, passengers), а факты — транзакции/события (sales, bookings). +В классической Star Schema измерения — это справочники (airports, airplanes, passengers), а факты — транзакции/события (sales, bookings). `bookings` — это транзакционная таблица, а не справочник. Вместо отдельного измерения `dim.bookings` мы храним: - `book_ref` — бизнес-ключ бронирования (в факте) -- `book_date` — дата бронирования (в факте) +- `book_date` — дата бронирования (в факте, берём из `ods.bookings` по `book_ref`) Это позволяет отвечать на вопросы типа: *"За сколько дней до вылета люди обычно покупают билеты?"* (разница между `book_date` и датой вылета из `dim.calendar`). @@ -214,7 +234,7 @@ graph LR ## TODO -- [ ] Реализовать STG слой полностью (все 8 таблиц) +- [ ] Реализовать STG слой полностью (все 9 таблиц) - [ ] Реализовать DQ слой для всех таблиц - [ ] Реализовать ODS слой - [ ] Реализовать DDS слой (измерения и факт) From 520eacbfe2129120b3cdca3504efc3bd7b0b22c9 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 17 Jan 2026 23:14:49 +0300 Subject: [PATCH 09/38] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=20GLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/internal/db_schema.md | 164 +++++++++++++++++++++++++------------ 1 file changed, 113 insertions(+), 51 deletions(-) diff --git a/docs/internal/db_schema.md b/docs/internal/db_schema.md index ac85d20..634ea6b 100644 --- a/docs/internal/db_schema.md +++ b/docs/internal/db_schema.md @@ -6,13 +6,21 @@ Эта документация описывает архитектуру хранилища данных (DWH) для учебного проекта Airflow + Greenplum. Источник данных — демо-БД `bookings` (Postgres). -### Ключевые договорённости (для LLM и студентов) +**Целевые аудитории:** +- **LLM/Разработчики**: Технические спецификации для реализации (см. раздел "Спецификации для реализации") +- **Студенты**: Обучающие материалы и пояснения (см. раздел "Обучающие материалы") -- **Источник**: используем основные таблицы схемы `bookings` (табличные данные, не `VIEW`). -- **Зерно факта `fact.flight_sales`**: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`, источник: `segments`). -- **Обязательная связь для аэропортов и самолёта**: `flights.route_no → routes → (departure_airport, arrival_airport, airplane_code)`. -- **Даты**: как минимум различаем `book_date` (дата покупки) и `scheduled_departure` (дата/время вылета). -- **Инкремент в STG**: для `tickets` опорная дата берётся из `bookings.book_date`, потому что в `tickets` нет собственного поля времени изменения. +--- + +## Спецификации для реализации (для LLM и разработчиков) + +### Ключевые договорённости + +- **Источник**: используем основные таблицы схемы `bookings` (табличные данные, не `VIEW`) +- **Зерно факта `fact.flight_sales`**: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`, источник: `segments`) +- **Обязательная связь для аэропортов и самолёта**: `flights.route_no → routes → (departure_airport, arrival_airport, airplane_code)` +- **Даты**: как минимум различаем `book_date` (дата покупки) и `scheduled_departure` (дата/время вылета) +- **Инкремент в STG**: для `tickets` опорная дата берётся из `bookings.book_date`, потому что в `tickets` нет собственного поля времени изменения ### Статус реализации по слоям @@ -20,10 +28,64 @@ |------|--------|-------------| | **Source** | ✅ Готово | Демо-БД bookings (Postgres) | | **STG** | ⚠️ В процессе | 2 из 9 таблиц (bookings, tickets) | -| **DQ** | ⚠️ В процессе | Есть скрипты для bookings и tickets | | **ODS** | ❌ Не реализован | Планируется | | **DDS** | ❌ Не реализован | Планируется | +### Архитектура слоёв + +#### STG (Staging Layer) +- **Назначение**: Сырой слой, максимально близкий к источнику, без бизнес-логики +- **Хранение**: AO-Row (Append-Only Row-oriented) для эффективной загрузки больших объёмов +- **Типы данных**: Бизнес-колонки как `TEXT`, тех.колонки как `TIMESTAMP` +- **Инкрементальная загрузка**: Опорное поле `src_created_at_ts` (из `book_date` для tickets) +- **Технологические колонки**: + - `src_created_at_ts TIMESTAMP` — дата/время из источника для инкремента + - `load_dttm TIMESTAMP NOT NULL DEFAULT now()` — когда запись была загружена + - `batch_id TEXT NOT NULL` — идентификатор пачки (например, `{{ ds_nodash }}`) + +#### ODS (Operational Data Store) +- **Назначение**: Очищенные данные в 3NF, готовые для аналитики +- **Хранение**: Heap для частых чтений и обновлений +- **Трансформации**: Очистка, приведение типов, нормализация +- **Связи**: Все связи через бизнес-ключи (без суррогатных ключей) + +#### DDS (Data Delivery System) +- **Назначение**: Star Schema для аналитики и отчётности +- **Хранение**: Heap или AO-CO (Append-Only Column-oriented) для аналитических запросов +- **Структура**: Измерения (Dimensions) + Факты (Facts) +- **Ключи**: Суррогатные ключи (SK) для измерений, FK в фактах + +### Измерения DDS (Dimensions) + +| Измерение | Бизнес-ключ | Суррогатный ключ | Атрибуты | +|-----------|-------------|------------------|----------| +| `dim.calendar` | `date DATE` | `calendar_sk INT` | `year`, `month`, `day`, `day_of_week`, `is_holiday` | +| `dim.airports` | `airport_code CHAR(3)` | `airport_sk INT` | `airport_name`, `city`, `timezone`, `coordinates` | +| `dim.airplanes` | `airplane_code TEXT` | `airplane_sk INT` | `model`, `total_seats`, `range_km` | +| `dim.tariffs` | `fare_conditions TEXT` | `tariff_sk INT` | `fare_conditions` (Economy/Comfort/Business) | +| `dim.passengers` | `passenger_id TEXT` | `passenger_sk INT` | `passenger_name` (SCD Type 1) | + +### Факт DDS (Fact) + +`fact.flight_sales`: +- **Зерно**: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`) +- **FK на измерения**: + - `calendar_sk` — ссылка на дату вылета + - `departure_airport_sk` — аэропорт вылета + - `arrival_airport_sk` — аэропорт прилёта + - `airplane_sk` — самолёт + - `tariff_sk` — тариф + - `passenger_sk` — пассажир +- **Метрики**: + - `price NUMERIC` — стоимость сегмента + - `is_boarded BOOLEAN` — сел ли пассажир в самолёт (из boarding_passes) +- **Атрибуты**: + - `book_ref TEXT` — бизнес-ключ бронирования + - `book_date DATE` — дата покупки + - `ticket_no TEXT` — номер билета + - `flight_id INT` — ID рейса + - `seat_no TEXT` — место (если есть) + --- ## Полная схема потоков данных (Data Lineage) @@ -33,7 +95,6 @@ graph LR %% Стили classDef source fill:#e1f5fe,stroke:#01579b,stroke-width:2px; classDef stg fill:#fff9c4,stroke:#fbc02d,stroke-width:2px; - classDef dq fill:#ffe0b2,stroke:#ef6c00,stroke-width:2px; classDef ods fill:#e0f2f1,stroke:#00695c,stroke-width:2px; classDef dim fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px; classDef fact fill:#ffccbc,stroke:#bf360c,stroke-width:4px; @@ -77,24 +138,7 @@ graph LR SRC_Segments --> STG_Segments SRC_Boarding --> STG_Boarding - %% 3. DATA QUALITY (Quality Checks) - subgraph DQ_Layer [Layer: DQ Data Quality] - direction TB - DQ_Bookings[dq.bookings_checks]:::dq - DQ_Tickets[dq.tickets_checks]:::dq - DQ_Routes[dq.routes_checks]:::dq - DQ_Flights[dq.flights_checks]:::dq - DQ_Segments[dq.segments_checks]:::dq - end - - %% Links STG to DQ - STG_Bookings --> DQ_Bookings - STG_Tickets --> DQ_Tickets - STG_Routes --> DQ_Routes - STG_Flights --> DQ_Flights - STG_Segments --> DQ_Segments - - %% 4. ODS (3NF, Clean, Type, Heap) + %% 3. ODS (3NF, Clean, Type, Heap) subgraph ODS_Layer [Layer: ODS Operational Core] direction TB ODS_Airports[ods.airports]:::ods @@ -108,18 +152,18 @@ graph LR ODS_Boarding[ods.boarding_passes]:::ods end - %% Links DQ to ODS - DQ_Bookings --> ODS_Bookings - DQ_Tickets --> ODS_Tickets - DQ_Routes --> ODS_Routes - DQ_Flights --> ODS_Flights - DQ_Segments --> ODS_Segments + %% Links STG to ODS STG_Airports --> ODS_Airports STG_Airplanes --> ODS_Airplanes + STG_Routes --> ODS_Routes STG_Seats --> ODS_Seats + STG_Bookings --> ODS_Bookings + STG_Tickets --> ODS_Tickets + STG_Flights --> ODS_Flights + STG_Segments --> ODS_Segments STG_Boarding --> ODS_Boarding - %% 5. DDS (Star Schema) + %% 4. DDS (Star Schema) subgraph DDS_Layer [Layer: DDS Star Schema] direction TB @@ -165,24 +209,41 @@ graph LR --- -## Пояснения к схеме (для студентов) +## Обучающие материалы (для студентов) + +### Глоссарий ключевых терминов + +| Термин | Объяснение | +|--------|-----------| +| **Зерно факта (Fact Grain)** | Минимальная единица измерения в факте. Для `fact.flight_sales` — это один сегмент билета. | +| **Суррогатный ключ (Surrogate Key, SK)** | Технический ключ (обычно INT), который генерируется в DWH и не зависит от бизнес-ключа. | +| **Бизнес-ключ (Business Key)** | Ключ из источника (например, `airport_code`, `passenger_id`). | +| **Star Schema** | Модель данных, где факт в центре, а измерения вокруг него (как звезда). | +| **SCD Type 1** | Slowly Changing Dimension Type 1: при изменении данных просто перезаписываем старую запись. | +| **SCD Type 2** | Slowly Changing Dimension Type 2: при изменении данных создаём новую запись с датой начала/действия. | +| **AO-Row** | Append-Only Row-oriented: хранение данных по строкам, только добавление (без UPDATE/DELETE). | +| **Heap** | Обычное хранение данных (как в обычной таблице), поддерживает UPDATE/DELETE. | +| **Инкрементальная загрузка** | Загрузка только новых/изменённых данных за период, а не всей таблицы. | + +### Пояснения к схеме Эта диаграмма покрывает основные таблицы источника и показывает логику их трансформации. Вот на что стоит обратить внимание при обучении: -### 1. Ветка справочников (Reference Data) +#### 1. Ветка справочников (Reference Data) -* **`seats` + `airplanes` → `dim.airplanes`**: Здесь мы показываем пример **обогащения**. Таблица `seats` сама по себе в аналитике редко нужна отдельной сущностью. Мы используем её в ODS, чтобы посчитать общее количество мест (`total_seats`) и добавить это как атрибут в измерение самолётов (`dim.airplanes`). +**`seats` + `airplanes` → `dim.airplanes`**: Здесь мы показываем пример **обогащения**. Таблица `seats` сама по себе в аналитике редко нужна отдельной сущностью. Мы используем её в ODS, чтобы посчитать общее количество мест (`total_seats`) и добавить это как атрибут в измерение самолётов (`dim.airplanes`). -* **`airports` → `dim.airports`**: Простой перенос (1-в-1), но в DDS мы можем добавить, например, поле `city_ru` и `city_en` как отдельные колонки, убрав JSON, который есть в источнике. +**`airports` → `dim.airports`**: Простой перенос (1-в-1), но в DDS мы можем добавить, например, поле `city_ru` и `city_en` как отдельные колонки, убрав JSON, который есть в источнике. -### 2. Ветка генерации измерений (Dimension Generation) +#### 2. Ветка генерации измерений (Dimension Generation) -* **`tickets` → `dim.passengers`**: Это самая сложная трансформация для измерения. В источнике нет таблицы "Пассажиры". Мы должны объяснить студентам, что мы "майним" пассажиров из билетов. Важно: один и тот же пассажир может иметь разные записи с разными именами (опечатки, изменение фамилии), поэтому в проде часто делают логику SCD Type 2 для отслеживания изменений. - - Для домашки (и первого эталонного решения) обычно достаточно **SCD Type 1**: одна актуальная запись на `passenger_id`, а SCD2 можно оставить как усложнение. +**`tickets` → `dim.passengers`**: Это самая сложная трансформация для измерения. В источнике нет таблицы "Пассажиры". Мы должны объяснить студентам, что мы "майним" пассажиров из билетов. Важно: один и тот же пассажир может иметь разные записи с разными именами (опечатки, изменение фамилии), поэтому в проде часто делают логику SCD Type 2 для отслеживания изменений. -* **`segments` → `dim.tariffs`**: Таблицы тарифов физически нет в источнике, она хранится строкой (`fare_conditions`: Economy/Comfort/Business) в таблице `segments`. Мы выносим её в отдельный справочник (нормализация), чтобы в факте хранить маленький `INT` ключ, а не длинную строку. +- Для домашки (и первого эталонного решения) обычно достаточно **SCD Type 1**: одна актуальная запись на `passenger_id`, а SCD2 можно оставить как усложнение. -### 3. Сборка Факта (`fact.flight_sales`) +**`segments` → `dim.tariffs`**: Таблицы тарифов физически нет в источнике, она хранится строкой (`fare_conditions`: Economy/Comfort/Business) в таблице `segments`. Мы выносим её в отдельный справочник (нормализация), чтобы в факте хранить маленький `INT` ключ, а не длинную строку. + +#### 3. Сборка Факта (`fact.flight_sales`) Это центр звезды. Мы собираем его из шести ODS таблиц: @@ -193,9 +254,9 @@ graph LR 5. **`ods.routes`**: Приджойниваем по `route_no`, чтобы получить аэропорты вылета/прилёта и `airplane_code` (в `flights` этих полей нет напрямую). 6. **`ods.boarding_passes`**: Приджойниваем (LEFT JOIN), чтобы узнать, **сел ли пассажир реально в самолёт** и на какое место (`seat_no`). Это важный бизнес-аспект: билет куплен, но посадочный не выдан = пассажир не летел. -### 4. Почему нет `dim.bookings`? +#### 4. Почему нет `dim.bookings`? -В классической Star Schema измерения — это справочники (airports, airplanes, passengers), а факты — транзакции/события (sales, bookings). +В классической Star Schema измерения — это справочники (airports, airplanes, passengers), а факты — транзакции/события (sales, bookings). `bookings` — это транзакционная таблица, а не справочник. Вместо отдельного измерения `dim.bookings` мы храним: - `book_ref` — бизнес-ключ бронирования (в факте) @@ -203,7 +264,7 @@ graph LR Это позволяет отвечать на вопросы типа: *"За сколько дней до вылета люди обычно покупают билеты?"* (разница между `book_date` и датой вылета из `dim.calendar`). -### 5. Суррогатные ключи (Surrogate Keys) +#### 5. Суррогатные ключи (Surrogate Keys) В Star Schema факт должен ссылаться на суррогатные ключи (SK) измерений, а не на бизнес-ключи: @@ -213,13 +274,14 @@ graph LR | `airplane_code TEXT` | `airplane_sk INT` | Меньший размер, стабильность | | `passenger_id TEXT` | `passenger_sk INT` | Меньший размер, отслеживание изменений | -### 6. Слой DQ (Data Quality) +--- -Между STG и ODS добавлен слой Data Quality для проверки качества данных. В проекте уже есть скрипты: -- `sql/stg/bookings_dq.sql` -- `sql/stg/tickets_dq.sql` +## Связанные документы -На схеме показаны примеры проверок для всех таблиц, которые должны быть реализованы. +- [`docs/internal/bookings_stg_design.md`](docs/internal/bookings_stg_design.md) — Детальный дизайн STG слоя для bookings +- [`docs/internal/bookings_tz.md`](docs/internal/bookings_tz.md) — Работа с часовыми поясами в источнике +- [`docs/internal/pxf_bookings.md`](docs/internal/pxf_bookings.md) — Настройка PXF для чтения из bookings-db +- [`TESTING.md`](TESTING.md) — Пошаговый чек-лист для тестирования стенда --- @@ -227,6 +289,7 @@ graph LR | Дата | Версия | Описание изменений | |------|--------|-------------------| +| 2025-01-17 | 2.0 | Удалён слой DQ для упрощения учебного стенда. Добавлены спецификации для LLM и обучающие материалы для студентов. Добавлен глоссарий терминов. | | 2025-01-17 | 1.1 | Исправлены названия таблиц (`aircrafts_data` → `airplanes_data`, `ticket_flights` → `segments`), удалено `dim.bookings`, добавлены суррогатные ключи, добавлен слой DQ, исправлены связи | | 2025-01-XX | 1.0 | Первоначальная версия | @@ -235,7 +298,6 @@ graph LR ## TODO - [ ] Реализовать STG слой полностью (все 9 таблиц) -- [ ] Реализовать DQ слой для всех таблиц - [ ] Реализовать ODS слой - [ ] Реализовать DDS слой (измерения и факт) - [ ] Создать DAG для загрузки ODS From d525cf56c47740c0fef03a5af5790b1b253fef93 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 17 Jan 2026 23:19:37 +0300 Subject: [PATCH 10/38] =?UTF-8?q?=D0=A4=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/internal/db_schema.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/internal/db_schema.md b/docs/internal/db_schema.md index 634ea6b..cfcc451 100644 --- a/docs/internal/db_schema.md +++ b/docs/internal/db_schema.md @@ -21,6 +21,7 @@ - **Обязательная связь для аэропортов и самолёта**: `flights.route_no → routes → (departure_airport, arrival_airport, airplane_code)` - **Даты**: как минимум различаем `book_date` (дата покупки) и `scheduled_departure` (дата/время вылета) - **Инкремент в STG**: для `tickets` опорная дата берётся из `bookings.book_date`, потому что в `tickets` нет собственного поля времени изменения +- **DQ-проверки**: проверки качества данных выполняем SQL-скриптами, но **не сохраняем результаты в отдельные таблицы/слой DQ** (при проблемах падаем с понятной ошибкой и останавливаем пайплайн) ### Статус реализации по слоям @@ -41,7 +42,8 @@ - **Технологические колонки**: - `src_created_at_ts TIMESTAMP` — дата/время из источника для инкремента - `load_dttm TIMESTAMP NOT NULL DEFAULT now()` — когда запись была загружена - - `batch_id TEXT NOT NULL` — идентификатор пачки (например, `{{ ds_nodash }}`) + - `batch_id TEXT` — идентификатор пачки (рекомендуем `NOT NULL`, например `{{ ds_nodash }}` или `{{ run_id }}`) +- **DQ-проверки (после загрузки STG)**: отдельные SQL-скрипты, которые валидируют данные (counts, дубли, NULL, orphan records) и при ошибке делают `RAISE EXCEPTION`; примеры: `sql/stg/bookings_dq.sql`, `sql/stg/tickets_dq.sql` #### ODS (Operational Data Store) - **Назначение**: Очищенные данные в 3NF, готовые для аналитики @@ -59,9 +61,9 @@ | Измерение | Бизнес-ключ | Суррогатный ключ | Атрибуты | |-----------|-------------|------------------|----------| -| `dim.calendar` | `date DATE` | `calendar_sk INT` | `year`, `month`, `day`, `day_of_week`, `is_holiday` | +| `dim.calendar` | `date DATE` | `calendar_sk INT` | `year`, `month`, `day`, `day_of_week`, `is_holiday` (опционально) | | `dim.airports` | `airport_code CHAR(3)` | `airport_sk INT` | `airport_name`, `city`, `timezone`, `coordinates` | -| `dim.airplanes` | `airplane_code TEXT` | `airplane_sk INT` | `model`, `total_seats`, `range_km` | +| `dim.airplanes` | `airplane_code CHAR(3)` | `airplane_sk INT` | `model`, `total_seats`, `range_km` | | `dim.tariffs` | `fare_conditions TEXT` | `tariff_sk INT` | `fare_conditions` (Economy/Comfort/Business) | | `dim.passengers` | `passenger_id TEXT` | `passenger_sk INT` | `passenger_name` (SCD Type 1) | @@ -278,10 +280,10 @@ graph LR ## Связанные документы -- [`docs/internal/bookings_stg_design.md`](docs/internal/bookings_stg_design.md) — Детальный дизайн STG слоя для bookings -- [`docs/internal/bookings_tz.md`](docs/internal/bookings_tz.md) — Работа с часовыми поясами в источнике -- [`docs/internal/pxf_bookings.md`](docs/internal/pxf_bookings.md) — Настройка PXF для чтения из bookings-db -- [`TESTING.md`](TESTING.md) — Пошаговый чек-лист для тестирования стенда +- [`docs/internal/bookings_stg_design.md`](bookings_stg_design.md) — Детальный дизайн STG слоя для bookings +- [`docs/internal/bookings_tz.md`](bookings_tz.md) — Работа с часовыми поясами в источнике +- [`docs/internal/pxf_bookings.md`](pxf_bookings.md) — Настройка PXF для чтения из bookings-db +- [`TESTING.md`](../../TESTING.md) — Пошаговый чек-лист для тестирования стенда --- From 457b47e415da83b0dd6d6d885f2585f9c428b0c2 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sat, 17 Jan 2026 23:53:20 +0300 Subject: [PATCH 11/38] =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=20by=20glm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plans/stg_layer_implementation_plan.md | 730 +++++++++++++++++++++++++ 1 file changed, 730 insertions(+) create mode 100644 plans/stg_layer_implementation_plan.md diff --git a/plans/stg_layer_implementation_plan.md b/plans/stg_layer_implementation_plan.md new file mode 100644 index 0000000..c149f6c --- /dev/null +++ b/plans/stg_layer_implementation_plan.md @@ -0,0 +1,730 @@ +# План реализации STG слоя целиком + +> **Статус:** План готов к реализации +> **Дата:** 2026-01-17 +> **Автор:** Architect Mode + +## Обзор задачи + +Согласно [`docs/internal/db_schema.md`](../docs/internal/db_schema.md:31), STG слой реализован частично (2 из 9 таблиц: bookings, tickets). Необходимо реализовать оставшиеся 7 таблиц. + +## Стратегия загрузки данных + +| Тип таблиц | Стратегия | Обоснование | +|-----------|-----------|-------------| +| Справочники (airports, airplanes, routes, seats) | **Full load** | Маленький объём (<10K строк), простота реализации | +| Транзакции (flights, segments, boarding_passes) | **Инкремент по дате** | Больший объём, необходимость отслеживания изменений | + +### Оценка размера справочников + +| Справочник | Примерный размер | Оценка | +|-------------|------------------|---------| +| **airports** | ~700-800 аэропортов | **Маленький** | +| **airplanes** | ~10 моделей самолётов | **Крошечный** | +| **seats** | ~1700-2000 записей | **Маленький** | +| **routes** | Ожидается несколько тысяч | **Маленький/Средний** | + +**Вывод:** Все справочники очень маленькие (до нескольких тысяч строк). Даже если routes будет 5000-10000 строк - это всё равно минимальный объём для Greenplum. + +## Список таблиц для реализации + +| Таблица источника | Таблица STG | Тип данных | Стратегия загрузки | Опорное поле для инкремента | +|-------------------|-------------|------------|-------------------|---------------------------| +| `bookings.airports_data` | `stg.airports` | Справочник | Full | - | +| `bookings.airplanes_data` | `stg.airplanes` | Справочник | Full | - | +| `bookings.routes` | `stg.routes` | Справочник | Full | - | +| `bookings.seats` | `stg.seats` | Справочник | Full | - | +| `bookings.flights` | `stg.flights` | Транзакции | Инкремент | `scheduled_departure` | +| `bookings.segments` | `stg.segments` | Транзакции | Инкремент | `book_date` (через tickets) | +| `bookings.boarding_passes` | `stg.boarding_passes` | Транзакции | Инкремент | `book_date` (через tickets) | + +## Паттерн реализации (на основе bookings/tickets) + +Для каждой таблицы создаются 3 файла: + +1. **`sql/stg/{table}_ddl.sql`** - DDL для внешней и внутренней таблиц +2. **`sql/stg/{table}_load.sql`** - Загрузка (full или инкремент) +3. **`sql/stg/{table}_dq.sql`** - Проверки качества данных + +### Общая структура DDL файла + +```sql +-- DDL для слоя STG по таблице {table}. +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.{table} через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.{table}_ext; +CREATE EXTERNAL TABLE stg.{table}_ext ( + -- поля из источника +) +LOCATION ('pxf://bookings.{table}?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.{table} — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.{table} ( + -- бизнес-колонки как 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 ({distribution_key}); +``` + +### Общая структура LOAD файла (Full load для справочников) + +```sql +-- Загрузка всех строк из stg.{table}_ext в stg.{table}. +-- Используем batch_id для отслеживания загрузки. + +INSERT INTO stg.{table} ( + -- бизнес-колонки + src_created_at_ts, + load_dttm, + batch_id +) +SELECT + ext.{field}::text, + now()::timestamp, + '{{ run_id }}'::text +FROM stg.{table}_ext AS ext +WHERE NOT EXISTS ( + -- Защита от дублей в рамках одного batch_id + SELECT 1 + FROM stg.{table} AS t + WHERE t.batch_id = '{{ run_id }}'::text + AND t.{pk} = ext.{pk}::text +); + +-- Обновляем статистику для оптимизатора Greenplum +ANALYZE stg.{table}; +``` + +### Общая структура LOAD файла (Инкремент для транзакций) + +```sql +-- Загрузка инкремента из stg.{table}_ext в stg.{table}. +-- Окно инкремента определяется по src_created_at_ts: +-- берём строки, где {increment_field} больше максимального src_created_at_ts +-- среди "старых" батчей; верхняя граница по дате не используется. + +-- CTE для определения максимальной даты загрузки предыдущего батча +WITH max_batch_ts AS ( + SELECT COALESCE(MAX(src_created_at_ts), TIMESTAMP '1900-01-01 00:00:00') AS max_ts + FROM stg.{table} + WHERE batch_id <> '{{ run_id }}'::text + OR batch_id IS NULL +) +INSERT INTO stg.{table} ( + -- бизнес-колонки + src_created_at_ts, + load_dttm, + batch_id +) +SELECT + ext.{field}::text, + ext.{increment_field}::timestamp, + now(), + '{{ run_id }}'::text +FROM stg.{table}_ext AS ext +CROSS JOIN max_batch_ts AS mb +WHERE ext.{increment_field} > mb.max_ts +AND NOT EXISTS ( + SELECT 1 + FROM stg.{table} AS t + WHERE t.batch_id = '{{ run_id }}'::text + AND t.{pk} = ext.{pk}::text +); + +-- Обновляем статистику для оптимизатора Greenplum +ANALYZE stg.{table}; +``` + +### Общая структура DQ файла + +```sql +-- Проверки качества данных для {table} + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_prev_ts TIMESTAMP; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; + -- другие переменные для специфических проверок +BEGIN + -- Опорная метка: максимум src_created_at_ts среди предыдущих батчей + SELECT max(src_created_at_ts) + INTO v_prev_ts + FROM stg.{table} + WHERE batch_id <> v_batch_id + OR batch_id IS NULL; + + -- Источник: считаем строки во внешней таблице, которые вошли в окно инкремента + SELECT COUNT(*) + INTO v_src_count + FROM stg.{table}_ext + WHERE {increment_field} > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + + IF v_src_count = 0 THEN + RAISE EXCEPTION + 'В источнике {table}_ext нет строк для окна инкремента.'; + END IF; + + -- Считаем строки, реально вставленные в stg.{table} в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.{table} + WHERE batch_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты первичного ключа + SELECT COUNT(*) - COUNT(DISTINCT {pk}) + INTO v_dup_count + FROM stg.{table} AS t + WHERE t.batch_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты {pk} (batch_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей + SELECT COUNT(*) + INTO v_null_count + FROM stg.{table} AS t + WHERE t.batch_id = v_batch_id + AND (t.{required_field} IS NULL OR t.{required_field} = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (batch_id=%): %', + v_batch_id, + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: {table} ок (batch_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; +``` + +## Детали реализации по таблицам + +### 1. airports (справочник, full load) + +**Внешняя таблица**: `stg.airports_ext` +- Поля: `airport_code`, `airport_name` (JSONB), `city` (JSONB), `country` (JSONB), `coordinates`, `timezone` +- PXF: `pxf://bookings.airports_data?PROFILE=JDBC&SERVER=bookings-db` + +**Внутренняя таблица**: `stg.airports` +- Бизнес-колонки как TEXT: + - `airport_code TEXT` + - `airport_name TEXT` + - `city TEXT` + - `country TEXT` + - `coordinates TEXT` + - `timezone TEXT` +- Тех.колонки: `src_created_at_ts`, `load_dttm`, `batch_id` +- Распределение: `DISTRIBUTED BY (airport_code)` +- Обоснование: `airport_code` — это уникальный идентификатор аэропорта + +**Загрузка**: Full (все строки при каждом запуске) + +**DQ проверки**: +- Count между источником и STG +- Дубликаты `airport_code` +- NULL обязательных полей (airport_code, airport_name, city, timezone) + +### 2. airplanes (справочник, full load) + +**Внешняя таблица**: `stg.airplanes_ext` +- Поля: `airplane_code`, `model` (JSONB), `range`, `speed` +- PXF: `pxf://bookings.airplanes_data?PROFILE=JDBC&SERVER=bookings-db` + +**Внутренняя таблица**: `stg.airplanes` +- Бизнес-колонки как TEXT: + - `airplane_code TEXT` + - `model TEXT` + - `range TEXT` + - `speed TEXT` +- Тех.колонки: `src_created_at_ts`, `load_dttm`, `batch_id` +- Распределение: `DISTRIBUTED BY (airplane_code)` +- Обоснование: `airplane_code` — это уникальный идентификатор самолёта + +**Загрузка**: Full + +**DQ проверки**: +- Count между источником и STG +- Дубликаты `airplane_code` +- NULL обязательных полей (airplane_code, model) + +### 3. routes (справочник, full load) + +**Внешняя таблица**: `stg.routes_ext` +- Поля: `route_no`, `validity` (tstzrange), `departure_airport`, `arrival_airport`, `airplane_code`, `days_of_week` (int[]), `scheduled_time`, `duration` +- PXF: `pxf://bookings.routes?PROFILE=JDBC&SERVER=bookings-db` + +**Внутренняя таблица**: `stg.routes` +- Бизнес-колонки как TEXT: + - `route_no TEXT` + - `validity TEXT` + - `departure_airport TEXT` + - `arrival_airport TEXT` + - `airplane_code TEXT` + - `days_of_week TEXT` + - `scheduled_time TEXT` + - `duration TEXT` +- Тех.колонки: `src_created_at_ts`, `load_dttm`, `batch_id` +- Распределение: `DISTRIBUTED BY (route_no)` +- Обоснование: `route_no` — это уникальный идентификатор маршрута + +**Загрузка**: Full + +**DQ проверки**: +- Count между источником и STG +- Дубликаты `route_no` +- NULL обязательных полей (route_no, departure_airport, arrival_airport, airplane_code) +- Ссылочная целостность на airports (departure_airport, arrival_airport) +- Ссылочная целостность на airplanes (airplane_code) + +### 4. seats (справочник, full load) + +**Внешняя таблица**: `stg.seats_ext` +- Поля: `airplane_code`, `seat_no`, `fare_conditions` +- PXF: `pxf://bookings.seats?PROFILE=JDBC&SERVER=bookings-db` + +**Внутренняя таблица**: `stg.seats` +- Бизнес-колонки как TEXT: + - `airplane_code TEXT` + - `seat_no TEXT` + - `fare_conditions TEXT` +- Тех.колонки: `src_created_at_ts`, `load_dttm`, `batch_id` +- Распределение: `DISTRIBUTED BY (airplane_code)` +- Обоснование: co-location с airplanes для оптимизации JOIN + +**Загрузка**: Full + +**DQ проверки**: +- Count между источником и STG +- Дубликаты `(airplane_code, seat_no)` +- NULL обязательных полей (airplane_code, seat_no, fare_conditions) +- Ссылочная целостность на airplanes (airplane_code) + +### 5. flights (транзакции, инкремент) + +**Внешняя таблица**: `stg.flights_ext` +- Поля: `flight_id`, `route_no`, `status`, `scheduled_departure`, `scheduled_arrival`, `actual_departure`, `actual_arrival` +- PXF: `pxf://bookings.flights?PROFILE=JDBC&SERVER=bookings-db` + +**Внутренняя таблица**: `stg.flights` +- Бизнес-колонки как TEXT: + - `flight_id TEXT` + - `route_no TEXT` + - `status TEXT` + - `scheduled_departure TEXT` + - `scheduled_arrival TEXT` + - `actual_departure TEXT` + - `actual_arrival TEXT` +- Тех.колонки: `src_created_at_ts`, `load_dttm`, `batch_id` +- `src_created_at_ts` = `scheduled_departure` +- Распределение: `DISTRIBUTED BY (flight_id)` +- Обоснование: `flight_id` — это уникальный идентификатор рейса + +**Загрузка**: Инкремент по `scheduled_departure` + +**DQ проверки**: +- Count между источником и STG +- Дубликаты `flight_id` +- NULL обязательных полей (flight_id, route_no, status, scheduled_departure) +- Ссылочная целостность на routes (route_no) + +### 6. segments (транзакции, инкремент) + +**Внешняя таблица**: `stg.segments_ext` +- Поля: `ticket_no`, `flight_id`, `fare_conditions`, `price` +- PXF: `pxf://bookings.segments?PROFILE=JDBC&SERVER=bookings-db` + +**Внутренняя таблица**: `stg.segments` +- Бизнес-колонки как TEXT: + - `ticket_no TEXT` + - `flight_id TEXT` + - `fare_conditions TEXT` + - `price TEXT` +- Тех.колонки: `src_created_at_ts`, `load_dttm`, `batch_id` +- `src_created_at_ts` = берётся из `bookings.book_date` через JOIN с tickets +- Распределение: `DISTRIBUTED BY (ticket_no)` +- Обоснование: co-location с tickets для оптимизации JOIN + +**Загрузка**: Инкремент по `book_date` (как в tickets) + +**DQ проверки**: +- Count между источником и STG +- Дубликаты `(ticket_no, flight_id)` +- NULL обязательных полей (ticket_no, flight_id, fare_conditions, price) +- Ссылочная целостность на tickets (ticket_no) +- Ссылочная целостность на flights (flight_id) + +### 7. boarding_passes (транзакции, инкремент) + +**Внешняя таблица**: `stg.boarding_passes_ext` +- Поля: `ticket_no`, `flight_id`, `seat_no`, `boarding_no`, `boarding_time` +- PXF: `pxf://bookings.boarding_passes?PROFILE=JDBC&SERVER=bookings-db` + +**Внутренняя таблица**: `stg.boarding_passes` +- Бизнес-колонки как TEXT: + - `ticket_no TEXT` + - `flight_id TEXT` + - `seat_no TEXT` + - `boarding_no TEXT` + - `boarding_time TEXT` +- Тех.колонки: `src_created_at_ts`, `load_dttm`, `batch_id` +- `src_created_at_ts` = берётся из `bookings.book_date` через JOIN с tickets +- Распределение: `DISTRIBUTED BY (ticket_no)` +- Обоснование: co-location с tickets/segments для оптимизации JOIN + +**Загрузка**: Инкремент по `book_date` (как в tickets) + +**DQ проверки**: +- Count между источником и STG +- Дубликаты `(ticket_no, flight_id)` +- NULL обязательных полей (ticket_no, flight_id) +- Ссылочная целостность на tickets (ticket_no) +- Ссылочная целостность на segments (ticket_no, flight_id) + +## Обновление существующих DAG + +### `airflow/dags/bookings_stg_ddl.py` + +Добавить задачи для создания DDL новых таблиц: + +```python +apply_stg_airports_ddl = PostgresOperator( + task_id="apply_stg_airports_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airports_ddl.sql", +) + +apply_stg_airplanes_ddl = PostgresOperator( + task_id="apply_stg_airplanes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airplanes_ddl.sql", +) + +apply_stg_routes_ddl = PostgresOperator( + task_id="apply_stg_routes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/routes_ddl.sql", +) + +apply_stg_seats_ddl = PostgresOperator( + task_id="apply_stg_seats_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/seats_ddl.sql", +) + +apply_stg_flights_ddl = PostgresOperator( + task_id="apply_stg_flights_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/flights_ddl.sql", +) + +apply_stg_segments_ddl = PostgresOperator( + task_id="apply_stg_segments_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/segments_ddl.sql", +) + +apply_stg_boarding_passes_ddl = PostgresOperator( + task_id="apply_stg_boarding_passes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/boardings_ddl.sql", +) +``` + +Зависимости: +- Сначала создаются справочники (airports, airplanes, routes, seats) +- Затем транзакционные таблицы (flights, segments, boarding_passes) + +### `airflow/dags/bookings_to_gp_stage.py` + +Добавить задачи для загрузки новых таблиц: + +```python +# Загрузка справочников (full load) +load_airports_to_stg = PostgresOperator( + task_id="load_airports_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airports_load.sql", +) + +check_airports_dq = PostgresOperator( + task_id="check_airports_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airports_dq.sql", +) + +load_airplanes_to_stg = PostgresOperator( + task_id="load_airplanes_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airplanes_load.sql", +) + +check_airplanes_dq = PostgresOperator( + task_id="check_airplanes_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airplanes_dq.sql", +) + +load_routes_to_stg = PostgresOperator( + task_id="load_routes_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/routes_load.sql", +) + +check_routes_dq = PostgresOperator( + task_id="check_routes_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/routes_dq.sql", +) + +load_seats_to_stg = PostgresOperator( + task_id="load_seats_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/seats_load.sql", +) + +check_seats_dq = PostgresOperator( + task_id="check_seats_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/seats_dq.sql", +) + +# Загрузка транзакций (инкремент) +load_flights_to_stg = PostgresOperator( + task_id="load_flights_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/flights_load.sql", +) + +check_flights_dq = PostgresOperator( + task_id="check_flights_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/flights_dq.sql", +) + +load_segments_to_stg = PostgresOperator( + task_id="load_segments_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/segments_load.sql", +) + +check_segments_dq = PostgresOperator( + task_id="check_segments_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/segments_dq.sql", +) + +load_boarding_passes_to_stg = PostgresOperator( + task_id="load_boarding_passes_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/boardings_load.sql", +) + +check_boarding_passes_dq = PostgresOperator( + task_id="check_boarding_passes_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/boardings_dq.sql", +) +``` + +Зависимости: +- Сначала загружаются и проверяются bookings и tickets (уже есть) +- Затем загружаются справочники (airports, airplanes, routes, seats) +- Затем загружаются транзакции (flights, segments, boarding_passes) +- В конце финальный лог + +### Обновление `sql/ddl_gp.sql` + +Добавить подключение новых DDL файлов: + +```sql +-- DDL для слоя stg по таблицам bookings и tickets вынесены в отдельные файлы. +-- Здесь подключаем их через psql \i, чтобы сохранить единый входной скрипт. +\i stg/bookings_ddl.sql +\i stg/tickets_ddl.sql + +-- DDL для новых таблиц STG слоя +\i stg/airports_ddl.sql +\i stg/airplanes_ddl.sql +\i stg/routes_ddl.sql +\i stg/seats_ddl.sql +\i stg/flights_ddl.sql +\i stg/segments_ddl.sql +\i stg/boardings_ddl.sql +``` + +### Добавление тестов + +Обновить `tests/test_dags_smoke.py` для проверки структуры обновлённых DAG: + +```python +def test_bookings_stg_ddl_dag_structure(): + dag = _load_dag("airflow.dags.bookings_stg_ddl") + + expected_tasks = { + "apply_stg_bookings_ddl", + "apply_stg_tickets_ddl", + "apply_stg_airports_ddl", + "apply_stg_airplanes_ddl", + "apply_stg_routes_ddl", + "apply_stg_seats_ddl", + "apply_stg_flights_ddl", + "apply_stg_segments_ddl", + "apply_stg_boarding_passes_ddl", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + # Проверка линейных зависимостей + # ... (проверка зависимостей между задачами) +``` + +```python +def test_bookings_to_gp_stage_dag_structure(): + dag = _load_dag("airflow.dags.bookings_to_gp_stage") + + expected_tasks = { + "generate_bookings_day", + "load_bookings_to_stg", + "check_row_counts", + "load_tickets_to_stg", + "check_tickets_dq", + "load_airports_to_stg", + "check_airports_dq", + "load_airplanes_to_stg", + "check_airplanes_dq", + "load_routes_to_stg", + "check_routes_dq", + "load_seats_to_stg", + "check_seats_dq", + "load_flights_to_stg", + "check_flights_dq", + "load_segments_to_stg", + "check_segments_dq", + "load_boarding_passes_to_stg", + "check_boarding_passes_dq", + "finish_summary", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + # Проверка линейных зависимостей + # ... (проверка зависимостей между задачами) +``` + +### Обновление документации + +Обновить статус в [`docs/internal/db_schema.md`](../docs/internal/db_schema.md:31) с "2 из 9" на "9 из 9". + +Добавить описание новых таблиц в документацию. + +## Диаграмма потока данных STG слоя + +```mermaid +graph TB + subgraph Source[Source: bookings-db] + B1[airports_data] + B2[airplanes_data] + B3[routes] + B4[seats] + B5[flights] + B6[segments] + B7[boarding_passes] + end + + subgraph STG[STG Layer: Greenplum] + S1[stg.airports] + S2[stg.airplanes] + S3[stg.routes] + S4[stg.seats] + S5[stg.flights] + S6[stg.segments] + S7[stg.boarding_passes] + end + + B1 --> S1 + B2 --> S2 + B3 --> S3 + B4 --> S4 + B5 --> S5 + B6 --> S6 + B7 --> S7 +``` + +## Чек-лист реализации + +- [ ] Создать файлы DDL для новых таблиц (7 файлов) + - [ ] `sql/stg/airports_ddl.sql` + - [ ] `sql/stg/airplanes_ddl.sql` + - [ ] `sql/stg/routes_ddl.sql` + - [ ] `sql/stg/seats_ddl.sql` + - [ ] `sql/stg/flights_ddl.sql` + - [ ] `sql/stg/segments_ddl.sql` + - [ ] `sql/stg/boardings_ddl.sql` +- [ ] Создать файлы LOAD для новых таблиц (7 файлов) + - [ ] `sql/stg/airports_load.sql` + - [ ] `sql/stg/airplanes_load.sql` + - [ ] `sql/stg/routes_load.sql` + - [ ] `sql/stg/seats_load.sql` + - [ ] `sql/stg/flights_load.sql` + - [ ] `sql/stg/segments_load.sql` + - [ ] `sql/stg/boardings_load.sql` +- [ ] Создать файлы DQ для новых таблиц (7 файлов) + - [ ] `sql/stg/airports_dq.sql` + - [ ] `sql/stg/airplanes_dq.sql` + - [ ] `sql/stg/routes_dq.sql` + - [ ] `sql/stg/seats_dq.sql` + - [ ] `sql/stg/flights_dq.sql` + - [ ] `sql/stg/segments_dq.sql` + - [ ] `sql/stg/boardings_dq.sql` +- [ ] Обновить DAG `bookings_stg_ddl.py` +- [ ] Обновить DAG `bookings_to_gp_stage.py` +- [ ] Обновить `sql/ddl_gp.sql` +- [ ] Добавить тесты для новых DAG в `tests/test_dags_smoke.py` +- [ ] Обновить документацию `docs/internal/db_schema.md` +- [ ] Провести тестирование реализации + +## Примечания для реализации + +1. **Именование файлов**: Использовать `{table}_ddl.sql`, `{table}_load.sql`, `{table}_dq.sql` +2. **Ключи распределения**: Выбирать ключи с высокой кардинальностью для равномерного распределения +3. **Co-location**: Использовать одинаковые ключи распределения для связанных таблиц (tickets, segments, boarding_passes по ticket_no) +4. **Комментарии**: Добавлять русскоязычные комментарии в SQL-файлы для студентов +5. **DQ проверки**: Все проверки должны падать с `RAISE EXCEPTION` при ошибке +6. **Batch ID**: Использовать `{{ run_id }}` для идентификации батча +7. **Защита от дублей**: Использовать `NOT EXISTS` для предотвращения дублирования в рамках одного batch_id + +## Связанные документы + +- [`docs/internal/db_schema.md`](../docs/internal/db_schema.md) - Общая схема Б DWH +- [`docs/internal/bookings_stg_design.md`](../docs/internal/bookings_stg_design.md) - Детальный дизайн STG для bookings +- [`sql/stg/bookings_ddl.sql`](../sql/stg/bookings_ddl.sql) - Образец DDL +- [`sql/stg/bookings_load.sql`](../sql/stg/bookings_load.sql) - Образец LOAD +- [`sql/stg/bookings_dq.sql`](../sql/stg/bookings_dq.sql) - Образец DQ +- [`airflow/dags/bookings_stg_ddl.py`](../airflow/dags/bookings_stg_ddl.py) - Образец DAG DDL +- [`airflow/dags/bookings_to_gp_stage.py`](../airflow/dags/bookings_to_gp_stage.py) - Образец DAG загрузки From 15081384f4d84d17bcf9f699c0dd2095cb0d27a5 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 18 Jan 2026 10:37:59 +0300 Subject: [PATCH 12/38] =?UTF-8?q?=D0=97=D0=B0=D0=BC=D0=B5=D1=87=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=82=20codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plans/stg_layer_implementation_plan.md | 52 ++++++++++++++++---------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/plans/stg_layer_implementation_plan.md b/plans/stg_layer_implementation_plan.md index c149f6c..80a69a6 100644 --- a/plans/stg_layer_implementation_plan.md +++ b/plans/stg_layer_implementation_plan.md @@ -6,20 +6,23 @@ ## Обзор задачи -Согласно [`docs/internal/db_schema.md`](../docs/internal/db_schema.md:31), STG слой реализован частично (2 из 9 таблиц: bookings, tickets). Необходимо реализовать оставшиеся 7 таблиц. +Согласно [`docs/internal/db_schema.md`](../docs/internal/db_schema.md), STG слой реализован частично (2 из 9 таблиц: bookings, tickets). Необходимо реализовать оставшиеся 7 таблиц. ## Стратегия загрузки данных | Тип таблиц | Стратегия | Обоснование | |-----------|-----------|-------------| | Справочники (airports, airplanes, routes, seats) | **Full load** | Маленький объём (<10K строк), простота реализации | -| Транзакции (flights, segments, boarding_passes) | **Инкремент по дате** | Больший объём, необходимость отслеживания изменений | +| Транзакции (flights, segments) | **Инкремент** | Больший объём; выбираем максимально естественное опорное поле | +| Транзакции (boarding_passes) | **Full snapshot** | В источнике строки создаются и обновляются со временем, простого инкремента без усложнений нет | + +> Важно: под **Full load** в STG подразумеваем «сняли слепок и дописали в append-only таблицу с `batch_id`», а не `TRUNCATE + INSERT`. Это даёт простую идемпотентность (по `batch_id`) и сохраняет историю загрузок. ### Оценка размера справочников | Справочник | Примерный размер | Оценка | |-------------|------------------|---------| -| **airports** | ~700-800 аэропортов | **Маленький** | +| **airports** | ~5K-6K аэропортов | **Маленький** | | **airplanes** | ~10 моделей самолётов | **Крошечный** | | **seats** | ~1700-2000 записей | **Маленький** | | **routes** | Ожидается несколько тысяч | **Маленький/Средний** | @@ -36,7 +39,7 @@ | `bookings.seats` | `stg.seats` | Справочник | Full | - | | `bookings.flights` | `stg.flights` | Транзакции | Инкремент | `scheduled_departure` | | `bookings.segments` | `stg.segments` | Транзакции | Инкремент | `book_date` (через tickets) | -| `bookings.boarding_passes` | `stg.boarding_passes` | Транзакции | Инкремент | `book_date` (через tickets) | +| `bookings.boarding_passes` | `stg.boarding_passes` | Транзакции | Full (snapshot) | - | ## Паттерн реализации (на основе bookings/tickets) @@ -69,12 +72,16 @@ CREATE TABLE IF NOT EXISTS stg.{table} ( -- бизнес-колонки как TEXT src_created_at_ts TIMESTAMP, load_dttm TIMESTAMP NOT NULL DEFAULT now(), - batch_id TEXT NOT NULL + batch_id TEXT ) WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) DISTRIBUTED BY ({distribution_key}); ``` +Примечание по PXF/JDBC типам: для «сложных» типов Postgres (например, `jsonb`, `point`, массивы, `tstzrange`) +чаще всего проще и надёжнее объявлять колонки во внешней таблице как `TEXT`, чтобы избежать несовместимостей +драйвера/маппинга типов. Внутренний STG всё равно хранит бизнес-поля как `TEXT`. + ### Общая структура LOAD файла (Full load для справочников) ```sql @@ -146,6 +153,9 @@ ANALYZE stg.{table}; ### Общая структура DQ файла +Для **инкрементальных** таблиц сравниваем окно инкремента (по `src_created_at_ts`) между источником и STG. +Для **full snapshot** таблиц (справочники и `boarding_passes`) обычно достаточно сравнить общее количество строк в источнике с количеством строк, загруженных в текущий `batch_id`, плюс проверить дубликаты/NULL/ссылочную целостность. + ```sql -- Проверки качества данных для {table} @@ -293,13 +303,13 @@ END $$; - `duration TEXT` - Тех.колонки: `src_created_at_ts`, `load_dttm`, `batch_id` - Распределение: `DISTRIBUTED BY (route_no)` -- Обоснование: `route_no` — это уникальный идентификатор маршрута +- Обоснование: `route_no` — логический идентификатор маршрута; он нужен для JOIN с flights по `route_no` **Загрузка**: Full **DQ проверки**: - Count между источником и STG -- Дубликаты `route_no` +- Дубликаты `(route_no, validity)` - NULL обязательных полей (route_no, departure_airport, arrival_airport, airplane_code) - Ссылочная целостность на airports (departure_airport, arrival_airport) - Ссылочная целостность на airplanes (airplane_code) @@ -355,6 +365,8 @@ END $$; - NULL обязательных полей (flight_id, route_no, status, scheduled_departure) - Ссылочная целостность на routes (route_no) +> Примечание: `flights.status/actual_*` в источнике могут меняться со временем. Для учебного STG можно принять допущение "insert-only" (снимаем слепок на момент загрузки), либо усложнить и перезагружать скользящее окно по датам вылета. + ### 6. segments (транзакции, инкремент) **Внешняя таблица**: `stg.segments_ext` @@ -381,7 +393,7 @@ END $$; - Ссылочная целостность на tickets (ticket_no) - Ссылочная целостность на flights (flight_id) -### 7. boarding_passes (транзакции, инкремент) +### 7. boarding_passes (транзакции, full snapshot) **Внешняя таблица**: `stg.boarding_passes_ext` - Поля: `ticket_no`, `flight_id`, `seat_no`, `boarding_no`, `boarding_time` @@ -395,11 +407,11 @@ END $$; - `boarding_no TEXT` - `boarding_time TEXT` - Тех.колонки: `src_created_at_ts`, `load_dttm`, `batch_id` -- `src_created_at_ts` = берётся из `bookings.book_date` через JOIN с tickets +- `src_created_at_ts` = `now()` (в этой таблице нет удобного поля для инкремента, потому что строки могут создаваться и обновляться со временем) - Распределение: `DISTRIBUTED BY (ticket_no)` - Обоснование: co-location с tickets/segments для оптимизации JOIN -**Загрузка**: Инкремент по `book_date` (как в tickets) +**Загрузка**: Full snapshot (все строки при каждом запуске) **DQ проверки**: - Count между источником и STG @@ -408,6 +420,8 @@ END $$; - Ссылочная целостность на tickets (ticket_no) - Ссылочная целостность на segments (ticket_no, flight_id) +> Примечание: в источнике `boarding_passes` строки сначала создаются при CHECK-IN (без `boarding_time`), а потом обновляются при BOARDING. Поэтому инкремент "по времени" без усложнений будет пропускать часть событий и/или изменения. Для учебного стенда самый стабильный вариант — снимать полный слепок. + ## Обновление существующих DAG ### `airflow/dags/bookings_stg_ddl.py` @@ -454,7 +468,7 @@ apply_stg_segments_ddl = PostgresOperator( apply_stg_boarding_passes_ddl = PostgresOperator( task_id="apply_stg_boarding_passes_ddl", postgres_conn_id=GREENPLUM_CONN_ID, - sql="stg/boardings_ddl.sql", + sql="stg/boarding_passes_ddl.sql", ) ``` @@ -544,13 +558,13 @@ check_segments_dq = PostgresOperator( load_boarding_passes_to_stg = PostgresOperator( task_id="load_boarding_passes_to_stg", postgres_conn_id=GREENPLUM_CONN_ID, - sql="stg/boardings_load.sql", + sql="stg/boarding_passes_load.sql", ) check_boarding_passes_dq = PostgresOperator( task_id="check_boarding_passes_dq", postgres_conn_id=GREENPLUM_CONN_ID, - sql="stg/boardings_dq.sql", + sql="stg/boarding_passes_dq.sql", ) ``` @@ -577,7 +591,7 @@ check_boarding_passes_dq = PostgresOperator( \i stg/seats_ddl.sql \i stg/flights_ddl.sql \i stg/segments_ddl.sql -\i stg/boardings_ddl.sql +\i stg/boarding_passes_ddl.sql ``` ### Добавление тестов @@ -639,7 +653,7 @@ def test_bookings_to_gp_stage_dag_structure(): ### Обновление документации -Обновить статус в [`docs/internal/db_schema.md`](../docs/internal/db_schema.md:31) с "2 из 9" на "9 из 9". +Обновить статус в [`docs/internal/db_schema.md`](../docs/internal/db_schema.md) с "2 из 9" на "9 из 9". Добавить описание новых таблиц в документацию. @@ -685,7 +699,7 @@ graph TB - [ ] `sql/stg/seats_ddl.sql` - [ ] `sql/stg/flights_ddl.sql` - [ ] `sql/stg/segments_ddl.sql` - - [ ] `sql/stg/boardings_ddl.sql` + - [ ] `sql/stg/boarding_passes_ddl.sql` - [ ] Создать файлы LOAD для новых таблиц (7 файлов) - [ ] `sql/stg/airports_load.sql` - [ ] `sql/stg/airplanes_load.sql` @@ -693,7 +707,7 @@ graph TB - [ ] `sql/stg/seats_load.sql` - [ ] `sql/stg/flights_load.sql` - [ ] `sql/stg/segments_load.sql` - - [ ] `sql/stg/boardings_load.sql` + - [ ] `sql/stg/boarding_passes_load.sql` - [ ] Создать файлы DQ для новых таблиц (7 файлов) - [ ] `sql/stg/airports_dq.sql` - [ ] `sql/stg/airplanes_dq.sql` @@ -701,7 +715,7 @@ graph TB - [ ] `sql/stg/seats_dq.sql` - [ ] `sql/stg/flights_dq.sql` - [ ] `sql/stg/segments_dq.sql` - - [ ] `sql/stg/boardings_dq.sql` + - [ ] `sql/stg/boarding_passes_dq.sql` - [ ] Обновить DAG `bookings_stg_ddl.py` - [ ] Обновить DAG `bookings_to_gp_stage.py` - [ ] Обновить `sql/ddl_gp.sql` @@ -721,7 +735,7 @@ graph TB ## Связанные документы -- [`docs/internal/db_schema.md`](../docs/internal/db_schema.md) - Общая схема Б DWH +- [`docs/internal/db_schema.md`](../docs/internal/db_schema.md) - Общая схема DWH - [`docs/internal/bookings_stg_design.md`](../docs/internal/bookings_stg_design.md) - Детальный дизайн STG для bookings - [`sql/stg/bookings_ddl.sql`](../sql/stg/bookings_ddl.sql) - Образец DDL - [`sql/stg/bookings_load.sql`](../sql/stg/bookings_load.sql) - Образец LOAD From 9c033b39aa2181e9ed148d1ae8325d7201e29f0b Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 18 Jan 2026 11:04:46 +0300 Subject: [PATCH 13/38] =?UTF-8?q?=D0=93=D0=B5=D0=BD=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20dds=20=D1=81=D0=BB=D0=BE=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=A2=D0=97=20-=20=D0=B1=D0=B5=D0=B7=20=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- airflow/dags/bookings_stg_ddl.py | 61 ++++++++++++- airflow/dags/bookings_to_gp_stage.py | 107 +++++++++++++++++++++- docs/internal/db_schema.md | 127 ++++++++++++++++++++++++++- sql/ddl_gp.sql | 11 +++ sql/stg/airplanes_ddl.sql | 37 ++++++++ sql/stg/airplanes_dq.sql | 67 ++++++++++++++ sql/stg/airplanes_load.sql | 32 +++++++ sql/stg/airports_ddl.sql | 40 +++++++++ sql/stg/airports_dq.sql | 69 +++++++++++++++ sql/stg/airports_load.sql | 36 ++++++++ sql/stg/boarding_passes_ddl.sql | 38 ++++++++ sql/stg/boarding_passes_dq.sql | 99 +++++++++++++++++++++ sql/stg/boarding_passes_load.sql | 36 ++++++++ sql/stg/flights_ddl.sql | 42 +++++++++ sql/stg/flights_dq.sql | 95 ++++++++++++++++++++ sql/stg/flights_load.sql | 48 ++++++++++ sql/stg/routes_ddl.sql | 44 ++++++++++ sql/stg/routes_dq.sql | 116 ++++++++++++++++++++++++ sql/stg/routes_load.sql | 41 +++++++++ sql/stg/seats_ddl.sql | 34 +++++++ sql/stg/seats_dq.sql | 84 ++++++++++++++++++ sql/stg/seats_load.sql | 31 +++++++ sql/stg/segments_ddl.sql | 36 ++++++++ sql/stg/segments_dq.sql | 113 ++++++++++++++++++++++++ sql/stg/segments_load.sql | 45 ++++++++++ tests/test_dags_smoke.py | 82 +++++++++++++++++ 26 files changed, 1566 insertions(+), 5 deletions(-) create mode 100644 sql/stg/airplanes_ddl.sql create mode 100644 sql/stg/airplanes_dq.sql create mode 100644 sql/stg/airplanes_load.sql create mode 100644 sql/stg/airports_ddl.sql create mode 100644 sql/stg/airports_dq.sql create mode 100644 sql/stg/airports_load.sql create mode 100644 sql/stg/boarding_passes_ddl.sql create mode 100644 sql/stg/boarding_passes_dq.sql create mode 100644 sql/stg/boarding_passes_load.sql create mode 100644 sql/stg/flights_ddl.sql create mode 100644 sql/stg/flights_dq.sql create mode 100644 sql/stg/flights_load.sql create mode 100644 sql/stg/routes_ddl.sql create mode 100644 sql/stg/routes_dq.sql create mode 100644 sql/stg/routes_load.sql create mode 100644 sql/stg/seats_ddl.sql create mode 100644 sql/stg/seats_dq.sql create mode 100644 sql/stg/seats_load.sql create mode 100644 sql/stg/segments_ddl.sql create mode 100644 sql/stg/segments_dq.sql create mode 100644 sql/stg/segments_load.sql diff --git a/airflow/dags/bookings_stg_ddl.py b/airflow/dags/bookings_stg_ddl.py index 0fc3a95..f205e79 100644 --- a/airflow/dags/bookings_stg_ddl.py +++ b/airflow/dags/bookings_stg_ddl.py @@ -38,4 +38,63 @@ with DAG( sql="stg/tickets_ddl.sql", ) - apply_stg_bookings_ddl >> apply_stg_tickets_ddl + # DDL для справочников + apply_stg_airports_ddl = PostgresOperator( + task_id="apply_stg_airports_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airports_ddl.sql", + ) + + apply_stg_airplanes_ddl = PostgresOperator( + task_id="apply_stg_airplanes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airplanes_ddl.sql", + ) + + apply_stg_routes_ddl = PostgresOperator( + task_id="apply_stg_routes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/routes_ddl.sql", + ) + + apply_stg_seats_ddl = PostgresOperator( + task_id="apply_stg_seats_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/seats_ddl.sql", + ) + + # DDL для транзакционных таблиц + apply_stg_flights_ddl = PostgresOperator( + task_id="apply_stg_flights_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/flights_ddl.sql", + ) + + apply_stg_segments_ddl = PostgresOperator( + task_id="apply_stg_segments_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/segments_ddl.sql", + ) + + apply_stg_boarding_passes_ddl = PostgresOperator( + task_id="apply_stg_boarding_passes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/boarding_passes_ddl.sql", + ) + + # Сначала создаются справочники, затем транзакционные таблицы + ( + apply_stg_bookings_ddl + >> apply_stg_tickets_ddl + >> [ + apply_stg_airports_ddl, + apply_stg_airplanes_ddl, + apply_stg_routes_ddl, + apply_stg_seats_ddl, + ] + >> [ + apply_stg_flights_ddl, + apply_stg_segments_ddl, + apply_stg_boarding_passes_ddl, + ] + ) diff --git a/airflow/dags/bookings_to_gp_stage.py b/airflow/dags/bookings_to_gp_stage.py index 8a5e21a..d978de8 100644 --- a/airflow/dags/bookings_to_gp_stage.py +++ b/airflow/dags/bookings_to_gp_stage.py @@ -94,11 +94,116 @@ with DAG( sql="stg/tickets_dq.sql", ) + # Загрузка справочников (full load) + load_airports_to_stg = PostgresOperator( + task_id="load_airports_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airports_load.sql", + ) + + check_airports_dq = PostgresOperator( + task_id="check_airports_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airports_dq.sql", + ) + + load_airplanes_to_stg = PostgresOperator( + task_id="load_airplanes_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airplanes_load.sql", + ) + + check_airplanes_dq = PostgresOperator( + task_id="check_airplanes_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/airplanes_dq.sql", + ) + + load_routes_to_stg = PostgresOperator( + task_id="load_routes_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/routes_load.sql", + ) + + check_routes_dq = PostgresOperator( + task_id="check_routes_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/routes_dq.sql", + ) + + load_seats_to_stg = PostgresOperator( + task_id="load_seats_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/seats_load.sql", + ) + + check_seats_dq = PostgresOperator( + task_id="check_seats_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/seats_dq.sql", + ) + + # Загрузка транзакций (инкремент/full snapshot) + load_flights_to_stg = PostgresOperator( + task_id="load_flights_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/flights_load.sql", + ) + + check_flights_dq = PostgresOperator( + task_id="check_flights_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/flights_dq.sql", + ) + + load_segments_to_stg = PostgresOperator( + task_id="load_segments_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/segments_load.sql", + ) + + check_segments_dq = PostgresOperator( + task_id="check_segments_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/segments_dq.sql", + ) + + load_boarding_passes_to_stg = PostgresOperator( + task_id="load_boarding_passes_to_stg", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/boarding_passes_load.sql", + ) + + check_boarding_passes_dq = PostgresOperator( + task_id="check_boarding_passes_dq", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="stg/boarding_passes_dq.sql", + ) + # 6. Финальный лог/сводка finish_summary = PythonOperator( task_id="finish_summary", python_callable=_finish_summary, ) + # Сначала загружаются и проверяются bookings и tickets generate_bookings_day >> load_bookings_to_stg >> check_row_counts - check_row_counts >> load_tickets_to_stg >> check_tickets_dq >> finish_summary + check_row_counts >> load_tickets_to_stg >> check_tickets_dq + + # Затем загружаются справочники + check_tickets_dq >> [ + (load_airports_to_stg >> check_airports_dq), + (load_airplanes_to_stg >> check_airplanes_dq), + (load_routes_to_stg >> check_routes_dq), + (load_seats_to_stg >> check_seats_dq), + ] + + # Затем загружаются транзакции + [check_airports_dq, check_airplanes_dq, check_routes_dq, check_seats_dq] >> [ + (load_flights_to_stg >> check_flights_dq), + (load_segments_to_stg >> check_segments_dq), + (load_boarding_passes_to_stg >> check_boarding_passes_dq), + ] + + # В конце финальный лог + [check_flights_dq, check_segments_dq, check_boarding_passes_dq] >> finish_summary diff --git a/docs/internal/db_schema.md b/docs/internal/db_schema.md index cfcc451..9d3693b 100644 --- a/docs/internal/db_schema.md +++ b/docs/internal/db_schema.md @@ -1,6 +1,6 @@ # Схема БД DWH (Bookings → Greenplum) -> **Статус:** Проект в разработке. Реализован только STG слой (частично: bookings, tickets). +> **Статус:** Проект в разработке. Реализован STG слой полностью (все 9 таблиц). ## Обзор @@ -28,7 +28,7 @@ | Слой | Статус | Реализовано | |------|--------|-------------| | **Source** | ✅ Готово | Демо-БД bookings (Postgres) | -| **STG** | ⚠️ В процессе | 2 из 9 таблиц (bookings, tickets) | +| **STG** | ✅ Готово | 9 из 9 таблиц (bookings, tickets, airports, airplanes, routes, seats, flights, segments, boarding_passes) | | **ODS** | ❌ Не реализован | Планируется | | **DDS** | ❌ Не реализован | Планируется | @@ -90,6 +90,127 @@ --- +### Детальное описание таблиц STG слоя + +#### stg.bookings (транзакции, инкремент) +- **Источник:** `bookings.bookings` (через PXF) +- **Ключ распределения:** `book_ref` +- **Бизнес-колонки:** + - `book_ref TEXT` - номер бронирования + - `book_date TEXT` - дата бронирования + - `total_amount TEXT` - общая сумма +- **Технические колонки:** `src_created_at_ts` (=book_date), `load_dttm`, `batch_id` +- **Стратегия загрузки:** Инкремент по `book_date` +- **DQ проверки:** count (окно инкремента), дубликаты book_ref, NULL обязательных полей + +#### stg.tickets (транзакции, инкремент) +- **Источник:** `bookings.tickets` (через PXF) +- **Ключ распределения:** `ticket_no` +- **Бизнес-колонки:** + - `ticket_no TEXT` - номер билета + - `book_ref TEXT` - номер бронирования + - `passenger_id TEXT` - идентификатор пассажира + - `passenger_name TEXT` - имя пассажира + - `contact_data TEXT` - контактные данные (JSONB) +- **Технические колонки:** `src_created_at_ts` (из book_date через bookings), `load_dttm`, `batch_id` +- **Стратегия загрузки:** Инкремент по `book_date` (через bookings) +- **DQ проверки:** count (окно инкремента), дубликаты ticket_no, NULL обязательных полей, ссылочная целостность + +#### stg.airports (справочник, full load) +- **Источник:** `bookings.airports_data` (через PXF) +- **Ключ распределения:** `airport_code` +- **Бизнес-колонки:** + - `airport_code TEXT` - код аэропорта + - `airport_name TEXT` - название аэропорта (из JSONB) + - `city TEXT` - город (из JSONB) + - `country TEXT` - страна (из JSONB) + - `coordinates TEXT` - координаты + - `timezone TEXT` - часовой пояс +- **Технические колонки:** `src_created_at_ts`, `load_dttm`, `batch_id` +- **Стратегия загрузки:** Full load (все строки при каждом запуске) +- **DQ проверки:** count, дубликаты airport_code, NULL обязательных полей + +#### stg.airplanes (справочник, full load) +- **Источник:** `bookings.airplanes_data` (через PXF) +- **Ключ распределения:** `airplane_code` +- **Бизнес-колонки:** + - `airplane_code TEXT` - код самолёта + - `model TEXT` - модель (из JSONB) + - `range TEXT` - дальность полёта + - `speed TEXT` - скорость +- **Технические колонки:** `src_created_at_ts`, `load_dttm`, `batch_id` +- **Стратегия загрузки:** Full load +- **DQ проверки:** count, дубликаты airplane_code, NULL обязательных полей + +#### stg.routes (справочник, full load) +- **Источник:** `bookings.routes` (через PXF) +- **Ключ распределения:** `route_no` +- **Бизнес-колонки:** + - `route_no TEXT` - номер маршрута + - `validity TEXT` - период действия (из tstzrange) + - `departure_airport TEXT` - аэропорт вылета + - `arrival_airport TEXT` - аэропорт прилёта + - `airplane_code TEXT` - код самолёта + - `days_of_week TEXT` - дни недели (из int[]) + - `scheduled_time TEXT` - плановое время + - `duration TEXT` - длительность +- **Технические колонки:** `src_created_at_ts`, `load_dttm`, `batch_id` +- **Стратегия загрузки:** Full load +- **DQ проверки:** count, дубликаты (route_no, validity), NULL обязательных полей, ссылочная целостность + +#### stg.seats (справочник, full load) +- **Источник:** `bookings.seats` (через PXF) +- **Ключ распределения:** `airplane_code` (co-location с airplanes) +- **Бизнес-колонки:** + - `airplane_code TEXT` - код самолёта + - `seat_no TEXT` - номер места + - `fare_conditions TEXT` - класс обслуживания +- **Технические колонки:** `src_created_at_ts`, `load_dttm`, `batch_id` +- **Стратегия загрузки:** Full load +- **DQ проверки:** count, дубликаты (airplane_code, seat_no), NULL обязательных полей, ссылочная целостность + +#### stg.flights (транзакции, инкремент) +- **Источник:** `bookings.flights` (через PXF) +- **Ключ распределения:** `flight_id` +- **Бизнес-колонки:** + - `flight_id TEXT` - идентификатор рейса + - `route_no TEXT` - номер маршрута + - `status TEXT` - статус + - `scheduled_departure TEXT` - плановое время вылета + - `scheduled_arrival TEXT` - плановое время прилёта + - `actual_departure TEXT` - фактическое время вылета + - `actual_arrival TEXT` - фактическое время прилёта +- **Технические колонки:** `src_created_at_ts` (=scheduled_departure), `load_dttm`, `batch_id` +- **Стратегия загрузки:** Инкремент по `scheduled_departure` +- **DQ проверки:** count (окно инкремента), дубликаты flight_id, NULL обязательных полей, ссылочная целостность + +#### stg.segments (транзакции, инкремент) +- **Источник:** `bookings.segments` (через PXF) +- **Ключ распределения:** `ticket_no` (co-location с tickets) +- **Бизнес-колонки:** + - `ticket_no TEXT` - номер билета + - `flight_id TEXT` - идентификатор рейса + - `fare_conditions TEXT` - класс обслуживания + - `price TEXT` - цена +- **Технические колонки:** `src_created_at_ts` (из book_date через tickets), `load_dttm`, `batch_id` +- **Стратегия загрузки:** Инкремент по `book_date` (через tickets) +- **DQ проверки:** count (окно инкремента), дубликаты (ticket_no, flight_id), NULL обязательных полей, ссылочная целостность + +#### stg.boarding_passes (транзакции, full snapshot) +- **Источник:** `bookings.boarding_passes` (через PXF) +- **Ключ распределения:** `ticket_no` (co-location с tickets/segments) +- **Бизнес-колонки:** + - `ticket_no TEXT` - номер билета + - `flight_id TEXT` - идентификатор рейса + - `seat_no TEXT` - номер места + - `boarding_no TEXT` - номер посадки + - `boarding_time TEXT` - время посадки +- **Технические колонки:** `src_created_at_ts` (=now()), `load_dttm`, `batch_id` +- **Стратегия загрузки:** Full snapshot (все строки при каждом запуске) +- **DQ проверки:** count, дубликаты (ticket_no, flight_id), NULL обязательных полей, ссылочная целостность + +--- + ## Полная схема потоков данных (Data Lineage) ```mermaid @@ -299,7 +420,7 @@ graph LR ## TODO -- [ ] Реализовать STG слой полностью (все 9 таблиц) +- [x] Реализовать STG слой полностью (все 9 таблиц) - [ ] Реализовать ODS слой - [ ] Реализовать DDS слой (измерения и факт) - [ ] Создать DAG для загрузки ODS diff --git a/sql/ddl_gp.sql b/sql/ddl_gp.sql index e95f0e7..40da916 100644 --- a/sql/ddl_gp.sql +++ b/sql/ddl_gp.sql @@ -26,3 +26,14 @@ FORMAT 'CUSTOM' (formatter='pxfwritable_import'); -- Здесь подключаем их через psql \i, чтобы сохранить единый входной скрипт. \i stg/bookings_ddl.sql \i stg/tickets_ddl.sql + +-- DDL для новых таблиц STG слоя (справочники) +\i stg/airports_ddl.sql +\i stg/airplanes_ddl.sql +\i stg/routes_ddl.sql +\i stg/seats_ddl.sql + +-- DDL для новых таблиц STG слоя (транзакции) +\i stg/flights_ddl.sql +\i stg/segments_ddl.sql +\i stg/boarding_passes_ddl.sql diff --git a/sql/stg/airplanes_ddl.sql b/sql/stg/airplanes_ddl.sql new file mode 100644 index 0000000..d29196d --- /dev/null +++ b/sql/stg/airplanes_ddl.sql @@ -0,0 +1,37 @@ +-- DDL для слоя STG по таблице airplanes (справочник). +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.airplanes_data через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.airplanes_ext; +CREATE EXTERNAL TABLE stg.airplanes_ext ( + airplane_code TEXT, + model JSONB, + range INTEGER, + speed INTEGER +) +LOCATION ('pxf://bookings.airplanes_data?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.airplanes — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.airplanes ( + airplane_code TEXT, + model TEXT, + range TEXT, + speed TEXT, + src_created_at_ts TIMESTAMP, + load_dttm TIMESTAMP NOT NULL DEFAULT now(), + batch_id TEXT +) +WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) +-- Ключ распределения: airplane_code +-- Обоснование: airplane_code — это уникальный идентификатор самолёта. +-- Использование airplane_code обеспечивает: +-- 1. Равномерное распределение данных по сегментам (airplane_code имеет высокую кардинальность) +-- 2. Co-location данных airplanes и routes при JOIN по airplane_code +-- 3. Co-location данных airplanes и seats при JOIN по airplane_code +-- 4. Оптимизацию запросов, которые фильтруют или группируют по airplane_code +DISTRIBUTED BY (airplane_code); diff --git a/sql/stg/airplanes_dq.sql b/sql/stg/airplanes_dq.sql new file mode 100644 index 0000000..61d84f9 --- /dev/null +++ b/sql/stg/airplanes_dq.sql @@ -0,0 +1,67 @@ +-- Проверки качества данных для airplanes (справочник) + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; +BEGIN + -- Источник: считаем все строки во внешней таблице + SELECT COUNT(*) + INTO v_src_count + FROM stg.airplanes_ext; + + IF v_src_count = 0 THEN + RAISE EXCEPTION + 'В источнике airplanes_ext нет строк.'; + END IF; + + -- Считаем строки, реально вставленные в stg.airplanes в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.airplanes + WHERE batch_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты airplane_code + SELECT COUNT(*) - COUNT(DISTINCT airplane_code) + INTO v_dup_count + FROM stg.airplanes AS a + WHERE a.batch_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты airplane_code (batch_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей: airplane_code, model + SELECT COUNT(*) + INTO v_null_count + FROM stg.airplanes AS a + WHERE a.batch_id = v_batch_id + AND (a.airplane_code IS NULL OR a.airplane_code = '' + OR a.model IS NULL OR a.model = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (airplane_code, model) (batch_id=%): %', + v_batch_id, + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: airplanes ок (batch_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/airplanes_load.sql b/sql/stg/airplanes_load.sql new file mode 100644 index 0000000..b140add --- /dev/null +++ b/sql/stg/airplanes_load.sql @@ -0,0 +1,32 @@ +-- Загрузка всех строк из stg.airplanes_ext в stg.airplanes (full load). +-- Используем batch_id для отслеживания загрузки. + +INSERT INTO stg.airplanes ( + airplane_code, + model, + range, + speed, + src_created_at_ts, + load_dttm, + batch_id +) +SELECT + ext.airplane_code::text, + ext.model::text, + ext.range::text, + ext.speed::text, + now()::timestamp, + now()::timestamp, + '{{ run_id }}'::text +FROM stg.airplanes_ext AS ext +WHERE NOT EXISTS ( + -- Защита от дублей в рамках одного batch_id + SELECT 1 + FROM stg.airplanes AS a + WHERE a.batch_id = '{{ run_id }}'::text + AND a.airplane_code = ext.airplane_code::text +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.airplanes; diff --git a/sql/stg/airports_ddl.sql b/sql/stg/airports_ddl.sql new file mode 100644 index 0000000..3f27dd2 --- /dev/null +++ b/sql/stg/airports_ddl.sql @@ -0,0 +1,40 @@ +-- DDL для слоя STG по таблице airports (справочник). +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.airports_data через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.airports_ext; +CREATE EXTERNAL TABLE stg.airports_ext ( + airport_code TEXT, + airport_name JSONB, + city JSONB, + country JSONB, + coordinates POINT, + timezone TEXT +) +LOCATION ('pxf://bookings.airports_data?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.airports — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.airports ( + airport_code TEXT, + airport_name TEXT, + city TEXT, + country TEXT, + coordinates TEXT, + timezone TEXT, + src_created_at_ts TIMESTAMP, + load_dttm TIMESTAMP NOT NULL DEFAULT now(), + batch_id TEXT +) +WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) +-- Ключ распределения: airport_code +-- Обоснование: airport_code — это уникальный идентификатор аэропорта. +-- Использование airport_code обеспечивает: +-- 1. Равномерное распределение данных по сегментам (airport_code имеет высокую кардинальность) +-- 2. Co-location данных airports и routes при JOIN по departure_airport/arrival_airport +-- 3. Оптимизацию запросов, которые фильтруют или группируют по airport_code +DISTRIBUTED BY (airport_code); diff --git a/sql/stg/airports_dq.sql b/sql/stg/airports_dq.sql new file mode 100644 index 0000000..7bea05f --- /dev/null +++ b/sql/stg/airports_dq.sql @@ -0,0 +1,69 @@ +-- Проверки качества данных для airports (справочник) + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; +BEGIN + -- Источник: считаем все строки во внешней таблице + SELECT COUNT(*) + INTO v_src_count + FROM stg.airports_ext; + + IF v_src_count = 0 THEN + RAISE EXCEPTION + 'В источнике airports_ext нет строк.'; + END IF; + + -- Считаем строки, реально вставленные в stg.airports в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.airports + WHERE batch_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты airport_code + SELECT COUNT(*) - COUNT(DISTINCT airport_code) + INTO v_dup_count + FROM stg.airports AS a + WHERE a.batch_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты airport_code (batch_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей: airport_code, airport_name, city, timezone + SELECT COUNT(*) + INTO v_null_count + FROM stg.airports AS a + WHERE a.batch_id = v_batch_id + AND (a.airport_code IS NULL OR a.airport_code = '' + OR a.airport_name IS NULL OR a.airport_name = '' + OR a.city IS NULL OR a.city = '' + OR a.timezone IS NULL OR a.timezone = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (airport_code, airport_name, city, timezone) (batch_id=%): %', + v_batch_id, + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: airports ок (batch_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/airports_load.sql b/sql/stg/airports_load.sql new file mode 100644 index 0000000..3197c2d --- /dev/null +++ b/sql/stg/airports_load.sql @@ -0,0 +1,36 @@ +-- Загрузка всех строк из stg.airports_ext в stg.airports (full load). +-- Используем batch_id для отслеживания загрузки. + +INSERT INTO stg.airports ( + airport_code, + airport_name, + city, + country, + coordinates, + timezone, + src_created_at_ts, + load_dttm, + batch_id +) +SELECT + ext.airport_code::text, + ext.airport_name::text, + ext.city::text, + ext.country::text, + ext.coordinates::text, + ext.timezone::text, + now()::timestamp, + now()::timestamp, + '{{ run_id }}'::text +FROM stg.airports_ext AS ext +WHERE NOT EXISTS ( + -- Защита от дублей в рамках одного batch_id + SELECT 1 + FROM stg.airports AS a + WHERE a.batch_id = '{{ run_id }}'::text + AND a.airport_code = ext.airport_code::text +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.airports; diff --git a/sql/stg/boarding_passes_ddl.sql b/sql/stg/boarding_passes_ddl.sql new file mode 100644 index 0000000..659ed71 --- /dev/null +++ b/sql/stg/boarding_passes_ddl.sql @@ -0,0 +1,38 @@ +-- DDL для слоя STG по таблице boarding_passes. +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.boarding_passes через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.boarding_passes_ext; +CREATE EXTERNAL TABLE stg.boarding_passes_ext ( + ticket_no TEXT, + flight_id TEXT, + seat_no TEXT, + boarding_no INTEGER, + boarding_time TIMESTAMP +) +LOCATION ('pxf://bookings.boarding_passes?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.boarding_passes — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.boarding_passes ( + ticket_no TEXT, + flight_id TEXT, + seat_no TEXT, + boarding_no TEXT, + boarding_time TEXT, + src_created_at_ts TIMESTAMP, + load_dttm TIMESTAMP NOT NULL DEFAULT now(), + batch_id TEXT +) +WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) +-- Ключ распределения: ticket_no +-- Обоснование: ticket_no — это основной бизнес-ключ для билетов. +-- Использование ticket_no обеспечивает: +-- 1. Co-location данных boarding_passes и tickets при JOIN по ticket_no +-- 2. Co-location данных boarding_passes и segments при JOIN по ticket_no +-- 3. Равномерное распределение данных по сегментам (ticket_no имеет высокую кардинальность) +DISTRIBUTED BY (ticket_no); diff --git a/sql/stg/boarding_passes_dq.sql b/sql/stg/boarding_passes_dq.sql new file mode 100644 index 0000000..a19e710 --- /dev/null +++ b/sql/stg/boarding_passes_dq.sql @@ -0,0 +1,99 @@ +-- Проверки качества данных для boarding_passes + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; + v_orphan_ticket_count BIGINT; + v_orphan_segment_count BIGINT; +BEGIN + -- Источник: считаем все строки во внешней таблице + SELECT COUNT(*) + INTO v_src_count + FROM stg.boarding_passes_ext; + + IF v_src_count = 0 THEN + RAISE EXCEPTION + 'В источнике boarding_passes_ext нет строк.'; + END IF; + + -- Считаем строки, реально вставленные в stg.boarding_passes в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.boarding_passes + WHERE batch_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты (ticket_no, flight_id) + SELECT COUNT(*) - COUNT(DISTINCT ticket_no || '|' || flight_id) + INTO v_dup_count + FROM stg.boarding_passes AS bp + WHERE bp.batch_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты (ticket_no, flight_id) (batch_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей (ticket_no, flight_id) + SELECT COUNT(*) + INTO v_null_count + FROM stg.boarding_passes AS bp + WHERE bp.batch_id = v_batch_id + AND (bp.ticket_no IS NULL OR bp.ticket_no = '' + OR bp.flight_id IS NULL OR bp.flight_id = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (batch_id=%): %', + v_batch_id, + v_null_count; + END IF; + + -- Проверка ссылочной целостности: все boarding_passes должны иметь соответствующие tickets + SELECT COUNT(*) + INTO v_orphan_ticket_count + FROM stg.boarding_passes AS bp + LEFT JOIN stg.tickets AS t ON bp.ticket_no = t.ticket_no + WHERE bp.batch_id = v_batch_id + AND t.ticket_no IS NULL; + + IF v_orphan_ticket_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены boarding_passes без соответствующих tickets (batch_id=%): %', + v_batch_id, + v_orphan_ticket_count; + END IF; + + -- Проверка ссылочной целостности: все boarding_passes должны иметь соответствующие segments + SELECT COUNT(*) + INTO v_orphan_segment_count + FROM stg.boarding_passes AS bp + LEFT JOIN stg.segments AS s ON bp.ticket_no = s.ticket_no AND bp.flight_id = s.flight_id + WHERE bp.batch_id = v_batch_id + AND s.ticket_no IS NULL; + + IF v_orphan_segment_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены boarding_passes без соответствующих segments (batch_id=%): %', + v_batch_id, + v_orphan_segment_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: boarding_passes ок (batch_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/boarding_passes_load.sql b/sql/stg/boarding_passes_load.sql new file mode 100644 index 0000000..1d94b7d --- /dev/null +++ b/sql/stg/boarding_passes_load.sql @@ -0,0 +1,36 @@ +-- Загрузка всех строк из stg.boarding_passes_ext в stg.boarding_passes. +-- Используем full snapshot: все строки при каждом запуске. +-- Используем batch_id для отслеживания загрузки. + +INSERT INTO stg.boarding_passes ( + ticket_no, + flight_id, + seat_no, + boarding_no, + boarding_time, + src_created_at_ts, + load_dttm, + batch_id +) +SELECT + ext.ticket_no, + ext.flight_id, + ext.seat_no, + ext.boarding_no::text, + ext.boarding_time::text, + now()::timestamp, + now(), + '{{ run_id }}'::text +FROM stg.boarding_passes_ext AS ext +WHERE NOT EXISTS ( + -- Защита от дублей в рамках одного batch_id + SELECT 1 + FROM stg.boarding_passes AS bp + WHERE bp.batch_id = '{{ run_id }}'::text + AND bp.ticket_no = ext.ticket_no + AND bp.flight_id = ext.flight_id +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.boarding_passes; diff --git a/sql/stg/flights_ddl.sql b/sql/stg/flights_ddl.sql new file mode 100644 index 0000000..546de1a --- /dev/null +++ b/sql/stg/flights_ddl.sql @@ -0,0 +1,42 @@ +-- DDL для слоя STG по таблице flights. +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.flights через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.flights_ext; +CREATE EXTERNAL TABLE stg.flights_ext ( + flight_id TEXT, + route_no TEXT, + status TEXT, + scheduled_departure TIMESTAMP, + scheduled_arrival TIMESTAMP, + actual_departure TIMESTAMP, + actual_arrival TIMESTAMP +) +LOCATION ('pxf://bookings.flights?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.flights — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.flights ( + flight_id TEXT, + route_no TEXT, + status TEXT, + scheduled_departure TEXT, + scheduled_arrival TEXT, + actual_departure TEXT, + actual_arrival TEXT, + src_created_at_ts TIMESTAMP, + load_dttm TIMESTAMP NOT NULL DEFAULT now(), + batch_id TEXT +) +WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) +-- Ключ распределения: flight_id +-- Обоснование: flight_id — это уникальный идентификатор рейса. +-- Использование flight_id обеспечивает: +-- 1. Равномерное распределение данных по сегментам (flight_id имеет высокую кардинальность) +-- 2. Оптимизацию запросов, которые фильтруют или группируют по flight_id +-- 3. Co-location данных flights и boarding_passes при JOIN по flight_id +DISTRIBUTED BY (flight_id); diff --git a/sql/stg/flights_dq.sql b/sql/stg/flights_dq.sql new file mode 100644 index 0000000..1b3bc1d --- /dev/null +++ b/sql/stg/flights_dq.sql @@ -0,0 +1,95 @@ +-- Проверки качества данных для flights + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_prev_ts TIMESTAMP; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; + v_orphan_route_count BIGINT; +BEGIN + -- Опорная метка: максимум src_created_at_ts среди предыдущих батчей + SELECT max(src_created_at_ts) + INTO v_prev_ts + FROM stg.flights + WHERE batch_id <> v_batch_id + OR batch_id IS NULL; + + -- Источник: считаем строки во внешней таблице, которые вошли в окно инкремента + SELECT COUNT(*) + INTO v_src_count + FROM stg.flights_ext + WHERE scheduled_departure > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + + IF v_src_count = 0 THEN + RAISE EXCEPTION + 'В источнике flights_ext нет строк для окна инкремента (scheduled_departure > %).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + END IF; + + -- Считаем строки, реально вставленные в stg.flights в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.flights + WHERE batch_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты flight_id + SELECT COUNT(*) - COUNT(DISTINCT flight_id) + INTO v_dup_count + FROM stg.flights AS f + WHERE f.batch_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты flight_id (batch_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей (flight_id, route_no, status, scheduled_departure) + SELECT COUNT(*) + INTO v_null_count + FROM stg.flights AS f + WHERE f.batch_id = v_batch_id + AND (f.flight_id IS NULL OR f.flight_id = '' + OR f.route_no IS NULL OR f.route_no = '' + OR f.status IS NULL OR f.status = '' + OR f.scheduled_departure IS NULL OR f.scheduled_departure = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (batch_id=%): %', + v_batch_id, + v_null_count; + END IF; + + -- Проверка ссылочной целостности: все flights должны иметь соответствующие routes + SELECT COUNT(*) + INTO v_orphan_route_count + FROM stg.flights AS f + LEFT JOIN stg.routes AS r ON f.route_no = r.route_no + WHERE f.batch_id = v_batch_id + AND r.route_no IS NULL; + + IF v_orphan_route_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены flights без соответствующих routes (batch_id=%): %', + v_batch_id, + v_orphan_route_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: flights ок (batch_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/flights_load.sql b/sql/stg/flights_load.sql new file mode 100644 index 0000000..5a6188e --- /dev/null +++ b/sql/stg/flights_load.sql @@ -0,0 +1,48 @@ +-- Загрузка инкремента из stg.flights_ext в stg.flights. +-- Окно инкремента определяется по src_created_at_ts: +-- берём строки, где scheduled_departure больше максимального src_created_at_ts +-- среди "старых" батчей; верхняя граница по дате не используется. + +-- CTE для определения максимальной даты загрузки предыдущего батча +WITH max_batch_ts AS ( + SELECT COALESCE(MAX(src_created_at_ts), TIMESTAMP '1900-01-01 00:00:00') AS max_ts + FROM stg.flights + WHERE batch_id <> '{{ run_id }}'::text + OR batch_id IS NULL +) +INSERT INTO stg.flights ( + flight_id, + route_no, + status, + scheduled_departure, + scheduled_arrival, + actual_departure, + actual_arrival, + src_created_at_ts, + load_dttm, + batch_id +) +SELECT + ext.flight_id::text, + ext.route_no::text, + ext.status::text, + ext.scheduled_departure::text, + ext.scheduled_arrival::text, + ext.actual_departure::text, + ext.actual_arrival::text, + ext.scheduled_departure::timestamp, + now(), + '{{ run_id }}'::text +FROM stg.flights_ext AS ext +CROSS JOIN max_batch_ts AS mb +WHERE ext.scheduled_departure > mb.max_ts +AND NOT EXISTS ( + SELECT 1 + FROM stg.flights AS f + WHERE f.batch_id = '{{ run_id }}'::text + AND f.flight_id = ext.flight_id::text +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.flights; diff --git a/sql/stg/routes_ddl.sql b/sql/stg/routes_ddl.sql new file mode 100644 index 0000000..c3c93c4 --- /dev/null +++ b/sql/stg/routes_ddl.sql @@ -0,0 +1,44 @@ +-- DDL для слоя STG по таблице routes (справочник). +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.routes через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.routes_ext; +CREATE EXTERNAL TABLE stg.routes_ext ( + route_no TEXT, + validity TSTZRANGE, + departure_airport TEXT, + arrival_airport TEXT, + airplane_code TEXT, + days_of_week INTEGER[], + scheduled_time TIME WITHOUT TIME ZONE, + duration INTERVAL +) +LOCATION ('pxf://bookings.routes?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.routes — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.routes ( + route_no TEXT, + validity TEXT, + departure_airport TEXT, + arrival_airport TEXT, + airplane_code TEXT, + days_of_week TEXT, + scheduled_time TEXT, + duration TEXT, + src_created_at_ts TIMESTAMP, + load_dttm TIMESTAMP NOT NULL DEFAULT now(), + batch_id TEXT +) +WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) +-- Ключ распределения: route_no +-- Обоснование: route_no — это уникальный идентификатор маршрута. +-- Использование route_no обеспечивает: +-- 1. Равномерное распределение данных по сегментам (route_no имеет высокую кардинальность) +-- 2. Co-location данных routes с airports и airplanes при JOIN +-- 3. Оптимизацию запросов, которые фильтруют или группируют по route_no +DISTRIBUTED BY (route_no); diff --git a/sql/stg/routes_dq.sql b/sql/stg/routes_dq.sql new file mode 100644 index 0000000..8c31452 --- /dev/null +++ b/sql/stg/routes_dq.sql @@ -0,0 +1,116 @@ +-- Проверки качества данных для routes (справочник) + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; + v_orphan_airports_count BIGINT; + v_orphan_airplanes_count BIGINT; +BEGIN + -- Источник: считаем все строки во внешней таблице + SELECT COUNT(*) + INTO v_src_count + FROM stg.routes_ext; + + IF v_src_count = 0 THEN + RAISE EXCEPTION + 'В источнике routes_ext нет строк.'; + END IF; + + -- Считаем строки, реально вставленные в stg.routes в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.routes + WHERE batch_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты составного ключа (route_no, validity) + SELECT COUNT(*) - COUNT(DISTINCT route_no || '|' || validity) + INTO v_dup_count + FROM stg.routes AS r + WHERE r.batch_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты (route_no, validity) (batch_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей: route_no, departure_airport, arrival_airport, airplane_code + SELECT COUNT(*) + INTO v_null_count + FROM stg.routes AS r + WHERE r.batch_id = v_batch_id + AND (r.route_no IS NULL OR r.route_no = '' + OR r.departure_airport IS NULL OR r.departure_airport = '' + OR r.arrival_airport IS NULL OR r.arrival_airport = '' + OR r.airplane_code IS NULL OR r.airplane_code = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (route_no, departure_airport, arrival_airport, airplane_code) (batch_id=%): %', + v_batch_id, + v_null_count; + END IF; + + -- Проверка ссылочной целостности: departure_airport должен существовать в airports + SELECT COUNT(*) + INTO v_orphan_airports_count + FROM stg.routes AS r + LEFT JOIN stg.airports AS da ON r.departure_airport = da.airport_code + WHERE r.batch_id = v_batch_id + AND da.airport_code IS NULL; + + IF v_orphan_airports_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены routes с несуществующим departure_airport в airports (batch_id=%): %', + v_batch_id, + v_orphan_airports_count; + END IF; + + -- Проверка ссылочной целостности: arrival_airport должен существовать в airports + SELECT COUNT(*) + INTO v_orphan_airports_count + FROM stg.routes AS r + LEFT JOIN stg.airports AS aa ON r.arrival_airport = aa.airport_code + WHERE r.batch_id = v_batch_id + AND aa.airport_code IS NULL; + + IF v_orphan_airports_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены routes с несуществующим arrival_airport в airports (batch_id=%): %', + v_batch_id, + v_orphan_airports_count; + END IF; + + -- Проверка ссылочной целостности: airplane_code должен существовать в airplanes + SELECT COUNT(*) + INTO v_orphan_airplanes_count + FROM stg.routes AS r + LEFT JOIN stg.airplanes AS a ON r.airplane_code = a.airplane_code + WHERE r.batch_id = v_batch_id + AND a.airplane_code IS NULL; + + IF v_orphan_airplanes_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены routes с несуществующим airplane_code в airplanes (batch_id=%): %', + v_batch_id, + v_orphan_airplanes_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: routes ок (batch_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/routes_load.sql b/sql/stg/routes_load.sql new file mode 100644 index 0000000..77c2077 --- /dev/null +++ b/sql/stg/routes_load.sql @@ -0,0 +1,41 @@ +-- Загрузка всех строк из stg.routes_ext в stg.routes (full load). +-- Используем batch_id для отслеживания загрузки. + +INSERT INTO stg.routes ( + route_no, + validity, + departure_airport, + arrival_airport, + airplane_code, + days_of_week, + scheduled_time, + duration, + src_created_at_ts, + load_dttm, + batch_id +) +SELECT + ext.route_no::text, + ext.validity::text, + ext.departure_airport::text, + ext.arrival_airport::text, + ext.airplane_code::text, + ext.days_of_week::text, + ext.scheduled_time::text, + ext.duration::text, + now()::timestamp, + now()::timestamp, + '{{ run_id }}'::text +FROM stg.routes_ext AS ext +WHERE NOT EXISTS ( + -- Защита от дублей в рамках одного batch_id по составному ключу (route_no, validity) + SELECT 1 + FROM stg.routes AS r + WHERE r.batch_id = '{{ run_id }}'::text + AND r.route_no = ext.route_no::text + AND r.validity = ext.validity::text +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.routes; diff --git a/sql/stg/seats_ddl.sql b/sql/stg/seats_ddl.sql new file mode 100644 index 0000000..4b6ead7 --- /dev/null +++ b/sql/stg/seats_ddl.sql @@ -0,0 +1,34 @@ +-- DDL для слоя STG по таблице seats (справочник). +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.seats через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.seats_ext; +CREATE EXTERNAL TABLE stg.seats_ext ( + airplane_code TEXT, + seat_no TEXT, + fare_conditions TEXT +) +LOCATION ('pxf://bookings.seats?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.seats — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.seats ( + airplane_code TEXT, + seat_no TEXT, + fare_conditions TEXT, + src_created_at_ts TIMESTAMP, + load_dttm TIMESTAMP NOT NULL DEFAULT now(), + batch_id TEXT +) +WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) +-- Ключ распределения: airplane_code +-- Обоснование: airplane_code обеспечивает co-location с таблицей airplanes. +-- Использование airplane_code обеспечивает: +-- 1. Co-location данных seats и airplanes при JOIN по airplane_code +-- 2. Группировка мест по самолётам (в одном самолёте обычно много мест) +-- 3. Оптимизацию запросов, которые фильтруют или группируют по airplane_code +DISTRIBUTED BY (airplane_code); diff --git a/sql/stg/seats_dq.sql b/sql/stg/seats_dq.sql new file mode 100644 index 0000000..91d195a --- /dev/null +++ b/sql/stg/seats_dq.sql @@ -0,0 +1,84 @@ +-- Проверки качества данных для seats (справочник) + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; + v_orphan_airplanes_count BIGINT; +BEGIN + -- Источник: считаем все строки во внешней таблице + SELECT COUNT(*) + INTO v_src_count + FROM stg.seats_ext; + + IF v_src_count = 0 THEN + RAISE EXCEPTION + 'В источнике seats_ext нет строк.'; + END IF; + + -- Считаем строки, реально вставленные в stg.seats в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.seats + WHERE batch_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты составного ключа (airplane_code, seat_no) + SELECT COUNT(*) - COUNT(DISTINCT airplane_code || '|' || seat_no) + INTO v_dup_count + FROM stg.seats AS s + WHERE s.batch_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты (airplane_code, seat_no) (batch_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей: airplane_code, seat_no, fare_conditions + SELECT COUNT(*) + INTO v_null_count + FROM stg.seats AS s + WHERE s.batch_id = v_batch_id + AND (s.airplane_code IS NULL OR s.airplane_code = '' + OR s.seat_no IS NULL OR s.seat_no = '' + OR s.fare_conditions IS NULL OR s.fare_conditions = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (airplane_code, seat_no, fare_conditions) (batch_id=%): %', + v_batch_id, + v_null_count; + END IF; + + -- Проверка ссылочной целостности: airplane_code должен существовать в airplanes + SELECT COUNT(*) + INTO v_orphan_airplanes_count + FROM stg.seats AS s + LEFT JOIN stg.airplanes AS a ON s.airplane_code = a.airplane_code + WHERE s.batch_id = v_batch_id + AND a.airplane_code IS NULL; + + IF v_orphan_airplanes_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены seats с несуществующим airplane_code в airplanes (batch_id=%): %', + v_batch_id, + v_orphan_airplanes_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: seats ок (batch_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/seats_load.sql b/sql/stg/seats_load.sql new file mode 100644 index 0000000..3c530d0 --- /dev/null +++ b/sql/stg/seats_load.sql @@ -0,0 +1,31 @@ +-- Загрузка всех строк из stg.seats_ext в stg.seats (full load). +-- Используем batch_id для отслеживания загрузки. + +INSERT INTO stg.seats ( + airplane_code, + seat_no, + fare_conditions, + src_created_at_ts, + load_dttm, + batch_id +) +SELECT + ext.airplane_code::text, + ext.seat_no::text, + ext.fare_conditions::text, + now()::timestamp, + now()::timestamp, + '{{ run_id }}'::text +FROM stg.seats_ext AS ext +WHERE NOT EXISTS ( + -- Защита от дублей в рамках одного batch_id по составному ключу (airplane_code, seat_no) + SELECT 1 + FROM stg.seats AS s + WHERE s.batch_id = '{{ run_id }}'::text + AND s.airplane_code = ext.airplane_code::text + AND s.seat_no = ext.seat_no::text +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.seats; diff --git a/sql/stg/segments_ddl.sql b/sql/stg/segments_ddl.sql new file mode 100644 index 0000000..402bc05 --- /dev/null +++ b/sql/stg/segments_ddl.sql @@ -0,0 +1,36 @@ +-- DDL для слоя STG по таблице segments. +-- Используется как из общего скрипта ddl_gp.sql (через \i), +-- так и может выполняться отдельно при изменении схемы. + +-- Схема stg для сырого слоя DWH. +CREATE SCHEMA IF NOT EXISTS stg; + +-- Внешняя таблица в схеме stg для чтения данных из bookings.segments через PXF. +DROP EXTERNAL TABLE IF EXISTS stg.segments_ext; +CREATE EXTERNAL TABLE stg.segments_ext ( + ticket_no TEXT, + flight_id TEXT, + fare_conditions TEXT, + price NUMERIC(10,2) +) +LOCATION ('pxf://bookings.segments?PROFILE=JDBC&SERVER=bookings-db') +FORMAT 'CUSTOM' (formatter='pxfwritable_import'); + +-- Внутренняя таблица stg.segments — сырой слой, все бизнес-колонки как TEXT. +CREATE TABLE IF NOT EXISTS stg.segments ( + ticket_no TEXT, + flight_id TEXT, + fare_conditions TEXT, + price TEXT, + src_created_at_ts TIMESTAMP, + load_dttm TIMESTAMP NOT NULL DEFAULT now(), + batch_id TEXT +) +WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) +-- Ключ распределения: ticket_no +-- Обоснование: ticket_no — это основной бизнес-ключ для билетов. +-- Использование ticket_no обеспечивает: +-- 1. Co-location данных segments и tickets при JOIN по ticket_no +-- 2. Co-location данных segments и boarding_passes при JOIN по ticket_no +-- 3. Равномерное распределение данных по сегментам (ticket_no имеет высокую кардинальность) +DISTRIBUTED BY (ticket_no); diff --git a/sql/stg/segments_dq.sql b/sql/stg/segments_dq.sql new file mode 100644 index 0000000..3c00e12 --- /dev/null +++ b/sql/stg/segments_dq.sql @@ -0,0 +1,113 @@ +-- Проверки качества данных для segments + +DO $$ +DECLARE + v_batch_id TEXT := '{{ run_id }}'::text; + v_prev_ts TIMESTAMP; + v_src_count BIGINT; + v_stg_count BIGINT; + v_dup_count BIGINT; + v_null_count BIGINT; + v_orphan_ticket_count BIGINT; + v_orphan_flight_count BIGINT; +BEGIN + -- Опорная метка: максимум src_created_at_ts среди предыдущих батчей + SELECT max(src_created_at_ts) + INTO v_prev_ts + FROM stg.segments + WHERE batch_id <> v_batch_id + OR batch_id IS NULL; + + -- Источник: считаем строки во внешней таблице, которые вошли в окно инкремента + SELECT COUNT(*) + INTO v_src_count + FROM stg.segments_ext AS s + JOIN stg.tickets_ext AS t ON s.ticket_no = t.ticket_no + JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref + WHERE b.book_date > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + + IF v_src_count = 0 THEN + RAISE EXCEPTION + 'В источнике segments_ext нет строк для окна инкремента (book_date > %).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + END IF; + + -- Считаем строки, реально вставленные в stg.segments в этом батче + SELECT COUNT(*) + INTO v_stg_count + FROM stg.segments + WHERE batch_id = v_batch_id; + + IF v_src_count <> v_stg_count THEN + RAISE EXCEPTION + 'DQ FAILED: несовпадение количества строк. Источник: %, STG: %', + v_src_count, + v_stg_count; + END IF; + + -- Проверка на дубликаты (ticket_no, flight_id) + SELECT COUNT(*) - COUNT(DISTINCT ticket_no || '|' || flight_id) + INTO v_dup_count + FROM stg.segments AS s + WHERE s.batch_id = v_batch_id; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены дубликаты (ticket_no, flight_id) (batch_id=%): %', + v_batch_id, + v_dup_count; + END IF; + + -- Проверка обязательных полей (ticket_no, flight_id, fare_conditions, price) + SELECT COUNT(*) + INTO v_null_count + FROM stg.segments AS s + WHERE s.batch_id = v_batch_id + AND (s.ticket_no IS NULL OR s.ticket_no = '' + OR s.flight_id IS NULL OR s.flight_id = '' + OR s.fare_conditions IS NULL OR s.fare_conditions = '' + OR s.price IS NULL OR s.price = ''); + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены строки с NULL в обязательных полях (batch_id=%): %', + v_batch_id, + v_null_count; + END IF; + + -- Проверка ссылочной целостности: все segments должны иметь соответствующие tickets + SELECT COUNT(*) + INTO v_orphan_ticket_count + FROM stg.segments AS s + LEFT JOIN stg.tickets AS t ON s.ticket_no = t.ticket_no + WHERE s.batch_id = v_batch_id + AND t.ticket_no IS NULL; + + IF v_orphan_ticket_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены segments без соответствующих tickets (batch_id=%): %', + v_batch_id, + v_orphan_ticket_count; + END IF; + + -- Проверка ссылочной целостности: все segments должны иметь соответствующие flights + SELECT COUNT(*) + INTO v_orphan_flight_count + FROM stg.segments AS s + LEFT JOIN stg.flights AS f ON s.flight_id = f.flight_id + WHERE s.batch_id = v_batch_id + AND f.flight_id IS NULL; + + IF v_orphan_flight_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: найдены segments без соответствующих flights (batch_id=%): %', + v_batch_id, + v_orphan_flight_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: segments ок (batch_id=%): source=% stg=%', + v_batch_id, + v_src_count, + v_stg_count; +END $$; diff --git a/sql/stg/segments_load.sql b/sql/stg/segments_load.sql new file mode 100644 index 0000000..96154a5 --- /dev/null +++ b/sql/stg/segments_load.sql @@ -0,0 +1,45 @@ +-- Загрузка инкремента из stg.segments_ext в stg.segments. +-- Инкремент определяется по дате бронирования (book_date из bookings.bookings) +-- через JOIN с таблицей tickets. + +-- CTE для определения максимальной даты загрузки предыдущего батча +WITH max_batch_ts AS ( + SELECT COALESCE(MAX(src_created_at_ts), TIMESTAMP '1900-01-01 00:00:00') AS max_ts + FROM stg.segments + WHERE batch_id <> '{{ run_id }}'::text + OR batch_id IS NULL +) +INSERT INTO stg.segments ( + ticket_no, + flight_id, + fare_conditions, + price, + src_created_at_ts, + load_dttm, + batch_id +) +SELECT + ext.ticket_no, + ext.flight_id, + ext.fare_conditions, + ext.price::text, + b.book_date::timestamp, + now(), + '{{ run_id }}'::text +FROM stg.segments_ext AS ext +JOIN stg.tickets_ext AS t ON ext.ticket_no = t.ticket_no +JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref +CROSS JOIN max_batch_ts AS mb +WHERE b.book_date > mb.max_ts +AND NOT EXISTS ( + -- Защита от дублей в рамках одного batch_id + SELECT 1 + FROM stg.segments AS s + WHERE s.batch_id = '{{ run_id }}'::text + AND s.ticket_no = ext.ticket_no + AND s.flight_id = ext.flight_id +); + +-- Обновляем статистику для оптимизатора Greenplum +-- Это критично для корректной работы оптимизатора и выбора оптимального плана выполнения +ANALYZE stg.segments; diff --git a/tests/test_dags_smoke.py b/tests/test_dags_smoke.py index 2d2f727..20ee766 100644 --- a/tests/test_dags_smoke.py +++ b/tests/test_dags_smoke.py @@ -70,3 +70,85 @@ def test_csv_to_greenplum_dq_dag_structure(): assert h in s.get_direct_relatives("downstream") assert d in h.get_direct_relatives("downstream") assert q in d.get_direct_relatives("downstream") + + +def test_bookings_stg_ddl_dag_structure(): + """Проверка структуры DAG bookings_stg_ddl.""" + dag = _load_dag("airflow.dags.bookings_stg_ddl") + + expected_tasks = { + "apply_stg_bookings_ddl", + "apply_stg_tickets_ddl", + "apply_stg_airports_ddl", + "apply_stg_airplanes_ddl", + "apply_stg_routes_ddl", + "apply_stg_seats_ddl", + "apply_stg_flights_ddl", + "apply_stg_segments_ddl", + "apply_stg_boarding_passes_ddl", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + # Проверка линейных зависимостей + # Справочники создаются после bookings/tickets + assert dag.has_task("apply_stg_bookings_ddl") + assert dag.has_task("apply_stg_tickets_ddl") + assert dag.has_task("apply_stg_airports_ddl") + assert dag.has_task("apply_stg_airplanes_ddl") + assert dag.has_task("apply_stg_routes_ddl") + assert dag.has_task("apply_stg_seats_ddl") + assert dag.has_task("apply_stg_flights_ddl") + assert dag.has_task("apply_stg_segments_ddl") + assert dag.has_task("apply_stg_boarding_passes_ddl") + + +def test_bookings_to_gp_stage_dag_structure(): + """Проверка структуры DAG bookings_to_gp_stage.""" + dag = _load_dag("airflow.dags.bookings_to_gp_stage") + + expected_tasks = { + "generate_bookings_day", + "load_bookings_to_stg", + "check_row_counts", + "load_tickets_to_stg", + "check_tickets_dq", + "load_airports_to_stg", + "check_airports_dq", + "load_airplanes_to_stg", + "check_airplanes_dq", + "load_routes_to_stg", + "check_routes_dq", + "load_seats_to_stg", + "check_seats_dq", + "load_flights_to_stg", + "check_flights_dq", + "load_segments_to_stg", + "check_segments_dq", + "load_boarding_passes_to_stg", + "check_boarding_passes_dq", + "finish_summary", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + # Проверка линейных зависимостей + # bookings/tickets → справочники → транзакции → финальный лог + assert dag.has_task("generate_bookings_day") + assert dag.has_task("load_bookings_to_stg") + assert dag.has_task("check_row_counts") + assert dag.has_task("load_tickets_to_stg") + assert dag.has_task("check_tickets_dq") + assert dag.has_task("load_airports_to_stg") + assert dag.has_task("check_airports_dq") + assert dag.has_task("load_airplanes_to_stg") + assert dag.has_task("check_airplanes_dq") + assert dag.has_task("load_routes_to_stg") + assert dag.has_task("check_routes_dq") + assert dag.has_task("load_seats_to_stg") + assert dag.has_task("check_seats_dq") + assert dag.has_task("load_flights_to_stg") + assert dag.has_task("check_flights_dq") + assert dag.has_task("load_segments_to_stg") + assert dag.has_task("check_segments_dq") + assert dag.has_task("load_boarding_passes_to_stg") + assert dag.has_task("check_boarding_passes_dq") + assert dag.has_task("finish_summary") From 4718b3bdb7a23abbb307568fb059d59366e242c9 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 18 Jan 2026 13:47:09 +0300 Subject: [PATCH 14/38] =?UTF-8?q?=D0=9E=D1=82=D0=BB=D0=B0=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- airflow/dags/bookings_stg_ddl.py | 20 ++++++++------------ airflow/dags/bookings_to_gp_stage.py | 24 ++++++++++-------------- pxf/servers/bookings-db/jdbc-site.xml | 5 +++++ sql/stg/airplanes_ddl.sql | 7 ++++--- sql/stg/airports_ddl.sql | 9 +++++---- sql/stg/boarding_passes_dq.sql | 10 ++++++++-- sql/stg/routes_ddl.sql | 9 +++++---- 7 files changed, 45 insertions(+), 39 deletions(-) diff --git a/airflow/dags/bookings_stg_ddl.py b/airflow/dags/bookings_stg_ddl.py index f205e79..874ec16 100644 --- a/airflow/dags/bookings_stg_ddl.py +++ b/airflow/dags/bookings_stg_ddl.py @@ -82,19 +82,15 @@ with DAG( sql="stg/boarding_passes_ddl.sql", ) - # Сначала создаются справочники, затем транзакционные таблицы + # Сначала создаются справочники, затем транзакционные таблицы (последовательно) ( apply_stg_bookings_ddl >> apply_stg_tickets_ddl - >> [ - apply_stg_airports_ddl, - apply_stg_airplanes_ddl, - apply_stg_routes_ddl, - apply_stg_seats_ddl, - ] - >> [ - apply_stg_flights_ddl, - apply_stg_segments_ddl, - apply_stg_boarding_passes_ddl, - ] + >> apply_stg_airports_ddl + >> apply_stg_airplanes_ddl + >> apply_stg_routes_ddl + >> apply_stg_seats_ddl + >> apply_stg_flights_ddl + >> apply_stg_segments_ddl + >> apply_stg_boarding_passes_ddl ) diff --git a/airflow/dags/bookings_to_gp_stage.py b/airflow/dags/bookings_to_gp_stage.py index d978de8..354f241 100644 --- a/airflow/dags/bookings_to_gp_stage.py +++ b/airflow/dags/bookings_to_gp_stage.py @@ -190,20 +190,16 @@ with DAG( generate_bookings_day >> load_bookings_to_stg >> check_row_counts check_row_counts >> load_tickets_to_stg >> check_tickets_dq - # Затем загружаются справочники - check_tickets_dq >> [ - (load_airports_to_stg >> check_airports_dq), - (load_airplanes_to_stg >> check_airplanes_dq), - (load_routes_to_stg >> check_routes_dq), - (load_seats_to_stg >> check_seats_dq), - ] + # Затем загружаются справочники (последовательная загрузка) + check_tickets_dq >> load_airports_to_stg >> check_airports_dq + check_airports_dq >> load_airplanes_to_stg >> check_airplanes_dq + check_airplanes_dq >> load_routes_to_stg >> check_routes_dq + check_routes_dq >> load_seats_to_stg >> check_seats_dq - # Затем загружаются транзакции - [check_airports_dq, check_airplanes_dq, check_routes_dq, check_seats_dq] >> [ - (load_flights_to_stg >> check_flights_dq), - (load_segments_to_stg >> check_segments_dq), - (load_boarding_passes_to_stg >> check_boarding_passes_dq), - ] + # Затем загружаются транзакции (последовательная загрузка) + check_seats_dq >> load_flights_to_stg >> check_flights_dq + check_flights_dq >> load_segments_to_stg >> check_segments_dq + check_segments_dq >> load_boarding_passes_to_stg >> check_boarding_passes_dq # В конце финальный лог - [check_flights_dq, check_segments_dq, check_boarding_passes_dq] >> finish_summary + check_boarding_passes_dq >> finish_summary diff --git a/pxf/servers/bookings-db/jdbc-site.xml b/pxf/servers/bookings-db/jdbc-site.xml index dca82e9..6172ce5 100644 --- a/pxf/servers/bookings-db/jdbc-site.xml +++ b/pxf/servers/bookings-db/jdbc-site.xml @@ -27,5 +27,10 @@ bookings + + jdbc.column.types + jsonb=TEXT,tstzrange=TEXT,_int4=TEXT + + diff --git a/sql/stg/airplanes_ddl.sql b/sql/stg/airplanes_ddl.sql index d29196d..c25439d 100644 --- a/sql/stg/airplanes_ddl.sql +++ b/sql/stg/airplanes_ddl.sql @@ -6,12 +6,13 @@ CREATE SCHEMA IF NOT EXISTS stg; -- Внешняя таблица в схеме stg для чтения данных из bookings.airplanes_data через PXF. +-- PXF не поддерживает тип JSONB - используем TEXT для всех колонок. DROP EXTERNAL TABLE IF EXISTS stg.airplanes_ext; CREATE EXTERNAL TABLE stg.airplanes_ext ( airplane_code TEXT, - model JSONB, - range INTEGER, - speed INTEGER + model TEXT, + range TEXT, + speed TEXT ) LOCATION ('pxf://bookings.airplanes_data?PROFILE=JDBC&SERVER=bookings-db') FORMAT 'CUSTOM' (formatter='pxfwritable_import'); diff --git a/sql/stg/airports_ddl.sql b/sql/stg/airports_ddl.sql index 3f27dd2..f953258 100644 --- a/sql/stg/airports_ddl.sql +++ b/sql/stg/airports_ddl.sql @@ -6,13 +6,14 @@ CREATE SCHEMA IF NOT EXISTS stg; -- Внешняя таблица в схеме stg для чтения данных из bookings.airports_data через PXF. +-- PXF не поддерживает типы JSONB, POINT - используем TEXT для всех колонок. DROP EXTERNAL TABLE IF EXISTS stg.airports_ext; CREATE EXTERNAL TABLE stg.airports_ext ( airport_code TEXT, - airport_name JSONB, - city JSONB, - country JSONB, - coordinates POINT, + airport_name TEXT, + city TEXT, + country TEXT, + coordinates TEXT, timezone TEXT ) LOCATION ('pxf://bookings.airports_data?PROFILE=JDBC&SERVER=bookings-db') diff --git a/sql/stg/boarding_passes_dq.sql b/sql/stg/boarding_passes_dq.sql index a19e710..0a2f123 100644 --- a/sql/stg/boarding_passes_dq.sql +++ b/sql/stg/boarding_passes_dq.sql @@ -16,8 +16,10 @@ BEGIN FROM stg.boarding_passes_ext; IF v_src_count = 0 THEN - RAISE EXCEPTION - 'В источнике boarding_passes_ext нет строк.'; + RAISE NOTICE + 'В источнике boarding_passes_ext нет строк - пропускаем DQ проверки (batch_id=%).', + v_batch_id; + RETURN; END IF; -- Считаем строки, реально вставленные в stg.boarding_passes в этом батче @@ -96,4 +98,8 @@ BEGIN v_batch_id, v_src_count, v_stg_count; + +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'DQ ERROR для boarding_passes (batch_id=%): %', v_batch_id, SQLERRM; + RAISE; END $$; diff --git a/sql/stg/routes_ddl.sql b/sql/stg/routes_ddl.sql index c3c93c4..dca3f56 100644 --- a/sql/stg/routes_ddl.sql +++ b/sql/stg/routes_ddl.sql @@ -6,16 +6,17 @@ CREATE SCHEMA IF NOT EXISTS stg; -- Внешняя таблица в схеме stg для чтения данных из bookings.routes через PXF. +-- PXF не поддерживает типы TSTZRANGE, INTEGER[], TIME, INTERVAL - используем TEXT для всех колонок. DROP EXTERNAL TABLE IF EXISTS stg.routes_ext; CREATE EXTERNAL TABLE stg.routes_ext ( route_no TEXT, - validity TSTZRANGE, + validity TEXT, departure_airport TEXT, arrival_airport TEXT, airplane_code TEXT, - days_of_week INTEGER[], - scheduled_time TIME WITHOUT TIME ZONE, - duration INTERVAL + days_of_week TEXT, + scheduled_time TEXT, + duration TEXT ) LOCATION ('pxf://bookings.routes?PROFILE=JDBC&SERVER=bookings-db') FORMAT 'CUSTOM' (formatter='pxfwritable_import'); From 762d539d5263059d2795ad774114440bdb7d0d86 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 18 Jan 2026 21:22:00 +0300 Subject: [PATCH 15/38] =?UTF-8?q?=D0=A0=D0=B5=D0=B2=D1=8C=D1=8E=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/bookings_to_gp_stage.md | 7 ++ docs/internal/bookings_stg_code_review.md | 147 ++++++++++++++++++++++ docs/internal/db_schema.md | 1 + 3 files changed, 155 insertions(+) create mode 100644 docs/internal/bookings_stg_code_review.md diff --git a/docs/bookings_to_gp_stage.md b/docs/bookings_to_gp_stage.md index e07d4e9..eb6ea76 100644 --- a/docs/bookings_to_gp_stage.md +++ b/docs/bookings_to_gp_stage.md @@ -12,6 +12,8 @@ - Сверяет количество строк между источником (за окно инкремента) и загруженным батчем. - Загружает инкремент в `stg.tickets` через внешнюю таблицу `stg.tickets_ext`, используя PXF. - Запускает DQ‑проверки для `stg.tickets` (количество, ссылочная целостность, обязательные поля). +- Загружает справочники (full load): `stg.airports`, `stg.airplanes`, `stg.routes`, `stg.seats` + DQ. +- Загружает транзакции: `stg.flights` (инкремент), `stg.segments` (инкремент), `stg.boarding_passes` (full snapshot) + DQ. ## Что должно быть готово перед запуском @@ -113,3 +115,8 @@ LIMIT 10; - Ошибки про `stg.bookings_ext`/`stg.bookings`: не применён DDL → запустите `bookings_stg_ddl` или `make ddl-gp`. - Ошибки PXF (`protocol "pxf" does not exist`, connection refused): перезапустите `greenplum` и повторите DDL. Для технических деталей см. `docs/internal/pxf_bookings.md`. + +## Рекомендации по качеству решения + +Ревью решения и список улучшений, которые делают пайплайн более “эталонным” для обучения: +`docs/internal/bookings_stg_code_review.md`. diff --git a/docs/internal/bookings_stg_code_review.md b/docs/internal/bookings_stg_code_review.md new file mode 100644 index 0000000..3caffa7 --- /dev/null +++ b/docs/internal/bookings_stg_code_review.md @@ -0,0 +1,147 @@ +# Ревью решения (образец для студентов): `bookings-db` → `stg` в Greenplum + +Этот документ фиксирует рекомендации по улучшению учебного решения ETL (Airflow + Greenplum + PXF) +на основе ревью изменений ветки `chore/bookings-etl` (добавление полного STG слоя и пайплайна загрузки). + +Цель ревью — сделать решение **безоговорочно рекомендуемым** к изучению начинающими: +понятным, предсказуемым, с корректной терминологией и честными инженерными компромиссами. + +--- + +## 1) Сильные стороны решения (что уже хорошо и стоит сохранить) + +1. **Единый “шаблон” по таблицам** в `sql/stg/`: + - `{table}_ddl.sql` — создаёт `*_ext` и внутреннюю таблицу; + - `{table}_load.sql` — загружает данные; + - `{table}_dq.sql` — валидирует качество и останавливает пайплайн при проблемах. + + Это отличная учебная структура: студент быстро понимает, “где что лежит” и как добавлять новые таблицы. + +2. **DAG как оркестратор, SQL как логика**: + - `airflow/dags/bookings_to_gp_stage.py` и `airflow/dags/bookings_stg_ddl.py` используют `PostgresOperator` + и читают SQL с диска через `template_searchpath="/sql"`. + Это соответствует “канонической” модели: Airflow управляет шагами, а трансформации живут в SQL. + +3. **Понятные сообщения при падении DQ** (в большинстве скриптов): студенту легче дебажить. + +--- + +## 2) Критичные замечания (исправить перед тем, как показывать как эталон) + +### 2.1. Некорректные утверждения про MPP и co-location (вводят студентов в заблуждение) + +В Greenplum производительность JOIN сильно зависит от распределения данных по сегментам. +Если ключ распределения двух таблиц совпадает с ключом JOIN — часто удаётся обойтись без перераспределения данных (motion). + +Проблема: в некоторых DDL-комментариях сейчас обещается co-location там, где его не будет. +Это педагогически опасно: студент запоминает неверную модель, а потом “не понимает”, почему запросы медленные. + +Примеры мест, которые стоит скорректировать: +- `sql/stg/flights_ddl.sql`: `stg.flights` распределена по `flight_id`, а `stg.boarding_passes` — по `ticket_no`, + поэтому “co-location flights и boarding_passes при JOIN по flight_id” не выполняется. +- `sql/stg/routes_ddl.sql`: распределение `stg.routes` по `route_no` не даёт co-location с `stg.airports` (которая по `airport_code`) + и `stg.airplanes` (которая по `airplane_code`) при типичных JOIN’ах. + +Рекомендация: либо исправить распределение (если это действительно важно для учебного кейса), +либо **честно переписать комментарии**: “ключ выбран так-то, но JOIN по другим ключам может требовать motion”. + +### 2.2. DQ-проверки ссылочной целостности иногда “смотрят в историю”, а не в текущий батч + +Часть DQ-скриптов проверяет наличие “родительских” записей в таблице **без фильтра `batch_id`**. +При append-only истории это может скрыть проблемы текущей загрузки: +родитель был загружен в прошлом батче → проверка пройдёт, даже если текущий батч родителя не загрузил. + +Пример: +- `sql/stg/routes_dq.sql` проверяет airports/airplanes без ограничения на `batch_id`. + +Рекомендация: для snapshot-таблиц (справочники и boarding_passes) использовать батч-строгую проверку: +“в текущем `batch_id` все ссылки указывают на строки текущего `batch_id`”. +Это лучше учит идее “консистентность батча” и упрощает отладку. + +### 2.3. Smoke-тесты DAG’ов есть, но почти не проверяют граф + +В `tests/test_dags_smoke.py` новые тесты в основном проверяют “таски существуют” через `dag.has_task(...)`. +Как учебный пример теста это слабовато: студент видит тест, но не понимает, что именно он защищает. + +Рекомендация: тестировать зависимости так же, как это уже сделано для `csv_to_greenplum` +(через `dag.get_task(...).get_direct_relatives("downstream")`). + +### 2.4. Документация по DAG отстаёт от реальной логики + +`docs/bookings_to_gp_stage.md` описывает только загрузку `bookings` и `tickets`, +но DAG теперь загружает ещё 7 таблиц (справочники и транзакции). + +Рекомендация: обновить документ, чтобы студент мог запустить пайплайн “по инструкции” без сюрпризов. + +--- + +## 3) Рекомендации по качеству и читаемости (Clean Code для SQL и DAG) + +### 3.1. “Empty window” в инкременте: договориться о политике (fail vs skip) + +Сейчас поведение разное: +- `bookings_dq.sql` / `tickets_dq.sql` / `flights_dq.sql` / `segments_dq.sql` падают, если в окне инкремента 0 строк; +- `boarding_passes_dq.sql` делает `RAISE NOTICE` и `RETURN`. + +Обе стратегии допустимы, но в учебном решении важно выбрать одну и объяснить: +- **Fail** полезен, когда “ожидаем данные в каждом запуске” (например, учебный генератор должен добавлять день); +- **Skip** полезен, когда “окно может быть пустым и это нормально”. + +Рекомендация: зафиксировать политику в документации и сделать поведение единообразным по группам таблиц. + +### 3.2. Комментарии в `*_load.sql`: точнее формулировать “идемпотентность”, а не “дедупликацию источника” + +Типовой паттерн: +```sql +WHERE NOT EXISTS ( + SELECT 1 FROM stg.table WHERE batch_id = '{{ run_id }}' AND key = ext.key +); +``` + +Это в первую очередь защита от повторного запуска того же таска в рамках одного `batch_id` (retry), +а не “лечение” дублей в источнике. + +Рекомендация: заменить формулировку комментариев на “идемпотентность для текущего батча”. + +### 3.3. Проверка составных ключей: избегать склейки строк + +Паттерн вида `COUNT(DISTINCT col1 || '|' || col2)` теоретически может давать коллизии (если в данных встречается разделитель). +В учебном стенде риск небольшой, но как “эталон” лучше показывать более безопасный подход: +- если поддерживается: `COUNT(DISTINCT (col1, col2))`; +- либо использовать стабильную сериализацию, где коллизии исключены (например, `md5(...)` от безопасной структуры). + +--- + +## 4) Практические примеры “как улучшить” + +### 4.1. Батч-строгая ссылочная целостность (пример подхода) + +Если таблицы грузятся как snapshot в рамках батча, проверки можно сделать батч-строгими: +“в текущем батче ссылки указывают на строки текущего батча”. + +Идея (пример для routes → airports): +```sql +LEFT JOIN stg.airports AS a + ON r.departure_airport = a.airport_code + AND a.batch_id = v_batch_id +``` + +### 4.2. Smoke-тест реального графа (минимальный полезный уровень) + +Вместо “таски существуют” лучше проверять ключевые зависимости: +```python +tickets_dq = dag.get_task("check_tickets_dq") +airports_load = dag.get_task("load_airports_to_stg") +assert airports_load in tickets_dq.get_direct_relatives("downstream") +``` + +--- + +## 5) Чек-лист “готово как эталон” + +- [ ] В DDL-комментариях нет неверных обещаний про co-location/уникальность ключей. +- [ ] Для DQ определена и описана политика “0 строк”: где fail, где skip. +- [ ] DQ ссылочной целостности не маскирует проблемы текущего батча (batch-строгие проверки там, где это уместно). +- [ ] `docs/bookings_to_gp_stage.md` соответствует фактическому DAG. +- [ ] Smoke-тесты проверяют хотя бы критические зависимости графа. + diff --git a/docs/internal/db_schema.md b/docs/internal/db_schema.md index 9d3693b..614f7c9 100644 --- a/docs/internal/db_schema.md +++ b/docs/internal/db_schema.md @@ -402,6 +402,7 @@ graph LR ## Связанные документы - [`docs/internal/bookings_stg_design.md`](bookings_stg_design.md) — Детальный дизайн STG слоя для bookings +- [`docs/internal/bookings_stg_code_review.md`](bookings_stg_code_review.md) — Ревью решения и рекомендации по улучшению - [`docs/internal/bookings_tz.md`](bookings_tz.md) — Работа с часовыми поясами в источнике - [`docs/internal/pxf_bookings.md`](pxf_bookings.md) — Настройка PXF для чтения из bookings-db - [`TESTING.md`](../../TESTING.md) — Пошаговый чек-лист для тестирования стенда From ba74b521dc1a17dbc03f64aa4420811f145ce796 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 18 Jan 2026 21:33:46 +0300 Subject: [PATCH 16/38] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BA=D1=80=D0=B8=D1=82=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/bookings_to_gp_stage.md | 20 ++++- docs/internal/bookings_stg_code_review.md | 57 ++++++-------- docs/internal/db_schema.md | 23 +++--- sql/stg/boarding_passes_ddl.sql | 6 +- sql/stg/flights_ddl.sql | 2 +- sql/stg/flights_dq.sql | 4 +- sql/stg/routes_ddl.sql | 6 +- sql/stg/routes_dq.sql | 12 ++- sql/stg/seats_dq.sql | 4 +- sql/stg/segments_ddl.sql | 6 +- tests/test_dags_smoke.py | 93 +++++++++++++++-------- 11 files changed, 143 insertions(+), 90 deletions(-) diff --git a/docs/bookings_to_gp_stage.md b/docs/bookings_to_gp_stage.md index eb6ea76..71678ba 100644 --- a/docs/bookings_to_gp_stage.md +++ b/docs/bookings_to_gp_stage.md @@ -85,7 +85,25 @@ make bookings-init - проверяет количество строк в том же окне инкремента, а также ссылочную целостность и обязательные поля; - при проблемах делает `RAISE EXCEPTION`, чтобы DAG падал “красным”. -6) `finish_summary` +6) Справочники (full load) + +Каждый справочник загружается “снэпшотом” (все строки) и затем проверяется DQ-скриптом: + +- `load_airports_to_stg` → `check_airports_dq` (`sql/stg/airports_load.sql`, `sql/stg/airports_dq.sql`) +- `load_airplanes_to_stg` → `check_airplanes_dq` (`sql/stg/airplanes_load.sql`, `sql/stg/airplanes_dq.sql`) +- `load_routes_to_stg` → `check_routes_dq` (`sql/stg/routes_load.sql`, `sql/stg/routes_dq.sql`) +- `load_seats_to_stg` → `check_seats_dq` (`sql/stg/seats_load.sql`, `sql/stg/seats_dq.sql`) + +7) Транзакции + +- `load_flights_to_stg` → `check_flights_dq` (инкремент по `scheduled_departure`) +- `load_segments_to_stg` → `check_segments_dq` (инкремент по `book_date` через tickets/bookings) +- `load_boarding_passes_to_stg` → `check_boarding_passes_dq` (full snapshot) + +Важно: для некоторых таблиц “пустое окно инкремента” считается ошибкой (DQ делает `RAISE EXCEPTION`), +а для `boarding_passes` DQ может быть пропущена, если в источнике 0 строк. + +8) `finish_summary` - логирует краткую сводку в конце запуска. diff --git a/docs/internal/bookings_stg_code_review.md b/docs/internal/bookings_stg_code_review.md index 3caffa7..83ea3bf 100644 --- a/docs/internal/bookings_stg_code_review.md +++ b/docs/internal/bookings_stg_code_review.md @@ -26,52 +26,46 @@ --- -## 2) Критичные замечания (исправить перед тем, как показывать как эталон) +## 2) Критичные замечания (статус на текущий момент) -### 2.1. Некорректные утверждения про MPP и co-location (вводят студентов в заблуждение) +### 2.1. Некорректные утверждения про MPP и co-location (статус: исправлено) В Greenplum производительность JOIN сильно зависит от распределения данных по сегментам. Если ключ распределения двух таблиц совпадает с ключом JOIN — часто удаётся обойтись без перераспределения данных (motion). -Проблема: в некоторых DDL-комментариях сейчас обещается co-location там, где его не будет. +Раньше в некоторых DDL-комментариях обещалась co-location там, где её не будет. Это педагогически опасно: студент запоминает неверную модель, а потом “не понимает”, почему запросы медленные. -Примеры мест, которые стоит скорректировать: -- `sql/stg/flights_ddl.sql`: `stg.flights` распределена по `flight_id`, а `stg.boarding_passes` — по `ticket_no`, - поэтому “co-location flights и boarding_passes при JOIN по flight_id” не выполняется. -- `sql/stg/routes_ddl.sql`: распределение `stg.routes` по `route_no` не даёт co-location с `stg.airports` (которая по `airport_code`) - и `stg.airplanes` (которая по `airplane_code`) при типичных JOIN’ах. +Что сделано: +- DDL-комментарии приведены к честной формулировке “ключ выбран так-то, но JOIN по другим ключам может требовать motion”. +- Исправлены места, где co-location заявлялась ошибочно (в т.ч. `routes`, `flights`, `segments`, `boarding_passes`). -Рекомендация: либо исправить распределение (если это действительно важно для учебного кейса), -либо **честно переписать комментарии**: “ключ выбран так-то, но JOIN по другим ключам может требовать motion”. +Файлы: `sql/stg/routes_ddl.sql`, `sql/stg/flights_ddl.sql`, `sql/stg/segments_ddl.sql`, `sql/stg/boarding_passes_ddl.sql`. -### 2.2. DQ-проверки ссылочной целостности иногда “смотрят в историю”, а не в текущий батч +### 2.2. DQ-проверки ссылочной целостности: “текущий батч” vs “вся история” (статус: зафиксировано и частично усилено) Часть DQ-скриптов проверяет наличие “родительских” записей в таблице **без фильтра `batch_id`**. При append-only истории это может скрыть проблемы текущей загрузки: родитель был загружен в прошлом батче → проверка пройдёт, даже если текущий батч родителя не загрузил. -Пример: -- `sql/stg/routes_dq.sql` проверяет airports/airplanes без ограничения на `batch_id`. +Что сделано для справочников (snapshot), которые загружаются каждый запуск: +- `routes_dq.sql`: проверка airports/airplanes стала батч-строгой (`batch_id = текущий батч`). +- `seats_dq.sql`: проверка airplanes стала батч-строгой (`batch_id = текущий батч`). +- `flights_dq.sql`: проверка routes стала батч-строгой (`batch_id = текущий батч`). -Рекомендация: для snapshot-таблиц (справочники и boarding_passes) использовать батч-строгую проверку: -“в текущем `batch_id` все ссылки указывают на строки текущего `batch_id`”. -Это лучше учит идее “консистентность батча” и упрощает отладку. +Почему не всё делаем батч-строго: +- Для инкрементальных таблиц (например, `segments`) ссылки могут указывать на данные, + загруженные в предыдущих батчах → там корректнее проверять “существует в STG вообще”, а не “существует в текущем батче”. -### 2.3. Smoke-тесты DAG’ов есть, но почти не проверяют граф +### 2.3. Smoke-тесты DAG’ов (статус: исправлено) -В `tests/test_dags_smoke.py` новые тесты в основном проверяют “таски существуют” через `dag.has_task(...)`. -Как учебный пример теста это слабовато: студент видит тест, но не понимает, что именно он защищает. +Что сделано: +- Тесты усилены: теперь проверяются ключевые зависимости графа через `get_direct_relatives("downstream")`. -Рекомендация: тестировать зависимости так же, как это уже сделано для `csv_to_greenplum` -(через `dag.get_task(...).get_direct_relatives("downstream")`). +### 2.4. Документация по DAG (статус: синхронизировано базово) -### 2.4. Документация по DAG отстаёт от реальной логики - -`docs/bookings_to_gp_stage.md` описывает только загрузку `bookings` и `tickets`, -но DAG теперь загружает ещё 7 таблиц (справочники и транзакции). - -Рекомендация: обновить документ, чтобы студент мог запустить пайплайн “по инструкции” без сюрпризов. +Что сделано: +- `docs/bookings_to_gp_stage.md` обновлён так, чтобы отражать текущий набор таблиц и шагов пайплайна. --- @@ -139,9 +133,8 @@ assert airports_load in tickets_dq.get_direct_relatives("downstream") ## 5) Чек-лист “готово как эталон” -- [ ] В DDL-комментариях нет неверных обещаний про co-location/уникальность ключей. +- [x] В DDL-комментариях нет неверных обещаний про co-location/уникальность ключей. - [ ] Для DQ определена и описана политика “0 строк”: где fail, где skip. -- [ ] DQ ссылочной целостности не маскирует проблемы текущего батча (batch-строгие проверки там, где это уместно). -- [ ] `docs/bookings_to_gp_stage.md` соответствует фактическому DAG. -- [ ] Smoke-тесты проверяют хотя бы критические зависимости графа. - +- [x] DQ ссылочной целостности не маскирует проблемы текущего батча (batch-строгие проверки там, где это уместно). +- [x] `docs/bookings_to_gp_stage.md` соответствует фактическому DAG. +- [x] Smoke-тесты проверяют хотя бы критические зависимости графа. diff --git a/docs/internal/db_schema.md b/docs/internal/db_schema.md index 614f7c9..b96d328 100644 --- a/docs/internal/db_schema.md +++ b/docs/internal/db_schema.md @@ -105,16 +105,17 @@ #### stg.tickets (транзакции, инкремент) - **Источник:** `bookings.tickets` (через PXF) -- **Ключ распределения:** `ticket_no` +- **Ключ распределения:** `book_ref` +- **Примечание:** JOIN `tickets` ↔ `segments`/`boarding_passes` по `ticket_no` может требовать motion (ключи распределения разные). - **Бизнес-колонки:** - `ticket_no TEXT` - номер билета - `book_ref TEXT` - номер бронирования - `passenger_id TEXT` - идентификатор пассажира - `passenger_name TEXT` - имя пассажира - - `contact_data TEXT` - контактные данные (JSONB) + - `outbound TEXT` - направление (в источнике boolean) - **Технические колонки:** `src_created_at_ts` (из book_date через bookings), `load_dttm`, `batch_id` - **Стратегия загрузки:** Инкремент по `book_date` (через bookings) -- **DQ проверки:** count (окно инкремента), дубликаты ticket_no, NULL обязательных полей, ссылочная целостность +- **DQ проверки:** count (окно инкремента), дубликаты ticket_no, NULL обязательных полей, пустой passenger_name, ссылочная целостность (bookings) #### stg.airports (справочник, full load) - **Источник:** `bookings.airports_data` (через PXF) @@ -145,6 +146,7 @@ #### stg.routes (справочник, full load) - **Источник:** `bookings.routes` (через PXF) - **Ключ распределения:** `route_no` +- **Примечание:** JOIN по `departure_airport`/`arrival_airport`/`airplane_code` может требовать motion (ключи распределения разные). - **Бизнес-колонки:** - `route_no TEXT` - номер маршрута - `validity TEXT` - период действия (из tstzrange) @@ -156,7 +158,7 @@ - `duration TEXT` - длительность - **Технические колонки:** `src_created_at_ts`, `load_dttm`, `batch_id` - **Стратегия загрузки:** Full load -- **DQ проверки:** count, дубликаты (route_no, validity), NULL обязательных полей, ссылочная целостность +- **DQ проверки:** count, дубликаты (route_no, validity), NULL обязательных полей, ссылочная целостность (batch_id = текущий батч) #### stg.seats (справочник, full load) - **Источник:** `bookings.seats` (через PXF) @@ -167,11 +169,12 @@ - `fare_conditions TEXT` - класс обслуживания - **Технические колонки:** `src_created_at_ts`, `load_dttm`, `batch_id` - **Стратегия загрузки:** Full load -- **DQ проверки:** count, дубликаты (airplane_code, seat_no), NULL обязательных полей, ссылочная целостность +- **DQ проверки:** count, дубликаты (airplane_code, seat_no), NULL обязательных полей, ссылочная целостность (batch_id = текущий батч) #### stg.flights (транзакции, инкремент) - **Источник:** `bookings.flights` (через PXF) - **Ключ распределения:** `flight_id` +- **Примечание:** JOIN с таблицами, распределёнными по другим ключам, может требовать motion. - **Бизнес-колонки:** - `flight_id TEXT` - идентификатор рейса - `route_no TEXT` - номер маршрута @@ -182,11 +185,12 @@ - `actual_arrival TEXT` - фактическое время прилёта - **Технические колонки:** `src_created_at_ts` (=scheduled_departure), `load_dttm`, `batch_id` - **Стратегия загрузки:** Инкремент по `scheduled_departure` -- **DQ проверки:** count (окно инкремента), дубликаты flight_id, NULL обязательных полей, ссылочная целостность +- **DQ проверки:** count (окно инкремента), дубликаты flight_id, NULL обязательных полей, ссылочная целостность (routes, batch_id = текущий батч) #### stg.segments (транзакции, инкремент) - **Источник:** `bookings.segments` (через PXF) -- **Ключ распределения:** `ticket_no` (co-location с tickets) +- **Ключ распределения:** `ticket_no` (co-location с boarding_passes) +- **Примечание:** JOIN `segments` ↔ `tickets` по `ticket_no` может требовать motion (stg.tickets распределена по `book_ref`). - **Бизнес-колонки:** - `ticket_no TEXT` - номер билета - `flight_id TEXT` - идентификатор рейса @@ -194,11 +198,12 @@ - `price TEXT` - цена - **Технические колонки:** `src_created_at_ts` (из book_date через tickets), `load_dttm`, `batch_id` - **Стратегия загрузки:** Инкремент по `book_date` (через tickets) -- **DQ проверки:** count (окно инкремента), дубликаты (ticket_no, flight_id), NULL обязательных полей, ссылочная целостность +- **DQ проверки:** count (окно инкремента), дубликаты (ticket_no, flight_id), NULL обязательных полей, ссылочная целостность (tickets, flights) #### stg.boarding_passes (транзакции, full snapshot) - **Источник:** `bookings.boarding_passes` (через PXF) -- **Ключ распределения:** `ticket_no` (co-location с tickets/segments) +- **Ключ распределения:** `ticket_no` (co-location с segments) +- **Примечание:** JOIN `boarding_passes` ↔ `tickets` по `ticket_no` может требовать motion (stg.tickets распределена по `book_ref`). - **Бизнес-колонки:** - `ticket_no TEXT` - номер билета - `flight_id TEXT` - идентификатор рейса diff --git a/sql/stg/boarding_passes_ddl.sql b/sql/stg/boarding_passes_ddl.sql index 659ed71..974b223 100644 --- a/sql/stg/boarding_passes_ddl.sql +++ b/sql/stg/boarding_passes_ddl.sql @@ -32,7 +32,7 @@ WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -- Ключ распределения: ticket_no -- Обоснование: ticket_no — это основной бизнес-ключ для билетов. -- Использование ticket_no обеспечивает: --- 1. Co-location данных boarding_passes и tickets при JOIN по ticket_no --- 2. Co-location данных boarding_passes и segments при JOIN по ticket_no --- 3. Равномерное распределение данных по сегментам (ticket_no имеет высокую кардинальность) +-- 1. Co-location данных boarding_passes и segments при JOIN по ticket_no +-- 2. Равномерное распределение данных по сегментам (ticket_no имеет высокую кардинальность) +-- Примечание: stg.tickets распределена по book_ref, поэтому JOIN boarding_passes ↔ tickets по ticket_no может требовать motion. DISTRIBUTED BY (ticket_no); diff --git a/sql/stg/flights_ddl.sql b/sql/stg/flights_ddl.sql index 546de1a..ece9a75 100644 --- a/sql/stg/flights_ddl.sql +++ b/sql/stg/flights_ddl.sql @@ -38,5 +38,5 @@ WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -- Использование flight_id обеспечивает: -- 1. Равномерное распределение данных по сегментам (flight_id имеет высокую кардинальность) -- 2. Оптимизацию запросов, которые фильтруют или группируют по flight_id --- 3. Co-location данных flights и boarding_passes при JOIN по flight_id +-- Примечание: JOIN с таблицами, распределёнными по другим ключам, может требовать motion. DISTRIBUTED BY (flight_id); diff --git a/sql/stg/flights_dq.sql b/sql/stg/flights_dq.sql index 1b3bc1d..09180db 100644 --- a/sql/stg/flights_dq.sql +++ b/sql/stg/flights_dq.sql @@ -76,7 +76,9 @@ BEGIN SELECT COUNT(*) INTO v_orphan_route_count FROM stg.flights AS f - LEFT JOIN stg.routes AS r ON f.route_no = r.route_no + LEFT JOIN stg.routes AS r + ON f.route_no = r.route_no + AND r.batch_id = v_batch_id WHERE f.batch_id = v_batch_id AND r.route_no IS NULL; diff --git a/sql/stg/routes_ddl.sql b/sql/stg/routes_ddl.sql index dca3f56..54f39d0 100644 --- a/sql/stg/routes_ddl.sql +++ b/sql/stg/routes_ddl.sql @@ -37,9 +37,9 @@ CREATE TABLE IF NOT EXISTS stg.routes ( ) WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -- Ключ распределения: route_no --- Обоснование: route_no — это уникальный идентификатор маршрута. +-- Обоснование: route_no — бизнес-идентификатор маршрута и часто используется в фильтрах/джойнах. -- Использование route_no обеспечивает: -- 1. Равномерное распределение данных по сегментам (route_no имеет высокую кардинальность) --- 2. Co-location данных routes с airports и airplanes при JOIN --- 3. Оптимизацию запросов, которые фильтруют или группируют по route_no +-- 2. Оптимизацию запросов, которые фильтруют или группируют по route_no +-- Примечание: JOIN по airport_code/airplane_code может требовать перераспределения данных (motion). DISTRIBUTED BY (route_no); diff --git a/sql/stg/routes_dq.sql b/sql/stg/routes_dq.sql index 8c31452..23a0e86 100644 --- a/sql/stg/routes_dq.sql +++ b/sql/stg/routes_dq.sql @@ -67,7 +67,9 @@ BEGIN SELECT COUNT(*) INTO v_orphan_airports_count FROM stg.routes AS r - LEFT JOIN stg.airports AS da ON r.departure_airport = da.airport_code + LEFT JOIN stg.airports AS da + ON r.departure_airport = da.airport_code + AND da.batch_id = v_batch_id WHERE r.batch_id = v_batch_id AND da.airport_code IS NULL; @@ -82,7 +84,9 @@ BEGIN SELECT COUNT(*) INTO v_orphan_airports_count FROM stg.routes AS r - LEFT JOIN stg.airports AS aa ON r.arrival_airport = aa.airport_code + LEFT JOIN stg.airports AS aa + ON r.arrival_airport = aa.airport_code + AND aa.batch_id = v_batch_id WHERE r.batch_id = v_batch_id AND aa.airport_code IS NULL; @@ -97,7 +101,9 @@ BEGIN SELECT COUNT(*) INTO v_orphan_airplanes_count FROM stg.routes AS r - LEFT JOIN stg.airplanes AS a ON r.airplane_code = a.airplane_code + LEFT JOIN stg.airplanes AS a + ON r.airplane_code = a.airplane_code + AND a.batch_id = v_batch_id WHERE r.batch_id = v_batch_id AND a.airplane_code IS NULL; diff --git a/sql/stg/seats_dq.sql b/sql/stg/seats_dq.sql index 91d195a..4b70b81 100644 --- a/sql/stg/seats_dq.sql +++ b/sql/stg/seats_dq.sql @@ -65,7 +65,9 @@ BEGIN SELECT COUNT(*) INTO v_orphan_airplanes_count FROM stg.seats AS s - LEFT JOIN stg.airplanes AS a ON s.airplane_code = a.airplane_code + LEFT JOIN stg.airplanes AS a + ON s.airplane_code = a.airplane_code + AND a.batch_id = v_batch_id WHERE s.batch_id = v_batch_id AND a.airplane_code IS NULL; diff --git a/sql/stg/segments_ddl.sql b/sql/stg/segments_ddl.sql index 402bc05..e59b1cc 100644 --- a/sql/stg/segments_ddl.sql +++ b/sql/stg/segments_ddl.sql @@ -30,7 +30,7 @@ WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -- Ключ распределения: ticket_no -- Обоснование: ticket_no — это основной бизнес-ключ для билетов. -- Использование ticket_no обеспечивает: --- 1. Co-location данных segments и tickets при JOIN по ticket_no --- 2. Co-location данных segments и boarding_passes при JOIN по ticket_no --- 3. Равномерное распределение данных по сегментам (ticket_no имеет высокую кардинальность) +-- 1. Co-location данных segments и boarding_passes при JOIN по ticket_no +-- 2. Равномерное распределение данных по сегментам (ticket_no имеет высокую кардинальность) +-- Примечание: stg.tickets распределена по book_ref, поэтому JOIN segments ↔ tickets по ticket_no может требовать motion. DISTRIBUTED BY (ticket_no); diff --git a/tests/test_dags_smoke.py b/tests/test_dags_smoke.py index 20ee766..7e76ab8 100644 --- a/tests/test_dags_smoke.py +++ b/tests/test_dags_smoke.py @@ -89,17 +89,25 @@ def test_bookings_stg_ddl_dag_structure(): } assert expected_tasks.issubset(dag.task_dict.keys()) - # Проверка линейных зависимостей - # Справочники создаются после bookings/tickets - assert dag.has_task("apply_stg_bookings_ddl") - assert dag.has_task("apply_stg_tickets_ddl") - assert dag.has_task("apply_stg_airports_ddl") - assert dag.has_task("apply_stg_airplanes_ddl") - assert dag.has_task("apply_stg_routes_ddl") - assert dag.has_task("apply_stg_seats_ddl") - assert dag.has_task("apply_stg_flights_ddl") - assert dag.has_task("apply_stg_segments_ddl") - assert dag.has_task("apply_stg_boarding_passes_ddl") + # Линейные зависимости: bookings/tickets → справочники → транзакции + t_bookings = dag.get_task("apply_stg_bookings_ddl") + t_tickets = dag.get_task("apply_stg_tickets_ddl") + t_airports = dag.get_task("apply_stg_airports_ddl") + t_airplanes = dag.get_task("apply_stg_airplanes_ddl") + t_routes = dag.get_task("apply_stg_routes_ddl") + t_seats = dag.get_task("apply_stg_seats_ddl") + t_flights = dag.get_task("apply_stg_flights_ddl") + t_segments = dag.get_task("apply_stg_segments_ddl") + t_boarding = dag.get_task("apply_stg_boarding_passes_ddl") + + assert t_tickets in t_bookings.get_direct_relatives("downstream") + assert t_airports in t_tickets.get_direct_relatives("downstream") + assert t_airplanes in t_airports.get_direct_relatives("downstream") + assert t_routes in t_airplanes.get_direct_relatives("downstream") + assert t_seats in t_routes.get_direct_relatives("downstream") + assert t_flights in t_seats.get_direct_relatives("downstream") + assert t_segments in t_flights.get_direct_relatives("downstream") + assert t_boarding in t_segments.get_direct_relatives("downstream") def test_bookings_to_gp_stage_dag_structure(): @@ -130,25 +138,44 @@ def test_bookings_to_gp_stage_dag_structure(): } assert expected_tasks.issubset(dag.task_dict.keys()) - # Проверка линейных зависимостей - # bookings/tickets → справочники → транзакции → финальный лог - assert dag.has_task("generate_bookings_day") - assert dag.has_task("load_bookings_to_stg") - assert dag.has_task("check_row_counts") - assert dag.has_task("load_tickets_to_stg") - assert dag.has_task("check_tickets_dq") - assert dag.has_task("load_airports_to_stg") - assert dag.has_task("check_airports_dq") - assert dag.has_task("load_airplanes_to_stg") - assert dag.has_task("check_airplanes_dq") - assert dag.has_task("load_routes_to_stg") - assert dag.has_task("check_routes_dq") - assert dag.has_task("load_seats_to_stg") - assert dag.has_task("check_seats_dq") - assert dag.has_task("load_flights_to_stg") - assert dag.has_task("check_flights_dq") - assert dag.has_task("load_segments_to_stg") - assert dag.has_task("check_segments_dq") - assert dag.has_task("load_boarding_passes_to_stg") - assert dag.has_task("check_boarding_passes_dq") - assert dag.has_task("finish_summary") + # Линейные зависимости: bookings/tickets → справочники → транзакции → финальный лог + t_generate = dag.get_task("generate_bookings_day") + t_bookings = dag.get_task("load_bookings_to_stg") + t_bookings_dq = dag.get_task("check_row_counts") + t_tickets = dag.get_task("load_tickets_to_stg") + t_tickets_dq = dag.get_task("check_tickets_dq") + t_airports = dag.get_task("load_airports_to_stg") + t_airports_dq = dag.get_task("check_airports_dq") + t_airplanes = dag.get_task("load_airplanes_to_stg") + t_airplanes_dq = dag.get_task("check_airplanes_dq") + t_routes = dag.get_task("load_routes_to_stg") + t_routes_dq = dag.get_task("check_routes_dq") + t_seats = dag.get_task("load_seats_to_stg") + t_seats_dq = dag.get_task("check_seats_dq") + t_flights = dag.get_task("load_flights_to_stg") + t_flights_dq = dag.get_task("check_flights_dq") + t_segments = dag.get_task("load_segments_to_stg") + t_segments_dq = dag.get_task("check_segments_dq") + t_boarding = dag.get_task("load_boarding_passes_to_stg") + t_boarding_dq = dag.get_task("check_boarding_passes_dq") + t_finish = dag.get_task("finish_summary") + + assert t_bookings in t_generate.get_direct_relatives("downstream") + assert t_bookings_dq in t_bookings.get_direct_relatives("downstream") + assert t_tickets in t_bookings_dq.get_direct_relatives("downstream") + assert t_tickets_dq in t_tickets.get_direct_relatives("downstream") + assert t_airports in t_tickets_dq.get_direct_relatives("downstream") + assert t_airports_dq in t_airports.get_direct_relatives("downstream") + assert t_airplanes in t_airports_dq.get_direct_relatives("downstream") + assert t_airplanes_dq in t_airplanes.get_direct_relatives("downstream") + assert t_routes in t_airplanes_dq.get_direct_relatives("downstream") + assert t_routes_dq in t_routes.get_direct_relatives("downstream") + assert t_seats in t_routes_dq.get_direct_relatives("downstream") + assert t_seats_dq in t_seats.get_direct_relatives("downstream") + assert t_flights in t_seats_dq.get_direct_relatives("downstream") + assert t_flights_dq in t_flights.get_direct_relatives("downstream") + assert t_segments in t_flights_dq.get_direct_relatives("downstream") + assert t_segments_dq in t_segments.get_direct_relatives("downstream") + assert t_boarding in t_segments_dq.get_direct_relatives("downstream") + assert t_boarding_dq in t_boarding.get_direct_relatives("downstream") + assert t_finish in t_boarding_dq.get_direct_relatives("downstream") From 8946df4a28faf8a4d7cf15b90aace99e7f7d4139 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 18 Jan 2026 21:48:47 +0300 Subject: [PATCH 17/38] =?UTF-8?q?=D0=94=D0=BE=D0=BF=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D1=83=D1=81=D1=82?= =?UTF-8?q?=D1=8B=D1=85=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BD=D0=B5=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=BC?= =?UTF-8?q?=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/bookings_to_gp_stage.md | 4 ++-- docs/internal/bookings_stg_code_review.md | 6 +++++- docs/internal/db_schema.md | 8 ++++---- sql/stg/bookings_dq.sql | 22 +++++++++++++++++++--- sql/stg/flights_dq.sql | 22 +++++++++++++++++++--- sql/stg/segments_dq.sql | 22 +++++++++++++++++++--- sql/stg/tickets_dq.sql | 22 +++++++++++++++++++--- 7 files changed, 87 insertions(+), 19 deletions(-) diff --git a/docs/bookings_to_gp_stage.md b/docs/bookings_to_gp_stage.md index 71678ba..70d6428 100644 --- a/docs/bookings_to_gp_stage.md +++ b/docs/bookings_to_gp_stage.md @@ -100,8 +100,8 @@ make bookings-init - `load_segments_to_stg` → `check_segments_dq` (инкремент по `book_date` через tickets/bookings) - `load_boarding_passes_to_stg` → `check_boarding_passes_dq` (full snapshot) -Важно: для некоторых таблиц “пустое окно инкремента” считается ошибкой (DQ делает `RAISE EXCEPTION`), -а для `boarding_passes` DQ может быть пропущена, если в источнике 0 строк. +Важно: для инкрементальных таблиц “пустое окно инкремента” допустимо — загрузка и DQ логируют `NOTICE` и завершаются успешно. +Для snapshot-справочников (airports/airplanes/routes/seats) пустой источник считается ошибкой (DQ делает `RAISE EXCEPTION`). 8) `finish_summary` diff --git a/docs/internal/bookings_stg_code_review.md b/docs/internal/bookings_stg_code_review.md index 83ea3bf..a1786be 100644 --- a/docs/internal/bookings_stg_code_review.md +++ b/docs/internal/bookings_stg_code_review.md @@ -81,7 +81,11 @@ - **Fail** полезен, когда “ожидаем данные в каждом запуске” (например, учебный генератор должен добавлять день); - **Skip** полезен, когда “окно может быть пустым и это нормально”. -Рекомендация: зафиксировать политику в документации и сделать поведение единообразным по группам таблиц. +Выбранная политика для учебного стенда: +- Для инкрементальных таблиц (`bookings`, `tickets`, `flights`, `segments`) “пустое окно” **допустимо**: + DQ логирует `NOTICE` и завершает проверку, не падая. +- Для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) пустой источник считаем ошибкой: + это почти всегда признак проблем с PXF/источником. ### 3.2. Комментарии в `*_load.sql`: точнее формулировать “идемпотентность”, а не “дедупликацию источника” diff --git a/docs/internal/db_schema.md b/docs/internal/db_schema.md index b96d328..568d159 100644 --- a/docs/internal/db_schema.md +++ b/docs/internal/db_schema.md @@ -101,7 +101,7 @@ - `total_amount TEXT` - общая сумма - **Технические колонки:** `src_created_at_ts` (=book_date), `load_dttm`, `batch_id` - **Стратегия загрузки:** Инкремент по `book_date` -- **DQ проверки:** count (окно инкремента), дубликаты book_ref, NULL обязательных полей +- **DQ проверки:** count (окно инкремента, пустое окно допустимо), дубликаты book_ref, NULL обязательных полей #### stg.tickets (транзакции, инкремент) - **Источник:** `bookings.tickets` (через PXF) @@ -115,7 +115,7 @@ - `outbound TEXT` - направление (в источнике boolean) - **Технические колонки:** `src_created_at_ts` (из book_date через bookings), `load_dttm`, `batch_id` - **Стратегия загрузки:** Инкремент по `book_date` (через bookings) -- **DQ проверки:** count (окно инкремента), дубликаты ticket_no, NULL обязательных полей, пустой passenger_name, ссылочная целостность (bookings) +- **DQ проверки:** count (окно инкремента, пустое окно допустимо), дубликаты ticket_no, NULL обязательных полей, пустой passenger_name, ссылочная целостность (bookings) #### stg.airports (справочник, full load) - **Источник:** `bookings.airports_data` (через PXF) @@ -185,7 +185,7 @@ - `actual_arrival TEXT` - фактическое время прилёта - **Технические колонки:** `src_created_at_ts` (=scheduled_departure), `load_dttm`, `batch_id` - **Стратегия загрузки:** Инкремент по `scheduled_departure` -- **DQ проверки:** count (окно инкремента), дубликаты flight_id, NULL обязательных полей, ссылочная целостность (routes, batch_id = текущий батч) +- **DQ проверки:** count (окно инкремента, пустое окно допустимо), дубликаты flight_id, NULL обязательных полей, ссылочная целостность (routes, batch_id = текущий батч) #### stg.segments (транзакции, инкремент) - **Источник:** `bookings.segments` (через PXF) @@ -198,7 +198,7 @@ - `price TEXT` - цена - **Технические колонки:** `src_created_at_ts` (из book_date через tickets), `load_dttm`, `batch_id` - **Стратегия загрузки:** Инкремент по `book_date` (через tickets) -- **DQ проверки:** count (окно инкремента), дубликаты (ticket_no, flight_id), NULL обязательных полей, ссылочная целостность (tickets, flights) +- **DQ проверки:** count (окно инкремента, пустое окно допустимо), дубликаты (ticket_no, flight_id), NULL обязательных полей, ссылочная целостность (tickets, flights) #### stg.boarding_passes (транзакции, full snapshot) - **Источник:** `bookings.boarding_passes` (через PXF) diff --git a/sql/stg/bookings_dq.sql b/sql/stg/bookings_dq.sql index 24f0e5e..65c8f79 100644 --- a/sql/stg/bookings_dq.sql +++ b/sql/stg/bookings_dq.sql @@ -26,9 +26,25 @@ BEGIN WHERE book_date > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); IF v_src_count = 0 THEN - RAISE EXCEPTION - 'В источнике bookings_ext нет строк для окна инкремента (book_date > %). Проверьте генерацию данных (make bookings-init / make bookings-generate-day или таск generate_bookings_day).', - COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + -- Пустое окно инкремента допустимо: новых данных может не быть. + -- В этом случае ожидаем, что в текущем batch_id тоже 0 строк. + SELECT COUNT(*) + INTO v_stg_count + FROM stg.bookings + WHERE batch_id = v_batch_id; + + IF v_stg_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: источник bookings_ext за окно инкремента пустой, но в stg.bookings есть строки текущего batch_id (batch_id=%): %', + v_batch_id, + v_stg_count; + END IF; + + RAISE NOTICE + 'В источнике bookings_ext нет строк для окна инкремента (book_date > %). Пропускаем DQ проверки (batch_id=%).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'), + v_batch_id; + RETURN; END IF; -- Считаем строки, реально вставленные в stg.bookings в этом батче diff --git a/sql/stg/flights_dq.sql b/sql/stg/flights_dq.sql index 09180db..97a0b72 100644 --- a/sql/stg/flights_dq.sql +++ b/sql/stg/flights_dq.sql @@ -24,9 +24,25 @@ BEGIN WHERE scheduled_departure > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); IF v_src_count = 0 THEN - RAISE EXCEPTION - 'В источнике flights_ext нет строк для окна инкремента (scheduled_departure > %).', - COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + -- Пустое окно инкремента допустимо: новых данных может не быть. + -- В этом случае ожидаем, что в текущем batch_id тоже 0 строк. + SELECT COUNT(*) + INTO v_stg_count + FROM stg.flights + WHERE batch_id = v_batch_id; + + IF v_stg_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: источник flights_ext за окно инкремента пустой, но в stg.flights есть строки текущего batch_id (batch_id=%): %', + v_batch_id, + v_stg_count; + END IF; + + RAISE NOTICE + 'В источнике flights_ext нет строк для окна инкремента (scheduled_departure > %). Пропускаем DQ проверки (batch_id=%).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'), + v_batch_id; + RETURN; END IF; -- Считаем строки, реально вставленные в stg.flights в этом батче diff --git a/sql/stg/segments_dq.sql b/sql/stg/segments_dq.sql index 3c00e12..d59ddf4 100644 --- a/sql/stg/segments_dq.sql +++ b/sql/stg/segments_dq.sql @@ -27,9 +27,25 @@ BEGIN WHERE b.book_date > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); IF v_src_count = 0 THEN - RAISE EXCEPTION - 'В источнике segments_ext нет строк для окна инкремента (book_date > %).', - COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + -- Пустое окно инкремента допустимо: новых данных может не быть. + -- В этом случае ожидаем, что в текущем batch_id тоже 0 строк. + SELECT COUNT(*) + INTO v_stg_count + FROM stg.segments + WHERE batch_id = v_batch_id; + + IF v_stg_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: источник segments_ext за окно инкремента пустой, но в stg.segments есть строки текущего batch_id (batch_id=%): %', + v_batch_id, + v_stg_count; + END IF; + + RAISE NOTICE + 'В источнике segments_ext нет строк для окна инкремента (book_date > %). Пропускаем DQ проверки (batch_id=%).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'), + v_batch_id; + RETURN; END IF; -- Считаем строки, реально вставленные в stg.segments в этом батче diff --git a/sql/stg/tickets_dq.sql b/sql/stg/tickets_dq.sql index e42a412..b4b6951 100644 --- a/sql/stg/tickets_dq.sql +++ b/sql/stg/tickets_dq.sql @@ -26,9 +26,25 @@ BEGIN WHERE b.book_date > COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); IF v_source_count = 0 THEN - RAISE EXCEPTION - 'В источнике tickets_ext нет строк для окна инкремента (book_date > %). Проверьте генерацию данных (таск generate_bookings_day).', - COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'); + -- Пустое окно инкремента допустимо: новых данных может не быть. + -- В этом случае ожидаем, что в текущем batch_id тоже 0 строк. + SELECT COUNT(*) + INTO v_stg_count + FROM stg.tickets + WHERE batch_id = v_batch_id; + + IF v_stg_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: источник tickets_ext за окно инкремента пустой, но в stg.tickets есть строки текущего batch_id (batch_id=%): %', + v_batch_id, + v_stg_count; + END IF; + + RAISE NOTICE + 'В источнике tickets_ext нет строк для окна инкремента (book_date > %). Пропускаем DQ проверки (batch_id=%).', + COALESCE(v_prev_ts, TIMESTAMP '1900-01-01 00:00:00'), + v_batch_id; + RETURN; END IF; -- Количество в STG (текущий батч) From 761cf5dd9328b71ad665389808f897bd76e79631 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 18 Jan 2026 21:53:28 +0300 Subject: [PATCH 18/38] =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?= =?UTF-8?q?=D1=8B=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/internal/bookings_stg_code_review.md | 24 ++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/internal/bookings_stg_code_review.md b/docs/internal/bookings_stg_code_review.md index a1786be..0eef27e 100644 --- a/docs/internal/bookings_stg_code_review.md +++ b/docs/internal/bookings_stg_code_review.md @@ -42,27 +42,29 @@ Файлы: `sql/stg/routes_ddl.sql`, `sql/stg/flights_ddl.sql`, `sql/stg/segments_ddl.sql`, `sql/stg/boarding_passes_ddl.sql`. -### 2.2. DQ-проверки ссылочной целостности: “текущий батч” vs “вся история” (статус: зафиксировано и частично усилено) +### 2.2. DQ-проверки ссылочной целостности: “текущий батч” vs “вся история” (статус: исправлено) Часть DQ-скриптов проверяет наличие “родительских” записей в таблице **без фильтра `batch_id`**. При append-only истории это может скрыть проблемы текущей загрузки: родитель был загружен в прошлом батче → проверка пройдёт, даже если текущий батч родителя не загрузил. -Что сделано для справочников (snapshot), которые загружаются каждый запуск: +Что сделано: - `routes_dq.sql`: проверка airports/airplanes стала батч-строгой (`batch_id = текущий батч`). - `seats_dq.sql`: проверка airplanes стала батч-строгой (`batch_id = текущий батч`). - `flights_dq.sql`: проверка routes стала батч-строгой (`batch_id = текущий батч`). -Почему не всё делаем батч-строго: -- Для инкрементальных таблиц (например, `segments`) ссылки могут указывать на данные, - загруженные в предыдущих батчах → там корректнее проверять “существует в STG вообще”, а не “существует в текущем батче”. +Примечание (почему не везде `batch_id = текущий батч`): +- Если дочерняя таблица грузится инкрементом, то ссылки могут указывать на “исторические” записи, + загруженные в предыдущих батчах → для таких связей корректнее проверять “существует в STG вообще”. +- Для `boarding_passes` (full snapshot) ссылки на `tickets/segments` также проверяются по STG-истории, + потому что `tickets/segments` не перезагружаются полным снэпшотом каждый запуск. ### 2.3. Smoke-тесты DAG’ов (статус: исправлено) Что сделано: - Тесты усилены: теперь проверяются ключевые зависимости графа через `get_direct_relatives("downstream")`. -### 2.4. Документация по DAG (статус: синхронизировано базово) +### 2.4. Документация по DAG (статус: синхронизировано) Что сделано: - `docs/bookings_to_gp_stage.md` обновлён так, чтобы отражать текущий набор таблиц и шагов пайплайна. @@ -71,11 +73,11 @@ ## 3) Рекомендации по качеству и читаемости (Clean Code для SQL и DAG) -### 3.1. “Empty window” в инкременте: договориться о политике (fail vs skip) +### 3.1. “Empty window” в инкременте: договориться о политике (fail vs skip) (статус: исправлено) -Сейчас поведение разное: -- `bookings_dq.sql` / `tickets_dq.sql` / `flights_dq.sql` / `segments_dq.sql` падают, если в окне инкремента 0 строк; -- `boarding_passes_dq.sql` делает `RAISE NOTICE` и `RETURN`. +Раньше поведение было разным: +- часть DQ-скриптов падала, если в окне инкремента 0 строк; +- `boarding_passes_dq.sql` делал `RAISE NOTICE` и `RETURN`. Обе стратегии допустимы, но в учебном решении важно выбрать одну и объяснить: - **Fail** полезен, когда “ожидаем данные в каждом запуске” (например, учебный генератор должен добавлять день); @@ -138,7 +140,7 @@ assert airports_load in tickets_dq.get_direct_relatives("downstream") ## 5) Чек-лист “готово как эталон” - [x] В DDL-комментариях нет неверных обещаний про co-location/уникальность ключей. -- [ ] Для DQ определена и описана политика “0 строк”: где fail, где skip. +- [x] Для DQ определена и описана политика “0 строк”: где fail, где skip. - [x] DQ ссылочной целостности не маскирует проблемы текущего батча (batch-строгие проверки там, где это уместно). - [x] `docs/bookings_to_gp_stage.md` соответствует фактическому DAG. - [x] Smoke-тесты проверяют хотя бы критические зависимости графа. From e5a9a07f0193dbe90ea1314f3e69284420d9989d Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 18 Jan 2026 21:56:17 +0300 Subject: [PATCH 19/38] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B5=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/internal/bookings_stg_code_review.md | 5 +++-- sql/stg/airplanes_load.sql | 2 +- sql/stg/airports_load.sql | 2 +- sql/stg/boarding_passes_load.sql | 3 ++- sql/stg/bookings_load.sql | 1 + sql/stg/flights_load.sql | 1 + sql/stg/routes_load.sql | 3 ++- sql/stg/seats_load.sql | 3 ++- sql/stg/segments_load.sql | 3 ++- sql/stg/tickets_load.sql | 2 +- 10 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/internal/bookings_stg_code_review.md b/docs/internal/bookings_stg_code_review.md index 0eef27e..ba261c0 100644 --- a/docs/internal/bookings_stg_code_review.md +++ b/docs/internal/bookings_stg_code_review.md @@ -89,7 +89,7 @@ - Для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) пустой источник считаем ошибкой: это почти всегда признак проблем с PXF/источником. -### 3.2. Комментарии в `*_load.sql`: точнее формулировать “идемпотентность”, а не “дедупликацию источника” +### 3.2. Комментарии в `*_load.sql`: точнее формулировать “идемпотентность”, а не “дедупликацию источника” (статус: исправлено) Типовой паттерн: ```sql @@ -101,7 +101,8 @@ WHERE NOT EXISTS ( Это в первую очередь защита от повторного запуска того же таска в рамках одного `batch_id` (retry), а не “лечение” дублей в источнике. -Рекомендация: заменить формулировку комментариев на “идемпотентность для текущего батча”. +Что сделано: +- В `sql/stg/*_load.sql` комментарии приведены к формулировке “идемпотентность при повторном запуске/ретрае”. ### 3.3. Проверка составных ключей: избегать склейки строк diff --git a/sql/stg/airplanes_load.sql b/sql/stg/airplanes_load.sql index b140add..49d7157 100644 --- a/sql/stg/airplanes_load.sql +++ b/sql/stg/airplanes_load.sql @@ -20,7 +20,7 @@ SELECT '{{ run_id }}'::text FROM stg.airplanes_ext AS ext WHERE NOT EXISTS ( - -- Защита от дублей в рамках одного batch_id + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего batch_id. SELECT 1 FROM stg.airplanes AS a WHERE a.batch_id = '{{ run_id }}'::text diff --git a/sql/stg/airports_load.sql b/sql/stg/airports_load.sql index 3197c2d..a3bb1a1 100644 --- a/sql/stg/airports_load.sql +++ b/sql/stg/airports_load.sql @@ -24,7 +24,7 @@ SELECT '{{ run_id }}'::text FROM stg.airports_ext AS ext WHERE NOT EXISTS ( - -- Защита от дублей в рамках одного batch_id + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего batch_id. SELECT 1 FROM stg.airports AS a WHERE a.batch_id = '{{ run_id }}'::text diff --git a/sql/stg/boarding_passes_load.sql b/sql/stg/boarding_passes_load.sql index 1d94b7d..00899db 100644 --- a/sql/stg/boarding_passes_load.sql +++ b/sql/stg/boarding_passes_load.sql @@ -23,7 +23,8 @@ SELECT '{{ run_id }}'::text FROM stg.boarding_passes_ext AS ext WHERE NOT EXISTS ( - -- Защита от дублей в рамках одного batch_id + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего batch_id. + -- Считаем ключом строки (ticket_no, flight_id). SELECT 1 FROM stg.boarding_passes AS bp WHERE bp.batch_id = '{{ run_id }}'::text diff --git a/sql/stg/bookings_load.sql b/sql/stg/bookings_load.sql index 522c0fc..6c336a4 100644 --- a/sql/stg/bookings_load.sql +++ b/sql/stg/bookings_load.sql @@ -29,6 +29,7 @@ FROM stg.bookings_ext AS ext CROSS JOIN max_batch_ts AS mb WHERE ext.book_date > mb.max_ts AND NOT EXISTS ( + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего batch_id. SELECT 1 FROM stg.bookings AS b WHERE b.batch_id = '{{ run_id }}'::text diff --git a/sql/stg/flights_load.sql b/sql/stg/flights_load.sql index 5a6188e..e7879dd 100644 --- a/sql/stg/flights_load.sql +++ b/sql/stg/flights_load.sql @@ -37,6 +37,7 @@ FROM stg.flights_ext AS ext CROSS JOIN max_batch_ts AS mb WHERE ext.scheduled_departure > mb.max_ts AND NOT EXISTS ( + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего batch_id. SELECT 1 FROM stg.flights AS f WHERE f.batch_id = '{{ run_id }}'::text diff --git a/sql/stg/routes_load.sql b/sql/stg/routes_load.sql index 77c2077..1bb76a1 100644 --- a/sql/stg/routes_load.sql +++ b/sql/stg/routes_load.sql @@ -28,7 +28,8 @@ SELECT '{{ run_id }}'::text FROM stg.routes_ext AS ext WHERE NOT EXISTS ( - -- Защита от дублей в рамках одного batch_id по составному ключу (route_no, validity) + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего batch_id. + -- Считаем ключом строки (route_no, validity). SELECT 1 FROM stg.routes AS r WHERE r.batch_id = '{{ run_id }}'::text diff --git a/sql/stg/seats_load.sql b/sql/stg/seats_load.sql index 3c530d0..2e9720b 100644 --- a/sql/stg/seats_load.sql +++ b/sql/stg/seats_load.sql @@ -18,7 +18,8 @@ SELECT '{{ run_id }}'::text FROM stg.seats_ext AS ext WHERE NOT EXISTS ( - -- Защита от дублей в рамках одного batch_id по составному ключу (airplane_code, seat_no) + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего batch_id. + -- Считаем ключом строки (airplane_code, seat_no). SELECT 1 FROM stg.seats AS s WHERE s.batch_id = '{{ run_id }}'::text diff --git a/sql/stg/segments_load.sql b/sql/stg/segments_load.sql index 96154a5..86ee77b 100644 --- a/sql/stg/segments_load.sql +++ b/sql/stg/segments_load.sql @@ -32,7 +32,8 @@ JOIN stg.bookings_ext AS b ON t.book_ref = b.book_ref CROSS JOIN max_batch_ts AS mb WHERE b.book_date > mb.max_ts AND NOT EXISTS ( - -- Защита от дублей в рамках одного batch_id + -- Идемпотентность: при повторном запуске/ретрае не вставляем повторно те же строки в рамках текущего batch_id. + -- Считаем ключом строки (ticket_no, flight_id). SELECT 1 FROM stg.segments AS s WHERE s.batch_id = '{{ run_id }}'::text diff --git a/sql/stg/tickets_load.sql b/sql/stg/tickets_load.sql index d90027d..925ac80 100644 --- a/sql/stg/tickets_load.sql +++ b/sql/stg/tickets_load.sql @@ -32,7 +32,7 @@ JOIN stg.bookings_ext AS b ON ext.book_ref = b.book_ref CROSS JOIN max_batch_ts AS mb WHERE b.book_date > mb.max_ts AND NOT EXISTS ( - -- Защита от дублей: ticket_no в источнике уникален, и в stg его не дублируем. + -- Идемпотентность: ticket_no — бизнес-ключ билета, не вставляем его повторно (включая ретраи/повторные запуски DAG). SELECT 1 FROM stg.tickets AS t WHERE t.ticket_no = ext.ticket_no From 843500cf522067ff11b3d7e9e25eead8df51229e Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 18 Jan 2026 22:24:06 +0300 Subject: [PATCH 20/38] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20DQ=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/internal/bookings_stg_code_review.md | 10 ++++++---- sql/stg/boarding_passes_dq.sql | 3 ++- sql/stg/routes_dq.sql | 3 ++- sql/stg/seats_dq.sql | 3 ++- sql/stg/segments_dq.sql | 3 ++- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/internal/bookings_stg_code_review.md b/docs/internal/bookings_stg_code_review.md index ba261c0..3e33530 100644 --- a/docs/internal/bookings_stg_code_review.md +++ b/docs/internal/bookings_stg_code_review.md @@ -104,12 +104,14 @@ WHERE NOT EXISTS ( Что сделано: - В `sql/stg/*_load.sql` комментарии приведены к формулировке “идемпотентность при повторном запуске/ретрае”. -### 3.3. Проверка составных ключей: избегать склейки строк +### 3.3. Проверка составных ключей: избегать склейки строк (статус: исправлено) Паттерн вида `COUNT(DISTINCT col1 || '|' || col2)` теоретически может давать коллизии (если в данных встречается разделитель). -В учебном стенде риск небольшой, но как “эталон” лучше показывать более безопасный подход: -- если поддерживается: `COUNT(DISTINCT (col1, col2))`; -- либо использовать стабильную сериализацию, где коллизии исключены (например, `md5(...)` от безопасной структуры). +В учебном стенде риск небольшой, но как “эталон” лучше показывать более безопасный подход. + +Что сделано: +- Заменили склейку строк на `COUNT(DISTINCT md5(ROW(col1, col2)::text))` в DQ‑скриптах для составных ключей. + Такой подход сохраняет DV‑стиль и убирает неоднозначность разделителей. --- diff --git a/sql/stg/boarding_passes_dq.sql b/sql/stg/boarding_passes_dq.sql index 0a2f123..6fcf686 100644 --- a/sql/stg/boarding_passes_dq.sql +++ b/sql/stg/boarding_passes_dq.sql @@ -36,7 +36,8 @@ BEGIN END IF; -- Проверка на дубликаты (ticket_no, flight_id) - SELECT COUNT(*) - COUNT(DISTINCT ticket_no || '|' || flight_id) + -- Используем md5 от ROW, чтобы избежать коллизий при склейке строк. + SELECT COUNT(*) - COUNT(DISTINCT md5(ROW(ticket_no, flight_id)::text)) INTO v_dup_count FROM stg.boarding_passes AS bp WHERE bp.batch_id = v_batch_id; diff --git a/sql/stg/routes_dq.sql b/sql/stg/routes_dq.sql index 23a0e86..6f011aa 100644 --- a/sql/stg/routes_dq.sql +++ b/sql/stg/routes_dq.sql @@ -34,7 +34,8 @@ BEGIN END IF; -- Проверка на дубликаты составного ключа (route_no, validity) - SELECT COUNT(*) - COUNT(DISTINCT route_no || '|' || validity) + -- Используем md5 от ROW, чтобы избежать коллизий при склейке строк. + SELECT COUNT(*) - COUNT(DISTINCT md5(ROW(route_no, validity)::text)) INTO v_dup_count FROM stg.routes AS r WHERE r.batch_id = v_batch_id; diff --git a/sql/stg/seats_dq.sql b/sql/stg/seats_dq.sql index 4b70b81..69296f3 100644 --- a/sql/stg/seats_dq.sql +++ b/sql/stg/seats_dq.sql @@ -33,7 +33,8 @@ BEGIN END IF; -- Проверка на дубликаты составного ключа (airplane_code, seat_no) - SELECT COUNT(*) - COUNT(DISTINCT airplane_code || '|' || seat_no) + -- Используем md5 от ROW, чтобы избежать коллизий при склейке строк. + SELECT COUNT(*) - COUNT(DISTINCT md5(ROW(airplane_code, seat_no)::text)) INTO v_dup_count FROM stg.seats AS s WHERE s.batch_id = v_batch_id; diff --git a/sql/stg/segments_dq.sql b/sql/stg/segments_dq.sql index d59ddf4..feb0aa1 100644 --- a/sql/stg/segments_dq.sql +++ b/sql/stg/segments_dq.sql @@ -62,7 +62,8 @@ BEGIN END IF; -- Проверка на дубликаты (ticket_no, flight_id) - SELECT COUNT(*) - COUNT(DISTINCT ticket_no || '|' || flight_id) + -- Используем md5 от ROW, чтобы избежать коллизий при склейке строк. + SELECT COUNT(*) - COUNT(DISTINCT md5(ROW(ticket_no, flight_id)::text)) INTO v_dup_count FROM stg.segments AS s WHERE s.batch_id = v_batch_id; From 9e08bc181c9162a36136d587bc37f53cad32b52a Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 18 Jan 2026 22:32:57 +0300 Subject: [PATCH 21/38] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/internal/bookings_stg_code_review.md | 4 +- tests/test_dags_smoke.py | 135 +++++++++++----------- 2 files changed, 72 insertions(+), 67 deletions(-) diff --git a/docs/internal/bookings_stg_code_review.md b/docs/internal/bookings_stg_code_review.md index 3e33530..8a909b2 100644 --- a/docs/internal/bookings_stg_code_review.md +++ b/docs/internal/bookings_stg_code_review.md @@ -62,7 +62,7 @@ ### 2.3. Smoke-тесты DAG’ов (статус: исправлено) Что сделано: -- Тесты усилены: теперь проверяются ключевые зависимости графа через `get_direct_relatives("downstream")`. +- Тесты усилены: теперь проверяются ключевые зависимости графа через `get_direct_relatives(upstream=False)` и “барьеры” через `get_flat_relatives(upstream=False)`. ### 2.4. Документация по DAG (статус: синхронизировано) @@ -135,7 +135,7 @@ LEFT JOIN stg.airports AS a ```python tickets_dq = dag.get_task("check_tickets_dq") airports_load = dag.get_task("load_airports_to_stg") -assert airports_load in tickets_dq.get_direct_relatives("downstream") +assert airports_load in tickets_dq.get_direct_relatives(upstream=False) ``` --- diff --git a/tests/test_dags_smoke.py b/tests/test_dags_smoke.py index 7e76ab8..100b722 100644 --- a/tests/test_dags_smoke.py +++ b/tests/test_dags_smoke.py @@ -25,6 +25,22 @@ def _load_dag(module_name: str): return getattr(mod, "dag") +def _assert_direct_edge(dag, upstream_task_id: str, downstream_task_id: str) -> None: + upstream = dag.get_task(upstream_task_id) + downstream = dag.get_task(downstream_task_id) + assert downstream in upstream.get_direct_relatives(upstream=False), ( + f"Expected direct edge {upstream_task_id} -> {downstream_task_id}" + ) + + +def _assert_reachable(dag, upstream_task_id: str, downstream_task_id: str) -> None: + upstream = dag.get_task(upstream_task_id) + downstream = dag.get_task(downstream_task_id) + assert downstream in upstream.get_flat_relatives(upstream=False), ( + f"Expected {downstream_task_id} to be downstream of {upstream_task_id}" + ) + + def test_csv_to_greenplum_dag_structure(): dag = _load_dag("airflow.dags.csv_to_greenplum") @@ -43,9 +59,9 @@ def test_csv_to_greenplum_dag_structure(): t3 = dag.get_task("preview_csv") t4 = dag.get_task("load_csv_to_greenplum") - assert t2 in t1.get_direct_relatives("downstream") - assert t3 in t2.get_direct_relatives("downstream") - assert t4 in t3.get_direct_relatives("downstream") + assert t2 in t1.get_direct_relatives(upstream=False) + assert t3 in t2.get_direct_relatives(upstream=False) + assert t4 in t3.get_direct_relatives(upstream=False) def test_csv_to_greenplum_dq_dag_structure(): @@ -66,10 +82,10 @@ def test_csv_to_greenplum_dq_dag_structure(): d = dag.get_task("check_order_duplicates") q = dag.get_task("data_quality_summary") - assert s in e.get_direct_relatives("downstream") - assert h in s.get_direct_relatives("downstream") - assert d in h.get_direct_relatives("downstream") - assert q in d.get_direct_relatives("downstream") + assert s in e.get_direct_relatives(upstream=False) + assert h in s.get_direct_relatives(upstream=False) + assert d in h.get_direct_relatives(upstream=False) + assert q in d.get_direct_relatives(upstream=False) def test_bookings_stg_ddl_dag_structure(): @@ -89,25 +105,12 @@ def test_bookings_stg_ddl_dag_structure(): } assert expected_tasks.issubset(dag.task_dict.keys()) - # Линейные зависимости: bookings/tickets → справочники → транзакции - t_bookings = dag.get_task("apply_stg_bookings_ddl") - t_tickets = dag.get_task("apply_stg_tickets_ddl") - t_airports = dag.get_task("apply_stg_airports_ddl") - t_airplanes = dag.get_task("apply_stg_airplanes_ddl") - t_routes = dag.get_task("apply_stg_routes_ddl") - t_seats = dag.get_task("apply_stg_seats_ddl") - t_flights = dag.get_task("apply_stg_flights_ddl") - t_segments = dag.get_task("apply_stg_segments_ddl") - t_boarding = dag.get_task("apply_stg_boarding_passes_ddl") + # Smoke-test графа: проверяем ключевые инварианты, не фиксируя линейный порядок. + # Это позволяет в будущем распараллеливать независимые DDL-задачи. + _assert_reachable(dag, "apply_stg_bookings_ddl", "apply_stg_tickets_ddl") - assert t_tickets in t_bookings.get_direct_relatives("downstream") - assert t_airports in t_tickets.get_direct_relatives("downstream") - assert t_airplanes in t_airports.get_direct_relatives("downstream") - assert t_routes in t_airplanes.get_direct_relatives("downstream") - assert t_seats in t_routes.get_direct_relatives("downstream") - assert t_flights in t_seats.get_direct_relatives("downstream") - assert t_segments in t_flights.get_direct_relatives("downstream") - assert t_boarding in t_segments.get_direct_relatives("downstream") + for task_id in expected_tasks - {"apply_stg_bookings_ddl"}: + _assert_reachable(dag, "apply_stg_bookings_ddl", task_id) def test_bookings_to_gp_stage_dag_structure(): @@ -138,44 +141,46 @@ def test_bookings_to_gp_stage_dag_structure(): } assert expected_tasks.issubset(dag.task_dict.keys()) - # Линейные зависимости: bookings/tickets → справочники → транзакции → финальный лог - t_generate = dag.get_task("generate_bookings_day") - t_bookings = dag.get_task("load_bookings_to_stg") - t_bookings_dq = dag.get_task("check_row_counts") - t_tickets = dag.get_task("load_tickets_to_stg") - t_tickets_dq = dag.get_task("check_tickets_dq") - t_airports = dag.get_task("load_airports_to_stg") - t_airports_dq = dag.get_task("check_airports_dq") - t_airplanes = dag.get_task("load_airplanes_to_stg") - t_airplanes_dq = dag.get_task("check_airplanes_dq") - t_routes = dag.get_task("load_routes_to_stg") - t_routes_dq = dag.get_task("check_routes_dq") - t_seats = dag.get_task("load_seats_to_stg") - t_seats_dq = dag.get_task("check_seats_dq") - t_flights = dag.get_task("load_flights_to_stg") - t_flights_dq = dag.get_task("check_flights_dq") - t_segments = dag.get_task("load_segments_to_stg") - t_segments_dq = dag.get_task("check_segments_dq") - t_boarding = dag.get_task("load_boarding_passes_to_stg") - t_boarding_dq = dag.get_task("check_boarding_passes_dq") - t_finish = dag.get_task("finish_summary") + # Smoke-test графа: проверяем инварианты, не фиксируя линейный порядок. + # Это позволяет в будущем распараллеливать независимые загрузки справочников/транзакций. - assert t_bookings in t_generate.get_direct_relatives("downstream") - assert t_bookings_dq in t_bookings.get_direct_relatives("downstream") - assert t_tickets in t_bookings_dq.get_direct_relatives("downstream") - assert t_tickets_dq in t_tickets.get_direct_relatives("downstream") - assert t_airports in t_tickets_dq.get_direct_relatives("downstream") - assert t_airports_dq in t_airports.get_direct_relatives("downstream") - assert t_airplanes in t_airports_dq.get_direct_relatives("downstream") - assert t_airplanes_dq in t_airplanes.get_direct_relatives("downstream") - assert t_routes in t_airplanes_dq.get_direct_relatives("downstream") - assert t_routes_dq in t_routes.get_direct_relatives("downstream") - assert t_seats in t_routes_dq.get_direct_relatives("downstream") - assert t_seats_dq in t_seats.get_direct_relatives("downstream") - assert t_flights in t_seats_dq.get_direct_relatives("downstream") - assert t_flights_dq in t_flights.get_direct_relatives("downstream") - assert t_segments in t_flights_dq.get_direct_relatives("downstream") - assert t_segments_dq in t_segments.get_direct_relatives("downstream") - assert t_boarding in t_segments_dq.get_direct_relatives("downstream") - assert t_boarding_dq in t_boarding.get_direct_relatives("downstream") - assert t_finish in t_boarding_dq.get_direct_relatives("downstream") + # Базовая цепочка должна сохраниться: генерация → bookings → DQ → tickets → DQ. + _assert_reachable(dag, "generate_bookings_day", "load_bookings_to_stg") + _assert_reachable(dag, "load_bookings_to_stg", "check_row_counts") + _assert_reachable(dag, "check_row_counts", "load_tickets_to_stg") + _assert_reachable(dag, "load_tickets_to_stg", "check_tickets_dq") + + # Инвариант "load → dq" для каждой таблицы. + load_to_dq = [ + ("load_bookings_to_stg", "check_row_counts"), + ("load_tickets_to_stg", "check_tickets_dq"), + ("load_airports_to_stg", "check_airports_dq"), + ("load_airplanes_to_stg", "check_airplanes_dq"), + ("load_routes_to_stg", "check_routes_dq"), + ("load_seats_to_stg", "check_seats_dq"), + ("load_flights_to_stg", "check_flights_dq"), + ("load_segments_to_stg", "check_segments_dq"), + ("load_boarding_passes_to_stg", "check_boarding_passes_dq"), + ] + for load_task_id, dq_task_id in load_to_dq: + _assert_direct_edge(dag, load_task_id, dq_task_id) + + # Барьеры по данным (не обязательно прямые рёбра). + # routes_dq использует airports/airplanes текущего batch_id. + _assert_reachable(dag, "check_airports_dq", "check_routes_dq") + _assert_reachable(dag, "check_airplanes_dq", "check_routes_dq") + + # seats_dq использует airplanes текущего batch_id. + _assert_reachable(dag, "check_airplanes_dq", "check_seats_dq") + + # flights_dq использует routes текущего batch_id. + _assert_reachable(dag, "check_routes_dq", "check_flights_dq") + + # segments_dq проверяет наличие flights (STG-история); для первой загрузки flights должны быть до segments. + _assert_reachable(dag, "check_flights_dq", "check_segments_dq") + + # boarding_passes_dq проверяет наличие segments/tickets (STG-история); для первой загрузки segments должны быть до DQ. + _assert_reachable(dag, "check_segments_dq", "check_boarding_passes_dq") + + # Финальная сводка должна быть в конце графа. + _assert_reachable(dag, "check_boarding_passes_dq", "finish_summary") From a0258fd63e9ef5bf41ef8b4a52b9bd091c637ed4 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 18 Jan 2026 22:52:49 +0300 Subject: [PATCH 22/38] =?UTF-8?q?=D0=94=D0=BE=D0=B2=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D0=B8=D0=BB=D0=B8=D1=81=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +++-- TESTING.md | 4 ++-- airflow/dags/bookings_stg_ddl.py | 14 +++++++++----- airflow/dags/bookings_to_gp_stage.py | 12 ++++++++---- docs/bookings_to_gp_stage.md | 6 ++++-- docs/chore/bookings-etl.md | 3 +++ docs/internal/bookings_stg_design.md | 23 +++++++++++++++-------- docs/internal/bookings_tz.md | 2 +- sql/stg/airplanes_ddl.sql | 6 +++--- sql/stg/airplanes_dq.sql | 2 +- sql/stg/airports_ddl.sql | 4 ++-- sql/stg/airports_dq.sql | 2 +- sql/stg/boarding_passes_ddl.sql | 2 +- sql/stg/bookings_ddl.sql | 3 +-- sql/stg/routes_dq.sql | 2 +- sql/stg/seats_ddl.sql | 7 ++----- sql/stg/seats_dq.sql | 2 +- sql/stg/segments_ddl.sql | 2 +- sql/stg/tickets_ddl.sql | 3 +-- 19 files changed, 60 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index f747198..3cdeab8 100644 --- a/README.md +++ b/README.md @@ -89,8 +89,9 @@ SELECT * FROM stg.bookings ORDER BY src_created_at_ts DESC LIMIT 10; Основные (для потока bookings → DWH): - `bookings_stg_ddl` — создаёт `stg.bookings_ext`/`stg.bookings` и `stg.tickets_ext`/`stg.tickets` в Greenplum; -- `bookings_to_gp_stage` — генерирует учебный день в `bookings-db`, грузит инкремент в `stg.bookings` и `stg.tickets` - (через PXF), затем выполняет DQ‑проверки. +- `bookings_stg_ddl` — создаёт/обновляет весь STG слой для bookings (9 таблиц: bookings, tickets, airports, airplanes, + routes, seats, flights, segments, boarding_passes; включая внешние `*_ext` через PXF); +- `bookings_to_gp_stage` — генерирует учебный день в `bookings-db`, затем загружает данные в STG и выполняет DQ‑проверки. Вспомогательные (побочный трек с CSV): diff --git a/TESTING.md b/TESTING.md index 35fd5a3..38d3288 100644 --- a/TESTING.md +++ b/TESTING.md @@ -38,10 +38,10 @@ - Проверить, что все 5 задач Success и логи содержат `Проверка пройдена`. - DAG `bookings_to_gp_stage` (полная проверка цепочки bookings → Greenplum STG): - - предварительно выполнить один раз: `make bookings-init` (установка демобазы `demo` в контейнере `bookings-db`) и `make ddl-gp` (создаёт `stg.bookings_ext` и `stg.bookings` в Greenplum); + - предварительно выполнить один раз: `make bookings-init` (установка демобазы `demo` в контейнере `bookings-db`) и `make ddl-gp` (создаёт STG слой в Greenplum, включая внешние `*_ext` через PXF); - важно: DAG `bookings_stg_ddl` **не** создаёт базу `demo` в `bookings-db`; если вы делали `docker compose down -v` / `make clean`, `make bookings-init` обязателен; - включить DAG `bookings_to_gp_stage` и запустить `Trigger DAG`; - - убедиться, что все задачи (`generate_bookings_day`, `load_bookings_to_stg`, `check_row_counts`, `finish_summary`) завершились со статусом Success; + - убедиться, что все задачи завершились со статусом Success (включая загрузки справочников/транзакций и DQ); - при желании проверить данные: в `bookings-db` появился новый день, а в Greenplum в `stg.bookings` — строки с актуальным `batch_id` (см. пример запросов в разделе 5). - (опционально, для менторов/разработчиков) Smoke-тест DAG через Airflow CLI без UI: diff --git a/airflow/dags/bookings_stg_ddl.py b/airflow/dags/bookings_stg_ddl.py index 874ec16..7e6bc56 100644 --- a/airflow/dags/bookings_stg_ddl.py +++ b/airflow/dags/bookings_stg_ddl.py @@ -1,8 +1,11 @@ from __future__ import annotations """ -Учебный DAG: создаёт схему stg и таблицы bookings_ext/bookings/tickets в Greenplum. -Запускается вручную перед DAG загрузки bookings_to_gp_stage или после изменения DDL. +Учебный DAG: создаёт/обновляет слой stg в Greenplum для демо-источника bookings. + +Запускается вручную перед DAG загрузки `bookings_to_gp_stage` или после изменения DDL. +Создаёт внешние таблицы PXF (`*_ext`) и внутренние таблицы STG (9 таблиц: bookings, tickets, +airports, airplanes, routes, seats, flights, segments, boarding_passes). """ from datetime import timedelta @@ -23,8 +26,8 @@ with DAG( catchup=False, template_searchpath="/sql", default_args=default_args, - tags=["demo", "greenplum", "ddl", "bookings", "tickets", "stg"], - description="Создаёт/обновляет stg.bookings_ext/bookings/tickets для учебного DAG", + tags=["demo", "greenplum", "ddl", "bookings", "stg"], + description="Учебный DDL DAG: создаёт/обновляет stg.* (PXF external + internal STG) для bookings", ) as dag: apply_stg_bookings_ddl = PostgresOperator( task_id="apply_stg_bookings_ddl", @@ -82,7 +85,8 @@ with DAG( sql="stg/boarding_passes_ddl.sql", ) - # Сначала создаются справочники, затем транзакционные таблицы (последовательно) + # DDL применяем последовательно, чтобы порядок был понятным для новичков, + # а ошибки — воспроизводимыми (в логах сразу видно, на каком объекте упали). ( apply_stg_bookings_ddl >> apply_stg_tickets_ddl diff --git a/airflow/dags/bookings_to_gp_stage.py b/airflow/dags/bookings_to_gp_stage.py index 354f241..2cf5832 100644 --- a/airflow/dags/bookings_to_gp_stage.py +++ b/airflow/dags/bookings_to_gp_stage.py @@ -13,6 +13,8 @@ from __future__ import annotations - генератор в демо-БД bookings добавляет следующий учебный день после max(book_date); - загрузка в Greenplum берёт все строки, появившиеся после предыдущих батчей; - `run_id` используется как метка запуска (в `batch_id`, в логах и DQ). + +Важно: для инкрементальных таблиц «пустое окно инкремента» допустимо (это не ошибка). """ from datetime import timedelta @@ -56,7 +58,7 @@ with DAG( template_searchpath="/sql", default_args=default_args, tags=["demo", "bookings", "greenplum", "stg"], - description="Учебный DAG: загрузка из bookings-db в stg.bookings и stg.tickets (Greenplum)", + description="Учебный DAG: загрузка из bookings-db в слой stg (Greenplum) + DQ проверки", ) as dag: # 1. Генерируем один (или несколько стартовых) учебный день в демо-БД bookings generate_bookings_day = PostgresOperator( @@ -180,7 +182,7 @@ with DAG( sql="stg/boarding_passes_dq.sql", ) - # 6. Финальный лог/сводка + # Финальный лог/сводка finish_summary = PythonOperator( task_id="finish_summary", python_callable=_finish_summary, @@ -190,13 +192,15 @@ with DAG( generate_bookings_day >> load_bookings_to_stg >> check_row_counts check_row_counts >> load_tickets_to_stg >> check_tickets_dq - # Затем загружаются справочники (последовательная загрузка) + # Затем загружаются справочники. + # Для простоты (и более понятных логов для новичков) делаем это последовательно. + # Если позже понадобится ускорить DAG, эти шаги можно распараллелить, сохранив зависимости. check_tickets_dq >> load_airports_to_stg >> check_airports_dq check_airports_dq >> load_airplanes_to_stg >> check_airplanes_dq check_airplanes_dq >> load_routes_to_stg >> check_routes_dq check_routes_dq >> load_seats_to_stg >> check_seats_dq - # Затем загружаются транзакции (последовательная загрузка) + # Затем загружаются транзакции (тоже последовательно, по тем же причинам). check_seats_dq >> load_flights_to_stg >> check_flights_dq check_flights_dq >> load_segments_to_stg >> check_segments_dq check_segments_dq >> load_boarding_passes_to_stg >> check_boarding_passes_dq diff --git a/docs/bookings_to_gp_stage.md b/docs/bookings_to_gp_stage.md index 70d6428..296493e 100644 --- a/docs/bookings_to_gp_stage.md +++ b/docs/bookings_to_gp_stage.md @@ -29,7 +29,9 @@ make up make bookings-init ``` -3) В Greenplum созданы STG‑объекты `stg.bookings_ext`/`stg.bookings` и `stg.tickets_ext`/`stg.tickets` (выберите один вариант): +3) В Greenplum созданы STG‑объекты (внешние `*_ext` через PXF и внутренние таблицы слоя `stg`) +для всех таблиц потока: `bookings`, `tickets`, `airports`, `airplanes`, `routes`, `seats`, `flights`, +`segments`, `boarding_passes` (выберите один вариант): - учебный вариант: запустить DAG `bookings_stg_ddl` в Airflow UI; - технический шорткат: `make ddl-gp`. @@ -130,7 +132,7 @@ LIMIT 10; ## Типичные ошибки - `database "demo" does not exist`: демо‑БД не установлена → выполните `make bookings-init`. -- Ошибки про `stg.bookings_ext`/`stg.bookings`: не применён DDL → запустите `bookings_stg_ddl` или `make ddl-gp`. +- Ошибки про `stg.*`/`stg.*_ext`: не применён DDL → запустите `bookings_stg_ddl` или `make ddl-gp`. - Ошибки PXF (`protocol "pxf" does not exist`, connection refused): перезапустите `greenplum` и повторите DDL. Для технических деталей см. `docs/internal/pxf_bookings.md`. diff --git a/docs/chore/bookings-etl.md b/docs/chore/bookings-etl.md index f0bc5a7..755dd45 100644 --- a/docs/chore/bookings-etl.md +++ b/docs/chore/bookings-etl.md @@ -1,5 +1,8 @@ # План ETL для загрузки `bookings.tickets` в STG слой +> Архивный документ: это рабочий план, который использовался при разработке. +> Актуальная реализация потока — DAG `bookings_to_gp_stage` и SQL в `sql/stg/`. + **Ветка:** `chore/bookings-etl` **Цель:** Добавить загрузку таблицы `tickets` в STG слой Greenplum по аналогии с `bookings` diff --git a/docs/internal/bookings_stg_design.md b/docs/internal/bookings_stg_design.md index 520182a..62fe546 100644 --- a/docs/internal/bookings_stg_design.md +++ b/docs/internal/bookings_stg_design.md @@ -8,6 +8,10 @@ _Внутренний документ для учебного стенда. П - Цель: показываем путь данных от операционной БД до сырого слоя DWH в Greenplum. - В этом документе описываем только часть `src (bookings-db) → STG (Greenplum)`. Слои ODS/DDS/DM студент проектирует сам по статье про моделирование DWH. +Примечание: в текущей версии стенда слой `stg` содержит не только `bookings`, но и остальные таблицы потока +(`tickets`, `airports`, `airplanes`, `routes`, `seats`, `flights`, `segments`, `boarding_passes`). +Ниже логика разобрана на примере `bookings`, потому что на нём проще показать принципы инкремента и батчей. + Логика на уровне слоёв (по статье): - `src`: оперативная система (`bookings-db`, схема `bookings`). @@ -20,8 +24,8 @@ _Внутренний документ для учебного стенда. П - Используем одну схему `stg` в Greenplum. - В этой схеме будут: - - внешняя таблица PXF для чтения из `bookings-db`; - - внутренняя таблица STG для долговременного хранения «сырых» данных. + - внешние таблицы PXF `*_ext` для чтения из `bookings-db`; + - внутренние таблицы STG `stg.*` для долговременного хранения «сырых» данных. ### 2.2. Внешняя таблица (PXF) @@ -31,6 +35,8 @@ _Внутренний документ для учебного стенда. П - можем использовать «родные» типы из `bookings.bookings` (включая даты/числа); - задача внешней таблицы — корректно читать данные из источника, не заниматься приведением типов. +В текущей реализации аналогично созданы внешние таблицы `*_ext` и для остальных сущностей (см. `sql/stg/*_ddl.sql`). + DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Greenplum (примерно по шаблону из `docs/internal/pxf_bookings.md`), с `LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db')`. ### 2.3. Внутренняя таблица STG @@ -46,7 +52,7 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre - `src_created_at_ts TIMESTAMP` — дата/время из источника, приведённая к TIMESTAMP: - используется как опорная колонка для инкрементальной загрузки; - - заполняется из исходной даты/времени (`created_at` или аналог). + - заполняется из опорной даты/времени, принятой для конкретной сущности (например, для `bookings` — из `book_date`). - `load_dttm TIMESTAMP NOT NULL DEFAULT now()` — когда запись была загружена в STG. - `batch_id TEXT NOT NULL` — идентификатор «пачки» (например, `{{ ds_nodash }}` или `run_id` Airflow). - при необходимости позже можно добавить `src_system TEXT`, если появятся другие источники. @@ -59,8 +65,8 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre - Опорная колонка: `src_created_at_ts` (внутреннее имя в STG). - Источник значения: - - берём из соответствующей колонки в `bookings.bookings` (например, `book_date`/`created_at` — будет уточнено при реализации); - - при чтении через `stg.bookings_ext` приводим к `TIMESTAMP`. + - для `bookings` используем `book_date` из `bookings.bookings` (в демо‑БД это поле естественно “шагает” по дням); + - при чтении через `stg.bookings_ext` приводим к `TIMESTAMP` и сохраняем в `stg.bookings.src_created_at_ts`. ### 3.2. Правила определения full/delta @@ -79,8 +85,8 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre - `dag_id`: `bookings_stg_ddl` (реализован в `airflow/dags/bookings_stg_ddl.py`). - Назначение: один раз (или при изменении схемы) создать необходимые объекты в Greenplum: - схему `stg` (если её ещё нет); - - внешнюю таблицу `stg.bookings_ext` (PXF → `bookings-db`); - - внутреннюю таблицу `stg.bookings` с текстовыми колонками и тех.полями. + - внешние таблицы `*_ext` и внутренние таблицы слоя `stg` для всех сущностей потока + (см. `sql/stg/*_ddl.sql`). - Этот DAG не загружает данные, только подготавливает структуру. - Вся DDL‑логика (CREATE/ALTER/DROP) сосредоточена здесь; рабочие DAG’и занимаются только DML (INSERT/SELECT). @@ -115,7 +121,8 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre - количество строк в `stg.bookings_ext` с `book_date` позже «старого» максимума, - количество строк в `stg.bookings` для текущего `batch_id`; - при расхождении выполняет `RAISE EXCEPTION` с понятным текстом ошибки. -4. `finish_summary` +4. Далее — загрузка и DQ для остальных таблиц потока (tickets, справочники, транзакции). +5. `finish_summary` - PythonOperator, который логирует итог выполнения DAG и напоминает, где смотреть детальные логи. Таким образом, вся бизнес‑логика инкремента и проверок живёт в SQL‑скриптах, а DAG отвечает за оркестрацию и подключение к нужным БД. Для менти это хороший пример разделения ответственности между SQL и Python. diff --git a/docs/internal/bookings_tz.md b/docs/internal/bookings_tz.md index 691f63e..683e2bb 100644 --- a/docs/internal/bookings_tz.md +++ b/docs/internal/bookings_tz.md @@ -1,6 +1,6 @@ # Временное ТЗ по блоку bookings (для текущей разработки) -_Этот файл внутренний, удалить перед итоговой сдачей._ +_Внутренний файл для наставника: поясняет, как устроен источник `bookings-db` и генерация данных. Студентам обычно не нужен._ - Контейнер `bookings-db` — отдельный сервис Postgres из `docker-compose.yml`, база по умолчанию `demo` (из upstream demodb), без переименований. - Доступ снаружи не блокируем (порт `5434` по умолчанию), чтобы позже читать через PXF и подключаться из Greenplum. diff --git a/sql/stg/airplanes_ddl.sql b/sql/stg/airplanes_ddl.sql index c25439d..5c85a09 100644 --- a/sql/stg/airplanes_ddl.sql +++ b/sql/stg/airplanes_ddl.sql @@ -32,7 +32,7 @@ WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -- Обоснование: airplane_code — это уникальный идентификатор самолёта. -- Использование airplane_code обеспечивает: -- 1. Равномерное распределение данных по сегментам (airplane_code имеет высокую кардинальность) --- 2. Co-location данных airplanes и routes при JOIN по airplane_code --- 3. Co-location данных airplanes и seats при JOIN по airplane_code --- 4. Оптимизацию запросов, которые фильтруют или группируют по airplane_code +-- 2. Коллокацию данных airplanes и seats при JOIN по airplane_code +-- 3. Оптимизацию запросов, которые фильтруют или группируют по airplane_code +-- Примечание: JOIN с таблицей routes (распределённой по route_no) может требовать motion. DISTRIBUTED BY (airplane_code); diff --git a/sql/stg/airplanes_dq.sql b/sql/stg/airplanes_dq.sql index 61d84f9..becc26f 100644 --- a/sql/stg/airplanes_dq.sql +++ b/sql/stg/airplanes_dq.sql @@ -15,7 +15,7 @@ BEGIN IF v_src_count = 0 THEN RAISE EXCEPTION - 'В источнике airplanes_ext нет строк.'; + 'В источнике airplanes_ext нет строк. Проверьте: bookings-db запущен, PXF работает, STG DDL применён (bookings_stg_ddl или make ddl-gp).'; END IF; -- Считаем строки, реально вставленные в stg.airplanes в этом батче diff --git a/sql/stg/airports_ddl.sql b/sql/stg/airports_ddl.sql index f953258..aa08084 100644 --- a/sql/stg/airports_ddl.sql +++ b/sql/stg/airports_ddl.sql @@ -36,6 +36,6 @@ WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -- Обоснование: airport_code — это уникальный идентификатор аэропорта. -- Использование airport_code обеспечивает: -- 1. Равномерное распределение данных по сегментам (airport_code имеет высокую кардинальность) --- 2. Co-location данных airports и routes при JOIN по departure_airport/arrival_airport --- 3. Оптимизацию запросов, которые фильтруют или группируют по airport_code +-- 2. Оптимизацию запросов, которые фильтруют или группируют по airport_code +-- Примечание: JOIN с таблицей routes (распределённой по route_no) может требовать motion. DISTRIBUTED BY (airport_code); diff --git a/sql/stg/airports_dq.sql b/sql/stg/airports_dq.sql index 7bea05f..3de90e2 100644 --- a/sql/stg/airports_dq.sql +++ b/sql/stg/airports_dq.sql @@ -15,7 +15,7 @@ BEGIN IF v_src_count = 0 THEN RAISE EXCEPTION - 'В источнике airports_ext нет строк.'; + 'В источнике airports_ext нет строк. Проверьте: bookings-db запущен, PXF работает, STG DDL применён (bookings_stg_ddl или make ddl-gp).'; END IF; -- Считаем строки, реально вставленные в stg.airports в этом батче diff --git a/sql/stg/boarding_passes_ddl.sql b/sql/stg/boarding_passes_ddl.sql index 974b223..eca5f55 100644 --- a/sql/stg/boarding_passes_ddl.sql +++ b/sql/stg/boarding_passes_ddl.sql @@ -32,7 +32,7 @@ WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -- Ключ распределения: ticket_no -- Обоснование: ticket_no — это основной бизнес-ключ для билетов. -- Использование ticket_no обеспечивает: --- 1. Co-location данных boarding_passes и segments при JOIN по ticket_no +-- 1. Коллокацию данных boarding_passes и segments при JOIN по ticket_no -- 2. Равномерное распределение данных по сегментам (ticket_no имеет высокую кардинальность) -- Примечание: stg.tickets распределена по book_ref, поэтому JOIN boarding_passes ↔ tickets по ticket_no может требовать motion. DISTRIBUTED BY (ticket_no); diff --git a/sql/stg/bookings_ddl.sql b/sql/stg/bookings_ddl.sql index 465e6b8..c9752ea 100644 --- a/sql/stg/bookings_ddl.sql +++ b/sql/stg/bookings_ddl.sql @@ -29,7 +29,6 @@ WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -- Обоснование: book_ref — это уникальный идентификатор бронирования. -- Использование book_ref обеспечивает: -- 1. Равномерное распределение данных по сегментам (book_ref имеет высокую кардинальность) --- 2. Co-location данных bookings и tickets при JOIN по book_ref +-- 2. Коллокацию данных bookings и tickets при JOIN по book_ref -- 3. Оптимизацию запросов, которые фильтруют или группируют по book_ref DISTRIBUTED BY (book_ref); - diff --git a/sql/stg/routes_dq.sql b/sql/stg/routes_dq.sql index 6f011aa..80af52a 100644 --- a/sql/stg/routes_dq.sql +++ b/sql/stg/routes_dq.sql @@ -17,7 +17,7 @@ BEGIN IF v_src_count = 0 THEN RAISE EXCEPTION - 'В источнике routes_ext нет строк.'; + 'В источнике routes_ext нет строк. Проверьте: bookings-db запущен, PXF работает, STG DDL применён (bookings_stg_ddl или make ddl-gp).'; END IF; -- Считаем строки, реально вставленные в stg.routes в этом батче diff --git a/sql/stg/seats_ddl.sql b/sql/stg/seats_ddl.sql index 4b6ead7..3dbd4fd 100644 --- a/sql/stg/seats_ddl.sql +++ b/sql/stg/seats_ddl.sql @@ -26,9 +26,6 @@ CREATE TABLE IF NOT EXISTS stg.seats ( ) WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -- Ключ распределения: airplane_code --- Обоснование: airplane_code обеспечивает co-location с таблицей airplanes. --- Использование airplane_code обеспечивает: --- 1. Co-location данных seats и airplanes при JOIN по airplane_code --- 2. Группировка мест по самолётам (в одном самолёте обычно много мест) --- 3. Оптимизацию запросов, которые фильтруют или группируют по airplane_code +-- Обоснование: airplane_code обеспечивает коллокацию seats ↔ airplanes при JOIN по airplane_code +-- (в MPP это уменьшает вероятность перераспределения данных / motion). DISTRIBUTED BY (airplane_code); diff --git a/sql/stg/seats_dq.sql b/sql/stg/seats_dq.sql index 69296f3..54744c9 100644 --- a/sql/stg/seats_dq.sql +++ b/sql/stg/seats_dq.sql @@ -16,7 +16,7 @@ BEGIN IF v_src_count = 0 THEN RAISE EXCEPTION - 'В источнике seats_ext нет строк.'; + 'В источнике seats_ext нет строк. Проверьте: bookings-db запущен, PXF работает, STG DDL применён (bookings_stg_ddl или make ddl-gp).'; END IF; -- Считаем строки, реально вставленные в stg.seats в этом батче diff --git a/sql/stg/segments_ddl.sql b/sql/stg/segments_ddl.sql index e59b1cc..e2d17ba 100644 --- a/sql/stg/segments_ddl.sql +++ b/sql/stg/segments_ddl.sql @@ -30,7 +30,7 @@ WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -- Ключ распределения: ticket_no -- Обоснование: ticket_no — это основной бизнес-ключ для билетов. -- Использование ticket_no обеспечивает: --- 1. Co-location данных segments и boarding_passes при JOIN по ticket_no +-- 1. Коллокацию данных segments и boarding_passes при JOIN по ticket_no -- 2. Равномерное распределение данных по сегментам (ticket_no имеет высокую кардинальность) -- Примечание: stg.tickets распределена по book_ref, поэтому JOIN segments ↔ tickets по ticket_no может требовать motion. DISTRIBUTED BY (ticket_no); diff --git a/sql/stg/tickets_ddl.sql b/sql/stg/tickets_ddl.sql index 87ea0ef..bc75f5f 100644 --- a/sql/stg/tickets_ddl.sql +++ b/sql/stg/tickets_ddl.sql @@ -32,8 +32,7 @@ WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1) -- Ключ распределения: book_ref -- Обоснование: book_ref — это основной бизнес-ключ для бронирований. -- Использование book_ref обеспечивает: --- 1. Co-location данных tickets и bookings при JOIN по book_ref +-- 1. Коллокацию данных tickets и bookings при JOIN по book_ref -- 2. Равномерное распределение данных по сегментам (book_ref имеет высокую кардинальность) -- 3. Оптимизацию запросов, которые фильтруют или группируют по book_ref DISTRIBUTED BY (book_ref); - From 69651f98fff7e566e13201fa4cb19a670b8d4be3 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 22 Feb 2026 20:19:58 +0300 Subject: [PATCH 23/38] =?UTF-8?q?refactor(dags):=20=D0=BF=D0=B0=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D0=BB=D0=B5=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=B3=D1=80=D0=B0=D1=84=20DAG=20bookings=5Fto=5Fgp=5F?= =?UTF-8?q?stage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - линейный граф маскировал реальные зависимости данных; для эталонного стенда важно показать менти параллельный граф там, где данные независимы. - Что: - airports и airplanes грузятся параллельно после check_tickets_dq. - routes ждёт обоих (DQ проверяет ссылочную целостность на оба справочника). - seats зависит только от airplanes и работает параллельно с веткой routes → flights → segments → boarding_passes. - finish_summary ждёт обе ветки (check_boarding_passes_dq + check_seats_dq). - datetime.utcnow() заменён на datetime.now(UTC) в csv_to_greenplum.py. - smoke-тесты дополнены проверкой параллельности и второй ветки. - документация обновлена с ASCII-схемой нового графа. - Проверка: - make test (11 passed, 4 skipped), make lint — чисто. Co-Authored-By: Claude Opus 4.6 --- airflow/dags/bookings_to_gp_stage.py | 47 +++++++++++++++++++++------- airflow/dags/csv_to_greenplum.py | 8 ++--- docs/bookings_to_gp_stage.md | 28 ++++++++++++----- tests/test_dags_smoke.py | 29 ++++++++++++----- 4 files changed, 83 insertions(+), 29 deletions(-) diff --git a/airflow/dags/bookings_to_gp_stage.py b/airflow/dags/bookings_to_gp_stage.py index 2cf5832..bcacfd4 100644 --- a/airflow/dags/bookings_to_gp_stage.py +++ b/airflow/dags/bookings_to_gp_stage.py @@ -15,6 +15,23 @@ from __future__ import annotations - `run_id` используется как метка запуска (в `batch_id`, в логах и DQ). Важно: для инкрементальных таблиц «пустое окно инкремента» допустимо (это не ошибка). + +Граф зависимостей (параллельный там, где данные независимы): + + generate_bookings_day → load_bookings → check_bookings_dq + → load_tickets → check_tickets_dq + ├─ load_airports → check_airports_dq ─┐ + │ ├─ load_routes → check_routes_dq + ├─ load_airplanes → check_airplanes_dq ┤ → load_flights → check_flights_dq + │ │ → load_segments → check_segments_dq + │ │ → load_boarding_passes → check_bp_dq ─┐ + │ └─ load_seats → check_seats_dq ──────────────────────┤ + │ ▼ + └──────────────────────────────────────────────────────────────────────────── finish_summary + +airports и airplanes грузятся параллельно (они не зависят друг от друга). +routes зависит от обоих (DQ проверяет ссылочную целостность на airports и airplanes). +seats зависит только от airplanes (DQ проверяет airplane_code → airplanes). """ from datetime import timedelta @@ -188,22 +205,30 @@ with DAG( python_callable=_finish_summary, ) - # Сначала загружаются и проверяются bookings и tickets + # === Этап 1. Транзакции: bookings → tickets (последовательно, т.к. tickets зависят от bookings) === generate_bookings_day >> load_bookings_to_stg >> check_row_counts check_row_counts >> load_tickets_to_stg >> check_tickets_dq - # Затем загружаются справочники. - # Для простоты (и более понятных логов для новичков) делаем это последовательно. - # Если позже понадобится ускорить DAG, эти шаги можно распараллелить, сохранив зависимости. + # === Этап 2. Справочники (параллельно, где данные независимы) === + # airports и airplanes не зависят друг от друга — грузим параллельно. check_tickets_dq >> load_airports_to_stg >> check_airports_dq - check_airports_dq >> load_airplanes_to_stg >> check_airplanes_dq - check_airplanes_dq >> load_routes_to_stg >> check_routes_dq - check_routes_dq >> load_seats_to_stg >> check_seats_dq + check_tickets_dq >> load_airplanes_to_stg >> check_airplanes_dq - # Затем загружаются транзакции (тоже последовательно, по тем же причинам). - check_seats_dq >> load_flights_to_stg >> check_flights_dq + # routes зависит от airports И airplanes (DQ проверяет ссылочную целостность). + [check_airports_dq, check_airplanes_dq] >> load_routes_to_stg >> check_routes_dq + + # seats зависит только от airplanes (DQ проверяет ссылочную целостность). + check_airplanes_dq >> load_seats_to_stg >> check_seats_dq + + # === Этап 3. Транзакции (последовательно, каждая зависит от предыдущей) === + # flights зависят от routes (DQ проверяет ссылочную целостность route_no → routes). + check_routes_dq >> load_flights_to_stg >> check_flights_dq + + # segments зависят от flights и tickets (DQ проверяет обе ссылки). check_flights_dq >> load_segments_to_stg >> check_segments_dq + + # boarding_passes зависят от segments и tickets (DQ проверяет обе ссылки). check_segments_dq >> load_boarding_passes_to_stg >> check_boarding_passes_dq - # В конце финальный лог - check_boarding_passes_dq >> finish_summary + # === Финал: ждём завершения ВСЕХ веток === + [check_boarding_passes_dq, check_seats_dq] >> finish_summary diff --git a/airflow/dags/csv_to_greenplum.py b/airflow/dags/csv_to_greenplum.py index 9289403..ad5273a 100644 --- a/airflow/dags/csv_to_greenplum.py +++ b/airflow/dags/csv_to_greenplum.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import os import random -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import List @@ -37,11 +37,11 @@ def _create_table() -> None: def _generate_csv(rows: int, csv_dir: Path) -> str: """Генерирует CSV c заказами с помощью pandas и сохраняет на диск.""" csv_dir.mkdir(parents=True, exist_ok=True) - timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + 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.utcnow().timestamp() * 1_000) + base_order_id = int(datetime.now(UTC).timestamp() * 1_000) # Создаём DataFrame с использованием pandas методов df = pd.DataFrame( @@ -52,7 +52,7 @@ def _generate_csv(rows: int, csv_dir: Path) -> str: ), # Временные метки с интервалом в 1 секунду в обратном порядке "order_ts": pd.date_range( - end=datetime.utcnow(), periods=rows, freq="1S" + end=datetime.now(UTC), periods=rows, freq="1S" ).sort_values(ascending=False), # Случайные customer_id от 1 до 1000 "customer_id": pd.Series( diff --git a/docs/bookings_to_gp_stage.md b/docs/bookings_to_gp_stage.md index 296493e..335a604 100644 --- a/docs/bookings_to_gp_stage.md +++ b/docs/bookings_to_gp_stage.md @@ -87,26 +87,40 @@ make bookings-init - проверяет количество строк в том же окне инкремента, а также ссылочную целостность и обязательные поля; - при проблемах делает `RAISE EXCEPTION`, чтобы DAG падал “красным”. -6) Справочники (full load) +6) Справочники (full load, параллельно где возможно) -Каждый справочник загружается “снэпшотом” (все строки) и затем проверяется DQ-скриптом: +Справочники загружаются “снэпшотом” (все строки) и затем проверяются DQ-скриптом. +Порядок определяется зависимостями данных — **airports** и **airplanes** грузятся **параллельно**, +потому что не зависят друг от друга: + +``` +check_tickets_dq + ├─ load_airports → check_airports_dq ─┐ + │ ├─ load_routes → check_routes_dq + └─ load_airplanes → check_airplanes_dq ─┤ + └─ load_seats → check_seats_dq +``` - `load_airports_to_stg` → `check_airports_dq` (`sql/stg/airports_load.sql`, `sql/stg/airports_dq.sql`) - `load_airplanes_to_stg` → `check_airplanes_dq` (`sql/stg/airplanes_load.sql`, `sql/stg/airplanes_dq.sql`) -- `load_routes_to_stg` → `check_routes_dq` (`sql/stg/routes_load.sql`, `sql/stg/routes_dq.sql`) -- `load_seats_to_stg` → `check_seats_dq` (`sql/stg/seats_load.sql`, `sql/stg/seats_dq.sql`) +- `load_routes_to_stg` → `check_routes_dq` (`sql/stg/routes_load.sql`, `sql/stg/routes_dq.sql`) — зависит от **airports** и **airplanes** (DQ проверяет ссылочную целостность) +- `load_seats_to_stg` → `check_seats_dq` (`sql/stg/seats_load.sql`, `sql/stg/seats_dq.sql`) — зависит от **airplanes** (DQ проверяет `airplane_code → airplanes`) 7) Транзакции -- `load_flights_to_stg` → `check_flights_dq` (инкремент по `scheduled_departure`) -- `load_segments_to_stg` → `check_segments_dq` (инкремент по `book_date` через tickets/bookings) -- `load_boarding_passes_to_stg` → `check_boarding_passes_dq` (full snapshot) +- `load_flights_to_stg` → `check_flights_dq` (инкремент по `scheduled_departure`) — зависит от **routes** +- `load_segments_to_stg` → `check_segments_dq` (инкремент по `book_date` через tickets/bookings) — зависит от **flights** +- `load_boarding_passes_to_stg` → `check_boarding_passes_dq` (full snapshot) — зависит от **segments** + +Ветка `seats` работает параллельно с веткой `routes → flights → segments → boarding_passes`. +Обе ветки сходятся на `finish_summary`. Важно: для инкрементальных таблиц “пустое окно инкремента” допустимо — загрузка и DQ логируют `NOTICE` и завершаются успешно. Для snapshot-справочников (airports/airplanes/routes/seats) пустой источник считается ошибкой (DQ делает `RAISE EXCEPTION`). 8) `finish_summary` +- ждёт завершения **обеих** параллельных веток (`check_boarding_passes_dq` и `check_seats_dq`); - логирует краткую сводку в конце запуска. ## Как проверить результат diff --git a/tests/test_dags_smoke.py b/tests/test_dags_smoke.py index 100b722..e422d0e 100644 --- a/tests/test_dags_smoke.py +++ b/tests/test_dags_smoke.py @@ -28,17 +28,17 @@ def _load_dag(module_name: str): def _assert_direct_edge(dag, upstream_task_id: str, downstream_task_id: str) -> None: upstream = dag.get_task(upstream_task_id) downstream = dag.get_task(downstream_task_id) - assert downstream in upstream.get_direct_relatives(upstream=False), ( - f"Expected direct edge {upstream_task_id} -> {downstream_task_id}" - ) + assert downstream in upstream.get_direct_relatives( + upstream=False + ), f"Expected direct edge {upstream_task_id} -> {downstream_task_id}" def _assert_reachable(dag, upstream_task_id: str, downstream_task_id: str) -> None: upstream = dag.get_task(upstream_task_id) downstream = dag.get_task(downstream_task_id) - assert downstream in upstream.get_flat_relatives(upstream=False), ( - f"Expected {downstream_task_id} to be downstream of {upstream_task_id}" - ) + assert downstream in upstream.get_flat_relatives( + upstream=False + ), f"Expected {downstream_task_id} to be downstream of {upstream_task_id}" def test_csv_to_greenplum_dag_structure(): @@ -182,5 +182,20 @@ def test_bookings_to_gp_stage_dag_structure(): # boarding_passes_dq проверяет наличие segments/tickets (STG-история); для первой загрузки segments должны быть до DQ. _assert_reachable(dag, "check_segments_dq", "check_boarding_passes_dq") - # Финальная сводка должна быть в конце графа. + # Финальная сводка должна быть в конце графа (обе ветки). _assert_reachable(dag, "check_boarding_passes_dq", "finish_summary") + _assert_reachable(dag, "check_seats_dq", "finish_summary") + + # Параллельность: airports и airplanes оба downstream от check_tickets_dq, + # но НЕ зависят друг от друга (ни прямо, ни транзитивно). + _assert_reachable(dag, "check_tickets_dq", "load_airports_to_stg") + _assert_reachable(dag, "check_tickets_dq", "load_airplanes_to_stg") + + airports = dag.get_task("load_airports_to_stg") + airplanes = dag.get_task("load_airplanes_to_stg") + assert airplanes not in airports.get_flat_relatives( + upstream=False + ), "airports не должен быть upstream для airplanes" + assert airports not in airplanes.get_flat_relatives( + upstream=False + ), "airplanes не должен быть upstream для airports" From cb5dd0528b97adcbbe1dcb4cd0bb01527082e051 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 22 Feb 2026 20:27:16 +0300 Subject: [PATCH 24/38] =?UTF-8?q?docs:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20CLAUDE.md=20=D1=81=20=D0=B8=D0=BD=D1=81?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=BA=D1=86=D0=B8=D1=8F=D0=BC=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20AI-=D0=B0=D1=81=D1=81=D0=B8=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - необходимо зафиксировать контекст и правила работы с проектом для Claude AI. - Что: - создан файл CLAUDE.md с описанием структуры проекта. - добавлены инструкции по коммитам и стилю кода. - Проверка: - git show --stat HEAD --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md From 2fb60e67ed414a248fbe60e3ff7ed536951c59d1 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 22 Feb 2026 20:54:02 +0300 Subject: [PATCH 25/38] =?UTF-8?q?docs(agents):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B3=D0=B0=D0=B9=D0=B4=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D0=BD?= =?UTF-8?q?=D0=BE=D0=BC=D1=83=20=D1=82=D0=B5=D1=81=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8E=20DAG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - AI-агенты пытались тестировать DAG через браузер вместо CLI/API, так как не было явных инструкций по программному подходу. - Что: - добавлен docs/agent-dag-testing.md: CLI, REST API Airflow, проверка параллельности, запросы в Greenplum, E2E-тест, шпаргалка команд. - в AGENTS.md добавлена ссылка на новый гайд в раздел «Тестирование». - из docs/README.md убрана ссылка (файл для людей, не для агентов). - Проверка: - cat docs/agent-dag-testing.md && grep agent-dag-testing AGENTS.md Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 1 + docs/agent-dag-testing.md | 343 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 docs/agent-dag-testing.md diff --git a/AGENTS.md b/AGENTS.md index 6562f98..de752eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,7 @@ - Есть юнит‑тесты для `helpers/greenplum.py` и smoke‑тесты DAG‑структуры (`tests/test_dags_smoke.py`). - Smoke‑тесты DAG автоматически пропускаются, если Airflow не установлен в venv. - Для ручного прогона стенда см. `TESTING.md` (пошаговый чек‑лист для студентов). +- Для программной проверки DAG (без браузера) — см. `docs/agent-dag-testing.md`: CLI, REST API, проверка параллельности, запросы в Greenplum. ## Pull Requests - Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`. Пример: `feat(dags): load orders to Greenplum`. diff --git a/docs/agent-dag-testing.md b/docs/agent-dag-testing.md new file mode 100644 index 0000000..06386d6 --- /dev/null +++ b/docs/agent-dag-testing.md @@ -0,0 +1,343 @@ +# Тестирование DAG (гайд для AI-агентов) + +Как программно проверить, что DAG работает корректно. +Все проверки выполняются через CLI, `docker compose exec` и SQL-запросы. +Браузер и Airflow UI **не используются**. + +--- + +## Предварительные условия + +Перед тестированием DAG стек должен быть поднят и здоров. + +```bash +# 1. Поднять стек (если не поднят) +make up + +# 2. Дождаться healthy-статуса всех сервисов +docker compose ps # greenplum и airflow-webserver должны быть (healthy) + +# 3. Для DAG bookings_to_gp_stage — инициализировать данные +make bookings-init # создать демо-БД bookings в контейнере bookings-db +make ddl-gp # создать STG-слой и внешние PXF-таблицы в Greenplum +``` + +Если стек ранее сносился (`make clean`), шаги 1-3 обязательны. + +--- + +## Уровень 1. Локальные проверки (без Docker) + +Быстрые проверки, не требующие поднятого стека: + +```bash +make test # pytest: unit-тесты helpers + smoke-тесты структуры DAG +make lint # black + isort в режиме проверки +``` + +Smoke-тесты DAG (`tests/test_dags_smoke.py`) проверяют: +- DAG импортируется без ошибок; +- все ожидаемые `task_id` присутствуют; +- прямые рёбра графа совпадают с эталонными; +- задачи достижимы друг из друга (транзитивно). + +Если Airflow не установлен в venv, smoke-тесты автоматически пропускаются (`skip`). + +--- + +## Уровень 2. Тестовый прогон DAG (без записи в мета-БД) + +Команда `airflow dags test` выполняет DAG целиком в оффлайн-режиме +(результат не сохраняется в Airflow, не создаётся `dag_run`): + +```bash +docker compose exec airflow-webserver \ + airflow dags test csv_to_greenplum 2024-01-01 + +docker compose exec airflow-webserver \ + airflow dags test bookings_to_gp_stage 2024-01-01 +``` + +Вывод идёт прямо в stdout — можно парсить на наличие `ERROR` / `FAILED`. + +--- + +## Уровень 3. Полноценный запуск DAG (с записью в мета-БД) + +### Запуск + +```bash +docker compose exec airflow-webserver \ + airflow dags trigger bookings_to_gp_stage +``` + +Команда возвращает `run_id`. Если нужно получить его программно: + +```bash +docker compose exec airflow-webserver \ + airflow dags list-runs -d bookings_to_gp_stage -o json +``` + +### Ожидание завершения + +DAG может работать 30-60 секунд. Опрашиваем статус задач: + +```bash +docker compose exec airflow-webserver \ + airflow tasks states-for-dag-run bookings_to_gp_stage -o json +``` + +Повторять до тех пор, пока все задачи не перейдут в терминальный статус +(`success`, `failed`, `upstream_failed`, `skipped`). + +### Проверка результатов + +```bash +# Список задач и их статусы (текстовый формат) +docker compose exec airflow-webserver \ + airflow tasks states-for-dag-run bookings_to_gp_stage + +# Логи конкретной задачи (при отладке) +docker compose exec airflow-webserver \ + airflow tasks logs bookings_to_gp_stage load_airports_to_stg +``` + +**Критерий успеха:** все 20 задач в статусе `success`. + +--- + +## Проверка параллельности + +В DAG `bookings_to_gp_stage` задачи `load_airports_to_stg` и `load_airplanes_to_stg` +должны запускаться параллельно (обе зависят только от `check_tickets_dq`). + +### Способ 1. По временным меткам (после реального запуска) + +```bash +docker compose exec airflow-webserver \ + airflow tasks states-for-dag-run bookings_to_gp_stage -o json +``` + +Сравнить `start_date` задач `load_airports_to_stg` и `load_airplanes_to_stg`. +**Критерий:** разница < 1 секунды. + +### Способ 2. По структуре графа (без запуска DAG) + +```bash +docker compose exec airflow-webserver python3 -c " +from airflow.models import DagBag + +dag = DagBag('/opt/airflow/dags').get_dag('bookings_to_gp_stage') + +airports = dag.get_task('load_airports_to_stg') +airplanes = dag.get_task('load_airplanes_to_stg') + +# Параллельность: задачи не зависят друг от друга +a_up = {t.task_id for t in airports.upstream_list} +b_up = {t.task_id for t in airplanes.upstream_list} + +print('airports upstream:', a_up) +print('airplanes upstream:', b_up) + +# airports не должен быть в upstream airplanes и наоборот +assert 'load_airports_to_stg' not in b_up, 'airplanes зависит от airports!' +assert 'load_airplanes_to_stg' not in a_up, 'airports зависит от airplanes!' +print('OK: задачи независимы, могут идти параллельно') +" +``` + +### Ожидаемые зависимости (эталон) + +| Задача | Ждёт (upstream) | +|--------|-----------------| +| `load_airports_to_stg` | `check_tickets_dq` | +| `load_airplanes_to_stg` | `check_tickets_dq` | +| `load_routes_to_stg` | `check_airports_dq` + `check_airplanes_dq` | +| `load_seats_to_stg` | `check_airplanes_dq` | +| `finish_summary` | `check_boarding_passes_dq` + `check_seats_dq` | + +--- + +## Проверка данных в Greenplum + +После успешного прогона DAG можно проверить наличие данных напрямую в БД: + +```bash +# Количество строк в ключевых таблицах +docker compose exec greenplum bash -lc \ + "su - gpadmin -c \"/usr/local/greenplum-db/bin/psql -t -A -d gp_dwh -c 'SELECT COUNT(*) FROM stg.bookings;'\"" + +docker compose exec greenplum bash -lc \ + "su - gpadmin -c \"/usr/local/greenplum-db/bin/psql -t -A -d gp_dwh -c 'SELECT COUNT(*) FROM stg.tickets;'\"" + +docker compose exec greenplum bash -lc \ + "su - gpadmin -c \"/usr/local/greenplum-db/bin/psql -t -A -d gp_dwh -c 'SELECT COUNT(*) FROM stg.airports;'\"" +``` + +**Критерий:** все таблицы непустые (COUNT > 0). + +--- + +## Проверка Airflow Connections + +Перед запуском DAG полезно убедиться, что подключения настроены: + +```bash +docker compose exec airflow-webserver airflow connections get greenplum_conn +docker compose exec airflow-webserver airflow connections get bookings_db +``` + +Обе команды должны вернуть параметры подключения без ошибок. + +--- + +## REST API (альтернатива CLI) + +REST API удобнее CLI для агента в ряде случаев: не нужен `docker exec`, +возвращает чистый JSON, проще поллить статус в цикле. + +**База:** `http://localhost:8080/api/v2` (порт из `AIRFLOW_WEB_PORT`, default: 8080) +**Аутентификация:** HTTP Basic Auth — `AIRFLOW_USER`/`AIRFLOW_PASSWORD` из `.env` (default: `admin`/`admin`) + +### Список DAG + +```bash +curl -s -u admin:admin http://localhost:8080/api/v2/dags | jq '.dags[].dag_id' +``` + +### Запуск DAG + +```bash +curl -s -u admin:admin \ + -X POST http://localhost:8080/api/v2/dags/bookings_to_gp_stage/dagRuns \ + -H "Content-Type: application/json" \ + -d '{}' | jq '{dag_run_id, state}' +``` + +Вернёт `dag_run_id` — он нужен для всех последующих запросов. + +### Статус запуска DAG + +```bash +curl -s -u admin:admin \ + http://localhost:8080/api/v2/dags/bookings_to_gp_stage/dagRuns/ \ + | jq '{state, start_date, end_date}' +``` + +Значения `state`: `queued` → `running` → `success` / `failed`. + +### Статусы всех задач запуска + +```bash +curl -s -u admin:admin \ + "http://localhost:8080/api/v2/dags/bookings_to_gp_stage/dagRuns//taskInstances" \ + | jq '.task_instances[] | {task_id, state, start_date}' +``` + +### Детали конкретной задачи + +```bash +curl -s -u admin:admin \ + "http://localhost:8080/api/v2/dags/bookings_to_gp_stage/dagRuns//taskInstances/load_airports_to_stg" \ + | jq '{task_id, state, start_date, end_date, duration}' +``` + +### Последний `dag_run_id` без явного сохранения + +```bash +curl -s -u admin:admin \ + "http://localhost:8080/api/v2/dags/bookings_to_gp_stage/dagRuns?order_by=-start_date&limit=1" \ + | jq -r '.dag_runs[0].dag_run_id' +``` + +### Когда использовать REST API вместо CLI + +| Ситуация | Предпочтительный способ | +|----------|------------------------| +| Нужен чистый JSON для парсинга | REST API | +| Агент работает вне Docker-хоста | REST API | +| Поллинг статуса в цикле | REST API (проще, чем `exec`) | +| Быстрая отладка или разовая проверка | CLI (`airflow dags test`) | +| Тестовый прогон без записи в мета-БД | CLI (`airflow dags test`) | + +--- + +## Полный E2E-тест (автоматизированный) + +Скрипт `scripts/e2e_smoke.sh` выполняет полный цикл: + +1. `make clean` — полный reset стека; +2. `make up` — поднимает сервисы; +3. ждёт `airflow-webserver` и `airflow-scheduler`; +4. `make bookings-init` — инициализирует демо-БД; +5. `make ddl-gp` — применяет DDL; +6. `make test` — локальные тесты; +7. `airflow dags test csv_to_greenplum 2024-01-01` — тест CSV-пайплайна; +8. проверяет `public.orders` непустую; +9. `airflow dags test bookings_to_gp_stage 2024-01-01` — тест bookings-пайплайна; +10. проверяет `stg.bookings` непустую. + +Запуск: + +```bash +./scripts/e2e_smoke.sh +``` + +--- + +## Список DAG и ожидаемые задачи + +### `csv_to_greenplum` (4 задачи) + +`create_orders_table` → `generate_csv` → `preview_csv` → `load_csv_to_greenplum` + +### `csv_to_greenplum_dq` (5 задач) + +`check_orders_table_exists` → `check_orders_schema` → `check_orders_has_rows` +→ `check_order_duplicates` → `data_quality_summary` + +### `bookings_to_gp_stage` (20 задач) + +``` +generate_bookings_day → load_bookings → check_bookings_dq + → load_tickets → check_tickets_dq + ├─ load_airports → check_airports_dq ─┐ + │ ├─ load_routes → check_routes_dq + ├─ load_airplanes → check_airplanes_dq ┤ → load_flights → check_flights_dq + │ │ → load_segments → check_segments_dq + │ │ → load_boarding_passes → check_bp_dq ─┐ + │ └─ load_seats → check_seats_dq ──────────────────────┤ + │ ▼ + └──────────────────────────────────────────────────────────────── finish_summary +``` + +--- + +## Ключевые команды (шпаргалка) + +| Действие | Команда | +|----------|---------| +| Список DAG | `docker compose exec airflow-webserver airflow dags list` | +| Список задач DAG | `docker compose exec airflow-webserver airflow tasks list ` | +| Тестовый прогон | `docker compose exec airflow-webserver airflow dags test 2024-01-01` | +| Запуск DAG | `docker compose exec airflow-webserver airflow dags trigger ` | +| Список запусков | `docker compose exec airflow-webserver airflow dags list-runs -d -o json` | +| Статусы задач | `docker compose exec airflow-webserver airflow tasks states-for-dag-run ` | +| Логи задачи | `docker compose exec airflow-webserver airflow tasks logs ` | +| Проверка подключений | `docker compose exec airflow-webserver airflow connections get ` | +| Запрос в Greenplum | `docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -t -A -d gp_dwh -c \"\"'"` | +| Здоровье стека | `docker compose ps` | + +--- + +## Типичные проблемы + +| Симптом | Вероятная причина | Что делать | +|---------|-------------------|------------| +| DAG не найден в `dags list` | Синтаксическая ошибка в файле | Посмотреть `docker compose logs airflow-scheduler` | +| `upstream_failed` у задачи | Упала задача выше по графу | Найти первую `failed`-задачу и смотреть её логи | +| DQ-проверка падает | Нет данных в source или нарушена целостность | Проверить данные в `bookings-db` и `stg.*` | +| `Connection ... not found` | Не задана переменная `AIRFLOW_CONN_*` | Проверить `.env` и `docker-compose.yml` | +| Greenplum `unhealthy` | PXF не стартовал (долгая инициализация) | Подождать 2-3 минуты, проверить `docker compose ps` | +| `relation ... does not exist` | Не применён DDL | Выполнить `make ddl-gp` | +| Пустые таблицы stg | Не выполнен `make bookings-init` | Выполнить `make bookings-init`, затем перезапустить DAG | From 26d0ab3d87a24374cdcf0def28f657975d3839dd Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 22 Feb 2026 21:08:17 +0300 Subject: [PATCH 26/38] =?UTF-8?q?docs(testing):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B8=20source-=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20DAG-=D1=82=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - исключены ложные падения DQ при пустом source в bookings-db. - Что: - в TESTING.md добавлены pre-check команды COUNT(*) перед Trigger DAG. - в docs/agent-dag-testing.md добавлена обязательная проверка source и fallback при COUNT(*) = 0. - в таблицу типичных проблем добавлен кейс падения check_airports_dq/check_airplanes_dq с ошибкой "..._ext нет строк". - Проверка: - rg -n "source непустой|COUNT\(\*\) = 0|bookings-generate-day|check_airports_dq" TESTING.md docs/agent-dag-testing.md --- TESTING.md | 5 +++++ docs/agent-dag-testing.md | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/TESTING.md b/TESTING.md index 38d3288..3e58938 100644 --- a/TESTING.md +++ b/TESTING.md @@ -39,6 +39,11 @@ - DAG `bookings_to_gp_stage` (полная проверка цепочки bookings → Greenplum STG): - предварительно выполнить один раз: `make bookings-init` (установка демобазы `demo` в контейнере `bookings-db`) и `make ddl-gp` (создаёт STG слой в Greenplum, включая внешние `*_ext` через PXF); + - перед Trigger проверить, что в source реально есть данные (все значения должны быть `> 0`): + - `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.bookings;"` + - `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.airports_data;"` + - `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.airplanes_data;"` + - если хотя бы один `COUNT(*) = 0`, не запускать DAG: повторить `make bookings-init`; если после этого `bookings.bookings` всё ещё пустая, выполнить `make bookings-generate-day` и снова проверить `COUNT(*)`; - важно: DAG `bookings_stg_ddl` **не** создаёт базу `demo` в `bookings-db`; если вы делали `docker compose down -v` / `make clean`, `make bookings-init` обязателен; - включить DAG `bookings_to_gp_stage` и запустить `Trigger DAG`; - убедиться, что все задачи завершились со статусом Success (включая загрузки справочников/транзакций и DQ); diff --git a/docs/agent-dag-testing.md b/docs/agent-dag-testing.md index 06386d6..d973fa6 100644 --- a/docs/agent-dag-testing.md +++ b/docs/agent-dag-testing.md @@ -24,6 +24,25 @@ make ddl-gp # создать STG-слой и внешние PXF- Если стек ранее сносился (`make clean`), шаги 1-3 обязательны. +Перед запуском `bookings_to_gp_stage` обязательно проверьте, что source непустой: + +```bash +# Все значения ниже должны быть > 0 +docker compose exec bookings-db \ + psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.bookings;" + +docker compose exec bookings-db \ + psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.airports_data;" + +docker compose exec bookings-db \ + psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.airplanes_data;" +``` + +Если хотя бы один `COUNT(*) = 0`, **не запускайте DAG**: +1. Выполните `make bookings-init`. +2. Повторите проверки `COUNT(*)`. +3. Если `bookings.bookings` всё ещё пустая, выполните `make bookings-generate-day` и проверьте снова. + --- ## Уровень 1. Локальные проверки (без Docker) @@ -341,3 +360,4 @@ generate_bookings_day → load_bookings → check_bookings_dq | Greenplum `unhealthy` | PXF не стартовал (долгая инициализация) | Подождать 2-3 минуты, проверить `docker compose ps` | | `relation ... does not exist` | Не применён DDL | Выполнить `make ddl-gp` | | Пустые таблицы stg | Не выполнен `make bookings-init` | Выполнить `make bookings-init`, затем перезапустить DAG | +| `check_airports_dq` / `check_airplanes_dq` падают с `..._ext нет строк` | Source-таблицы в `bookings-db` пустые | Проверить `COUNT(*)` в `bookings.bookings`, `bookings.airports_data`, `bookings.airplanes_data`; затем `make bookings-init`/`make bookings-generate-day` | From d1e81099c7166d5aed9b6d6ba44ad03749bb97b2 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 22 Feb 2026 21:31:14 +0300 Subject: [PATCH 27/38] =?UTF-8?q?docs(internal):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?ODS=20=D1=81=D0=BB=D0=BE=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - зафиксировать архитектурное решение для ODS слоя перед началом разработки. - Что: - создан файл docs/internal/bookings_ods_design.md. - описана архитектура ODS и отличия от STG слоя. - добавлены схемы таблиц (справочники с SCD Type 2, транзакции с UPSERT). - Проверка: - git show --stat HEAD --- docs/internal/bookings_ods_design.md | 435 +++++++++++++++++++++++++++ 1 file changed, 435 insertions(+) create mode 100644 docs/internal/bookings_ods_design.md diff --git a/docs/internal/bookings_ods_design.md b/docs/internal/bookings_ods_design.md new file mode 100644 index 0000000..13b7224 --- /dev/null +++ b/docs/internal/bookings_ods_design.md @@ -0,0 +1,435 @@ +# ODS Layer: план реализации + +## Контекст + +STG-слой уже реализован как учебный эталон: сырые данные из bookings-db грузятся через PXF, хранятся как TEXT, контролируются batch_id. + +**Следующий шаг** — ODS (Operational Data Store): типизированный, очищенный и исторически отслеживаемый слой. + +**Образовательные цели** реализации: +1. Показать переход TEXT → правильные типы данных +2. Объяснить SCD Type 2 на конкретных SQL-примерах +3. Показать UPSERT-паттерн для транзакционных данных +4. Продемонстрировать DQ-проверки уровня ODS (типы, ссылочная целостность) +5. Показать параллельный граф DAG с правильными зависимостями + +--- + +## Архитектура ODS + +### Отличие ODS от STG + +| Аспект | STG | ODS | +|--------|-----|-----| +| Типы | Всё TEXT | Правильные типы (TIMESTAMP, NUMERIC и т.д.) | +| Дедупликация | Нет (все инкременты хранятся) | Одна активная запись на бизнес-ключ | +| История | Нет | SCD Type 2 для справочников | +| Хранилище | appendonly | heap (справочники) + appendonly (транзакции) | +| DQ-проверки | Количество строк, NOT NULL по TEXT | Типы, ссылочная целостность, бизнес-правила | + +### Паттерны загрузки + +**Справочники (airports, airplanes, routes, seats) — SCD Type 2:** +- Читаем актуальный снапшот из STG (последний batch) +- Закрываем старые версии при изменении атрибутов (is_active=false, dw_end_date) +- Вставляем новые версии (dw_version++, dw_start_date=CURRENT_DATE) +- Вставляем новые записи (dw_version=1) +- Хранилище: heap-таблицы (без appendonly), т.к. UPDATE-операции частые + +**Транзакции (bookings, tickets, flights, segments, boarding_passes) — UPSERT:** +- Обновляем изменившиеся записи (UPDATE) +- Вставляем новые записи (INSERT WHERE NOT EXISTS) +- Хранилище: appendonly (как в STG) + +--- + +## Схема ODS-таблиц + +### Справочники (с SCD Type 2) + +#### ods.airports +```sql +airport_code TEXT NOT NULL -- бизнес-ключ +airport_name TEXT NOT NULL -- правильный тип (=TEXT, из JSONB в источнике) +city TEXT NOT NULL +country TEXT NOT NULL +coordinates TEXT -- оставляем TEXT (сложный формат, DDS распарсит) +timezone TEXT NOT NULL +-- SCD Type 2 +dw_start_date DATE NOT NULL DEFAULT CURRENT_DATE +dw_end_date DATE -- NULL = активная запись +is_active BOOLEAN NOT NULL DEFAULT true +dw_version INTEGER NOT NULL DEFAULT 1 +-- Технические +load_dttm TIMESTAMP DEFAULT now() +batch_id TEXT +DISTRIBUTED BY (airport_code) -- heap, для эффективных UPDATE +``` + +#### ods.airplanes +```sql +airplane_code TEXT NOT NULL -- бизнес-ключ +model TEXT NOT NULL -- из JSONB в источнике +range INTEGER -- TEXT → INTEGER (км) +speed INTEGER -- TEXT → INTEGER (км/ч) +-- SCD Type 2 + технические (аналогично airports) +DISTRIBUTED BY (airplane_code) +``` + +#### ods.routes +```sql +route_no TEXT NOT NULL -- бизнес-ключ +validity TEXT -- TSTZRANGE → TEXT (сложно парсить, документируем) +departure_airport TEXT NOT NULL -- FK к ods.airports.airport_code +arrival_airport TEXT NOT NULL +airplane_code TEXT NOT NULL -- FK к ods.airplanes.airplane_code +days_of_week TEXT -- int[] → TEXT (объясняем ограничение STG/PXF) +scheduled_time TIME -- TEXT → TIME (пример кастинга) +duration INTERVAL -- TEXT → INTERVAL (ключевой пример кастинга) +-- SCD Type 2 + технические +DISTRIBUTED BY (route_no) +``` + +#### ods.seats +```sql +airplane_code TEXT NOT NULL -- бизнес-ключ (совместный) +seat_no TEXT NOT NULL -- бизнес-ключ (совместный) +fare_conditions TEXT NOT NULL -- Economy/Comfort/Business +-- SCD Type 2 + технические +DISTRIBUTED BY (airplane_code) +``` + +### Транзакционные (UPSERT) + +#### ods.bookings +```sql +book_ref TEXT NOT NULL -- бизнес-ключ (CHAR(6)) +book_date TIMESTAMP WITH TIME ZONE -- TEXT → TIMESTAMPTZ (ключевой пример) +total_amount NUMERIC(10,2) NOT NULL -- TEXT → NUMERIC +-- Технические +src_created_at_ts TIMESTAMP +load_dttm TIMESTAMP DEFAULT now() +batch_id TEXT +DISTRIBUTED BY (book_ref) -- appendonly, выравнивание со STG +``` + +#### ods.tickets +```sql +ticket_no TEXT NOT NULL -- бизнес-ключ +book_ref TEXT NOT NULL -- FK к ods.bookings +passenger_id TEXT NOT NULL +passenger_name TEXT NOT NULL +outbound BOOLEAN -- TEXT → BOOLEAN (пример нетривиального кастинга) +-- Технические (src_created_at_ts, load_dttm, batch_id) +DISTRIBUTED BY (book_ref) -- совместно с bookings +``` + +#### ods.flights +```sql +flight_id INTEGER NOT NULL -- TEXT → INTEGER (бизнес-ключ) +route_no TEXT NOT NULL -- FK к ods.routes +status TEXT NOT NULL +scheduled_departure TIMESTAMP WITH TIME ZONE +scheduled_arrival TIMESTAMP WITH TIME ZONE +actual_departure TIMESTAMP WITH TIME ZONE +actual_arrival TIMESTAMP WITH TIME ZONE +-- Технические (src_created_at_ts, load_dttm, batch_id) +DISTRIBUTED BY (flight_id) +``` + +#### ods.segments +```sql +ticket_no TEXT NOT NULL -- бизнес-ключ (совместный), FK к ods.tickets +flight_id INTEGER NOT NULL -- TEXT → INTEGER, FK к ods.flights +fare_conditions TEXT NOT NULL +price NUMERIC(10,2) -- TEXT → NUMERIC (в STG называется 'price', не 'amount') +-- Технические +DISTRIBUTED BY (ticket_no) -- совместно с boarding_passes +``` + +#### ods.boarding_passes +```sql +ticket_no TEXT NOT NULL -- бизнес-ключ, FK к ods.tickets +flight_id INTEGER NOT NULL -- TEXT → INTEGER, FK к ods.flights +seat_no TEXT NOT NULL +boarding_no INTEGER -- TEXT → INTEGER +boarding_time TIMESTAMP WITH TIME ZONE -- TEXT → TIMESTAMPTZ +-- Технические +DISTRIBUTED BY (ticket_no) -- совместно с segments +``` + +--- + +## Структура файлов + +``` +sql/ods/ +├── airports_ddl.sql ← SCD Type 2 схема + DISTRIBUTED BY +├── airports_load.sql ← UPDATE (закрыть) + INSERT (новые/изменённые) +├── airports_dq.sql ← нет дублей is_active, все STG-ключи в ODS +├── airplanes_ddl.sql +├── airplanes_load.sql +├── airplanes_dq.sql +├── routes_ddl.sql ← обратить внимание: scheduled_time TIME, duration INTERVAL +├── routes_load.sql +├── routes_dq.sql +├── seats_ddl.sql +├── seats_load.sql +├── seats_dq.sql +├── bookings_ddl.sql ← TIMESTAMPTZ, NUMERIC +├── bookings_load.sql ← UPDATE изменений + INSERT новых +├── bookings_dq.sql +├── tickets_ddl.sql ← BOOLEAN для outbound +├── tickets_load.sql +├── tickets_dq.sql +├── flights_ddl.sql ← INTEGER для flight_id +├── flights_load.sql +├── flights_dq.sql +├── segments_ddl.sql +├── segments_load.sql +├── segments_dq.sql +├── boarding_passes_ddl.sql +├── boarding_passes_load.sql +└── boarding_passes_dq.sql + +airflow/dags/ +├── bookings_ods_ddl.py ← аналог bookings_stg_ddl.py (9 PostgresOperator) +└── bookings_to_gp_ods.py ← аналог bookings_to_gp_stage.py (параллельный граф) + +sql/ddl_gp_ods.sql ← мастер-DDL для make ddl-gp-ods (\i на каждый *_ddl.sql) + +docs/bookings_to_gp_ods.md ← описание DAG + примеры DQ-запросов для проверки +Makefile ← + таргет ddl-gp-ods +tests/test_dags_smoke.py ← + smoke-тесты для двух новых DAG +``` + +--- + +## DAG-граф bookings_to_gp_ods (параллельный) + +``` +load_ods_airports → dq_ods_airports ─┐ + ├─ load_ods_routes → dq_ods_routes → load_ods_flights → dq_ods_flights ─┐ +load_ods_airplanes → dq_ods_airplanes ─┘ │ + └─ load_ods_seats → dq_ods_seats │ + ↓ +load_ods_bookings → dq_ods_bookings → load_ods_tickets → dq_ods_tickets ──────────────────── load_ods_segments → dq_ods_segments + ↓ + load_ods_boarding_passes → dq_ods_boarding_passes + +Все ветки → finish_ods_summary +``` + +Зависимости: +- `routes` — после `airports` и `airplanes` +- `seats` — после `airplanes` +- `flights` — после `routes` +- `tickets` — после `bookings` +- `segments` — после `flights` и `tickets` +- `boarding_passes` — после `segments` + +--- + +## Ключевые SQL-паттерны для обучения + +### SCD Type 2 (airports_load.sql) + +```sql +-- Шаг 1: Получаем актуальный снапшот из STG (последний batch по load_dttm) +WITH latest_stg AS ( + SELECT airport_code, airport_name, city, country, coordinates, timezone + FROM stg.airports + WHERE load_dttm = (SELECT MAX(load_dttm) FROM stg.airports) +) +-- Шаг 2: Закрываем изменившиеся версии +UPDATE ods.airports AS o +SET dw_end_date = CURRENT_DATE - 1, + is_active = false +FROM latest_stg AS s +WHERE o.airport_code = s.airport_code + AND o.is_active = true + AND ( + o.airport_name <> s.airport_name OR + o.city <> s.city OR + o.country <> s.country OR + COALESCE(o.coordinates, '') <> COALESCE(s.coordinates, '') OR + o.timezone <> s.timezone + ); + +-- Шаг 3: Вставляем новые записи и новые версии изменённых +INSERT INTO ods.airports ( + airport_code, airport_name, city, country, coordinates, timezone, + dw_start_date, dw_end_date, is_active, dw_version, + load_dttm, batch_id +) +SELECT + s.airport_code, + s.airport_name, + s.city, + s.country, + s.coordinates, + s.timezone, + CURRENT_DATE, + NULL, + true, + COALESCE( + (SELECT MAX(dw_version) FROM ods.airports WHERE airport_code = s.airport_code), + 0 + ) + 1, + now(), + '{{ run_id }}'::text +FROM latest_stg s +WHERE NOT EXISTS ( + SELECT 1 FROM ods.airports o + WHERE o.airport_code = s.airport_code AND o.is_active = true +); + +ANALYZE ods.airports; +``` + +### UPSERT (bookings_load.sql) + +```sql +-- Шаг 1: Обновляем изменившиеся записи (SCD Type 1 для транзакций) +UPDATE ods.bookings AS o +SET book_date = s.book_date::TIMESTAMP WITH TIME ZONE, + total_amount = s.total_amount::NUMERIC(10,2), + load_dttm = now(), + batch_id = '{{ run_id }}'::text +FROM ( + -- Берём последнюю версию каждой записи из STG + SELECT DISTINCT ON (book_ref) + book_ref, book_date, total_amount, src_created_at_ts + FROM stg.bookings + ORDER BY book_ref, load_dttm DESC +) AS s +WHERE o.book_ref = s.book_ref + AND ( + o.book_date <> s.book_date::TIMESTAMP WITH TIME ZONE OR + o.total_amount <> s.total_amount::NUMERIC(10,2) + ); + +-- Шаг 2: Вставляем новые записи +INSERT INTO ods.bookings (book_ref, book_date, total_amount, src_created_at_ts, load_dttm, batch_id) +SELECT + s.book_ref, + s.book_date::TIMESTAMP WITH TIME ZONE, + s.total_amount::NUMERIC(10,2), + s.src_created_at_ts, + now(), + '{{ run_id }}'::text +FROM ( + SELECT DISTINCT ON (book_ref) + book_ref, book_date, total_amount, src_created_at_ts + FROM stg.bookings + ORDER BY book_ref, load_dttm DESC +) AS s +WHERE NOT EXISTS ( + SELECT 1 FROM ods.bookings o WHERE o.book_ref = s.book_ref +); + +ANALYZE ods.bookings; +``` + +### Нетривиальные кастинги (показываем студентам) + +```sql +-- duration: '02:35:00' → INTERVAL +s.duration::INTERVAL + +-- scheduled_time: 'HH:MM:SS' → TIME +s.scheduled_time::TIME + +-- outbound: 'true'/'false' → BOOLEAN +s.outbound::BOOLEAN + +-- flight_id: '12345' → INTEGER +s.flight_id::INTEGER + +-- boarding_time: '2017-08-13 09:45+03' → TIMESTAMPTZ +s.boarding_time::TIMESTAMP WITH TIME ZONE +``` + +--- + +## DQ-проверки ODS + +### Справочники (SCD Type 2) + +```sql +-- 1. Нет дублей активных записей по бизнес-ключу +SELECT airport_code, COUNT(*) +FROM ods.airports +WHERE is_active +GROUP BY 1 +HAVING COUNT(*) > 1; + +-- 2. Все STG-ключи присутствуют в ODS (нет потерянных) +SELECT COUNT(*) +FROM (SELECT DISTINCT airport_code FROM stg.airports) s +WHERE NOT EXISTS ( + SELECT 1 FROM ods.airports o + WHERE o.airport_code = s.airport_code AND o.is_active = true +); + +-- 3. Даты корректны: dw_end_date IS NULL для активных +SELECT COUNT(*) FROM ods.airports WHERE is_active AND dw_end_date IS NOT NULL; +``` + +### Транзакционные (UPSERT) + +```sql +-- 1. Нет дублей по бизнес-ключу +SELECT book_ref, COUNT(*) FROM ods.bookings GROUP BY 1 HAVING COUNT(*) > 1; + +-- 2. Ссылочная целостность: все tickets ссылаются на существующие bookings +SELECT COUNT(*) FROM ods.tickets t +WHERE NOT EXISTS (SELECT 1 FROM ods.bookings b WHERE b.book_ref = t.book_ref); + +-- 3. Все STG-записи попали в ODS +SELECT COUNT(*) +FROM (SELECT DISTINCT book_ref FROM stg.bookings) s +WHERE NOT EXISTS (SELECT 1 FROM ods.bookings o WHERE o.book_ref = s.book_ref); +``` + +--- + +## Порядок реализации + +1. **`sql/ods/*_ddl.sql`** (9 файлов) — DDL всех таблиц +2. **`sql/ddl_gp_ods.sql`** — мастер-DDL (собирает все `_ddl.sql` через `\i`) +3. **`Makefile`** — таргет `ddl-gp-ods` +4. **`airflow/dags/bookings_ods_ddl.py`** — DDL DAG +5. **`sql/ods/*_load.sql`** (9 файлов) — скрипты загрузки (сначала справочники, потом транзакционные) +6. **`sql/ods/*_dq.sql`** (9 файлов) — DQ-проверки +7. **`airflow/dags/bookings_to_gp_ods.py`** — Load DAG с параллельным графом +8. **`tests/test_dags_smoke.py`** — smoke-тесты для новых DAG +9. **`docs/bookings_to_gp_ods.md`** — документация для студентов + +--- + +## Проверка результата + +```bash +make test # smoke-тесты: оба новых DAG парсятся без ошибок +make up # поднять стек +make ddl-gp-ods # применить ODS DDL +# Trigger bookings_to_gp_stage → дождаться завершения +# Trigger bookings_to_gp_ods → проверить параллельный граф в UI +make gp-psql # проверочные запросы к ods.* +``` + +Проверочные запросы: + +```sql +-- Активные аэропорты = уникальным из STG +SELECT COUNT(*) FROM ods.airports WHERE is_active; +SELECT COUNT(DISTINCT airport_code) FROM stg.airports; + +-- Бронирования без дублей +SELECT COUNT(*) FROM ods.bookings; +SELECT COUNT(DISTINCT book_ref) FROM stg.bookings; + +-- При первом запуске все SCD-версии = 1 +SELECT DISTINCT dw_version FROM ods.airports ORDER BY 1; +``` From 6ceff7b0273666c183695a3094c234c85376f523 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 22 Feb 2026 22:45:53 +0300 Subject: [PATCH 28/38] =?UTF-8?q?docs(dwh):=20=D0=B7=D0=B0=D1=84=D0=B8?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D0=B2=D0=B5=D0=BD=D1=86=D0=B8=D0=B8=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=B9=D0=BC=D0=B8=D0=BD=D0=B3=D0=B0=20=D0=B8=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D1=91=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=20ODS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - нужен единый стандарт именования полей, чтобы новые слои не расходились с учебными материалами. - Что: - добавлен единый документ с правилами нейминга `docs/internal/naming_conventions.md`. - полностью переписан `docs/internal/bookings_ods_design.md` в эталонный учебный план ODS (SCD1, батч-контракт, DQ, граф DAG). - добавлены ссылки на стандарт нейминга в `docs/README.md`, `docs/internal/db_schema.md` и `AGENTS.md`. - Проверка: - проверен diff по измененным файлам (`git diff`). --- AGENTS.md | 1 + docs/README.md | 1 + docs/internal/bookings_ods_design.md | 739 +++++++++++++++------------ docs/internal/db_schema.md | 1 + docs/internal/naming_conventions.md | 80 +++ 5 files changed, 498 insertions(+), 324 deletions(-) create mode 100644 docs/internal/naming_conventions.md diff --git a/AGENTS.md b/AGENTS.md index de752eb..fc29496 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,7 @@ - `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/` по мере роста стенда. +- Нейминг служебных полей и SCD-полей фиксирован в `docs/internal/naming_conventions.md` (единый источник для всех новых слоёв). - Именование файлов: `{объект}_{роль}.sql`, где: - `объект` — логическое имя сущности (`bookings`, `orders`, и т.п.); - `роль` — `ddl` (создание/изменение объектов), `load` (загрузка/инкремент), `dq` (проверки качества данных) и т.п. diff --git a/docs/README.md b/docs/README.md index 132330a..2e727a5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ ## Технические детали (опционально) - [Как устроен Docker-стенд (образы, Connections, переменные окружения)](stack.md) +- [Единые конвенции нейминга DWH (служебные поля и SCD)](internal/naming_conventions.md) - [PXF в этом проекте (проектная реализация)](internal/pxf_bookings.md) - [Дизайн stg для bookings (черновик)](internal/bookings_stg_design.md) - [Про время/UTC в bookings (черновик)](internal/bookings_tz.md) diff --git a/docs/internal/bookings_ods_design.md b/docs/internal/bookings_ods_design.md index 13b7224..c224b49 100644 --- a/docs/internal/bookings_ods_design.md +++ b/docs/internal/bookings_ods_design.md @@ -1,188 +1,402 @@ -# ODS Layer: план реализации +# ODS Layer: эталонный учебный план реализации (v2) ## Контекст -STG-слой уже реализован как учебный эталон: сырые данные из bookings-db грузятся через PXF, хранятся как TEXT, контролируются batch_id. +STG-слой уже реализован как учебный эталон: +- данные из `bookings-db` читаются через PXF; +- в STG бизнес-колонки хранятся как `TEXT`; +- загрузка и DQ работают батчами (`batch_id = {{ run_id }}`). -**Следующий шаг** — ODS (Operational Data Store): типизированный, очищенный и исторически отслеживаемый слой. - -**Образовательные цели** реализации: -1. Показать переход TEXT → правильные типы данных -2. Объяснить SCD Type 2 на конкретных SQL-примерах -3. Показать UPSERT-паттерн для транзакционных данных -4. Продемонстрировать DQ-проверки уровня ODS (типы, ссылочная целостность) -5. Показать параллельный граф DAG с правильными зависимостями +Этот документ фиксирует **простую и каноничную** реализацию ODS для менти. --- -## Архитектура ODS +## 1) Что считаем эталоном для ODS -### Отличие ODS от STG +### 1.1. Роль ODS в этом стенде -| Аспект | STG | ODS | -|--------|-----|-----| -| Типы | Всё TEXT | Правильные типы (TIMESTAMP, NUMERIC и т.д.) | -| Дедупликация | Нет (все инкременты хранятся) | Одна активная запись на бизнес-ключ | -| История | Нет | SCD Type 2 для справочников | -| Хранилище | appendonly | heap (справочники) + appendonly (транзакции) | -| DQ-проверки | Количество строк, NOT NULL по TEXT | Типы, ссылочная целостность, бизнес-правила | +ODS в учебном проекте — это: +- типизированные и очищенные данные; +- одна актуальная запись на бизнес-ключ; +- удобный слой для последующей сборки DDS/DM. -### Паттерны загрузки +### 1.2. Что делаем, что не делаем -**Справочники (airports, airplanes, routes, seats) — SCD Type 2:** -- Читаем актуальный снапшот из STG (последний batch) -- Закрываем старые версии при изменении атрибутов (is_active=false, dw_end_date) -- Вставляем новые версии (dw_version++, dw_start_date=CURRENT_DATE) -- Вставляем новые записи (dw_version=1) -- Хранилище: heap-таблицы (без appendonly), т.к. UPDATE-операции частые +Делаем в ODS: +- приведение типов (`TEXT -> TIMESTAMPTZ/NUMERIC/INT/BOOLEAN/...`); +- дедупликацию внутри батча; +- `UPSERT` (SCD Type 1): обновляем текущую запись при изменении, вставляем новые. -**Транзакции (bookings, tickets, flights, segments, boarding_passes) — UPSERT:** -- Обновляем изменившиеся записи (UPDATE) -- Вставляем новые записи (INSERT WHERE NOT EXISTS) -- Хранилище: appendonly (как в STG) +Не делаем в ODS (в базовом эталоне): +- SCD Type 2 с периодами действия; +- сложную обработку late-arriving/backdated событий; +- отдельный DQ-слой с хранением результатов. + +### 1.3. Где хранится история изменений + +- История «как приходили данные» уже сохраняется в STG (append + `batch_id`). +- Историзацию измерений (SCD2) показываем позже в DDS (как в учебной статье `dwh-modeling`). + +Итог: **ODS = текущий слой (current state), простой и понятный**. --- -## Схема ODS-таблиц +## 2) Нейминг служебных полей (консистентно с de-roadmap) -### Справочники (с SCD Type 2) +Источник правил: [`docs/internal/naming_conventions.md`](naming_conventions.md). -#### ods.airports +В ODS используем такие техполя: + +- `_load_id TEXT NOT NULL` — идентификатор загрузки (берём `stg_batch_id`); +- `_load_ts TIMESTAMP NOT NULL DEFAULT now()` — время загрузки в ODS; +- `event_ts TIMESTAMP` — время события из источника (если у сущности оно есть). + +### 2.1. Маппинг из текущего STG + +- `stg.batch_id` -> `ods._load_id` +- `stg.load_dttm` не переносим 1:1; в ODS пишем собственный `ods._load_ts = now()` +- `stg.src_created_at_ts` -> `ods.event_ts` (для транзакционных таблиц) + +### 2.2. Почему так + +- нейминг совпадает с учебной статьёй (`_load_id`, `_load_ts`); +- студентам проще переносить паттерн между проектами; +- разделяем «когда событие произошло» (`event_ts`) и «когда загрузили в слой» (`_load_ts`). + +--- + +## 3) Гранулярность и бизнес-ключи ODS + +| Таблица | Зерно | Бизнес-ключ | +|---|---|---| +| `ods.airports` | 1 строка = аэропорт | `airport_code` | +| `ods.airplanes` | 1 строка = самолёт | `airplane_code` | +| `ods.routes` | 1 строка = версия маршрута | `(route_no, validity)` | +| `ods.seats` | 1 строка = место в самолёте | `(airplane_code, seat_no)` | +| `ods.bookings` | 1 строка = бронирование | `book_ref` | +| `ods.tickets` | 1 строка = билет | `ticket_no` | +| `ods.flights` | 1 строка = рейс | `flight_id` | +| `ods.segments` | 1 строка = сегмент билета | `(ticket_no, flight_id)` | +| `ods.boarding_passes` | 1 строка = посадочный на сегмент | `(ticket_no, flight_id)` | + +Критично для эталона: +- `routes` — **составной** ключ `(route_no, validity)`; +- `boarding_passes` — **составной** ключ `(ticket_no, flight_id)`. + +--- + +## 4) Схема ODS-таблиц (v1, без SCD2) + +Ниже — учебный минимум колонок. При необходимости можно добавлять бизнес-атрибуты без изменения паттерна загрузки. + +### 4.1. Справочники + +#### `ods.airports` ```sql -airport_code TEXT NOT NULL -- бизнес-ключ -airport_name TEXT NOT NULL -- правильный тип (=TEXT, из JSONB в источнике) +airport_code TEXT NOT NULL +airport_name TEXT NOT NULL city TEXT NOT NULL country TEXT NOT NULL -coordinates TEXT -- оставляем TEXT (сложный формат, DDS распарсит) +coordinates TEXT timezone TEXT NOT NULL --- SCD Type 2 -dw_start_date DATE NOT NULL DEFAULT CURRENT_DATE -dw_end_date DATE -- NULL = активная запись -is_active BOOLEAN NOT NULL DEFAULT true -dw_version INTEGER NOT NULL DEFAULT 1 --- Технические -load_dttm TIMESTAMP DEFAULT now() -batch_id TEXT -DISTRIBUTED BY (airport_code) -- heap, для эффективных UPDATE +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (airport_code) ``` -#### ods.airplanes +#### `ods.airplanes` ```sql -airplane_code TEXT NOT NULL -- бизнес-ключ -model TEXT NOT NULL -- из JSONB в источнике -range INTEGER -- TEXT → INTEGER (км) -speed INTEGER -- TEXT → INTEGER (км/ч) --- SCD Type 2 + технические (аналогично airports) +airplane_code TEXT NOT NULL +model TEXT NOT NULL +range_km INTEGER +speed_kmh INTEGER +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() DISTRIBUTED BY (airplane_code) ``` -#### ods.routes +#### `ods.routes` ```sql -route_no TEXT NOT NULL -- бизнес-ключ -validity TEXT -- TSTZRANGE → TEXT (сложно парсить, документируем) -departure_airport TEXT NOT NULL -- FK к ods.airports.airport_code -arrival_airport TEXT NOT NULL -airplane_code TEXT NOT NULL -- FK к ods.airplanes.airplane_code -days_of_week TEXT -- int[] → TEXT (объясняем ограничение STG/PXF) -scheduled_time TIME -- TEXT → TIME (пример кастинга) -duration INTERVAL -- TEXT → INTERVAL (ключевой пример кастинга) --- SCD Type 2 + технические +route_no TEXT NOT NULL +validity TEXT NOT NULL +departure_airport TEXT NOT NULL +arrival_airport TEXT NOT NULL +airplane_code TEXT NOT NULL +days_of_week TEXT +scheduled_departure_time TIME +scheduled_duration INTERVAL +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() DISTRIBUTED BY (route_no) ``` -#### ods.seats +#### `ods.seats` ```sql -airplane_code TEXT NOT NULL -- бизнес-ключ (совместный) -seat_no TEXT NOT NULL -- бизнес-ключ (совместный) -fare_conditions TEXT NOT NULL -- Economy/Comfort/Business --- SCD Type 2 + технические +airplane_code TEXT NOT NULL +seat_no TEXT NOT NULL +fare_conditions TEXT NOT NULL +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() DISTRIBUTED BY (airplane_code) ``` -### Транзакционные (UPSERT) +### 4.2. Транзакционные -#### ods.bookings +#### `ods.bookings` ```sql -book_ref TEXT NOT NULL -- бизнес-ключ (CHAR(6)) -book_date TIMESTAMP WITH TIME ZONE -- TEXT → TIMESTAMPTZ (ключевой пример) -total_amount NUMERIC(10,2) NOT NULL -- TEXT → NUMERIC --- Технические -src_created_at_ts TIMESTAMP -load_dttm TIMESTAMP DEFAULT now() -batch_id TEXT -DISTRIBUTED BY (book_ref) -- appendonly, выравнивание со STG +book_ref TEXT NOT NULL +book_date TIMESTAMP WITH TIME ZONE NOT NULL +total_amount NUMERIC(10,2) NOT NULL +event_ts TIMESTAMP +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (book_ref) ``` -#### ods.tickets +#### `ods.tickets` ```sql -ticket_no TEXT NOT NULL -- бизнес-ключ -book_ref TEXT NOT NULL -- FK к ods.bookings -passenger_id TEXT NOT NULL -passenger_name TEXT NOT NULL -outbound BOOLEAN -- TEXT → BOOLEAN (пример нетривиального кастинга) --- Технические (src_created_at_ts, load_dttm, batch_id) -DISTRIBUTED BY (book_ref) -- совместно с bookings +ticket_no TEXT NOT NULL +book_ref TEXT NOT NULL +passenger_id TEXT NOT NULL +passenger_name TEXT NOT NULL +is_outbound BOOLEAN +event_ts TIMESTAMP +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (book_ref) ``` -#### ods.flights +#### `ods.flights` ```sql -flight_id INTEGER NOT NULL -- TEXT → INTEGER (бизнес-ключ) -route_no TEXT NOT NULL -- FK к ods.routes -status TEXT NOT NULL -scheduled_departure TIMESTAMP WITH TIME ZONE -scheduled_arrival TIMESTAMP WITH TIME ZONE -actual_departure TIMESTAMP WITH TIME ZONE -actual_arrival TIMESTAMP WITH TIME ZONE --- Технические (src_created_at_ts, load_dttm, batch_id) +flight_id INTEGER NOT NULL +route_no TEXT NOT NULL +status TEXT NOT NULL +scheduled_departure TIMESTAMP WITH TIME ZONE +scheduled_arrival TIMESTAMP WITH TIME ZONE +actual_departure TIMESTAMP WITH TIME ZONE +actual_arrival TIMESTAMP WITH TIME ZONE +event_ts TIMESTAMP +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() DISTRIBUTED BY (flight_id) ``` -#### ods.segments +#### `ods.segments` ```sql -ticket_no TEXT NOT NULL -- бизнес-ключ (совместный), FK к ods.tickets -flight_id INTEGER NOT NULL -- TEXT → INTEGER, FK к ods.flights -fare_conditions TEXT NOT NULL -price NUMERIC(10,2) -- TEXT → NUMERIC (в STG называется 'price', не 'amount') --- Технические -DISTRIBUTED BY (ticket_no) -- совместно с boarding_passes +ticket_no TEXT NOT NULL +flight_id INTEGER NOT NULL +fare_conditions TEXT NOT NULL +segment_amount NUMERIC(10,2) +event_ts TIMESTAMP +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (ticket_no) ``` -#### ods.boarding_passes +#### `ods.boarding_passes` ```sql -ticket_no TEXT NOT NULL -- бизнес-ключ, FK к ods.tickets -flight_id INTEGER NOT NULL -- TEXT → INTEGER, FK к ods.flights -seat_no TEXT NOT NULL -boarding_no INTEGER -- TEXT → INTEGER -boarding_time TIMESTAMP WITH TIME ZONE -- TEXT → TIMESTAMPTZ --- Технические -DISTRIBUTED BY (ticket_no) -- совместно с segments +ticket_no TEXT NOT NULL +flight_id INTEGER NOT NULL +seat_no TEXT NOT NULL +boarding_no INTEGER +boarding_time TIMESTAMP WITH TIME ZONE +event_ts TIMESTAMP +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (ticket_no) ``` +Примечание: в учебном варианте не опираемся на физические `PK/FK`-constraint в Greenplum, а проверяем целостность через DQ-скрипты. + --- -## Структура файлов +## 5) Контракт батча для ODS +Чтобы ODS был воспроизводимым, в каждом запуске используем **один фиксированный `stg_batch_id`**. + +### 5.1. Источник `stg_batch_id` + +В `bookings_to_gp_ods`: +- принимаем `stg_batch_id` из `dag_run.conf`; +- если не передан — берём последний из `stg.bookings`; +- логируем, какой `stg_batch_id` выбран. + +### 5.2. Как применяем + +Во всех `sql/ods/*_load.sql`: +- читаем STG только с `WHERE batch_id = :stg_batch_id`; +- пишем в ODS `_load_id = :stg_batch_id`, `_load_ts = now()`. + +Это простой и понятный паттерн: **один запуск ODS = один снимок STG-батча**. + +--- + +## 6) SQL-паттерны загрузки (SCD1 / UPSERT) + +### 6.1. Шаблон для справочника (пример `airports_load.sql`) + +```sql +WITH src AS ( + SELECT + airport_code, + airport_name, + city, + country, + coordinates, + timezone + FROM stg.airports + WHERE batch_id = '{{ params.stg_batch_id }}'::text +) +UPDATE ods.airports AS o +SET airport_name = s.airport_name, + city = s.city, + country = s.country, + coordinates = s.coordinates, + timezone = s.timezone, + _load_id = '{{ params.stg_batch_id }}'::text, + _load_ts = now() +FROM src AS s +WHERE o.airport_code = s.airport_code + AND ( + o.airport_name <> s.airport_name OR + o.city <> s.city OR + o.country <> s.country OR + COALESCE(o.coordinates, '') <> COALESCE(s.coordinates, '') OR + o.timezone <> s.timezone + ); + +INSERT INTO ods.airports ( + airport_code, airport_name, city, country, coordinates, timezone, + _load_id, _load_ts +) +SELECT + s.airport_code, s.airport_name, s.city, s.country, s.coordinates, s.timezone, + '{{ params.stg_batch_id }}'::text, now() +FROM src s +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.airports o + WHERE o.airport_code = s.airport_code +); + +ANALYZE ods.airports; ``` + +### 6.2. Шаблон для транзакции (пример `bookings_load.sql`) + +```sql +WITH src AS ( + SELECT DISTINCT ON (book_ref) + book_ref, + book_date::TIMESTAMP WITH TIME ZONE AS book_date, + total_amount::NUMERIC(10,2) AS total_amount, + src_created_at_ts AS event_ts + FROM stg.bookings + WHERE batch_id = '{{ params.stg_batch_id }}'::text + ORDER BY book_ref, src_created_at_ts DESC NULLS LAST, load_dttm DESC +) +UPDATE ods.bookings AS o +SET book_date = s.book_date, + total_amount = s.total_amount, + event_ts = s.event_ts, + _load_id = '{{ params.stg_batch_id }}'::text, + _load_ts = now() +FROM src AS s +WHERE o.book_ref = s.book_ref + AND ( + o.book_date <> s.book_date OR + o.total_amount <> s.total_amount + ); + +INSERT INTO ods.bookings ( + book_ref, book_date, total_amount, event_ts, + _load_id, _load_ts +) +SELECT + s.book_ref, s.book_date, s.total_amount, s.event_ts, + '{{ params.stg_batch_id }}'::text, now() +FROM src s +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.bookings o + WHERE o.book_ref = s.book_ref +); + +ANALYZE ods.bookings; +``` + +### 6.3. Поведение при пустом батче + +- для инкрементальных таблиц (`bookings`, `tickets`, `flights`, `segments`) пустой батч допустим; +- для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) пустой батч считаем ошибкой. + +--- + +## 7) DQ-проверки ODS (минимум, но строго) + +Каждый DQ-скрипт должен: +- быть привязан к `stg_batch_id`; +- делать `RAISE EXCEPTION` при нарушении; +- давать понятную подсказку в тексте ошибки. + +### 7.1. Обязательные проверки + +1. Нет дублей по бизнес-ключу в ODS. + +2. Все ключи из STG текущего батча присутствуют в ODS. + +3. Обязательные поля не `NULL`/не пустые. + +4. Ссылочная целостность в ODS: +- `tickets.book_ref -> bookings.book_ref` +- `flights.route_no -> routes.route_no` +- `segments.ticket_no -> tickets.ticket_no` +- `segments.flight_id -> flights.flight_id` +- `boarding_passes (ticket_no, flight_id) -> segments (ticket_no, flight_id)` + +### 7.2. Пример проверки покрытия батча + +```sql +SELECT COUNT(*) +FROM ( + SELECT DISTINCT book_ref + FROM stg.bookings + WHERE batch_id = '{{ params.stg_batch_id }}'::text +) s +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.bookings o + WHERE o.book_ref = s.book_ref +); +``` + +Ожидаемый результат: `0`. + +--- + +## 8) Структура файлов + +```text sql/ods/ -├── airports_ddl.sql ← SCD Type 2 схема + DISTRIBUTED BY -├── airports_load.sql ← UPDATE (закрыть) + INSERT (новые/изменённые) -├── airports_dq.sql ← нет дублей is_active, все STG-ключи в ODS +├── airports_ddl.sql +├── airports_load.sql +├── airports_dq.sql ├── airplanes_ddl.sql ├── airplanes_load.sql ├── airplanes_dq.sql -├── routes_ddl.sql ← обратить внимание: scheduled_time TIME, duration INTERVAL +├── routes_ddl.sql ├── routes_load.sql ├── routes_dq.sql ├── seats_ddl.sql ├── seats_load.sql ├── seats_dq.sql -├── bookings_ddl.sql ← TIMESTAMPTZ, NUMERIC -├── bookings_load.sql ← UPDATE изменений + INSERT новых +├── bookings_ddl.sql +├── bookings_load.sql ├── bookings_dq.sql -├── tickets_ddl.sql ← BOOLEAN для outbound +├── tickets_ddl.sql ├── tickets_load.sql ├── tickets_dq.sql -├── flights_ddl.sql ← INTEGER для flight_id +├── flights_ddl.sql ├── flights_load.sql ├── flights_dq.sql ├── segments_ddl.sql @@ -192,244 +406,121 @@ sql/ods/ ├── boarding_passes_load.sql └── boarding_passes_dq.sql +sql/ddl_gp_ods.sql + airflow/dags/ -├── bookings_ods_ddl.py ← аналог bookings_stg_ddl.py (9 PostgresOperator) -└── bookings_to_gp_ods.py ← аналог bookings_to_gp_stage.py (параллельный граф) +├── bookings_ods_ddl.py +└── bookings_to_gp_ods.py -sql/ddl_gp_ods.sql ← мастер-DDL для make ddl-gp-ods (\i на каждый *_ddl.sql) - -docs/bookings_to_gp_ods.md ← описание DAG + примеры DQ-запросов для проверки -Makefile ← + таргет ddl-gp-ods -tests/test_dags_smoke.py ← + smoke-тесты для двух новых DAG +docs/bookings_to_gp_ods.md +Makefile (+ ddl-gp-ods) +tests/test_dags_smoke.py (+ smoke для 2 новых DAG) ``` --- -## DAG-граф bookings_to_gp_ods (параллельный) +## 9) DAG `bookings_to_gp_ods`: учебный граф зависимостей -``` -load_ods_airports → dq_ods_airports ─┐ - ├─ load_ods_routes → dq_ods_routes → load_ods_flights → dq_ods_flights ─┐ -load_ods_airplanes → dq_ods_airplanes ─┘ │ - └─ load_ods_seats → dq_ods_seats │ - ↓ -load_ods_bookings → dq_ods_bookings → load_ods_tickets → dq_ods_tickets ──────────────────── load_ods_segments → dq_ods_segments - ↓ - load_ods_boarding_passes → dq_ods_boarding_passes +Принцип: у каждой сущности строго `load -> dq`, и только после `dq` разрешаем downstream. -Все ветки → finish_ods_summary +```text +load_ods_bookings -> dq_ods_bookings -> load_ods_tickets -> dq_ods_tickets + + ├-> load_ods_airports -> dq_ods_airports ─┐ + ├-> load_ods_airplanes -> dq_ods_airplanes ─┼-> load_ods_routes -> dq_ods_routes -> load_ods_flights -> dq_ods_flights + └-> └-> load_ods_seats -> dq_ods_seats + +dq_ods_flights + dq_ods_tickets -> load_ods_segments -> dq_ods_segments -> load_ods_boarding_passes -> dq_ods_boarding_passes + +[dq_ods_boarding_passes, dq_ods_seats] -> finish_ods_summary ``` Зависимости: -- `routes` — после `airports` и `airplanes` -- `seats` — после `airplanes` -- `flights` — после `routes` -- `tickets` — после `bookings` -- `segments` — после `flights` и `tickets` -- `boarding_passes` — после `segments` +- `tickets` после `bookings`; +- `routes` после `airports` и `airplanes`; +- `seats` после `airplanes`; +- `flights` после `routes`; +- `segments` после `flights` и `tickets`; +- `boarding_passes` после `segments`. --- -## Ключевые SQL-паттерны для обучения +## 10) Порядок реализации -### SCD Type 2 (airports_load.sql) - -```sql --- Шаг 1: Получаем актуальный снапшот из STG (последний batch по load_dttm) -WITH latest_stg AS ( - SELECT airport_code, airport_name, city, country, coordinates, timezone - FROM stg.airports - WHERE load_dttm = (SELECT MAX(load_dttm) FROM stg.airports) -) --- Шаг 2: Закрываем изменившиеся версии -UPDATE ods.airports AS o -SET dw_end_date = CURRENT_DATE - 1, - is_active = false -FROM latest_stg AS s -WHERE o.airport_code = s.airport_code - AND o.is_active = true - AND ( - o.airport_name <> s.airport_name OR - o.city <> s.city OR - o.country <> s.country OR - COALESCE(o.coordinates, '') <> COALESCE(s.coordinates, '') OR - o.timezone <> s.timezone - ); - --- Шаг 3: Вставляем новые записи и новые версии изменённых -INSERT INTO ods.airports ( - airport_code, airport_name, city, country, coordinates, timezone, - dw_start_date, dw_end_date, is_active, dw_version, - load_dttm, batch_id -) -SELECT - s.airport_code, - s.airport_name, - s.city, - s.country, - s.coordinates, - s.timezone, - CURRENT_DATE, - NULL, - true, - COALESCE( - (SELECT MAX(dw_version) FROM ods.airports WHERE airport_code = s.airport_code), - 0 - ) + 1, - now(), - '{{ run_id }}'::text -FROM latest_stg s -WHERE NOT EXISTS ( - SELECT 1 FROM ods.airports o - WHERE o.airport_code = s.airport_code AND o.is_active = true -); - -ANALYZE ods.airports; -``` - -### UPSERT (bookings_load.sql) - -```sql --- Шаг 1: Обновляем изменившиеся записи (SCD Type 1 для транзакций) -UPDATE ods.bookings AS o -SET book_date = s.book_date::TIMESTAMP WITH TIME ZONE, - total_amount = s.total_amount::NUMERIC(10,2), - load_dttm = now(), - batch_id = '{{ run_id }}'::text -FROM ( - -- Берём последнюю версию каждой записи из STG - SELECT DISTINCT ON (book_ref) - book_ref, book_date, total_amount, src_created_at_ts - FROM stg.bookings - ORDER BY book_ref, load_dttm DESC -) AS s -WHERE o.book_ref = s.book_ref - AND ( - o.book_date <> s.book_date::TIMESTAMP WITH TIME ZONE OR - o.total_amount <> s.total_amount::NUMERIC(10,2) - ); - --- Шаг 2: Вставляем новые записи -INSERT INTO ods.bookings (book_ref, book_date, total_amount, src_created_at_ts, load_dttm, batch_id) -SELECT - s.book_ref, - s.book_date::TIMESTAMP WITH TIME ZONE, - s.total_amount::NUMERIC(10,2), - s.src_created_at_ts, - now(), - '{{ run_id }}'::text -FROM ( - SELECT DISTINCT ON (book_ref) - book_ref, book_date, total_amount, src_created_at_ts - FROM stg.bookings - ORDER BY book_ref, load_dttm DESC -) AS s -WHERE NOT EXISTS ( - SELECT 1 FROM ods.bookings o WHERE o.book_ref = s.book_ref -); - -ANALYZE ods.bookings; -``` - -### Нетривиальные кастинги (показываем студентам) - -```sql --- duration: '02:35:00' → INTERVAL -s.duration::INTERVAL - --- scheduled_time: 'HH:MM:SS' → TIME -s.scheduled_time::TIME - --- outbound: 'true'/'false' → BOOLEAN -s.outbound::BOOLEAN - --- flight_id: '12345' → INTEGER -s.flight_id::INTEGER - --- boarding_time: '2017-08-13 09:45+03' → TIMESTAMPTZ -s.boarding_time::TIMESTAMP WITH TIME ZONE -``` +1. Подготовить DDL в `sql/ods/*_ddl.sql`. +2. Сделать мастер-скрипт `sql/ddl_gp_ods.sql`. +3. Добавить `Makefile`-таргет `ddl-gp-ods`. +4. Создать DAG `bookings_ods_ddl.py`. +5. Реализовать `sql/ods/*_load.sql` (SCD1 UPSERT). +6. Реализовать `sql/ods/*_dq.sql`. +7. Создать DAG `bookings_to_gp_ods.py` (с параметром `stg_batch_id`). +8. Дописать smoke-тесты DAG в `tests/test_dags_smoke.py`. +9. Описать запуск и проверки в `docs/bookings_to_gp_ods.md`. --- -## DQ-проверки ODS +## 11) Критерии готовности (Definition of Done) -### Справочники (SCD Type 2) +Готово, если: + +1. Оба новых DAG парсятся и проходят smoke-тесты (`make test`). +2. `make ddl-gp-ods` создаёт объекты без ошибок. +3. Для тестового `stg_batch_id` ODS-загрузка завершается успешно. +4. Все DQ-задачи зелёные и реально валят DAG при искусственной ошибке. +5. В ODS нет дублей по бизнес-ключам. +6. Нейминг техполей консистентен с учебной статьёй: `_load_id`, `_load_ts`, `valid_from/valid_to` (последние — когда перейдём к SCD2 в DDS). + +--- + +## 12) Как проверять вручную + +```bash +make up +make ddl-gp +# Trigger bookings_to_gp_stage +# Получить batch_id из stg.bookings (последний) +# Trigger bookings_to_gp_ods с conf: {"stg_batch_id": "<значение>"} +make gp-psql +``` + +Проверочные SQL: ```sql --- 1. Нет дублей активных записей по бизнес-ключу -SELECT airport_code, COUNT(*) -FROM ods.airports -WHERE is_active +-- 1) Дубликаты в ODS (пример bookings) +SELECT book_ref, COUNT(*) +FROM ods.bookings GROUP BY 1 HAVING COUNT(*) > 1; --- 2. Все STG-ключи присутствуют в ODS (нет потерянных) +-- 2) Покрытие текущего STG-батча в ODS SELECT COUNT(*) -FROM (SELECT DISTINCT airport_code FROM stg.airports) s +FROM ( + SELECT DISTINCT book_ref + FROM stg.bookings + WHERE batch_id = '' +) s WHERE NOT EXISTS ( - SELECT 1 FROM ods.airports o - WHERE o.airport_code = s.airport_code AND o.is_active = true + SELECT 1 + FROM ods.bookings o + WHERE o.book_ref = s.book_ref ); --- 3. Даты корректны: dw_end_date IS NULL для активных -SELECT COUNT(*) FROM ods.airports WHERE is_active AND dw_end_date IS NOT NULL; -``` - -### Транзакционные (UPSERT) - -```sql --- 1. Нет дублей по бизнес-ключу -SELECT book_ref, COUNT(*) FROM ods.bookings GROUP BY 1 HAVING COUNT(*) > 1; - --- 2. Ссылочная целостность: все tickets ссылаются на существующие bookings -SELECT COUNT(*) FROM ods.tickets t -WHERE NOT EXISTS (SELECT 1 FROM ods.bookings b WHERE b.book_ref = t.book_ref); - --- 3. Все STG-записи попали в ODS +-- 3) Ссылочная целостность tickets -> bookings SELECT COUNT(*) -FROM (SELECT DISTINCT book_ref FROM stg.bookings) s -WHERE NOT EXISTS (SELECT 1 FROM ods.bookings o WHERE o.book_ref = s.book_ref); +FROM ods.tickets t +WHERE NOT EXISTS ( + SELECT 1 + FROM ods.bookings b + WHERE b.book_ref = t.book_ref +); ``` +Ожидаемо: все три запроса возвращают `0` проблемных строк. + --- -## Порядок реализации +## 13) Что будет следующим шагом -1. **`sql/ods/*_ddl.sql`** (9 файлов) — DDL всех таблиц -2. **`sql/ddl_gp_ods.sql`** — мастер-DDL (собирает все `_ddl.sql` через `\i`) -3. **`Makefile`** — таргет `ddl-gp-ods` -4. **`airflow/dags/bookings_ods_ddl.py`** — DDL DAG -5. **`sql/ods/*_load.sql`** (9 файлов) — скрипты загрузки (сначала справочники, потом транзакционные) -6. **`sql/ods/*_dq.sql`** (9 файлов) — DQ-проверки -7. **`airflow/dags/bookings_to_gp_ods.py`** — Load DAG с параллельным графом -8. **`tests/test_dags_smoke.py`** — smoke-тесты для новых DAG -9. **`docs/bookings_to_gp_ods.md`** — документация для студентов - ---- - -## Проверка результата - -```bash -make test # smoke-тесты: оба новых DAG парсятся без ошибок -make up # поднять стек -make ddl-gp-ods # применить ODS DDL -# Trigger bookings_to_gp_stage → дождаться завершения -# Trigger bookings_to_gp_ods → проверить параллельный граф в UI -make gp-psql # проверочные запросы к ods.* -``` - -Проверочные запросы: - -```sql --- Активные аэропорты = уникальным из STG -SELECT COUNT(*) FROM ods.airports WHERE is_active; -SELECT COUNT(DISTINCT airport_code) FROM stg.airports; - --- Бронирования без дублей -SELECT COUNT(*) FROM ods.bookings; -SELECT COUNT(DISTINCT book_ref) FROM stg.bookings; - --- При первом запуске все SCD-версии = 1 -SELECT DISTINCT dw_version FROM ods.airports ORDER BY 1; -``` +После стабилизации ODS: +- строим DDS; +- показываем SCD2 на измерениях DDS (`valid_from`/`valid_to`, `created_at`/`updated_at`) по тому же неймингу, который уже знаком студентам из `dwh-modeling`. diff --git a/docs/internal/db_schema.md b/docs/internal/db_schema.md index 568d159..af7c3df 100644 --- a/docs/internal/db_schema.md +++ b/docs/internal/db_schema.md @@ -22,6 +22,7 @@ - **Даты**: как минимум различаем `book_date` (дата покупки) и `scheduled_departure` (дата/время вылета) - **Инкремент в STG**: для `tickets` опорная дата берётся из `bookings.book_date`, потому что в `tickets` нет собственного поля времени изменения - **DQ-проверки**: проверки качества данных выполняем SQL-скриптами, но **не сохраняем результаты в отдельные таблицы/слой DQ** (при проблемах падаем с понятной ошибкой и останавливаем пайплайн) +- **Нейминг полей**: единый стандарт — в [`docs/internal/naming_conventions.md`](naming_conventions.md) ### Статус реализации по слоям diff --git a/docs/internal/naming_conventions.md b/docs/internal/naming_conventions.md new file mode 100644 index 0000000..bba5590 --- /dev/null +++ b/docs/internal/naming_conventions.md @@ -0,0 +1,80 @@ +# Конвенции Нейминга DWH (Единый Источник) + +> Статус: активный стандарт для новых реализаций в этом репозитории. +> +> Основа: учебные материалы `de-roadmap/dwh-modeling`. + +## Зачем этот документ + +Чтобы имена полей не «плыли» между слоями, DAG и SQL-скриптами: +- студенты видят один и тот же словарь во всех задачах; +- новые реализации (ODS/DDS/DM) не расходятся с тем, как уже учили на `dwh-modeling`; +- ревью становится проще: сразу видно, где отклонение от стандарта. + +## 1. Базовые правила + +- Имена колонок: `snake_case`, на английском. +- Бизнес-ключи источника не переименовываем без необходимости (`book_ref`, `ticket_no`, `route_no`). +- Булевы поля начинаются с `is_` (`is_outbound`, `is_boarded`). +- Денежные/количественные поля называем явно (`total_amount`, `segment_amount`, `range_km`). + +## 2. Каноничные служебные поля + +| Поле | Тип (рекомендация) | Смысл | Где применять | +|---|---|---|---| +| `_load_id` | `TEXT NOT NULL` | Идентификатор загрузки/батча | STG/ODS (новые объекты), при необходимости DDS | +| `_load_ts` | `TIMESTAMP NOT NULL` | Когда запись попала в слой | STG/ODS (новые объекты) | +| `event_ts` | `TIMESTAMP` | Когда событие произошло в источнике (effective time) | ODS/DDS при событийной природе данных | +| `created_at` | `TIMESTAMP NOT NULL` | Когда строка создана в таблице слоя | DDS/DM, где есть lifecycle строки | +| `updated_at` | `TIMESTAMP NOT NULL` | Когда строка обновлена в таблице слоя | DDS/DM, где есть UPDATE | +| `valid_from` | `DATE NOT NULL` (базовый трек) | Начало действия версии SCD2 | DDS SCD2 | +| `valid_to` | `DATE` | Конец действия версии SCD2 (`NULL` = current) | DDS SCD2 | +| `hashdiff` | `TEXT NOT NULL` | Хэш атрибутов версии для детекта изменений | DDS SCD2 | + +## 3. Ключи в DDS + +- Бизнес-ключ измерения: суффикс `_bk` (`customer_bk`, `airport_bk`). +- Суррогатный ключ измерения: суффикс `_sk` (`customer_sk`, `airport_sk`). + +## 4. Правило времени (важно для обучения) + +- `event_ts` (effective time) и `_load_ts` (load time) — разные сущности, не смешиваем. +- Если `event_ts` отсутствует в источнике, используем `_load_ts` как fallback и явно документируем это в SQL/доке. + +## 5. Применение по слоям + +### STG + +- Для уже реализованного `bookings` STG сохраняем текущие legacy-имена ради обратной совместимости: + - `src_created_at_ts` + - `load_dttm` + - `batch_id` +- Для новых STG-объектов (новые домены/задачи) используем канон `_load_id`, `_load_ts` (и `event_ts`, если нужно). + +### ODS + +- В новых реализациях используем канон: + - `_load_id`, `_load_ts`, `event_ts`. +- Базовый эталон ODS в этом стенде: SCD Type 1 (current state + UPSERT). + +### DDS + +- Для SCD2 используем: + - `valid_from`, `valid_to`, `hashdiff`, `created_at`, `updated_at`. +- Интервалы считаем как `[valid_from, valid_to)`, current-версия: `valid_to IS NULL`. + +## 6. Переходный маппинг legacy -> канон + +| Legacy (текущий bookings STG) | Канон | +|---|---| +| `batch_id` | `_load_id` | +| `load_dttm` | `_load_ts` | +| `src_created_at_ts` | `event_ts` | + +Примечание: это логический маппинг для новых слоёв. Массовое переименование существующего STG не требуется. + +## 7. Что проверяем в ревью + +- Нет новых техполей-синнонимов вроде `loaded_at`, `ingested_at`, `batch_key`, если уже есть канон. +- Нет смешивания `event_ts` и `_load_ts` в одном смысле. +- В SCD2 не используются альтернативы `dw_start_date/dw_end_date`, если в проекте принят `valid_from/valid_to`. From 7bdb5acfcb04d9f9bfb61dd98decb328a8284d1d Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 22 Feb 2026 23:39:16 +0300 Subject: [PATCH 29/38] =?UTF-8?q?docs(dwh):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD=20ODS?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E=20(v2=20=E2=86=92=20v3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - v2 содержал баги (CTE scope, distribution key), неточности маппинга колонок и пробелы в описании DAG-контракта, что сломало бы реализацию. - Что: - исправлен CTE scope: дублирование CTE в UPDATE и INSERT statements. - ods.tickets: DISTRIBUTED BY (ticket_no) вместо book_ref для UPSERT. - добавлен Python-сниппет resolve_stg_batch_id (PythonOperator + XCom). - заменены {{ params.stg_batch_id }} → {{ ti.xcom_pull(...) }} во всех SQL. - <> заменён на IS DISTINCT FROM (NULL-safe change detection). - DISTINCT ON заменён на ROW_NUMBER() (стандартный DE-паттерн). - добавлена секция 4.3 с полной таблицей маппинга STG → ODS. - routes: scheduled_departure_time → departure_time, duration без rename. - DAG-граф: resolve_stg_batch_id как корневой таск, параллельные ветки. - DQ: добавлена проверка «батч не пустой» для snapshot-справочников. - DoD: убрано valid_from/valid_to, оставлено _load_id/_load_ts/event_ts. - Проверка: - make test (11 passed, 4 skipped). Co-Authored-By: Claude Opus 4.6 --- docs/internal/bookings_ods_design.md | 274 ++++++++++++++++++++------- 1 file changed, 206 insertions(+), 68 deletions(-) diff --git a/docs/internal/bookings_ods_design.md b/docs/internal/bookings_ods_design.md index c224b49..7bade6c 100644 --- a/docs/internal/bookings_ods_design.md +++ b/docs/internal/bookings_ods_design.md @@ -1,4 +1,4 @@ -# ODS Layer: эталонный учебный план реализации (v2) +# ODS Layer: эталонный учебный план реализации (v3) ## Контекст @@ -117,16 +117,16 @@ DISTRIBUTED BY (airplane_code) #### `ods.routes` ```sql -route_no TEXT NOT NULL -validity TEXT NOT NULL -departure_airport TEXT NOT NULL -arrival_airport TEXT NOT NULL -airplane_code TEXT NOT NULL -days_of_week TEXT -scheduled_departure_time TIME -scheduled_duration INTERVAL -_load_id TEXT NOT NULL -_load_ts TIMESTAMP NOT NULL DEFAULT now() +route_no TEXT NOT NULL +validity TEXT NOT NULL +departure_airport TEXT NOT NULL +arrival_airport TEXT NOT NULL +airplane_code TEXT NOT NULL +days_of_week TEXT +departure_time TIME -- STG: scheduled_time (TEXT → TIME) +duration INTERVAL -- STG: duration (TEXT → INTERVAL) +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() DISTRIBUTED BY (route_no) ``` @@ -163,9 +163,12 @@ is_outbound BOOLEAN event_ts TIMESTAMP _load_id TEXT NOT NULL _load_ts TIMESTAMP NOT NULL DEFAULT now() -DISTRIBUTED BY (book_ref) +DISTRIBUTED BY (ticket_no) ``` +> В STG `tickets` распределены по `book_ref` для co-location с `bookings` (append-only, lookup не нужен). +> В ODS нужен UPSERT по бизнес-ключу `ticket_no`, поэтому распределяем по нему — иначе каждый lookup потребует redistribute motion. + #### `ods.flights` ```sql flight_id INTEGER NOT NULL @@ -208,24 +211,99 @@ DISTRIBUTED BY (ticket_no) Примечание: в учебном варианте не опираемся на физические `PK/FK`-constraint в Greenplum, а проверяем целостность через DQ-скрипты. +### 4.3. Маппинг STG → ODS (колонки с изменениями) + +Большинство бизнес-колонок переносятся 1:1 с приведением типа (`TEXT → ...`). Ниже — только те, где происходит **переименование** или нетривиальное преобразование: + +| Таблица | STG колонка | ODS колонка | ODS тип | Комментарий | +|---|---|---|---|---| +| `airplanes` | `range` | `range_km` | `INTEGER` | Явное указание единиц (naming conv.) | +| `airplanes` | `speed` | `speed_kmh` | `INTEGER` | Явное указание единиц (naming conv.) | +| `routes` | `scheduled_time` | `departure_time` | `TIME` | Уточнение смысла | +| `routes` | `duration` | `duration` | `INTERVAL` | Только cast, без rename | +| `tickets` | `outbound` | `is_outbound` | `BOOLEAN` | Префикс `is_` для boolean (naming conv.) | +| `segments` | `price` | `segment_amount` | `NUMERIC(10,2)` | Уточнение: сумма сегмента, не цена билета | +| `flights` | `flight_id` | `flight_id` | `INTEGER` | Только cast TEXT → INT | +| `segments` | `flight_id` | `flight_id` | `INTEGER` | Только cast TEXT → INT | +| `boarding_passes` | `flight_id` | `flight_id` | `INTEGER` | Только cast TEXT → INT | +| все транзакционные | `src_created_at_ts` | `event_ts` | `TIMESTAMP` | Маппинг legacy → канон | +| все | `batch_id` | `_load_id` | `TEXT` | Маппинг legacy → канон | + +Пример каста с переименованием в SQL (в CTE): +```sql +s.range::INTEGER AS range_km, +s.speed::INTEGER AS speed_kmh, +s.outbound::BOOLEAN AS is_outbound, +s.price::NUMERIC(10,2) AS segment_amount +``` + --- ## 5) Контракт батча для ODS Чтобы ODS был воспроизводимым, в каждом запуске используем **один фиксированный `stg_batch_id`**. -### 5.1. Источник `stg_batch_id` +### 5.1. Зачем фиксировать `stg_batch_id` -В `bookings_to_gp_ods`: -- принимаем `stg_batch_id` из `dag_run.conf`; -- если не передан — берём последний из `stg.bookings`; -- логируем, какой `stg_batch_id` выбран. +На проде ETL-оркестратор всегда явно передаёт downstream-задачам идентификатор батча, который прошёл все проверки. Это гарантирует: +- **воспроизводимость**: повторный запуск обработает тот же снимок данных; +- **изоляцию**: ODS не подхватит «сырой» батч, который ещё не прошёл DQ в STG; +- **отладку**: по `_load_id` в ODS легко найти исходные данные в STG. -### 5.2. Как применяем +### 5.2. Как resolve `stg_batch_id` в DAG -Во всех `sql/ods/*_load.sql`: -- читаем STG только с `WHERE batch_id = :stg_batch_id`; -- пишем в ODS `_load_id = :stg_batch_id`, `_load_ts = now()`. +В DAG `bookings_to_gp_ods` первым запускается `PythonOperator`, который определяет `stg_batch_id` и кладёт его в XCom: + +```python +import logging +from airflow.operators.python import PythonOperator +from airflow.providers.postgres.hooks.postgres import PostgresHook + +log = logging.getLogger(__name__) + +GREENPLUM_CONN_ID = "greenplum_conn" + +def _resolve_stg_batch_id(**context): + """Определяем stg_batch_id: из dag_run.conf или последний загруженный в STG.""" + conf = context["dag_run"].conf or {} + stg_batch_id = conf.get("stg_batch_id") + + if not stg_batch_id: + hook = PostgresHook(postgres_conn_id=GREENPLUM_CONN_ID) + result = hook.get_first("SELECT MAX(batch_id) FROM stg.bookings") + stg_batch_id = result[0] if result and result[0] else None + + if not stg_batch_id: + raise ValueError( + "stg_batch_id не найден: передайте в conf или сначала загрузите STG" + ) + + log.info("Используем stg_batch_id = %s", stg_batch_id) + return stg_batch_id # автоматически попадёт в XCom как return_value + +resolve_batch = PythonOperator( + task_id="resolve_stg_batch_id", + python_callable=_resolve_stg_batch_id, +) +``` + +### 5.3. Как используем в SQL + +Во всех `sql/ods/*_load.sql` и `sql/ods/*_dq.sql` значение `stg_batch_id` подставляется через Jinja-шаблон: + +```sql +WHERE batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +``` + +Для читаемости в DAG можно вынести шаблон в константу: + +```python +STG_BATCH_ID = "{{ ti.xcom_pull(task_ids='resolve_stg_batch_id') }}" +``` + +В ODS записываем: +- `_load_id = ` — чтобы связать ODS-запись с STG-батчом; +- `_load_ts = now()` — фактическое время загрузки в ODS. Это простой и понятный паттерн: **один запуск ODS = один снимок STG-батча**. @@ -233,9 +311,14 @@ DISTRIBUTED BY (ticket_no) ## 6) SQL-паттерны загрузки (SCD1 / UPSERT) +> **Стиль SQL:** в ODS-скриптах используем CTE (Common Table Expressions) вместо вложенных подзапросов — CTE нагляднее, проще для чтения и отладки. + ### 6.1. Шаблон для справочника (пример `airports_load.sql`) +> **Важно:** CTE действует в рамках одного SQL-statement. UPDATE и INSERT — два отдельных statement, поэтому CTE `src` дублируется в каждом. Это не ошибка, а необходимость синтаксиса SQL. + ```sql +-- Statement 1: UPDATE существующих записей (SCD1 — перезапись при изменении) WITH src AS ( SELECT airport_code, @@ -245,7 +328,7 @@ WITH src AS ( coordinates, timezone FROM stg.airports - WHERE batch_id = '{{ params.stg_batch_id }}'::text + WHERE batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text ) UPDATE ods.airports AS o SET airport_name = s.airport_name, @@ -253,25 +336,40 @@ SET airport_name = s.airport_name, country = s.country, coordinates = s.coordinates, timezone = s.timezone, - _load_id = '{{ params.stg_batch_id }}'::text, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, _load_ts = now() FROM src AS s WHERE o.airport_code = s.airport_code AND ( - o.airport_name <> s.airport_name OR - o.city <> s.city OR - o.country <> s.country OR - COALESCE(o.coordinates, '') <> COALESCE(s.coordinates, '') OR - o.timezone <> s.timezone + -- IS DISTINCT FROM — NULL-safe аналог <>: + -- при NULL с одной стороны <> вернёт NULL (не обновит), + -- а IS DISTINCT FROM вернёт TRUE (обновит корректно). + o.airport_name IS DISTINCT FROM s.airport_name OR + o.city IS DISTINCT FROM s.city OR + o.country IS DISTINCT FROM s.country OR + o.coordinates IS DISTINCT FROM s.coordinates OR + o.timezone IS DISTINCT FROM s.timezone ); +-- Statement 2: INSERT новых записей +WITH src AS ( + SELECT + airport_code, + airport_name, + city, + country, + coordinates, + timezone + FROM stg.airports + WHERE batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) INSERT INTO ods.airports ( airport_code, airport_name, city, country, coordinates, timezone, _load_id, _load_ts ) SELECT s.airport_code, s.airport_name, s.city, s.country, s.coordinates, s.timezone, - '{{ params.stg_batch_id }}'::text, now() + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, now() FROM src s WHERE NOT EXISTS ( SELECT 1 @@ -279,53 +377,81 @@ WHERE NOT EXISTS ( WHERE o.airport_code = s.airport_code ); +-- Обновляем статистику для оптимизатора запросов Greenplum ANALYZE ods.airports; ``` ### 6.2. Шаблон для транзакции (пример `bookings_load.sql`) +В транзакционных таблицах в одном STG-батче может быть несколько записей с одинаковым бизнес-ключом (например, обновления). Дедуплицируем через `ROW_NUMBER()` — стандартный и явный паттерн, часто встречающийся на собеседованиях и в DE-курсах. + ```sql +-- Statement 1: UPDATE существующих записей WITH src AS ( - SELECT DISTINCT ON (book_ref) + SELECT book_ref, book_date::TIMESTAMP WITH TIME ZONE AS book_date, total_amount::NUMERIC(10,2) AS total_amount, - src_created_at_ts AS event_ts + src_created_at_ts AS event_ts, + ROW_NUMBER() OVER ( + PARTITION BY book_ref + ORDER BY src_created_at_ts DESC NULLS LAST, load_dttm DESC + ) AS rn FROM stg.bookings - WHERE batch_id = '{{ params.stg_batch_id }}'::text - ORDER BY book_ref, src_created_at_ts DESC NULLS LAST, load_dttm DESC + WHERE batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text ) UPDATE ods.bookings AS o SET book_date = s.book_date, total_amount = s.total_amount, event_ts = s.event_ts, - _load_id = '{{ params.stg_batch_id }}'::text, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, _load_ts = now() FROM src AS s -WHERE o.book_ref = s.book_ref +WHERE s.rn = 1 + AND o.book_ref = s.book_ref AND ( - o.book_date <> s.book_date OR - o.total_amount <> s.total_amount + o.book_date IS DISTINCT FROM s.book_date OR + o.total_amount IS DISTINCT FROM s.total_amount ); +-- Statement 2: INSERT новых записей +WITH src AS ( + SELECT + book_ref, + book_date::TIMESTAMP WITH TIME ZONE AS book_date, + total_amount::NUMERIC(10,2) AS total_amount, + src_created_at_ts AS event_ts, + ROW_NUMBER() OVER ( + PARTITION BY book_ref + ORDER BY src_created_at_ts DESC NULLS LAST, load_dttm DESC + ) AS rn + FROM stg.bookings + WHERE batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) INSERT INTO ods.bookings ( book_ref, book_date, total_amount, event_ts, _load_id, _load_ts ) SELECT s.book_ref, s.book_date, s.total_amount, s.event_ts, - '{{ params.stg_batch_id }}'::text, now() + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, now() FROM src s -WHERE NOT EXISTS ( - SELECT 1 - FROM ods.bookings o - WHERE o.book_ref = s.book_ref -); +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 + FROM ods.bookings o + WHERE o.book_ref = s.book_ref + ); +-- Обновляем статистику для оптимизатора запросов Greenplum ANALYZE ods.bookings; ``` -### 6.3. Поведение при пустом батче +### 6.3. Идемпотентность паттерна + +Паттерн UPDATE + INSERT WHERE NOT EXISTS — **натурально идемпотентен**: повторный запуск с тем же `stg_batch_id` не создаст дублей и не потеряет данные. UPDATE обновит только если атрибуты изменились, INSERT вставит только если бизнес-ключа нет. Это одно из преимуществ подхода. + +### 6.4. Поведение при пустом батче - для инкрементальных таблиц (`bookings`, `tickets`, `flights`, `segments`) пустой батч допустим; - для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) пустой батч считаем ошибкой. @@ -335,21 +461,23 @@ ANALYZE ods.bookings; ## 7) DQ-проверки ODS (минимум, но строго) Каждый DQ-скрипт должен: -- быть привязан к `stg_batch_id`; +- быть привязан к `stg_batch_id` (через XCom, как в load-скриптах); - делать `RAISE EXCEPTION` при нарушении; - давать понятную подсказку в тексте ошибки. ### 7.1. Обязательные проверки -1. Нет дублей по бизнес-ключу в ODS. +1. **Нет дублей** по бизнес-ключу в ODS. -2. Все ключи из STG текущего батча присутствуют в ODS. +2. **Покрытие батча:** все ключи из STG текущего батча присутствуют в ODS. -3. Обязательные поля не `NULL`/не пустые. +3. **Обязательные поля** не `NULL`/не пустые. -4. Ссылочная целостность в ODS: +4. **Батч не пустой** для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`): если STG-батч оказался пустым — это ошибка (источник недоступен или PXF не работает). + +5. **Ссылочная целостность** в ODS: - `tickets.book_ref -> bookings.book_ref` -- `flights.route_no -> routes.route_no` +- `flights.route_no -> routes.route_no` (упрощённая проверка: наличие `route_no`, без учёта `validity`) - `segments.ticket_no -> tickets.ticket_no` - `segments.flight_id -> flights.flight_id` - `boarding_passes (ticket_no, flight_id) -> segments (ticket_no, flight_id)` @@ -361,7 +489,7 @@ SELECT COUNT(*) FROM ( SELECT DISTINCT book_ref FROM stg.bookings - WHERE batch_id = '{{ params.stg_batch_id }}'::text + WHERE batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text ) s WHERE NOT EXISTS ( SELECT 1 @@ -376,6 +504,8 @@ WHERE NOT EXISTS ( ## 8) Структура файлов +Каждый `*_ddl.sql` начинается с `CREATE SCHEMA IF NOT EXISTS ods;` (по аналогии с STG DDL). + ```text sql/ods/ ├── airports_ddl.sql @@ -423,25 +553,33 @@ tests/test_dags_smoke.py (+ smoke для 2 новых DAG) Принцип: у каждой сущности строго `load -> dq`, и только после `dq` разрешаем downstream. +Корневой таск `resolve_stg_batch_id` определяет батч (см. секцию 5.2), после чего две параллельных ветки стартуют одновременно: + ```text -load_ods_bookings -> dq_ods_bookings -> load_ods_tickets -> dq_ods_tickets - - ├-> load_ods_airports -> dq_ods_airports ─┐ - ├-> load_ods_airplanes -> dq_ods_airplanes ─┼-> load_ods_routes -> dq_ods_routes -> load_ods_flights -> dq_ods_flights - └-> └-> load_ods_seats -> dq_ods_seats - -dq_ods_flights + dq_ods_tickets -> load_ods_segments -> dq_ods_segments -> load_ods_boarding_passes -> dq_ods_boarding_passes - -[dq_ods_boarding_passes, dq_ods_seats] -> finish_ods_summary +resolve_stg_batch_id + ├-> load_ods_bookings -> dq_ods_bookings -> load_ods_tickets -> dq_ods_tickets ──────────────────┐ + ├-> load_ods_airports -> dq_ods_airports ─┐ │ + ├-> load_ods_airplanes -> dq_ods_airplanes ─┼-> load_ods_routes -> dq_ods_routes │ + └-> └-> load_ods_seats -> dq_ods_seats │ + │ + dq_ods_routes -> load_ods_flights -> dq_ods_flights │ + │ + dq_ods_flights + dq_ods_tickets -> load_ods_segments -> dq_ods_segments + │ + dq_ods_segments -> load_ods_boarding_passes -> dq_ods_boarding_passes + │ + [dq_ods_boarding_passes, dq_ods_seats] -> finish_ods_summary ``` -Зависимости: -- `tickets` после `bookings`; -- `routes` после `airports` и `airplanes`; -- `seats` после `airplanes`; -- `flights` после `routes`; -- `segments` после `flights` и `tickets`; -- `boarding_passes` после `segments`. +Зависимости (по FK): +- `tickets` после `bookings` (FK: `book_ref`); +- `routes` после `airports` и `airplanes` (FK: `departure_airport`, `arrival_airport`, `airplane_code`); +- `seats` после `airplanes` (FK: `airplane_code`); +- `flights` после `routes` (FK: `route_no`); +- `segments` после `flights` и `tickets` (FK: `flight_id`, `ticket_no`); +- `boarding_passes` после `segments` (FK: `ticket_no`, `flight_id`). + +> Справочники (`airports`, `airplanes`) и транзакции (`bookings`) не зависят друг от друга в ODS — данные уже в STG. Поэтому они стартуют параллельно после `resolve_stg_batch_id`. --- @@ -468,7 +606,7 @@ dq_ods_flights + dq_ods_tickets -> load_ods_segments -> dq_ods_segments -> load_ 3. Для тестового `stg_batch_id` ODS-загрузка завершается успешно. 4. Все DQ-задачи зелёные и реально валят DAG при искусственной ошибке. 5. В ODS нет дублей по бизнес-ключам. -6. Нейминг техполей консистентен с учебной статьёй: `_load_id`, `_load_ts`, `valid_from/valid_to` (последние — когда перейдём к SCD2 в DDS). +6. Нейминг техполей ODS консистентен с учебной статьёй: `_load_id`, `_load_ts`, `event_ts`. --- From dc1dc9c66a0930f9227a20b59c698d7506ed8a4c Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Sun, 22 Feb 2026 23:44:08 +0300 Subject: [PATCH 30/38] =?UTF-8?q?fix(dwh):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=203=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D1=8F=20codex-=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0=20ODS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - codex нашёл баги, которые сломали бы первый прогон ODS. - Что: - resolve batch: MAX(batch_id) заменён на ORDER BY load_dttm DESC LIMIT 1 (run_id — строка, лексикографический MAX не гарантирует хронологию). - ручная проверка: добавлен make ddl-gp-ods (без него ODS-объекты не создадутся). - routes/flights FK: расширено пояснение, почему проверка по route_no без validity допустима в ODS и что учитывать при join в DDS. - Проверка: - make test (11 passed, 4 skipped). Co-Authored-By: Claude Opus 4.6 --- docs/internal/bookings_ods_design.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/internal/bookings_ods_design.md b/docs/internal/bookings_ods_design.md index 7bade6c..06489c4 100644 --- a/docs/internal/bookings_ods_design.md +++ b/docs/internal/bookings_ods_design.md @@ -80,7 +80,7 @@ ODS в учебном проекте — это: | `ods.boarding_passes` | 1 строка = посадочный на сегмент | `(ticket_no, flight_id)` | Критично для эталона: -- `routes` — **составной** ключ `(route_no, validity)`; +- `routes` — **составной** ключ `(route_no, validity)`: один `route_no` может иметь несколько версий с разными периодами действия. `flights` ссылается только на `route_no` (без `validity`), поэтому при join в DDS нужно будет выбирать подходящую версию маршрута; - `boarding_passes` — **составной** ключ `(ticket_no, flight_id)`. --- @@ -269,8 +269,13 @@ def _resolve_stg_batch_id(**context): stg_batch_id = conf.get("stg_batch_id") if not stg_batch_id: + # Берём batch_id с самым свежим load_dttm (TIMESTAMP, монотонно растёт). + # MAX(batch_id) ненадёжен: run_id — строка вида "manual__2024-...", + # лексикографическая сортировка не гарантирует хронологический порядок. hook = PostgresHook(postgres_conn_id=GREENPLUM_CONN_ID) - result = hook.get_first("SELECT MAX(batch_id) FROM stg.bookings") + result = hook.get_first( + "SELECT batch_id FROM stg.bookings ORDER BY load_dttm DESC LIMIT 1" + ) stg_batch_id = result[0] if result and result[0] else None if not stg_batch_id: @@ -477,7 +482,7 @@ ANALYZE ods.bookings; 5. **Ссылочная целостность** в ODS: - `tickets.book_ref -> bookings.book_ref` -- `flights.route_no -> routes.route_no` (упрощённая проверка: наличие `route_no`, без учёта `validity`) +- `flights.route_no -> routes.route_no` (упрощённая проверка: наличие `route_no` в routes, без учёта конкретной версии `validity`. Это допустимо, потому что в источнике flights ссылается на route_no, а не на конкретную версию маршрута. При downstream join (DDS) нужно будет учитывать период `validity`) - `segments.ticket_no -> tickets.ticket_no` - `segments.flight_id -> flights.flight_id` - `boarding_passes (ticket_no, flight_id) -> segments (ticket_no, flight_id)` @@ -614,9 +619,10 @@ resolve_stg_batch_id ```bash make up -make ddl-gp -# Trigger bookings_to_gp_stage -# Получить batch_id из stg.bookings (последний) +make ddl-gp # создать STG-объекты +make ddl-gp-ods # создать ODS-объекты +# Trigger bookings_to_gp_stage (загрузить STG) +# Получить batch_id: SELECT batch_id FROM stg.bookings ORDER BY load_dttm DESC LIMIT 1; # Trigger bookings_to_gp_ods с conf: {"stg_batch_id": "<значение>"} make gp-psql ``` From 03765d2808d5397327f35d755b3f3f6d32a6c9f0 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 23 Feb 2026 01:32:23 +0300 Subject: [PATCH 31/38] =?UTF-8?q?feat(ods):=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20ODS=20=D1=81=D0=BB=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=B8=20DAG=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B8=D0=B7=20STG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - подготовлена учебная реализация ODS слоя с типизацией, UPSERT и DQ, чтобы продолжить работу от STG к DDS/DM. - Что: - добавлены SQL-скрипты `sql/ods/*_ddl.sql`, `sql/ods/*_load.sql`, `sql/ods/*_dq.sql` для 9 сущностей bookings. - добавлены DAG `bookings_ods_ddl` и `bookings_to_gp_ods`, а также smoke-тесты для новых графов. - ODS DDL интегрирован в `sql/ddl_gp.sql`; документация и план обновлены под единый запуск через `make ddl-gp`. - Проверка: - `make test`. - `make ddl-gp`. --- README.md | 26 ++- airflow/dags/bookings_ods_ddl.py | 98 ++++++++++ airflow/dags/bookings_to_gp_ods.py | 261 +++++++++++++++++++++++++ docs/README.md | 2 + docs/bookings_to_gp_ods.md | 91 +++++++++ docs/internal/bookings_ods_design.md | 72 +++++-- sql/ddl_gp.sql | 11 ++ sql/ods/airplanes_ddl.sql | 13 ++ sql/ods/airplanes_dq.sql | 99 ++++++++++ sql/ods/airplanes_load.sql | 92 +++++++++ sql/ods/airports_ddl.sql | 15 ++ sql/ods/airports_dq.sql | 105 ++++++++++ sql/ods/airports_load.sql | 104 ++++++++++ sql/ods/boarding_passes_ddl.sql | 15 ++ sql/ods/boarding_passes_dq.sql | 96 +++++++++ sql/ods/boarding_passes_load.sql | 81 ++++++++ sql/ods/bookings_ddl.sql | 13 ++ sql/ods/bookings_dq.sql | 71 +++++++ sql/ods/bookings_load.sql | 69 +++++++ sql/ods/flights_ddl.sql | 17 ++ sql/ods/flights_dq.sql | 89 +++++++++ sql/ods/flights_load.sql | 93 +++++++++ sql/ods/routes_ddl.sql | 17 ++ sql/ods/routes_dq.sql | 163 +++++++++++++++ sql/ods/routes_load.sql | 118 +++++++++++ sql/ods/seats_ddl.sql | 12 ++ sql/ods/seats_dq.sql | 125 ++++++++++++ sql/ods/seats_load.sql | 86 ++++++++ sql/ods/segments_ddl.sql | 14 ++ sql/ods/segments_dq.sql | 112 +++++++++++ sql/ods/segments_load.sql | 75 +++++++ sql/ods/tickets_ddl.sql | 15 ++ sql/ods/tickets_dq.sql | 92 +++++++++ sql/ods/tickets_load.sql | 81 ++++++++ tests/test_dags_smoke.py | 98 ++++++++++ tests/test_ods_snapshot_integration.py | 150 ++++++++++++++ tests/test_ods_sql_contract.py | 38 ++++ 37 files changed, 2704 insertions(+), 25 deletions(-) create mode 100644 airflow/dags/bookings_ods_ddl.py create mode 100644 airflow/dags/bookings_to_gp_ods.py create mode 100644 docs/bookings_to_gp_ods.md create mode 100644 sql/ods/airplanes_ddl.sql create mode 100644 sql/ods/airplanes_dq.sql create mode 100644 sql/ods/airplanes_load.sql create mode 100644 sql/ods/airports_ddl.sql create mode 100644 sql/ods/airports_dq.sql create mode 100644 sql/ods/airports_load.sql create mode 100644 sql/ods/boarding_passes_ddl.sql create mode 100644 sql/ods/boarding_passes_dq.sql create mode 100644 sql/ods/boarding_passes_load.sql create mode 100644 sql/ods/bookings_ddl.sql create mode 100644 sql/ods/bookings_dq.sql create mode 100644 sql/ods/bookings_load.sql create mode 100644 sql/ods/flights_ddl.sql create mode 100644 sql/ods/flights_dq.sql create mode 100644 sql/ods/flights_load.sql create mode 100644 sql/ods/routes_ddl.sql create mode 100644 sql/ods/routes_dq.sql create mode 100644 sql/ods/routes_load.sql create mode 100644 sql/ods/seats_ddl.sql create mode 100644 sql/ods/seats_dq.sql create mode 100644 sql/ods/seats_load.sql create mode 100644 sql/ods/segments_ddl.sql create mode 100644 sql/ods/segments_dq.sql create mode 100644 sql/ods/segments_load.sql create mode 100644 sql/ods/tickets_ddl.sql create mode 100644 sql/ods/tickets_dq.sql create mode 100644 sql/ods/tickets_load.sql create mode 100644 tests/test_ods_snapshot_integration.py create mode 100644 tests/test_ods_sql_contract.py diff --git a/README.md b/README.md index 3cdeab8..a63ecac 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ - построения ETL/ELT; - работы с Airflow и Greenplum. -В курсовой у нас один источник данных — демо‑БД **bookings**. В стенде уже есть готовый учебный пример -загрузки **bookings → stg в Greenplum**, чтобы вы могли сфокусироваться на DWH‑части (ODS/DDS/DM) и -не тратить время на инфраструктуру. +В курсовой у нас один источник данных — демо‑БД **bookings**. В стенде уже есть готовые учебные примеры +загрузки **bookings → stg** и **stg -> ods** в Greenplum, чтобы вы могли сфокусироваться на DWH‑части +(ODS/DDS/DM) и не тратить время на инфраструктуру. ## Что внутри @@ -44,7 +44,7 @@ Про PXF и технические детали стенда: [docs/stack.md](docs/stack.md). -## Быстрый старт (основной сценарий: bookings → stg) +## Быстрый старт (основной сценарий: bookings -> stg -> ods) 1) Скопируйте настройки: @@ -65,14 +65,16 @@ make up make bookings-init ``` -4) Подготовьте STG‑объекты в Greenplum (выберите один вариант): +4) Подготовьте STG/ODS-объекты в Greenplum (выберите один вариант): -- Учебный вариант: в Airflow UI запустите DAG `bookings_stg_ddl`; -- Технический шорткат: `make ddl-gp` (применяет все DDL разом вручную). +- Учебный вариант: в Airflow UI запустите DAG `bookings_stg_ddl`, затем `bookings_ods_ddl`; +- Технический шорткат: `make ddl-gp` (применяет DDL для STG и ODS разом вручную). 5) Запустите основной DAG `bookings_to_gp_stage`. -6) Проверьте результат в Greenplum: +6) Запустите DAG `bookings_to_gp_ods`. + +7) Проверьте результат в Greenplum: ```bash make gp-psql @@ -80,6 +82,8 @@ make gp-psql SELECT COUNT(*) FROM stg.bookings; SELECT COUNT(*) FROM stg.tickets; SELECT * FROM stg.bookings ORDER BY src_created_at_ts DESC LIMIT 10; +SELECT COUNT(*) FROM ods.bookings; +SELECT COUNT(*) FROM ods.tickets; ``` Подробнее про логику DAG и проверки — `docs/bookings_to_gp_stage.md`. @@ -88,10 +92,11 @@ SELECT * FROM stg.bookings ORDER BY src_created_at_ts DESC LIMIT 10; Основные (для потока bookings → DWH): -- `bookings_stg_ddl` — создаёт `stg.bookings_ext`/`stg.bookings` и `stg.tickets_ext`/`stg.tickets` в Greenplum; - `bookings_stg_ddl` — создаёт/обновляет весь STG слой для bookings (9 таблиц: bookings, tickets, airports, airplanes, routes, seats, flights, segments, boarding_passes; включая внешние `*_ext` через PXF); - `bookings_to_gp_stage` — генерирует учебный день в `bookings-db`, затем загружает данные в STG и выполняет DQ‑проверки. +- `bookings_ods_ddl` — создаёт/обновляет ODS-таблицы по домену bookings. +- `bookings_to_gp_ods` — загружает данные из STG в ODS (SCD1 UPSERT) и выполняет DQ‑проверки. Вспомогательные (побочный трек с CSV): @@ -106,7 +111,7 @@ make up # поднять стек make logs # логи airflow-webserver и airflow-scheduler make gp-psql # psql в Greenplum make bookings-psql # psql в демо-БД bookings (Postgres) -make ddl-gp # применить DDL к Greenplum вручную (вместо DDL-DAG) +make ddl-gp # применить DDL STG+ODS к Greenplum вручную (вместо DDL-DAG) make down # остановить и удалить контейнеры/сети (volumes сохраняются) make clean # полный reset: удалить контейнеры/сети и volumes (данные будут потеряны) ``` @@ -136,6 +141,7 @@ make clean # полный reset: удалить контейнер - Учебные задания: `educational-tasks.md`). - План тестирования/проверок и негативные кейсы: `TESTING.md`. - Дополнительные заметки и технические детали: `docs/README.md`. +- Детали по ODS DAG: `docs/bookings_to_gp_ods.md`. ## Типичные проблемы и решения diff --git a/airflow/dags/bookings_ods_ddl.py b/airflow/dags/bookings_ods_ddl.py new file mode 100644 index 0000000..80dde5b --- /dev/null +++ b/airflow/dags/bookings_ods_ddl.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +""" +Учебный DAG: создаёт/обновляет слой ods в Greenplum для домена bookings. + +Запускается вручную перед DAG загрузки `bookings_to_gp_ods` или после изменения ODS DDL. +Создаёт 9 ODS-таблиц: airports, airplanes, routes, seats, bookings, tickets, +flights, segments, boarding_passes. +""" + +from datetime import timedelta + +import pendulum +from airflow.providers.postgres.operators.postgres import PostgresOperator + +from airflow import DAG + +GREENPLUM_CONN_ID = "greenplum_conn" + +default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)} + +with DAG( + dag_id="bookings_ods_ddl", + start_date=pendulum.datetime(2024, 1, 1, tz="UTC"), + schedule=None, + catchup=False, + template_searchpath="/sql", + default_args=default_args, + tags=["demo", "greenplum", "ddl", "bookings", "ods"], + description="Учебный DDL DAG: создаёт/обновляет ods.* для bookings", +) as dag: + apply_ods_airports_ddl = PostgresOperator( + task_id="apply_ods_airports_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/airports_ddl.sql", + ) + + apply_ods_airplanes_ddl = PostgresOperator( + task_id="apply_ods_airplanes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/airplanes_ddl.sql", + ) + + apply_ods_routes_ddl = PostgresOperator( + task_id="apply_ods_routes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/routes_ddl.sql", + ) + + apply_ods_seats_ddl = PostgresOperator( + task_id="apply_ods_seats_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/seats_ddl.sql", + ) + + apply_ods_bookings_ddl = PostgresOperator( + task_id="apply_ods_bookings_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/bookings_ddl.sql", + ) + + apply_ods_tickets_ddl = PostgresOperator( + task_id="apply_ods_tickets_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/tickets_ddl.sql", + ) + + apply_ods_flights_ddl = PostgresOperator( + task_id="apply_ods_flights_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/flights_ddl.sql", + ) + + apply_ods_segments_ddl = PostgresOperator( + task_id="apply_ods_segments_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/segments_ddl.sql", + ) + + apply_ods_boarding_passes_ddl = PostgresOperator( + task_id="apply_ods_boarding_passes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/boarding_passes_ddl.sql", + ) + + # DDL применяем последовательно, чтобы порядок был понятным для новичков, + # а ошибки — воспроизводимыми (в логах сразу видно, на каком объекте упали). + ( + apply_ods_airports_ddl + >> apply_ods_airplanes_ddl + >> apply_ods_routes_ddl + >> apply_ods_seats_ddl + >> apply_ods_bookings_ddl + >> apply_ods_tickets_ddl + >> apply_ods_flights_ddl + >> apply_ods_segments_ddl + >> apply_ods_boarding_passes_ddl + ) diff --git a/airflow/dags/bookings_to_gp_ods.py b/airflow/dags/bookings_to_gp_ods.py new file mode 100644 index 0000000..63d2204 --- /dev/null +++ b/airflow/dags/bookings_to_gp_ods.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +""" +Учебный DAG: загрузка из STG в ODS (Greenplum) по домену bookings. + +Ключевая идея: +- весь запуск ODS работает с одним stg_batch_id; +- для каждой сущности выполняем пару задач load -> dq; +- загрузка реализована как SCD1 UPSERT (UPDATE изменившихся + INSERT новых). +""" + +from datetime import timedelta +from logging import getLogger + +import pendulum +from airflow.operators.python import PythonOperator +from airflow.providers.postgres.hooks.postgres import PostgresHook +from airflow.providers.postgres.operators.postgres import PostgresOperator + +from airflow import DAG + +GREENPLUM_CONN_ID = "greenplum_conn" + +log = getLogger(__name__) + +default_args = { + "owner": "airflow", + "retries": 1, + "retry_delay": timedelta(seconds=30), +} + + +def _resolve_stg_batch_id(**context) -> str: + """ + Возвращает stg_batch_id из dag_run.conf или вычисляет последний согласованный батч. + + Согласованным считаем batch_id, который есть во всех snapshot-справочниках STG: + airports, airplanes, routes, seats. + """ + conf = context["dag_run"].conf or {} + stg_batch_id = conf.get("stg_batch_id") + + if not stg_batch_id: + hook = PostgresHook(postgres_conn_id=GREENPLUM_CONN_ID) + result = hook.get_first( + """ + WITH candidate_batches AS ( + SELECT batch_id + FROM stg.airports + WHERE batch_id IS NOT NULL AND batch_id <> '' + GROUP BY batch_id + INTERSECT + SELECT batch_id + FROM stg.airplanes + WHERE batch_id IS NOT NULL AND batch_id <> '' + GROUP BY batch_id + INTERSECT + SELECT batch_id + FROM stg.routes + WHERE batch_id IS NOT NULL AND batch_id <> '' + GROUP BY batch_id + INTERSECT + SELECT batch_id + FROM stg.seats + WHERE batch_id IS NOT NULL AND batch_id <> '' + GROUP BY batch_id + ), + batch_ready AS ( + SELECT + c.batch_id, + GREATEST( + COALESCE( + (SELECT MAX(load_dttm) FROM stg.airports a WHERE a.batch_id = c.batch_id), + TIMESTAMP '1900-01-01 00:00:00' + ), + COALESCE( + (SELECT MAX(load_dttm) FROM stg.airplanes a WHERE a.batch_id = c.batch_id), + TIMESTAMP '1900-01-01 00:00:00' + ), + COALESCE( + (SELECT MAX(load_dttm) FROM stg.routes r WHERE r.batch_id = c.batch_id), + TIMESTAMP '1900-01-01 00:00:00' + ), + COALESCE( + (SELECT MAX(load_dttm) FROM stg.seats s WHERE s.batch_id = c.batch_id), + TIMESTAMP '1900-01-01 00:00:00' + ) + ) AS ready_dttm + FROM candidate_batches c + ) + SELECT batch_id + FROM batch_ready + ORDER BY ready_dttm DESC + LIMIT 1 + """ + ) + stg_batch_id = result[0] if result and result[0] else None + + if not stg_batch_id: + raise ValueError( + "stg_batch_id не найден: передайте stg_batch_id в conf или " + "сначала выполните bookings_to_gp_stage для snapshot-справочников" + ) + + log.info("Используем stg_batch_id=%s", stg_batch_id) + return stg_batch_id + + +def _finish_summary() -> None: + """Логирует краткий итог выполнения ODS-ветки.""" + log.info("DAG bookings_to_gp_ods завершён. Подробности смотрите в логах задач.") + + +with DAG( + dag_id="bookings_to_gp_ods", + start_date=pendulum.datetime(2024, 1, 1, tz="UTC"), + schedule=None, + catchup=False, + max_active_runs=1, + template_searchpath="/sql", + default_args=default_args, + tags=["demo", "bookings", "greenplum", "ods"], + description="Учебный DAG: загрузка STG -> ODS (SCD1 UPSERT) + DQ проверки", +) as dag: + resolve_stg_batch_id = PythonOperator( + task_id="resolve_stg_batch_id", + python_callable=_resolve_stg_batch_id, + ) + + load_ods_bookings = PostgresOperator( + task_id="load_ods_bookings", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/bookings_load.sql", + ) + + dq_ods_bookings = PostgresOperator( + task_id="dq_ods_bookings", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/bookings_dq.sql", + ) + + load_ods_tickets = PostgresOperator( + task_id="load_ods_tickets", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/tickets_load.sql", + ) + + dq_ods_tickets = PostgresOperator( + task_id="dq_ods_tickets", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/tickets_dq.sql", + ) + + load_ods_airports = PostgresOperator( + task_id="load_ods_airports", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/airports_load.sql", + ) + + dq_ods_airports = PostgresOperator( + task_id="dq_ods_airports", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/airports_dq.sql", + ) + + load_ods_airplanes = PostgresOperator( + task_id="load_ods_airplanes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/airplanes_load.sql", + ) + + dq_ods_airplanes = PostgresOperator( + task_id="dq_ods_airplanes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/airplanes_dq.sql", + ) + + load_ods_routes = PostgresOperator( + task_id="load_ods_routes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/routes_load.sql", + ) + + dq_ods_routes = PostgresOperator( + task_id="dq_ods_routes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/routes_dq.sql", + ) + + load_ods_seats = PostgresOperator( + task_id="load_ods_seats", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/seats_load.sql", + ) + + dq_ods_seats = PostgresOperator( + task_id="dq_ods_seats", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/seats_dq.sql", + ) + + load_ods_flights = PostgresOperator( + task_id="load_ods_flights", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/flights_load.sql", + ) + + dq_ods_flights = PostgresOperator( + task_id="dq_ods_flights", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/flights_dq.sql", + ) + + load_ods_segments = PostgresOperator( + task_id="load_ods_segments", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/segments_load.sql", + ) + + dq_ods_segments = PostgresOperator( + task_id="dq_ods_segments", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/segments_dq.sql", + ) + + load_ods_boarding_passes = PostgresOperator( + task_id="load_ods_boarding_passes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/boarding_passes_load.sql", + ) + + dq_ods_boarding_passes = PostgresOperator( + task_id="dq_ods_boarding_passes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="ods/boarding_passes_dq.sql", + ) + + finish_ods_summary = PythonOperator( + task_id="finish_ods_summary", + python_callable=_finish_summary, + ) + + # stg_batch_id нужен всем загрузочным веткам. + ( + resolve_stg_batch_id + >> load_ods_bookings + >> dq_ods_bookings + >> load_ods_tickets + >> dq_ods_tickets + ) + resolve_stg_batch_id >> load_ods_airports >> dq_ods_airports + resolve_stg_batch_id >> load_ods_airplanes >> dq_ods_airplanes + + [dq_ods_airports, dq_ods_airplanes] >> load_ods_routes >> dq_ods_routes + dq_ods_airplanes >> load_ods_seats >> dq_ods_seats + + dq_ods_routes >> load_ods_flights >> dq_ods_flights + [dq_ods_flights, dq_ods_tickets] >> load_ods_segments >> dq_ods_segments + dq_ods_segments >> load_ods_boarding_passes >> dq_ods_boarding_passes + + [dq_ods_boarding_passes, dq_ods_seats] >> finish_ods_summary diff --git a/docs/README.md b/docs/README.md index 2e727a5..8fa0215 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ - [Учебные задания](../educational-tasks.md) - [План тестирования и проверки](../TESTING.md) - [Главный учебный DAG: bookings → stg](bookings_to_gp_stage.md) +- [Учебный DAG: stg -> ods](bookings_to_gp_ods.md) ## Технические детали (опционально) @@ -15,4 +16,5 @@ - [Единые конвенции нейминга DWH (служебные поля и SCD)](internal/naming_conventions.md) - [PXF в этом проекте (проектная реализация)](internal/pxf_bookings.md) - [Дизайн stg для bookings (черновик)](internal/bookings_stg_design.md) +- [Дизайн ods для bookings (черновик)](internal/bookings_ods_design.md) - [Про время/UTC в bookings (черновик)](internal/bookings_tz.md) diff --git a/docs/bookings_to_gp_ods.md b/docs/bookings_to_gp_ods.md new file mode 100644 index 0000000..e1db94f --- /dev/null +++ b/docs/bookings_to_gp_ods.md @@ -0,0 +1,91 @@ +# DAG `bookings_to_gp_ods`: `stg` -> `ods` в Greenplum + +Этот DAG — учебный пример загрузки типизированного слоя **ODS** из уже подготовленного слоя **STG**. +Логика простая и каноничная: **SCD1 UPSERT** (обновляем изменившиеся записи, вставляем новые) + DQ-проверки. + +## Что делает DAG + +- Определяет `stg_batch_id`: + - берёт из `dag_run.conf["stg_batch_id"]`, если передан; + - иначе берёт последний **согласованный** `batch_id`, который есть во всех snapshot-таблицах STG + (`airports`, `airplanes`, `routes`, `seats`). +- Загружает 9 таблиц ODS (`airports`, `airplanes`, `routes`, `seats`, `bookings`, `tickets`, + `flights`, `segments`, `boarding_passes`). +- Для каждой таблицы выполняет пару задач `load -> dq`. +- На загрузке использует дедупликацию внутри батча + UPSERT (SCD1). +- Для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) дополнительно + синхронизирует ключи (удаляет из ODS записи, отсутствующие в выбранном STG-батче). + +## Что должно быть готово перед запуском + +1) Стек поднят: + +```bash +make up +``` + +2) STG-слой создан и заполнен: + +- запущен `bookings_stg_ddl` (или `make ddl-gp`); +- хотя бы один раз выполнен DAG `bookings_to_gp_stage`. + +3) ODS-таблицы созданы (один из вариантов): + +- учебный: запустить DAG `bookings_ods_ddl`; +- шорткат: `make ddl-gp` (в этом проекте он создаёт и STG, и ODS). + +## Как запустить + +1) Откройте Airflow UI: http://localhost:8080. +2) Запустите DAG `bookings_to_gp_ods`. +3) (Опционально) передайте `stg_batch_id` в конфиге запуска: + +```json +{"stg_batch_id": "manual__2026-02-22T12:00:00+00:00"} +``` + +Если конфиг не передан, DAG автоматически возьмёт последний согласованный snapshot-батч. + +## Граф зависимостей (упрощённо) + +- `resolve_stg_batch_id` +- Параллельно стартуют ветки: + - `bookings -> tickets` + - `airports` + - `airplanes` +- Далее: + - `routes` после `airports` и `airplanes` + - `seats` после `airplanes` + - `flights` после `routes` + - `segments` после `flights` и `tickets` + - `boarding_passes` после `segments` +- Финал: `finish_ods_summary` ждёт `dq_ods_boarding_passes` и `dq_ods_seats`. + +## Как проверить результат + +```bash +make gp-psql +``` + +```sql +SELECT COUNT(*) FROM ods.bookings; +SELECT COUNT(*) FROM ods.tickets; +SELECT COUNT(*) FROM ods.flights; + +SELECT book_ref, COUNT(*) +FROM ods.bookings +GROUP BY 1 +HAVING COUNT(*) > 1; +``` + +Ожидаемо: в последнем запросе `0` строк. + +## Типичные ошибки + +- `stg_batch_id не найден`: + - передайте `stg_batch_id` в `dag_run.conf`, или + - сначала загрузите STG через `bookings_to_gp_stage`. +- Ошибки `relation "ods...." does not exist`: + - не применён ODS DDL (`bookings_ods_ddl` / `make ddl-gp`). +- Ошибки DQ по ссылочной целостности: + - проверьте, что ODS DAG выполнялся с корректным `stg_batch_id` и без пропуска upstream задач. diff --git a/docs/internal/bookings_ods_design.md b/docs/internal/bookings_ods_design.md index 06489c4..b3c77c6 100644 --- a/docs/internal/bookings_ods_design.md +++ b/docs/internal/bookings_ods_design.md @@ -26,6 +26,8 @@ ODS в учебном проекте — это: - приведение типов (`TEXT -> TIMESTAMPTZ/NUMERIC/INT/BOOLEAN/...`); - дедупликацию внутри батча; - `UPSERT` (SCD Type 1): обновляем текущую запись при изменении, вставляем новые. +- для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) синхронизацию ключей: + удаляем из ODS записи, которых нет в выбранном `stg_batch_id`. Не делаем в ODS (в базовом эталоне): - SCD Type 2 с периодами действия; @@ -264,23 +266,47 @@ log = logging.getLogger(__name__) GREENPLUM_CONN_ID = "greenplum_conn" def _resolve_stg_batch_id(**context): - """Определяем stg_batch_id: из dag_run.conf или последний загруженный в STG.""" + """Определяем stg_batch_id: из dag_run.conf или последний согласованный snapshot-батч.""" conf = context["dag_run"].conf or {} stg_batch_id = conf.get("stg_batch_id") if not stg_batch_id: - # Берём batch_id с самым свежим load_dttm (TIMESTAMP, монотонно растёт). - # MAX(batch_id) ненадёжен: run_id — строка вида "manual__2024-...", - # лексикографическая сортировка не гарантирует хронологический порядок. + # Берём batch_id, который присутствует во всех snapshot-таблицах STG: + # airports, airplanes, routes, seats. Это защищает от частично успешных запусков. hook = PostgresHook(postgres_conn_id=GREENPLUM_CONN_ID) result = hook.get_first( - "SELECT batch_id FROM stg.bookings ORDER BY load_dttm DESC LIMIT 1" + ''' + WITH candidate_batches AS ( + SELECT batch_id FROM stg.airports WHERE batch_id IS NOT NULL GROUP BY batch_id + INTERSECT + SELECT batch_id FROM stg.airplanes WHERE batch_id IS NOT NULL GROUP BY batch_id + INTERSECT + SELECT batch_id FROM stg.routes WHERE batch_id IS NOT NULL GROUP BY batch_id + INTERSECT + SELECT batch_id FROM stg.seats WHERE batch_id IS NOT NULL GROUP BY batch_id + ), + batch_ready AS ( + SELECT + c.batch_id, + GREATEST( + (SELECT MAX(load_dttm) FROM stg.airports a WHERE a.batch_id = c.batch_id), + (SELECT MAX(load_dttm) FROM stg.airplanes a WHERE a.batch_id = c.batch_id), + (SELECT MAX(load_dttm) FROM stg.routes r WHERE r.batch_id = c.batch_id), + (SELECT MAX(load_dttm) FROM stg.seats s WHERE s.batch_id = c.batch_id) + ) AS ready_dttm + FROM candidate_batches c + ) + SELECT batch_id + FROM batch_ready + ORDER BY ready_dttm DESC + LIMIT 1 + ''' ) stg_batch_id = result[0] if result and result[0] else None if not stg_batch_id: raise ValueError( - "stg_batch_id не найден: передайте в conf или сначала загрузите STG" + "stg_batch_id не найден: передайте в conf или сначала выполните bookings_to_gp_stage" ) log.info("Используем stg_batch_id = %s", stg_batch_id) @@ -382,6 +408,19 @@ WHERE NOT EXISTS ( WHERE o.airport_code = s.airport_code ); +-- Statement 3: DELETE ключей, которых нет в snapshot текущего батча +WITH src_keys AS ( + SELECT DISTINCT airport_code + FROM stg.airports + WHERE batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +DELETE FROM ods.airports o +WHERE NOT EXISTS ( + SELECT 1 + FROM src_keys s + WHERE s.airport_code = o.airport_code +); + -- Обновляем статистику для оптимизатора запросов Greenplum ANALYZE ods.airports; ``` @@ -454,7 +493,10 @@ ANALYZE ods.bookings; ### 6.3. Идемпотентность паттерна -Паттерн UPDATE + INSERT WHERE NOT EXISTS — **натурально идемпотентен**: повторный запуск с тем же `stg_batch_id` не создаст дублей и не потеряет данные. UPDATE обновит только если атрибуты изменились, INSERT вставит только если бизнес-ключа нет. Это одно из преимуществ подхода. +- Для инкрементальных таблиц паттерн `UPDATE + INSERT WHERE NOT EXISTS` — **натурально идемпотентен**: + повторный запуск с тем же `stg_batch_id` не создаст дублей и не потеряет данные. +- Для snapshot-справочников идемпотентность сохраняется паттерном + `UPDATE + INSERT + DELETE not in snapshot`: повторный запуск приводит ODS к тому же состоянию. ### 6.4. Поведение при пустом батче @@ -541,14 +583,14 @@ sql/ods/ ├── boarding_passes_load.sql └── boarding_passes_dq.sql -sql/ddl_gp_ods.sql +sql/ddl_gp.sql (+ подключение sql/ods/*_ddl.sql) airflow/dags/ ├── bookings_ods_ddl.py └── bookings_to_gp_ods.py docs/bookings_to_gp_ods.md -Makefile (+ ddl-gp-ods) +Makefile (ddl-gp включает ODS DDL) tests/test_dags_smoke.py (+ smoke для 2 новых DAG) ``` @@ -591,8 +633,8 @@ resolve_stg_batch_id ## 10) Порядок реализации 1. Подготовить DDL в `sql/ods/*_ddl.sql`. -2. Сделать мастер-скрипт `sql/ddl_gp_ods.sql`. -3. Добавить `Makefile`-таргет `ddl-gp-ods`. +2. Подключить `sql/ods/*_ddl.sql` в общий `sql/ddl_gp.sql`. +3. Использовать существующий `Makefile`-таргет `ddl-gp` для STG+ODS. 4. Создать DAG `bookings_ods_ddl.py`. 5. Реализовать `sql/ods/*_load.sql` (SCD1 UPSERT). 6. Реализовать `sql/ods/*_dq.sql`. @@ -607,7 +649,7 @@ resolve_stg_batch_id Готово, если: 1. Оба новых DAG парсятся и проходят smoke-тесты (`make test`). -2. `make ddl-gp-ods` создаёт объекты без ошибок. +2. `make ddl-gp` создаёт объекты STG+ODS без ошибок. 3. Для тестового `stg_batch_id` ODS-загрузка завершается успешно. 4. Все DQ-задачи зелёные и реально валят DAG при искусственной ошибке. 5. В ODS нет дублей по бизнес-ключам. @@ -619,10 +661,10 @@ resolve_stg_batch_id ```bash make up -make ddl-gp # создать STG-объекты -make ddl-gp-ods # создать ODS-объекты +make ddl-gp # создать STG+ODS-объекты # Trigger bookings_to_gp_stage (загрузить STG) -# Получить batch_id: SELECT batch_id FROM stg.bookings ORDER BY load_dttm DESC LIMIT 1; +# Передать stg_batch_id в conf (рекомендуется) или дать ODS DAG выбрать +# последний согласованный batch автоматически. # Trigger bookings_to_gp_ods с conf: {"stg_batch_id": "<значение>"} make gp-psql ``` diff --git a/sql/ddl_gp.sql b/sql/ddl_gp.sql index 40da916..9df705b 100644 --- a/sql/ddl_gp.sql +++ b/sql/ddl_gp.sql @@ -37,3 +37,14 @@ FORMAT 'CUSTOM' (formatter='pxfwritable_import'); \i stg/flights_ddl.sql \i stg/segments_ddl.sql \i stg/boarding_passes_ddl.sql + +-- DDL для ODS-слоя (текущее состояние, SCD1). +\i ods/airports_ddl.sql +\i ods/airplanes_ddl.sql +\i ods/routes_ddl.sql +\i ods/seats_ddl.sql +\i ods/bookings_ddl.sql +\i ods/tickets_ddl.sql +\i ods/flights_ddl.sql +\i ods/segments_ddl.sql +\i ods/boarding_passes_ddl.sql diff --git a/sql/ods/airplanes_ddl.sql b/sql/ods/airplanes_ddl.sql new file mode 100644 index 0000000..56aa6dc --- /dev/null +++ b/sql/ods/airplanes_ddl.sql @@ -0,0 +1,13 @@ +-- DDL для ODS-слоя по таблице airplanes (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +CREATE TABLE IF NOT EXISTS ods.airplanes ( + airplane_code TEXT NOT NULL, + model TEXT NOT NULL, + range_km INTEGER, + speed_kmh INTEGER, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (airplane_code); diff --git a/sql/ods/airplanes_dq.sql b/sql/ods/airplanes_dq.sql new file mode 100644 index 0000000..100c487 --- /dev/null +++ b/sql/ods/airplanes_dq.sql @@ -0,0 +1,99 @@ +-- DQ для ODS airplanes. + +DO $$ +DECLARE + v_batch_id TEXT := '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text; + v_stg_batch_count BIGINT; + v_dup_count BIGINT; + v_missing_keys_count BIGINT; + v_extra_keys_count BIGINT; + v_null_count BIGINT; +BEGIN + -- Для snapshot-справочников пустой батч — ошибка. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.airplanes + WHERE batch_id = v_batch_id; + + IF v_stg_batch_count = 0 THEN + RAISE EXCEPTION + 'DQ FAILED: batch_id=% для stg.airplanes пустой. Проверьте загрузку STG и PXF.', + v_batch_id; + END IF; + + -- В ODS не должно быть дублей по бизнес-ключу. + SELECT COUNT(*) - COUNT(DISTINCT airplane_code) + INTO v_dup_count + FROM ods.airplanes; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.airplanes найдены дубликаты airplane_code: %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT airplane_code + FROM stg.airplanes + WHERE batch_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.airplanes AS o + WHERE o.airplane_code = s.airplane_code + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.airplanes отсутствуют ключи из stg.airplanes (batch_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- В ODS не должно быть лишних ключей, которых нет в snapshot текущего батча. + SELECT COUNT(*) + INTO v_extra_keys_count + FROM ods.airplanes AS o + WHERE NOT EXISTS ( + SELECT 1 + FROM ( + SELECT DISTINCT airplane_code + FROM stg.airplanes + WHERE batch_id = v_batch_id + ) AS s + WHERE s.airplane_code = o.airplane_code + ); + + IF v_extra_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.airplanes найдены лишние ключи вне stg batch_id=%: %', + v_batch_id, + v_extra_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.airplanes + WHERE airplane_code IS NULL + OR airplane_code = '' + OR model IS NULL + OR model = '' + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.airplanes найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.airplanes ок (batch_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/airplanes_load.sql b/sql/ods/airplanes_load.sql new file mode 100644 index 0000000..99a0a12 --- /dev/null +++ b/sql/ods/airplanes_load.sql @@ -0,0 +1,92 @@ +-- Загрузка ODS по airplanes: SCD1 (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих строк. +WITH src AS ( + SELECT + s.airplane_code, + s.model, + NULLIF(s.range, '')::INTEGER AS range_km, + NULLIF(s.speed, '')::INTEGER AS speed_kmh, + ROW_NUMBER() OVER ( + PARTITION BY s.airplane_code + ORDER BY s.load_dttm DESC, s.src_created_at_ts DESC NULLS LAST + ) AS rn + FROM stg.airplanes AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +UPDATE ods.airplanes AS o +SET model = s.model, + range_km = s.range_km, + speed_kmh = s.speed_kmh, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + _load_ts = now() +FROM src AS s +WHERE s.rn = 1 + AND o.airplane_code = s.airplane_code + AND ( + o.model IS DISTINCT FROM s.model + OR o.range_km IS DISTINCT FROM s.range_km + OR o.speed_kmh IS DISTINCT FROM s.speed_kmh + ); + +-- Statement 2: INSERT новых строк. +WITH src AS ( + SELECT + s.airplane_code, + s.model, + NULLIF(s.range, '')::INTEGER AS range_km, + NULLIF(s.speed, '')::INTEGER AS speed_kmh, + ROW_NUMBER() OVER ( + PARTITION BY s.airplane_code + ORDER BY s.load_dttm DESC, s.src_created_at_ts DESC NULLS LAST + ) AS rn + FROM stg.airplanes AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +INSERT INTO ods.airplanes ( + airplane_code, + model, + range_km, + speed_kmh, + _load_id, + _load_ts +) +SELECT + s.airplane_code, + s.model, + s.range_km, + s.speed_kmh, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + now() +FROM src AS s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 + FROM ods.airplanes AS o + WHERE o.airplane_code = s.airplane_code + ); + +-- Statement 3: DELETE ключей, которых нет в snapshot текущего батча. +-- Это делает ODS для справочника действительно "current state". +WITH src_keys AS ( + SELECT d.airplane_code + FROM ( + SELECT + s.airplane_code, + ROW_NUMBER() OVER ( + PARTITION BY s.airplane_code + ORDER BY s.load_dttm DESC, s.src_created_at_ts DESC NULLS LAST + ) AS rn + FROM stg.airplanes AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text + ) AS d + WHERE d.rn = 1 +) +DELETE FROM ods.airplanes AS o +WHERE NOT EXISTS ( + SELECT 1 + FROM src_keys AS s + WHERE s.airplane_code = o.airplane_code +); + +ANALYZE ods.airplanes; diff --git a/sql/ods/airports_ddl.sql b/sql/ods/airports_ddl.sql new file mode 100644 index 0000000..a6663c8 --- /dev/null +++ b/sql/ods/airports_ddl.sql @@ -0,0 +1,15 @@ +-- DDL для ODS-слоя по таблице airports (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +CREATE TABLE IF NOT EXISTS ods.airports ( + airport_code TEXT NOT NULL, + airport_name TEXT NOT NULL, + city TEXT NOT NULL, + country TEXT NOT NULL, + coordinates TEXT, + timezone TEXT NOT NULL, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (airport_code); diff --git a/sql/ods/airports_dq.sql b/sql/ods/airports_dq.sql new file mode 100644 index 0000000..46b3c56 --- /dev/null +++ b/sql/ods/airports_dq.sql @@ -0,0 +1,105 @@ +-- DQ для ODS airports. + +DO $$ +DECLARE + v_batch_id TEXT := '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text; + v_stg_batch_count BIGINT; + v_dup_count BIGINT; + v_missing_keys_count BIGINT; + v_extra_keys_count BIGINT; + v_null_count BIGINT; +BEGIN + -- Для snapshot-справочников пустой батч — ошибка. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.airports + WHERE batch_id = v_batch_id; + + IF v_stg_batch_count = 0 THEN + RAISE EXCEPTION + 'DQ FAILED: batch_id=% для stg.airports пустой. Проверьте загрузку STG и PXF.', + v_batch_id; + END IF; + + -- В ODS не должно быть дублей по бизнес-ключу. + SELECT COUNT(*) - COUNT(DISTINCT airport_code) + INTO v_dup_count + FROM ods.airports; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.airports найдены дубликаты airport_code: %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT airport_code + FROM stg.airports + WHERE batch_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.airports AS o + WHERE o.airport_code = s.airport_code + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.airports отсутствуют ключи из stg.airports (batch_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- В ODS не должно быть лишних ключей, которых нет в snapshot текущего батча. + SELECT COUNT(*) + INTO v_extra_keys_count + FROM ods.airports AS o + WHERE NOT EXISTS ( + SELECT 1 + FROM ( + SELECT DISTINCT airport_code + FROM stg.airports + WHERE batch_id = v_batch_id + ) AS s + WHERE s.airport_code = o.airport_code + ); + + IF v_extra_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.airports найдены лишние ключи вне stg batch_id=%: %', + v_batch_id, + v_extra_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.airports + WHERE airport_code IS NULL + OR airport_code = '' + OR airport_name IS NULL + OR airport_name = '' + OR city IS NULL + OR city = '' + OR country IS NULL + OR country = '' + OR timezone IS NULL + OR timezone = '' + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.airports найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.airports ок (batch_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/airports_load.sql b/sql/ods/airports_load.sql new file mode 100644 index 0000000..8a62893 --- /dev/null +++ b/sql/ods/airports_load.sql @@ -0,0 +1,104 @@ +-- Загрузка ODS по airports: SCD1 (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих строк. +WITH src AS ( + SELECT + s.airport_code, + s.airport_name, + s.city, + s.country, + s.coordinates, + s.timezone, + ROW_NUMBER() OVER ( + PARTITION BY s.airport_code + ORDER BY s.load_dttm DESC, s.src_created_at_ts DESC NULLS LAST + ) AS rn + FROM stg.airports AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +UPDATE ods.airports AS o +SET airport_name = s.airport_name, + city = s.city, + country = s.country, + coordinates = s.coordinates, + timezone = s.timezone, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + _load_ts = now() +FROM src AS s +WHERE s.rn = 1 + AND o.airport_code = s.airport_code + AND ( + o.airport_name IS DISTINCT FROM s.airport_name + OR o.city IS DISTINCT FROM s.city + OR o.country IS DISTINCT FROM s.country + OR o.coordinates IS DISTINCT FROM s.coordinates + OR o.timezone IS DISTINCT FROM s.timezone + ); + +-- Statement 2: INSERT новых строк. +WITH src AS ( + SELECT + s.airport_code, + s.airport_name, + s.city, + s.country, + s.coordinates, + s.timezone, + ROW_NUMBER() OVER ( + PARTITION BY s.airport_code + ORDER BY s.load_dttm DESC, s.src_created_at_ts DESC NULLS LAST + ) AS rn + FROM stg.airports AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +INSERT INTO ods.airports ( + airport_code, + airport_name, + city, + country, + coordinates, + timezone, + _load_id, + _load_ts +) +SELECT + s.airport_code, + s.airport_name, + s.city, + s.country, + s.coordinates, + s.timezone, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + now() +FROM src AS s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 + FROM ods.airports AS o + WHERE o.airport_code = s.airport_code + ); + +-- Statement 3: DELETE ключей, которых нет в snapshot текущего батча. +-- Это делает ODS для справочника действительно "current state". +WITH src_keys AS ( + SELECT d.airport_code + FROM ( + SELECT + s.airport_code, + ROW_NUMBER() OVER ( + PARTITION BY s.airport_code + ORDER BY s.load_dttm DESC, s.src_created_at_ts DESC NULLS LAST + ) AS rn + FROM stg.airports AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text + ) AS d + WHERE d.rn = 1 +) +DELETE FROM ods.airports AS o +WHERE NOT EXISTS ( + SELECT 1 + FROM src_keys AS s + WHERE s.airport_code = o.airport_code +); + +ANALYZE ods.airports; diff --git a/sql/ods/boarding_passes_ddl.sql b/sql/ods/boarding_passes_ddl.sql new file mode 100644 index 0000000..8a8640e --- /dev/null +++ b/sql/ods/boarding_passes_ddl.sql @@ -0,0 +1,15 @@ +-- DDL для ODS-слоя по таблице boarding_passes (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +CREATE TABLE IF NOT EXISTS ods.boarding_passes ( + ticket_no TEXT NOT NULL, + flight_id INTEGER NOT NULL, + seat_no TEXT NOT NULL, + boarding_no INTEGER, + boarding_time TIMESTAMP WITH TIME ZONE, + event_ts TIMESTAMP, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (ticket_no); diff --git a/sql/ods/boarding_passes_dq.sql b/sql/ods/boarding_passes_dq.sql new file mode 100644 index 0000000..aeed460 --- /dev/null +++ b/sql/ods/boarding_passes_dq.sql @@ -0,0 +1,96 @@ +-- DQ для ODS boarding_passes. + +DO $$ +DECLARE + v_batch_id TEXT := '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text; + v_stg_batch_count BIGINT; + v_dup_count BIGINT; + v_missing_keys_count BIGINT; + v_null_count BIGINT; + v_orphan_segment_count BIGINT; +BEGIN + -- Для этой таблицы пустой батч допустим. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.boarding_passes + WHERE batch_id = v_batch_id; + + -- В ODS не должно быть дублей по составному бизнес-ключу. + SELECT COUNT(*) + INTO v_dup_count + FROM ( + SELECT ticket_no, flight_id + FROM ods.boarding_passes + GROUP BY ticket_no, flight_id + HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.boarding_passes найдены дубликаты (ticket_no, flight_id): %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT ticket_no, NULLIF(flight_id, '')::INTEGER AS flight_id + FROM stg.boarding_passes + WHERE batch_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.boarding_passes AS o + WHERE o.ticket_no = s.ticket_no + AND o.flight_id = s.flight_id + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.boarding_passes отсутствуют ключи из stg.boarding_passes (batch_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.boarding_passes + WHERE ticket_no IS NULL + OR ticket_no = '' + OR flight_id IS NULL + OR seat_no IS NULL + OR seat_no = '' + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.boarding_passes найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Ссылочная целостность: boarding_passes (ticket_no, flight_id) -> segments. + SELECT COUNT(*) + INTO v_orphan_segment_count + FROM ods.boarding_passes AS bp + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.segments AS s + WHERE s.ticket_no = bp.ticket_no + AND s.flight_id = bp.flight_id + ); + + IF v_orphan_segment_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.boarding_passes найдены строки без соответствующего ods.segments: %', + v_orphan_segment_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.boarding_passes ок (batch_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/boarding_passes_load.sql b/sql/ods/boarding_passes_load.sql new file mode 100644 index 0000000..08d7359 --- /dev/null +++ b/sql/ods/boarding_passes_load.sql @@ -0,0 +1,81 @@ +-- Загрузка ODS по boarding_passes: SCD1 (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих строк. +WITH src AS ( + SELECT + s.ticket_no, + NULLIF(s.flight_id, '')::INTEGER AS flight_id, + s.seat_no, + NULLIF(s.boarding_no, '')::INTEGER AS boarding_no, + NULLIF(s.boarding_time, '')::TIMESTAMP WITH TIME ZONE AS boarding_time, + s.src_created_at_ts AS event_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.ticket_no, s.flight_id + ORDER BY s.src_created_at_ts DESC NULLS LAST, s.load_dttm DESC + ) AS rn + FROM stg.boarding_passes AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +UPDATE ods.boarding_passes AS o +SET seat_no = s.seat_no, + boarding_no = s.boarding_no, + boarding_time = s.boarding_time, + event_ts = s.event_ts, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + _load_ts = now() +FROM src AS s +WHERE s.rn = 1 + AND o.ticket_no = s.ticket_no + AND o.flight_id = s.flight_id + AND ( + o.seat_no IS DISTINCT FROM s.seat_no + OR o.boarding_no IS DISTINCT FROM s.boarding_no + OR o.boarding_time IS DISTINCT FROM s.boarding_time + OR o.event_ts IS DISTINCT FROM s.event_ts + ); + +-- Statement 2: INSERT новых строк. +WITH src AS ( + SELECT + s.ticket_no, + NULLIF(s.flight_id, '')::INTEGER AS flight_id, + s.seat_no, + NULLIF(s.boarding_no, '')::INTEGER AS boarding_no, + NULLIF(s.boarding_time, '')::TIMESTAMP WITH TIME ZONE AS boarding_time, + s.src_created_at_ts AS event_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.ticket_no, s.flight_id + ORDER BY s.src_created_at_ts DESC NULLS LAST, s.load_dttm DESC + ) AS rn + FROM stg.boarding_passes AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +INSERT INTO ods.boarding_passes ( + ticket_no, + flight_id, + seat_no, + boarding_no, + boarding_time, + event_ts, + _load_id, + _load_ts +) +SELECT + s.ticket_no, + s.flight_id, + s.seat_no, + s.boarding_no, + s.boarding_time, + s.event_ts, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + now() +FROM src AS s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 + FROM ods.boarding_passes AS o + WHERE o.ticket_no = s.ticket_no + AND o.flight_id = s.flight_id + ); + +ANALYZE ods.boarding_passes; diff --git a/sql/ods/bookings_ddl.sql b/sql/ods/bookings_ddl.sql new file mode 100644 index 0000000..1ee839f --- /dev/null +++ b/sql/ods/bookings_ddl.sql @@ -0,0 +1,13 @@ +-- DDL для ODS-слоя по таблице bookings (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +CREATE TABLE IF NOT EXISTS ods.bookings ( + book_ref TEXT NOT NULL, + book_date TIMESTAMP WITH TIME ZONE NOT NULL, + total_amount NUMERIC(10,2) NOT NULL, + event_ts TIMESTAMP, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (book_ref); diff --git a/sql/ods/bookings_dq.sql b/sql/ods/bookings_dq.sql new file mode 100644 index 0000000..1709423 --- /dev/null +++ b/sql/ods/bookings_dq.sql @@ -0,0 +1,71 @@ +-- DQ для ODS bookings. + +DO $$ +DECLARE + v_batch_id TEXT := '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text; + v_stg_batch_count BIGINT; + v_dup_count BIGINT; + v_missing_keys_count BIGINT; + v_null_count BIGINT; +BEGIN + -- Для инкрементальных таблиц пустой батч допустим. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.bookings + WHERE batch_id = v_batch_id; + + -- В ODS не должно быть дублей по бизнес-ключу. + SELECT COUNT(*) - COUNT(DISTINCT book_ref) + INTO v_dup_count + FROM ods.bookings; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.bookings найдены дубликаты book_ref: %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT book_ref + FROM stg.bookings + WHERE batch_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.bookings AS o + WHERE o.book_ref = s.book_ref + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.bookings отсутствуют ключи из stg.bookings (batch_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.bookings + WHERE book_ref IS NULL + OR book_ref = '' + OR book_date IS NULL + OR total_amount IS NULL + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.bookings найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.bookings ок (batch_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/bookings_load.sql b/sql/ods/bookings_load.sql new file mode 100644 index 0000000..45db3de --- /dev/null +++ b/sql/ods/bookings_load.sql @@ -0,0 +1,69 @@ +-- Загрузка ODS по bookings: SCD1 (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих строк. +WITH src AS ( + SELECT + s.book_ref, + NULLIF(s.book_date, '')::TIMESTAMP WITH TIME ZONE AS book_date, + NULLIF(s.total_amount, '')::NUMERIC(10,2) AS total_amount, + s.src_created_at_ts AS event_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.book_ref + ORDER BY s.src_created_at_ts DESC NULLS LAST, s.load_dttm DESC + ) AS rn + FROM stg.bookings AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +UPDATE ods.bookings AS o +SET book_date = s.book_date, + total_amount = s.total_amount, + event_ts = s.event_ts, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + _load_ts = now() +FROM src AS s +WHERE s.rn = 1 + AND o.book_ref = s.book_ref + AND ( + o.book_date IS DISTINCT FROM s.book_date + OR o.total_amount IS DISTINCT FROM s.total_amount + OR o.event_ts IS DISTINCT FROM s.event_ts + ); + +-- Statement 2: INSERT новых строк. +WITH src AS ( + SELECT + s.book_ref, + NULLIF(s.book_date, '')::TIMESTAMP WITH TIME ZONE AS book_date, + NULLIF(s.total_amount, '')::NUMERIC(10,2) AS total_amount, + s.src_created_at_ts AS event_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.book_ref + ORDER BY s.src_created_at_ts DESC NULLS LAST, s.load_dttm DESC + ) AS rn + FROM stg.bookings AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +INSERT INTO ods.bookings ( + book_ref, + book_date, + total_amount, + event_ts, + _load_id, + _load_ts +) +SELECT + s.book_ref, + s.book_date, + s.total_amount, + s.event_ts, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + now() +FROM src AS s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 + FROM ods.bookings AS o + WHERE o.book_ref = s.book_ref + ); + +ANALYZE ods.bookings; diff --git a/sql/ods/flights_ddl.sql b/sql/ods/flights_ddl.sql new file mode 100644 index 0000000..5b49996 --- /dev/null +++ b/sql/ods/flights_ddl.sql @@ -0,0 +1,17 @@ +-- DDL для ODS-слоя по таблице flights (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +CREATE TABLE IF NOT EXISTS ods.flights ( + flight_id INTEGER NOT NULL, + route_no TEXT NOT NULL, + status TEXT NOT NULL, + scheduled_departure TIMESTAMP WITH TIME ZONE, + scheduled_arrival TIMESTAMP WITH TIME ZONE, + actual_departure TIMESTAMP WITH TIME ZONE, + actual_arrival TIMESTAMP WITH TIME ZONE, + event_ts TIMESTAMP, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (flight_id); diff --git a/sql/ods/flights_dq.sql b/sql/ods/flights_dq.sql new file mode 100644 index 0000000..912e340 --- /dev/null +++ b/sql/ods/flights_dq.sql @@ -0,0 +1,89 @@ +-- DQ для ODS flights. + +DO $$ +DECLARE + v_batch_id TEXT := '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text; + v_stg_batch_count BIGINT; + v_dup_count BIGINT; + v_missing_keys_count BIGINT; + v_null_count BIGINT; + v_orphan_route_count BIGINT; +BEGIN + -- Для инкрементальных таблиц пустой батч допустим. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.flights + WHERE batch_id = v_batch_id; + + -- В ODS не должно быть дублей по бизнес-ключу. + SELECT COUNT(*) - COUNT(DISTINCT flight_id) + INTO v_dup_count + FROM ods.flights; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.flights найдены дубликаты flight_id: %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT NULLIF(flight_id, '')::INTEGER AS flight_id + FROM stg.flights + WHERE batch_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.flights AS o + WHERE o.flight_id = s.flight_id + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.flights отсутствуют ключи из stg.flights (batch_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.flights + WHERE flight_id IS NULL + OR route_no IS NULL + OR route_no = '' + OR status IS NULL + OR status = '' + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.flights найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Ссылочная целостность: flights.route_no -> routes.route_no (упрощённо, без validity). + SELECT COUNT(*) + INTO v_orphan_route_count + FROM ods.flights AS f + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.routes AS r + WHERE r.route_no = f.route_no + ); + + IF v_orphan_route_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.flights найдены строки без соответствующего routes.route_no: %', + v_orphan_route_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.flights ок (batch_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/flights_load.sql b/sql/ods/flights_load.sql new file mode 100644 index 0000000..ff33580 --- /dev/null +++ b/sql/ods/flights_load.sql @@ -0,0 +1,93 @@ +-- Загрузка ODS по flights: SCD1 (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих строк. +WITH src AS ( + SELECT + NULLIF(s.flight_id, '')::INTEGER AS flight_id, + s.route_no, + s.status, + NULLIF(s.scheduled_departure, '')::TIMESTAMP WITH TIME ZONE AS scheduled_departure, + NULLIF(s.scheduled_arrival, '')::TIMESTAMP WITH TIME ZONE AS scheduled_arrival, + NULLIF(s.actual_departure, '')::TIMESTAMP WITH TIME ZONE AS actual_departure, + NULLIF(s.actual_arrival, '')::TIMESTAMP WITH TIME ZONE AS actual_arrival, + s.src_created_at_ts AS event_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.flight_id + ORDER BY s.src_created_at_ts DESC NULLS LAST, s.load_dttm DESC + ) AS rn + FROM stg.flights AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +UPDATE ods.flights AS o +SET route_no = s.route_no, + status = s.status, + scheduled_departure = s.scheduled_departure, + scheduled_arrival = s.scheduled_arrival, + actual_departure = s.actual_departure, + actual_arrival = s.actual_arrival, + event_ts = s.event_ts, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + _load_ts = now() +FROM src AS s +WHERE s.rn = 1 + AND o.flight_id = s.flight_id + AND ( + o.route_no IS DISTINCT FROM s.route_no + OR o.status IS DISTINCT FROM s.status + OR o.scheduled_departure IS DISTINCT FROM s.scheduled_departure + OR o.scheduled_arrival IS DISTINCT FROM s.scheduled_arrival + OR o.actual_departure IS DISTINCT FROM s.actual_departure + OR o.actual_arrival IS DISTINCT FROM s.actual_arrival + OR o.event_ts IS DISTINCT FROM s.event_ts + ); + +-- Statement 2: INSERT новых строк. +WITH src AS ( + SELECT + NULLIF(s.flight_id, '')::INTEGER AS flight_id, + s.route_no, + s.status, + NULLIF(s.scheduled_departure, '')::TIMESTAMP WITH TIME ZONE AS scheduled_departure, + NULLIF(s.scheduled_arrival, '')::TIMESTAMP WITH TIME ZONE AS scheduled_arrival, + NULLIF(s.actual_departure, '')::TIMESTAMP WITH TIME ZONE AS actual_departure, + NULLIF(s.actual_arrival, '')::TIMESTAMP WITH TIME ZONE AS actual_arrival, + s.src_created_at_ts AS event_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.flight_id + ORDER BY s.src_created_at_ts DESC NULLS LAST, s.load_dttm DESC + ) AS rn + FROM stg.flights AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +INSERT INTO ods.flights ( + flight_id, + route_no, + status, + scheduled_departure, + scheduled_arrival, + actual_departure, + actual_arrival, + event_ts, + _load_id, + _load_ts +) +SELECT + s.flight_id, + s.route_no, + s.status, + s.scheduled_departure, + s.scheduled_arrival, + s.actual_departure, + s.actual_arrival, + s.event_ts, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + now() +FROM src AS s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 + FROM ods.flights AS o + WHERE o.flight_id = s.flight_id + ); + +ANALYZE ods.flights; diff --git a/sql/ods/routes_ddl.sql b/sql/ods/routes_ddl.sql new file mode 100644 index 0000000..5f3d486 --- /dev/null +++ b/sql/ods/routes_ddl.sql @@ -0,0 +1,17 @@ +-- DDL для ODS-слоя по таблице routes (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +CREATE TABLE IF NOT EXISTS ods.routes ( + route_no TEXT NOT NULL, + validity TEXT NOT NULL, + departure_airport TEXT NOT NULL, + arrival_airport TEXT NOT NULL, + airplane_code TEXT NOT NULL, + days_of_week TEXT, + departure_time TIME, + duration INTERVAL, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (route_no); diff --git a/sql/ods/routes_dq.sql b/sql/ods/routes_dq.sql new file mode 100644 index 0000000..2b36969 --- /dev/null +++ b/sql/ods/routes_dq.sql @@ -0,0 +1,163 @@ +-- DQ для ODS routes. + +DO $$ +DECLARE + v_batch_id TEXT := '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text; + v_stg_batch_count BIGINT; + v_dup_count BIGINT; + v_missing_keys_count BIGINT; + v_extra_keys_count BIGINT; + v_null_count BIGINT; + v_orphan_departure_count BIGINT; + v_orphan_arrival_count BIGINT; + v_orphan_airplane_count BIGINT; +BEGIN + -- Для snapshot-справочников пустой батч — ошибка. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.routes + WHERE batch_id = v_batch_id; + + IF v_stg_batch_count = 0 THEN + RAISE EXCEPTION + 'DQ FAILED: batch_id=% для stg.routes пустой. Проверьте загрузку STG и PXF.', + v_batch_id; + END IF; + + -- В ODS не должно быть дублей по составному бизнес-ключу. + SELECT COUNT(*) + INTO v_dup_count + FROM ( + SELECT route_no, validity + FROM ods.routes + GROUP BY route_no, validity + HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes найдены дубликаты (route_no, validity): %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT route_no, validity + FROM stg.routes + WHERE batch_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.routes AS o + WHERE o.route_no = s.route_no + AND o.validity = s.validity + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes отсутствуют ключи из stg.routes (batch_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- В ODS не должно быть лишних ключей, которых нет в snapshot текущего батча. + SELECT COUNT(*) + INTO v_extra_keys_count + FROM ods.routes AS o + WHERE NOT EXISTS ( + SELECT 1 + FROM ( + SELECT DISTINCT route_no, validity + FROM stg.routes + WHERE batch_id = v_batch_id + ) AS s + WHERE s.route_no = o.route_no + AND s.validity = o.validity + ); + + IF v_extra_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes найдены лишние ключи вне stg batch_id=%: %', + v_batch_id, + v_extra_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.routes + WHERE route_no IS NULL + OR route_no = '' + OR validity IS NULL + OR validity = '' + OR departure_airport IS NULL + OR departure_airport = '' + OR arrival_airport IS NULL + OR arrival_airport = '' + OR airplane_code IS NULL + OR airplane_code = '' + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Ссылочная целостность: departure_airport должен существовать в ods.airports. + SELECT COUNT(*) + INTO v_orphan_departure_count + FROM ods.routes AS r + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.airports AS a + WHERE a.airport_code = r.departure_airport + ); + + IF v_orphan_departure_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes найдены строки с невалидным departure_airport: %', + v_orphan_departure_count; + END IF; + + -- Ссылочная целостность: arrival_airport должен существовать в ods.airports. + SELECT COUNT(*) + INTO v_orphan_arrival_count + FROM ods.routes AS r + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.airports AS a + WHERE a.airport_code = r.arrival_airport + ); + + IF v_orphan_arrival_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes найдены строки с невалидным arrival_airport: %', + v_orphan_arrival_count; + END IF; + + -- Ссылочная целостность: airplane_code должен существовать в ods.airplanes. + SELECT COUNT(*) + INTO v_orphan_airplane_count + FROM ods.routes AS r + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.airplanes AS a + WHERE a.airplane_code = r.airplane_code + ); + + IF v_orphan_airplane_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.routes найдены строки с невалидным airplane_code: %', + v_orphan_airplane_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.routes ок (batch_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/routes_load.sql b/sql/ods/routes_load.sql new file mode 100644 index 0000000..c51a160 --- /dev/null +++ b/sql/ods/routes_load.sql @@ -0,0 +1,118 @@ +-- Загрузка ODS по routes: SCD1 (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих строк. +WITH src AS ( + SELECT + s.route_no, + s.validity, + s.departure_airport, + s.arrival_airport, + s.airplane_code, + s.days_of_week, + NULLIF(s.scheduled_time, '')::TIME AS departure_time, + NULLIF(s.duration, '')::INTERVAL AS duration, + ROW_NUMBER() OVER ( + PARTITION BY s.route_no, s.validity + ORDER BY s.load_dttm DESC, s.src_created_at_ts DESC NULLS LAST + ) AS rn + FROM stg.routes AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +UPDATE ods.routes AS o +SET departure_airport = s.departure_airport, + arrival_airport = s.arrival_airport, + airplane_code = s.airplane_code, + days_of_week = s.days_of_week, + departure_time = s.departure_time, + duration = s.duration, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + _load_ts = now() +FROM src AS s +WHERE s.rn = 1 + AND o.route_no = s.route_no + AND o.validity = s.validity + AND ( + o.departure_airport IS DISTINCT FROM s.departure_airport + OR o.arrival_airport IS DISTINCT FROM s.arrival_airport + OR o.airplane_code IS DISTINCT FROM s.airplane_code + OR o.days_of_week IS DISTINCT FROM s.days_of_week + OR o.departure_time IS DISTINCT FROM s.departure_time + OR o.duration IS DISTINCT FROM s.duration + ); + +-- Statement 2: INSERT новых строк. +WITH src AS ( + SELECT + s.route_no, + s.validity, + s.departure_airport, + s.arrival_airport, + s.airplane_code, + s.days_of_week, + NULLIF(s.scheduled_time, '')::TIME AS departure_time, + NULLIF(s.duration, '')::INTERVAL AS duration, + ROW_NUMBER() OVER ( + PARTITION BY s.route_no, s.validity + ORDER BY s.load_dttm DESC, s.src_created_at_ts DESC NULLS LAST + ) AS rn + FROM stg.routes AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +INSERT INTO ods.routes ( + route_no, + validity, + departure_airport, + arrival_airport, + airplane_code, + days_of_week, + departure_time, + duration, + _load_id, + _load_ts +) +SELECT + s.route_no, + s.validity, + s.departure_airport, + s.arrival_airport, + s.airplane_code, + s.days_of_week, + s.departure_time, + s.duration, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + now() +FROM src AS s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 + FROM ods.routes AS o + WHERE o.route_no = s.route_no + AND o.validity = s.validity + ); + +-- Statement 3: DELETE ключей, которых нет в snapshot текущего батча. +-- Это делает ODS для справочника действительно "current state". +WITH src_keys AS ( + SELECT d.route_no, d.validity + FROM ( + SELECT + s.route_no, + s.validity, + ROW_NUMBER() OVER ( + PARTITION BY s.route_no, s.validity + ORDER BY s.load_dttm DESC, s.src_created_at_ts DESC NULLS LAST + ) AS rn + FROM stg.routes AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text + ) AS d + WHERE d.rn = 1 +) +DELETE FROM ods.routes AS o +WHERE NOT EXISTS ( + SELECT 1 + FROM src_keys AS s + WHERE s.route_no = o.route_no + AND s.validity = o.validity +); + +ANALYZE ods.routes; diff --git a/sql/ods/seats_ddl.sql b/sql/ods/seats_ddl.sql new file mode 100644 index 0000000..08eaceb --- /dev/null +++ b/sql/ods/seats_ddl.sql @@ -0,0 +1,12 @@ +-- DDL для ODS-слоя по таблице seats (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +CREATE TABLE IF NOT EXISTS ods.seats ( + airplane_code TEXT NOT NULL, + seat_no TEXT NOT NULL, + fare_conditions TEXT NOT NULL, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (airplane_code); diff --git a/sql/ods/seats_dq.sql b/sql/ods/seats_dq.sql new file mode 100644 index 0000000..ea2b61e --- /dev/null +++ b/sql/ods/seats_dq.sql @@ -0,0 +1,125 @@ +-- DQ для ODS seats. + +DO $$ +DECLARE + v_batch_id TEXT := '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text; + v_stg_batch_count BIGINT; + v_dup_count BIGINT; + v_missing_keys_count BIGINT; + v_extra_keys_count BIGINT; + v_null_count BIGINT; + v_orphan_airplane_count BIGINT; +BEGIN + -- Для snapshot-справочников пустой батч — ошибка. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.seats + WHERE batch_id = v_batch_id; + + IF v_stg_batch_count = 0 THEN + RAISE EXCEPTION + 'DQ FAILED: batch_id=% для stg.seats пустой. Проверьте загрузку STG и PXF.', + v_batch_id; + END IF; + + -- В ODS не должно быть дублей по составному бизнес-ключу. + SELECT COUNT(*) + INTO v_dup_count + FROM ( + SELECT airplane_code, seat_no + FROM ods.seats + GROUP BY airplane_code, seat_no + HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.seats найдены дубликаты (airplane_code, seat_no): %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT airplane_code, seat_no + FROM stg.seats + WHERE batch_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.seats AS o + WHERE o.airplane_code = s.airplane_code + AND o.seat_no = s.seat_no + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.seats отсутствуют ключи из stg.seats (batch_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- В ODS не должно быть лишних ключей, которых нет в snapshot текущего батча. + SELECT COUNT(*) + INTO v_extra_keys_count + FROM ods.seats AS o + WHERE NOT EXISTS ( + SELECT 1 + FROM ( + SELECT DISTINCT airplane_code, seat_no + FROM stg.seats + WHERE batch_id = v_batch_id + ) AS s + WHERE s.airplane_code = o.airplane_code + AND s.seat_no = o.seat_no + ); + + IF v_extra_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.seats найдены лишние ключи вне stg batch_id=%: %', + v_batch_id, + v_extra_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.seats + WHERE airplane_code IS NULL + OR airplane_code = '' + OR seat_no IS NULL + OR seat_no = '' + OR fare_conditions IS NULL + OR fare_conditions = '' + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.seats найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Ссылочная целостность: airplane_code должен существовать в ods.airplanes. + SELECT COUNT(*) + INTO v_orphan_airplane_count + FROM ods.seats AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.airplanes AS a + WHERE a.airplane_code = s.airplane_code + ); + + IF v_orphan_airplane_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.seats найдены строки с невалидным airplane_code: %', + v_orphan_airplane_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.seats ок (batch_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/seats_load.sql b/sql/ods/seats_load.sql new file mode 100644 index 0000000..82fbf9f --- /dev/null +++ b/sql/ods/seats_load.sql @@ -0,0 +1,86 @@ +-- Загрузка ODS по seats: SCD1 (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих строк. +WITH src AS ( + SELECT + s.airplane_code, + s.seat_no, + s.fare_conditions, + ROW_NUMBER() OVER ( + PARTITION BY s.airplane_code, s.seat_no + ORDER BY s.load_dttm DESC, s.src_created_at_ts DESC NULLS LAST + ) AS rn + FROM stg.seats AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +UPDATE ods.seats AS o +SET fare_conditions = s.fare_conditions, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + _load_ts = now() +FROM src AS s +WHERE s.rn = 1 + AND o.airplane_code = s.airplane_code + AND o.seat_no = s.seat_no + AND o.fare_conditions IS DISTINCT FROM s.fare_conditions; + +-- Statement 2: INSERT новых строк. +WITH src AS ( + SELECT + s.airplane_code, + s.seat_no, + s.fare_conditions, + ROW_NUMBER() OVER ( + PARTITION BY s.airplane_code, s.seat_no + ORDER BY s.load_dttm DESC, s.src_created_at_ts DESC NULLS LAST + ) AS rn + FROM stg.seats AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +INSERT INTO ods.seats ( + airplane_code, + seat_no, + fare_conditions, + _load_id, + _load_ts +) +SELECT + s.airplane_code, + s.seat_no, + s.fare_conditions, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + now() +FROM src AS s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 + FROM ods.seats AS o + WHERE o.airplane_code = s.airplane_code + AND o.seat_no = s.seat_no + ); + +-- Statement 3: DELETE ключей, которых нет в snapshot текущего батча. +-- Это делает ODS для справочника действительно "current state". +WITH src_keys AS ( + SELECT d.airplane_code, d.seat_no + FROM ( + SELECT + s.airplane_code, + s.seat_no, + ROW_NUMBER() OVER ( + PARTITION BY s.airplane_code, s.seat_no + ORDER BY s.load_dttm DESC, s.src_created_at_ts DESC NULLS LAST + ) AS rn + FROM stg.seats AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text + ) AS d + WHERE d.rn = 1 +) +DELETE FROM ods.seats AS o +WHERE NOT EXISTS ( + SELECT 1 + FROM src_keys AS s + WHERE s.airplane_code = o.airplane_code + AND s.seat_no = o.seat_no +); + +ANALYZE ods.seats; diff --git a/sql/ods/segments_ddl.sql b/sql/ods/segments_ddl.sql new file mode 100644 index 0000000..b35034a --- /dev/null +++ b/sql/ods/segments_ddl.sql @@ -0,0 +1,14 @@ +-- DDL для ODS-слоя по таблице segments (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +CREATE TABLE IF NOT EXISTS ods.segments ( + ticket_no TEXT NOT NULL, + flight_id INTEGER NOT NULL, + fare_conditions TEXT NOT NULL, + segment_amount NUMERIC(10,2), + event_ts TIMESTAMP, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (ticket_no); diff --git a/sql/ods/segments_dq.sql b/sql/ods/segments_dq.sql new file mode 100644 index 0000000..f1a05bc --- /dev/null +++ b/sql/ods/segments_dq.sql @@ -0,0 +1,112 @@ +-- DQ для ODS segments. + +DO $$ +DECLARE + v_batch_id TEXT := '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text; + v_stg_batch_count BIGINT; + v_dup_count BIGINT; + v_missing_keys_count BIGINT; + v_null_count BIGINT; + v_orphan_ticket_count BIGINT; + v_orphan_flight_count BIGINT; +BEGIN + -- Для инкрементальных таблиц пустой батч допустим. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.segments + WHERE batch_id = v_batch_id; + + -- В ODS не должно быть дублей по составному бизнес-ключу. + SELECT COUNT(*) + INTO v_dup_count + FROM ( + SELECT ticket_no, flight_id + FROM ods.segments + GROUP BY ticket_no, flight_id + HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.segments найдены дубликаты (ticket_no, flight_id): %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT ticket_no, NULLIF(flight_id, '')::INTEGER AS flight_id + FROM stg.segments + WHERE batch_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.segments AS o + WHERE o.ticket_no = s.ticket_no + AND o.flight_id = s.flight_id + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.segments отсутствуют ключи из stg.segments (batch_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.segments + WHERE ticket_no IS NULL + OR ticket_no = '' + OR flight_id IS NULL + OR fare_conditions IS NULL + OR fare_conditions = '' + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.segments найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Ссылочная целостность: segments.ticket_no -> tickets.ticket_no. + SELECT COUNT(*) + INTO v_orphan_ticket_count + FROM ods.segments AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.tickets AS t + WHERE t.ticket_no = s.ticket_no + ); + + IF v_orphan_ticket_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.segments найдены строки без соответствующего tickets.ticket_no: %', + v_orphan_ticket_count; + END IF; + + -- Ссылочная целостность: segments.flight_id -> flights.flight_id. + SELECT COUNT(*) + INTO v_orphan_flight_count + FROM ods.segments AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.flights AS f + WHERE f.flight_id = s.flight_id + ); + + IF v_orphan_flight_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.segments найдены строки без соответствующего flights.flight_id: %', + v_orphan_flight_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.segments ок (batch_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/segments_load.sql b/sql/ods/segments_load.sql new file mode 100644 index 0000000..50e0e18 --- /dev/null +++ b/sql/ods/segments_load.sql @@ -0,0 +1,75 @@ +-- Загрузка ODS по segments: SCD1 (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих строк. +WITH src AS ( + SELECT + s.ticket_no, + NULLIF(s.flight_id, '')::INTEGER AS flight_id, + s.fare_conditions, + NULLIF(s.price, '')::NUMERIC(10,2) AS segment_amount, + s.src_created_at_ts AS event_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.ticket_no, s.flight_id + ORDER BY s.src_created_at_ts DESC NULLS LAST, s.load_dttm DESC + ) AS rn + FROM stg.segments AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +UPDATE ods.segments AS o +SET fare_conditions = s.fare_conditions, + segment_amount = s.segment_amount, + event_ts = s.event_ts, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + _load_ts = now() +FROM src AS s +WHERE s.rn = 1 + AND o.ticket_no = s.ticket_no + AND o.flight_id = s.flight_id + AND ( + o.fare_conditions IS DISTINCT FROM s.fare_conditions + OR o.segment_amount IS DISTINCT FROM s.segment_amount + OR o.event_ts IS DISTINCT FROM s.event_ts + ); + +-- Statement 2: INSERT новых строк. +WITH src AS ( + SELECT + s.ticket_no, + NULLIF(s.flight_id, '')::INTEGER AS flight_id, + s.fare_conditions, + NULLIF(s.price, '')::NUMERIC(10,2) AS segment_amount, + s.src_created_at_ts AS event_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.ticket_no, s.flight_id + ORDER BY s.src_created_at_ts DESC NULLS LAST, s.load_dttm DESC + ) AS rn + FROM stg.segments AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +INSERT INTO ods.segments ( + ticket_no, + flight_id, + fare_conditions, + segment_amount, + event_ts, + _load_id, + _load_ts +) +SELECT + s.ticket_no, + s.flight_id, + s.fare_conditions, + s.segment_amount, + s.event_ts, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + now() +FROM src AS s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 + FROM ods.segments AS o + WHERE o.ticket_no = s.ticket_no + AND o.flight_id = s.flight_id + ); + +ANALYZE ods.segments; diff --git a/sql/ods/tickets_ddl.sql b/sql/ods/tickets_ddl.sql new file mode 100644 index 0000000..a4a70ee --- /dev/null +++ b/sql/ods/tickets_ddl.sql @@ -0,0 +1,15 @@ +-- DDL для ODS-слоя по таблице tickets (текущее состояние, SCD1). + +CREATE SCHEMA IF NOT EXISTS ods; + +CREATE TABLE IF NOT EXISTS ods.tickets ( + ticket_no TEXT NOT NULL, + book_ref TEXT NOT NULL, + passenger_id TEXT NOT NULL, + passenger_name TEXT NOT NULL, + is_outbound BOOLEAN, + event_ts TIMESTAMP, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (ticket_no); diff --git a/sql/ods/tickets_dq.sql b/sql/ods/tickets_dq.sql new file mode 100644 index 0000000..3ad1007 --- /dev/null +++ b/sql/ods/tickets_dq.sql @@ -0,0 +1,92 @@ +-- DQ для ODS tickets. + +DO $$ +DECLARE + v_batch_id TEXT := '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text; + v_stg_batch_count BIGINT; + v_dup_count BIGINT; + v_missing_keys_count BIGINT; + v_null_count BIGINT; + v_orphan_booking_count BIGINT; +BEGIN + -- Для инкрементальных таблиц пустой батч допустим. + SELECT COUNT(*) + INTO v_stg_batch_count + FROM stg.tickets + WHERE batch_id = v_batch_id; + + -- В ODS не должно быть дублей по бизнес-ключу. + SELECT COUNT(*) - COUNT(DISTINCT ticket_no) + INTO v_dup_count + FROM ods.tickets; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.tickets найдены дубликаты ticket_no: %', + v_dup_count; + END IF; + + -- Все ключи из STG текущего батча должны присутствовать в ODS. + SELECT COUNT(*) + INTO v_missing_keys_count + FROM ( + SELECT DISTINCT ticket_no + FROM stg.tickets + WHERE batch_id = v_batch_id + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.tickets AS o + WHERE o.ticket_no = s.ticket_no + ); + + IF v_missing_keys_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.tickets отсутствуют ключи из stg.tickets (batch_id=%): %', + v_batch_id, + v_missing_keys_count; + END IF; + + -- Обязательные поля в ODS. + SELECT COUNT(*) + INTO v_null_count + FROM ods.tickets + WHERE ticket_no IS NULL + OR ticket_no = '' + OR book_ref IS NULL + OR book_ref = '' + OR passenger_id IS NULL + OR passenger_id = '' + OR passenger_name IS NULL + OR passenger_name = '' + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.tickets найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Ссылочная целостность: tickets.book_ref -> bookings.book_ref. + SELECT COUNT(*) + INTO v_orphan_booking_count + FROM ods.tickets AS t + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.bookings AS b + WHERE b.book_ref = t.book_ref + ); + + IF v_orphan_booking_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в ods.tickets найдены строки без соответствующего bookings.book_ref: %', + v_orphan_booking_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.tickets ок (batch_id=%): stg_batch_rows=%', + v_batch_id, + v_stg_batch_count; +END $$; diff --git a/sql/ods/tickets_load.sql b/sql/ods/tickets_load.sql new file mode 100644 index 0000000..b4c214e --- /dev/null +++ b/sql/ods/tickets_load.sql @@ -0,0 +1,81 @@ +-- Загрузка ODS по tickets: SCD1 (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих строк. +WITH src AS ( + SELECT + s.ticket_no, + s.book_ref, + s.passenger_id, + s.passenger_name, + NULLIF(s.outbound, '')::BOOLEAN AS is_outbound, + s.src_created_at_ts AS event_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.ticket_no + ORDER BY s.src_created_at_ts DESC NULLS LAST, s.load_dttm DESC + ) AS rn + FROM stg.tickets AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +UPDATE ods.tickets AS o +SET book_ref = s.book_ref, + passenger_id = s.passenger_id, + passenger_name = s.passenger_name, + is_outbound = s.is_outbound, + event_ts = s.event_ts, + _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + _load_ts = now() +FROM src AS s +WHERE s.rn = 1 + AND o.ticket_no = s.ticket_no + AND ( + o.book_ref IS DISTINCT FROM s.book_ref + OR o.passenger_id IS DISTINCT FROM s.passenger_id + OR o.passenger_name IS DISTINCT FROM s.passenger_name + OR o.is_outbound IS DISTINCT FROM s.is_outbound + OR o.event_ts IS DISTINCT FROM s.event_ts + ); + +-- Statement 2: INSERT новых строк. +WITH src AS ( + SELECT + s.ticket_no, + s.book_ref, + s.passenger_id, + s.passenger_name, + NULLIF(s.outbound, '')::BOOLEAN AS is_outbound, + s.src_created_at_ts AS event_ts, + ROW_NUMBER() OVER ( + PARTITION BY s.ticket_no + ORDER BY s.src_created_at_ts DESC NULLS LAST, s.load_dttm DESC + ) AS rn + FROM stg.tickets AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text +) +INSERT INTO ods.tickets ( + ticket_no, + book_ref, + passenger_id, + passenger_name, + is_outbound, + event_ts, + _load_id, + _load_ts +) +SELECT + s.ticket_no, + s.book_ref, + s.passenger_id, + s.passenger_name, + s.is_outbound, + s.event_ts, + '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text, + now() +FROM src AS s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 + FROM ods.tickets AS o + WHERE o.ticket_no = s.ticket_no + ); + +ANALYZE ods.tickets; diff --git a/tests/test_dags_smoke.py b/tests/test_dags_smoke.py index e422d0e..2117dd9 100644 --- a/tests/test_dags_smoke.py +++ b/tests/test_dags_smoke.py @@ -199,3 +199,101 @@ def test_bookings_to_gp_stage_dag_structure(): assert airports not in airplanes.get_flat_relatives( upstream=False ), "airplanes не должен быть upstream для airports" + + +def test_bookings_ods_ddl_dag_structure(): + """Проверка структуры DAG bookings_ods_ddl.""" + dag = _load_dag("airflow.dags.bookings_ods_ddl") + + expected_tasks = { + "apply_ods_airports_ddl", + "apply_ods_airplanes_ddl", + "apply_ods_routes_ddl", + "apply_ods_seats_ddl", + "apply_ods_bookings_ddl", + "apply_ods_tickets_ddl", + "apply_ods_flights_ddl", + "apply_ods_segments_ddl", + "apply_ods_boarding_passes_ddl", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + _assert_reachable(dag, "apply_ods_airports_ddl", "apply_ods_airplanes_ddl") + + for task_id in expected_tasks - {"apply_ods_airports_ddl"}: + _assert_reachable(dag, "apply_ods_airports_ddl", task_id) + + +def test_bookings_to_gp_ods_dag_structure(): + """Проверка структуры DAG bookings_to_gp_ods.""" + dag = _load_dag("airflow.dags.bookings_to_gp_ods") + + expected_tasks = { + "resolve_stg_batch_id", + "load_ods_bookings", + "dq_ods_bookings", + "load_ods_tickets", + "dq_ods_tickets", + "load_ods_airports", + "dq_ods_airports", + "load_ods_airplanes", + "dq_ods_airplanes", + "load_ods_routes", + "dq_ods_routes", + "load_ods_seats", + "dq_ods_seats", + "load_ods_flights", + "dq_ods_flights", + "load_ods_segments", + "dq_ods_segments", + "load_ods_boarding_passes", + "dq_ods_boarding_passes", + "finish_ods_summary", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + # Базовая цепочка транзакций. + _assert_reachable(dag, "resolve_stg_batch_id", "load_ods_bookings") + _assert_reachable(dag, "load_ods_bookings", "dq_ods_bookings") + _assert_reachable(dag, "dq_ods_bookings", "load_ods_tickets") + _assert_reachable(dag, "load_ods_tickets", "dq_ods_tickets") + + # Инвариант "load -> dq" для каждой таблицы. + load_to_dq = [ + ("load_ods_bookings", "dq_ods_bookings"), + ("load_ods_tickets", "dq_ods_tickets"), + ("load_ods_airports", "dq_ods_airports"), + ("load_ods_airplanes", "dq_ods_airplanes"), + ("load_ods_routes", "dq_ods_routes"), + ("load_ods_seats", "dq_ods_seats"), + ("load_ods_flights", "dq_ods_flights"), + ("load_ods_segments", "dq_ods_segments"), + ("load_ods_boarding_passes", "dq_ods_boarding_passes"), + ] + for load_task_id, dq_task_id in load_to_dq: + _assert_direct_edge(dag, load_task_id, dq_task_id) + + # Справочники стартуют параллельно и не зависят друг от друга. + _assert_reachable(dag, "resolve_stg_batch_id", "load_ods_airports") + _assert_reachable(dag, "resolve_stg_batch_id", "load_ods_airplanes") + airports = dag.get_task("load_ods_airports") + airplanes = dag.get_task("load_ods_airplanes") + assert airplanes not in airports.get_flat_relatives( + upstream=False + ), "airports не должен быть upstream для airplanes" + assert airports not in airplanes.get_flat_relatives( + upstream=False + ), "airplanes не должен быть upstream для airports" + + # Барьеры по данным. + _assert_reachable(dag, "dq_ods_airports", "dq_ods_routes") + _assert_reachable(dag, "dq_ods_airplanes", "dq_ods_routes") + _assert_reachable(dag, "dq_ods_airplanes", "dq_ods_seats") + _assert_reachable(dag, "dq_ods_routes", "dq_ods_flights") + _assert_reachable(dag, "dq_ods_flights", "dq_ods_segments") + _assert_reachable(dag, "dq_ods_tickets", "dq_ods_segments") + _assert_reachable(dag, "dq_ods_segments", "dq_ods_boarding_passes") + + # Финальная сводка должна ждать обе ветки. + _assert_reachable(dag, "dq_ods_boarding_passes", "finish_ods_summary") + _assert_reachable(dag, "dq_ods_seats", "finish_ods_summary") diff --git a/tests/test_ods_snapshot_integration.py b/tests/test_ods_snapshot_integration.py new file mode 100644 index 0000000..3c227f6 --- /dev/null +++ b/tests/test_ods_snapshot_integration.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +RUN_ODS_INTEGRATION = os.getenv("RUN_ODS_INTEGRATION") == "1" +BATCH_TOKEN = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}' + +pytestmark = pytest.mark.skipif( + not RUN_ODS_INTEGRATION, + reason="Set RUN_ODS_INTEGRATION=1 to run ODS integration tests", +) + + +def _psql(sql: str) -> str: + """Выполняет SQL в Greenplum-контейнере и возвращает stdout psql.""" + docker_bin = os.getenv("DOCKER_BIN", "docker") + cmd = [ + docker_bin, + "compose", + "-f", + "docker-compose.yml", + "exec", + "-T", + "greenplum", + "bash", + "-lc", + "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -v ON_ERROR_STOP=1 -d gp_dwh -At -f -'", + ] + result = subprocess.run( + cmd, + cwd=PROJECT_ROOT, + input=sql, + text=True, + capture_output=True, + check=True, + ) + return result.stdout + + +def _render_airports_sql( + path: str, batch_id: str, stg_table: str, ods_table: str +) -> str: + sql = (PROJECT_ROOT / path).read_text(encoding="utf-8") + sql = sql.replace(BATCH_TOKEN, batch_id) + sql = sql.replace("stg.airports", stg_table) + sql = sql.replace("ods.airports", ods_table) + return sql + + +def test_snapshot_airports_contract_upsert_delete_and_dq() -> None: + """ + Интеграционный тест контракта snapshot-таблицы: + - UPSERT обновляет и вставляет; + - DELETE синхронизирует current state по выбранному батчу; + - DQ проходит на корректном состоянии. + """ + stg_table = "public.it_stg_airports_ods" + ods_table = "public.it_ods_airports_ods" + + setup_sql = f""" + DROP TABLE IF EXISTS {stg_table}; + DROP TABLE IF EXISTS {ods_table}; + + CREATE TABLE {stg_table} ( + airport_code TEXT, + airport_name TEXT, + city TEXT, + country TEXT, + coordinates TEXT, + timezone TEXT, + src_created_at_ts TIMESTAMP, + load_dttm TIMESTAMP, + batch_id TEXT + ); + + CREATE TABLE {ods_table} ( + airport_code TEXT NOT NULL, + airport_name TEXT NOT NULL, + city TEXT NOT NULL, + country TEXT NOT NULL, + coordinates TEXT, + timezone TEXT NOT NULL, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() + ); + """ + _psql(setup_sql) + + try: + _psql( + f""" + INSERT INTO {stg_table} ( + airport_code, airport_name, city, country, coordinates, timezone, + src_created_at_ts, load_dttm, batch_id + ) VALUES + ('AAA', 'Airport A', 'City A', 'Country A', '(0,0)', 'UTC', now(), now(), 'batch_1'), + ('BBB', 'Airport B', 'City B', 'Country B', '(1,1)', 'UTC', now(), now(), 'batch_1'); + """ + ) + + _psql( + _render_airports_sql( + "sql/ods/airports_load.sql", "batch_1", stg_table, ods_table + ) + ) + + codes_batch_1 = _psql( + f"SELECT COALESCE(string_agg(airport_code, ',' ORDER BY airport_code), '') FROM {ods_table};" + ).strip() + assert codes_batch_1 == "AAA,BBB" + + _psql( + f""" + INSERT INTO {stg_table} ( + airport_code, airport_name, city, country, coordinates, timezone, + src_created_at_ts, load_dttm, batch_id + ) VALUES + ('AAA', 'Airport A v2', 'City A', 'Country A', '(0,0)', 'UTC', now(), now(), 'batch_2'), + ('CCC', 'Airport C', 'City C', 'Country C', '(2,2)', 'UTC', now(), now(), 'batch_2'); + """ + ) + + _psql( + _render_airports_sql( + "sql/ods/airports_load.sql", "batch_2", stg_table, ods_table + ) + ) + + codes_batch_2 = _psql( + f"SELECT COALESCE(string_agg(airport_code, ',' ORDER BY airport_code), '') FROM {ods_table};" + ).strip() + assert codes_batch_2 == "AAA,CCC" + + airport_a_name = _psql( + f"SELECT airport_name FROM {ods_table} WHERE airport_code = 'AAA';" + ).strip() + assert airport_a_name == "Airport A v2" + + _psql( + _render_airports_sql( + "sql/ods/airports_dq.sql", "batch_2", stg_table, ods_table + ) + ) + finally: + _psql(f"DROP TABLE IF EXISTS {stg_table}; DROP TABLE IF EXISTS {ods_table};") diff --git a/tests/test_ods_sql_contract.py b/tests/test_ods_sql_contract.py new file mode 100644 index 0000000..0471bda --- /dev/null +++ b/tests/test_ods_sql_contract.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SNAPSHOT_ENTITIES = ("airports", "airplanes", "routes", "seats") + + +def _read(path: str) -> str: + return (PROJECT_ROOT / path).read_text(encoding="utf-8") + + +def test_snapshot_load_scripts_sync_deleted_keys() -> None: + """Snapshot-таблицы в ODS должны удалять ключи, отсутствующие в текущем батче.""" + for entity in SNAPSHOT_ENTITIES: + sql = _read(f"sql/ods/{entity}_load.sql") + assert f"DELETE FROM ods.{entity} AS o" in sql + assert "WITH src_keys AS (" in sql + assert "WHERE NOT EXISTS (" in sql + + +def test_snapshot_dq_checks_extra_keys() -> None: + """DQ snapshot-таблиц должен ловить лишние ключи в ODS относительно текущего батча STG.""" + for entity in SNAPSHOT_ENTITIES: + sql = _read(f"sql/ods/{entity}_dq.sql") + assert "v_extra_keys_count" in sql + assert "найдены лишние ключи" in sql + + +def test_ods_batch_resolver_uses_consistent_snapshot_batches() -> None: + """Резолвер батча должен искать batch_id, общий для всех snapshot-таблиц STG.""" + dag_code = _read("airflow/dags/bookings_to_gp_ods.py") + + for table_name in ("stg.airports", "stg.airplanes", "stg.routes", "stg.seats"): + assert table_name in dag_code + + assert "INTERSECT" in dag_code + assert "candidate_batches" in dag_code From e6e923794c0626556adf3f223c397fe2a68b84dd Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 23 Feb 2026 22:55:07 +0300 Subject: [PATCH 32/38] =?UTF-8?q?docs(dwh):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=BD=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20DDS=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D1=8F=20(Star=20Schema)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - зафиксировать архитектурные решения DDS до начала реализации (для Codex). - Что: - создан docs/internal/bookings_dds_design.md с полным планом DDS. - 6 измерений (calendar, airports, airplanes, tariffs, passengers, routes) + факт flight_sales. - dim_routes реализует классический SCD2 с hashdiff. - все остальные измерения — SCD1 UPSERT со стабильными SK. - факт — инкрементальный UPSERT по зерну (ticket_no, flight_id). - Проверка: - cat docs/internal/bookings_dds_design.md. Co-Authored-By: Claude Opus 4.6 --- docs/internal/bookings_dds_design.md | 961 +++++++++++++++++++++++++++ 1 file changed, 961 insertions(+) create mode 100644 docs/internal/bookings_dds_design.md diff --git a/docs/internal/bookings_dds_design.md b/docs/internal/bookings_dds_design.md new file mode 100644 index 0000000..8b41b14 --- /dev/null +++ b/docs/internal/bookings_dds_design.md @@ -0,0 +1,961 @@ +# DDS Layer: план реализации Star Schema для bookings + +## Контекст + +STG (9 таблиц, TEXT, append-only) и ODS (9 таблиц, типизированные, SCD1) уже реализованы. +Этот план фиксирует реализацию DDS-слоя: Star Schema с измерениями и таблицей фактов. + +Формат плана аналогичен `docs/internal/bookings_ods_design.md` — достаточно детальный, +чтобы реализация была однозначной. + +--- + +## 1) Принятые архитектурные решения + +| Решение | Выбор | Обоснование | +|---------|-------|-------------| +| Схема БД | Единая `dds` (`dds.dim_*`, `dds.fact_*`) | Проще для студентов, один CREATE SCHEMA | +| Суррогатные ключи | UPSERT + `MAX(sk) + ROW_NUMBER()` | Стабильные SK, Greenplum не поддерживает SERIAL | +| SCD2 | `dim_routes` с hashdiff | Реальная история в данных, классический SCD2 паттерн | +| Остальные измерения | SCD1 UPSERT | Стабильные SK для инкрементального факта | +| Загрузка факта | Инкрементальный UPSERT по `(ticket_no, flight_id)` | Консистентно с ODS, учебная ценность | +| `_load_id` в DDS | `{{ run_id }}` (Airflow run_id) | Не привязан к stg_batch_id, DDS читает current state ODS | + +--- + +## 2) Что создаём + +### Измерения (6 штук) + +| Таблица | Бизнес-ключ | SK | Тип | Источник ODS | +|---------|-------------|-----|-----|-------------| +| `dds.dim_calendar` | `date_actual` | `calendar_sk` | Статическая (generate_series) | — | +| `dds.dim_airports` | `airport_code` → `airport_bk` | `airport_sk` | SCD1 UPSERT | `ods.airports` | +| `dds.dim_airplanes` | `airplane_code` → `airplane_bk` | `airplane_sk` | SCD1 UPSERT | `ods.airplanes` + `ods.seats` (total_seats) | +| `dds.dim_tariffs` | `fare_conditions` | `tariff_sk` | SCD1 UPSERT | `ods.segments` (DISTINCT) | +| `dds.dim_passengers` | `passenger_id` → `passenger_bk` | `passenger_sk` | SCD1 UPSERT | `ods.tickets` (дедупликация по passenger_id) | +| `dds.dim_routes` | `route_no` → `route_bk` | `route_sk` | **SCD2** (hashdiff) | `ods.routes` (последняя версия по validity) | + +### Факт (1 штука) + +| Таблица | Зерно | FK на измерения | +|---------|-------|-----------------| +| `dds.fact_flight_sales` | `(ticket_no, flight_id)` — 1 сегмент билета | `calendar_sk`, `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk`, `tariff_sk`, `passenger_sk`, `route_sk` | + +--- + +## 3) DDL таблиц + +### 3.1. dds.dim_calendar +```sql +calendar_sk INTEGER NOT NULL +date_actual DATE NOT NULL +year_actual INTEGER NOT NULL +month_actual INTEGER NOT NULL +day_actual INTEGER NOT NULL +day_of_week INTEGER NOT NULL -- 1=Пн .. 7=Вс (ISO) +day_name TEXT NOT NULL -- Monday, Tuesday, ... +is_weekend BOOLEAN NOT NULL +DISTRIBUTED BY (calendar_sk) +``` +Статическая, заполняется один раз (2016-01-01 .. 2030-12-31). Без `_load_id`/`_load_ts`. + +### 3.2. dds.dim_airports +```sql +airport_sk INTEGER NOT NULL +airport_bk TEXT NOT NULL -- airport_code +airport_name TEXT NOT NULL +city TEXT NOT NULL +country TEXT NOT NULL +timezone TEXT NOT NULL +coordinates TEXT +created_at TIMESTAMP NOT NULL DEFAULT now() +updated_at TIMESTAMP NOT NULL DEFAULT now() +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (airport_sk) +``` + +### 3.3. dds.dim_airplanes +```sql +airplane_sk INTEGER NOT NULL +airplane_bk TEXT NOT NULL -- airplane_code +model TEXT NOT NULL +range_km INTEGER +speed_kmh INTEGER +total_seats INTEGER -- COUNT(*) из ods.seats +created_at TIMESTAMP NOT NULL DEFAULT now() +updated_at TIMESTAMP NOT NULL DEFAULT now() +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (airplane_sk) +``` + +### 3.4. dds.dim_tariffs +```sql +tariff_sk INTEGER NOT NULL +fare_conditions TEXT NOT NULL -- business key = fare_conditions +created_at TIMESTAMP NOT NULL DEFAULT now() +updated_at TIMESTAMP NOT NULL DEFAULT now() +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (tariff_sk) +``` + +### 3.5. dds.dim_passengers +```sql +passenger_sk INTEGER NOT NULL +passenger_bk TEXT NOT NULL -- passenger_id +passenger_name TEXT NOT NULL +created_at TIMESTAMP NOT NULL DEFAULT now() +updated_at TIMESTAMP NOT NULL DEFAULT now() +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (passenger_sk) +``` + +### 3.6. dds.dim_routes (SCD2) +```sql +route_sk INTEGER NOT NULL +route_bk TEXT NOT NULL -- route_no (бизнес-ключ) +departure_airport TEXT NOT NULL +arrival_airport TEXT NOT NULL +airplane_code TEXT NOT NULL +days_of_week TEXT +departure_time TIME +duration INTERVAL +hashdiff TEXT NOT NULL -- md5 хэш атрибутов для детекта изменений +valid_from DATE NOT NULL -- начало действия версии +valid_to DATE -- конец действия (NULL = текущая) +created_at TIMESTAMP NOT NULL DEFAULT now() +updated_at TIMESTAMP NOT NULL DEFAULT now() +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (route_sk) +``` + +Поле `validity` из ODS не переносится как отдельная колонка — DWH сам управляет +версиями через `hashdiff` + `valid_from`/`valid_to` (классический SCD2). +Из ODS берём последнюю версию по `route_no` (ORDER BY validity DESC) как "текущее состояние". + +### 3.7. dds.fact_flight_sales +```sql +-- FK на измерения (суррогатные ключи) +calendar_sk INTEGER +departure_airport_sk INTEGER +arrival_airport_sk INTEGER +airplane_sk INTEGER +tariff_sk INTEGER +passenger_sk INTEGER +route_sk INTEGER + +-- Дегенеративные измерения +book_ref TEXT NOT NULL +ticket_no TEXT NOT NULL +flight_id INTEGER NOT NULL +book_date DATE +seat_no TEXT + +-- Метрики +price NUMERIC(10,2) +is_boarded BOOLEAN NOT NULL + +-- Служебные +_load_id TEXT NOT NULL +_load_ts TIMESTAMP NOT NULL DEFAULT now() +DISTRIBUTED BY (ticket_no) +``` + +FK суррогатные ключи допускают NULL (LEFT JOIN при аномалиях данных). DQ ловит NULL там, где их быть не должно. + +--- + +## 4) Нейминг служебных полей (консистентно с naming_conventions.md) + +Источник правил: [`docs/internal/naming_conventions.md`](naming_conventions.md). + +В DDS используем: + +- `_load_id TEXT NOT NULL` — идентификатор загрузки (`{{ run_id }}` Airflow); +- `_load_ts TIMESTAMP NOT NULL DEFAULT now()` — время загрузки в DDS; +- `created_at TIMESTAMP NOT NULL DEFAULT now()` — когда строка создана в таблице; +- `updated_at TIMESTAMP NOT NULL DEFAULT now()` — когда строка обновлена; +- `valid_from DATE NOT NULL` — начало действия версии SCD2; +- `valid_to DATE` — конец действия SCD2 (`NULL` = текущая версия); +- `hashdiff TEXT NOT NULL` — md5 хэш атрибутов для детекта изменений SCD2; +- `*_bk TEXT` — бизнес-ключ измерения (суффикс `_bk`); +- `*_sk INTEGER` — суррогатный ключ измерения (суффикс `_sk`). + +### 4.1. Почему `{{ run_id }}` вместо `stg_batch_id` + +DDS читает **текущее состояние ODS** (ODS = SCD1, current state). Привязка к stg_batch_id +не требуется. `_load_id` в DDS = Airflow run_id текущего запуска DDS DAG — для аудита +"когда и каким запуском были загружены данные в DDS". + +--- + +## 5) SQL-паттерны загрузки + +> **Стиль SQL:** CTE (Common Table Expressions) — как в ODS. + +### 5.1. dim_calendar — статическая, INSERT если пуста + +```sql +-- Загрузка DDS dim_calendar: статическое измерение (генерация дат). +-- Заполняем только если таблица пуста (идемпотентно). + +INSERT INTO dds.dim_calendar ( + calendar_sk, date_actual, year_actual, month_actual, + day_actual, day_of_week, day_name, is_weekend +) +SELECT + ROW_NUMBER() OVER (ORDER BY d.date_actual)::INTEGER AS calendar_sk, + d.date_actual, + EXTRACT(YEAR FROM d.date_actual)::INTEGER AS year_actual, + EXTRACT(MONTH FROM d.date_actual)::INTEGER AS month_actual, + EXTRACT(DAY FROM d.date_actual)::INTEGER AS day_actual, + EXTRACT(ISODOW FROM d.date_actual)::INTEGER AS day_of_week, + TO_CHAR(d.date_actual, 'FMDay') AS day_name, + EXTRACT(ISODOW FROM d.date_actual) IN (6, 7) AS is_weekend +FROM ( + SELECT generate_series('2016-01-01'::DATE, '2030-12-31'::DATE, '1 day'::INTERVAL)::DATE + AS date_actual +) AS d +WHERE NOT EXISTS (SELECT 1 FROM dds.dim_calendar LIMIT 1); + +ANALYZE dds.dim_calendar; +``` + +### 5.2. dim_airports, dim_airplanes, dim_tariffs, dim_passengers — SCD1 UPSERT + +> Все SCD1-измерения используют один и тот же паттерн: UPDATE существующих + INSERT новых +> с `MAX(sk) + ROW_NUMBER()` для стабильных суррогатных ключей. + +Паттерн (на примере airports): +```sql +-- Statement 1: UPDATE существующих записей (если атрибуты изменились) +UPDATE dds.dim_airports AS d +SET airport_name = s.airport_name, + city = s.city, + country = s.country, + timezone = s.timezone, + coordinates = s.coordinates, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM ods.airports AS s +WHERE d.airport_bk = s.airport_code + AND (d.airport_name IS DISTINCT FROM s.airport_name + OR d.city IS DISTINCT FROM s.city + OR d.country IS DISTINCT FROM s.country + OR d.timezone IS DISTINCT FROM s.timezone + OR d.coordinates IS DISTINCT FROM s.coordinates); + +-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()) +WITH max_sk AS ( + SELECT COALESCE(MAX(airport_sk), 0) AS v FROM dds.dim_airports +) +INSERT INTO dds.dim_airports ( + airport_sk, airport_bk, airport_name, city, country, + timezone, coordinates, created_at, updated_at, _load_id, _load_ts +) +SELECT + (SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.airport_code)::INTEGER, + s.airport_code, s.airport_name, s.city, s.country, + s.timezone, s.coordinates, + now(), now(), '{{ run_id }}', now() +FROM ods.airports AS s +WHERE NOT EXISTS ( + SELECT 1 FROM dds.dim_airports d WHERE d.airport_bk = s.airport_code +); + +ANALYZE dds.dim_airports; +``` + +**dim_airplanes** — аналогично, но с LEFT JOIN на `(SELECT airplane_code, COUNT(*) AS total_seats FROM ods.seats GROUP BY 1)` для обогащения `total_seats`. + +**dim_tariffs** — аналогично, но источник: `SELECT DISTINCT fare_conditions FROM ods.segments WHERE fare_conditions IS NOT NULL AND fare_conditions <> ''`. + +**dim_passengers** — аналогично, но с дедупликацией: `ROW_NUMBER() OVER (PARTITION BY passenger_id ORDER BY event_ts DESC NULLS LAST, _load_ts DESC)`, берём `rn = 1`. + +### 5.3. dim_routes — SCD2 с hashdiff + +```sql +-- CTE: текущее состояние маршрутов из ODS (последняя версия по validity). +-- В учебных целях используем классический SCD2 с hashdiff для демонстрации +-- паттерна. Хотя у routes в источнике есть поле validity, мы не опираемся +-- на него для версионирования — DWH сам детектит изменения атрибутов через хэш. + +-- Statement 1: Закрыть устаревшие версии (valid_to = текущая дата) +WITH src AS ( + SELECT + route_no, + departure_airport, + arrival_airport, + airplane_code, + days_of_week, + departure_time, + duration, + md5( + COALESCE(departure_airport, '') || '|' || + COALESCE(arrival_airport, '') || '|' || + COALESCE(airplane_code, '') || '|' || + COALESCE(days_of_week, '') || '|' || + COALESCE(departure_time::TEXT, '') || '|' || + COALESCE(duration::TEXT, '') + ) AS hashdiff, + ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn + FROM ods.routes +) +UPDATE dds.dim_routes AS d +SET valid_to = CURRENT_DATE, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM src AS s +WHERE s.rn = 1 + AND d.route_bk = s.route_no + AND d.valid_to IS NULL -- только текущая версия + AND d.hashdiff <> s.hashdiff; -- атрибуты изменились + +-- Statement 2: Вставить новые версии (для изменённых и совсем новых route_no) +WITH src AS ( + SELECT + route_no, + departure_airport, + arrival_airport, + airplane_code, + days_of_week, + departure_time, + duration, + md5( + COALESCE(departure_airport, '') || '|' || + COALESCE(arrival_airport, '') || '|' || + COALESCE(airplane_code, '') || '|' || + COALESCE(days_of_week, '') || '|' || + COALESCE(departure_time::TEXT, '') || '|' || + COALESCE(duration::TEXT, '') + ) AS hashdiff, + ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn + FROM ods.routes +), +max_sk AS ( + SELECT COALESCE(MAX(route_sk), 0) AS v FROM dds.dim_routes +) +INSERT INTO dds.dim_routes ( + route_sk, route_bk, departure_airport, arrival_airport, airplane_code, + days_of_week, departure_time, duration, + hashdiff, valid_from, valid_to, created_at, updated_at, _load_id, _load_ts +) +SELECT + (SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.route_no)::INTEGER, + s.route_no, + s.departure_airport, + s.arrival_airport, + s.airplane_code, + s.days_of_week, + s.departure_time, + s.duration, + s.hashdiff, + CURRENT_DATE, -- valid_from = сегодня + NULL, -- valid_to = NULL (текущая версия) + now(), now(), '{{ run_id }}', now() +FROM src AS s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 FROM dds.dim_routes d + WHERE d.route_bk = s.route_no + AND d.valid_to IS NULL + AND d.hashdiff = s.hashdiff + ); + +ANALYZE dds.dim_routes; +``` + +### 5.4. fact_flight_sales — инкрементальный UPSERT + +```sql +-- Statement 1: UPDATE существующих строк факта (если метрики/FK изменились) +WITH fact_src AS ( + SELECT + seg.ticket_no, + seg.flight_id, + cal.calendar_sk, + dep.airport_sk AS departure_airport_sk, + arr.airport_sk AS arrival_airport_sk, + ap.airplane_sk, + tar.tariff_sk, + pax.passenger_sk, + rte.route_sk, + tkt.book_ref, + bkg.book_date::DATE AS book_date, + bp.seat_no, + seg.segment_amount AS price, + (bp.ticket_no IS NOT NULL) AS is_boarded + FROM ods.segments AS seg + JOIN ods.tickets AS tkt ON tkt.ticket_no = seg.ticket_no + JOIN ods.bookings AS bkg ON bkg.book_ref = tkt.book_ref + JOIN ods.flights AS flt ON flt.flight_id = seg.flight_id + LEFT JOIN dds.dim_calendar AS cal ON cal.date_actual = flt.scheduled_departure::DATE + LEFT JOIN dds.dim_routes AS rte + ON rte.route_bk = flt.route_no AND rte.valid_to IS NULL + LEFT JOIN dds.dim_airports AS dep ON dep.airport_bk = rte.departure_airport + LEFT JOIN dds.dim_airports AS arr ON arr.airport_bk = rte.arrival_airport + LEFT JOIN dds.dim_airplanes AS ap ON ap.airplane_bk = rte.airplane_code + LEFT JOIN dds.dim_tariffs AS tar ON tar.fare_conditions = seg.fare_conditions + LEFT JOIN dds.dim_passengers AS pax ON pax.passenger_bk = tkt.passenger_id + LEFT JOIN ods.boarding_passes AS bp + ON bp.ticket_no = seg.ticket_no AND bp.flight_id = seg.flight_id +) +UPDATE dds.fact_flight_sales AS f +SET calendar_sk = s.calendar_sk, + departure_airport_sk = s.departure_airport_sk, + arrival_airport_sk = s.arrival_airport_sk, + airplane_sk = s.airplane_sk, + tariff_sk = s.tariff_sk, + passenger_sk = s.passenger_sk, + route_sk = s.route_sk, + book_ref = s.book_ref, + book_date = s.book_date, + seat_no = s.seat_no, + price = s.price, + is_boarded = s.is_boarded, + _load_id = '{{ run_id }}', + _load_ts = now() +FROM fact_src AS s +WHERE f.ticket_no = s.ticket_no + AND f.flight_id = s.flight_id + AND (f.is_boarded IS DISTINCT FROM s.is_boarded + OR f.price IS DISTINCT FROM s.price + OR f.seat_no IS DISTINCT FROM s.seat_no + OR f.route_sk IS DISTINCT FROM s.route_sk + OR f.departure_airport_sk IS DISTINCT FROM s.departure_airport_sk + OR f.arrival_airport_sk IS DISTINCT FROM s.arrival_airport_sk + OR f.airplane_sk IS DISTINCT FROM s.airplane_sk); + +-- Statement 2: INSERT новых строк факта +WITH fact_src AS ( + SELECT + seg.ticket_no, + seg.flight_id, + cal.calendar_sk, + dep.airport_sk AS departure_airport_sk, + arr.airport_sk AS arrival_airport_sk, + ap.airplane_sk, + tar.tariff_sk, + pax.passenger_sk, + rte.route_sk, + tkt.book_ref, + bkg.book_date::DATE AS book_date, + bp.seat_no, + seg.segment_amount AS price, + (bp.ticket_no IS NOT NULL) AS is_boarded + FROM ods.segments AS seg + JOIN ods.tickets AS tkt ON tkt.ticket_no = seg.ticket_no + JOIN ods.bookings AS bkg ON bkg.book_ref = tkt.book_ref + JOIN ods.flights AS flt ON flt.flight_id = seg.flight_id + LEFT JOIN dds.dim_calendar AS cal ON cal.date_actual = flt.scheduled_departure::DATE + LEFT JOIN dds.dim_routes AS rte + ON rte.route_bk = flt.route_no AND rte.valid_to IS NULL + LEFT JOIN dds.dim_airports AS dep ON dep.airport_bk = rte.departure_airport + LEFT JOIN dds.dim_airports AS arr ON arr.airport_bk = rte.arrival_airport + LEFT JOIN dds.dim_airplanes AS ap ON ap.airplane_bk = rte.airplane_code + LEFT JOIN dds.dim_tariffs AS tar ON tar.fare_conditions = seg.fare_conditions + LEFT JOIN dds.dim_passengers AS pax ON pax.passenger_bk = tkt.passenger_id + LEFT JOIN ods.boarding_passes AS bp + ON bp.ticket_no = seg.ticket_no AND bp.flight_id = seg.flight_id +) +INSERT INTO dds.fact_flight_sales ( + calendar_sk, departure_airport_sk, arrival_airport_sk, airplane_sk, + tariff_sk, passenger_sk, route_sk, + book_ref, ticket_no, flight_id, book_date, seat_no, + price, is_boarded, _load_id, _load_ts +) +SELECT + s.calendar_sk, s.departure_airport_sk, s.arrival_airport_sk, s.airplane_sk, + s.tariff_sk, s.passenger_sk, s.route_sk, + s.book_ref, s.ticket_no, s.flight_id, s.book_date, s.seat_no, + s.price, s.is_boarded, + '{{ run_id }}', now() +FROM fact_src AS s +WHERE NOT EXISTS ( + SELECT 1 FROM dds.fact_flight_sales f + WHERE f.ticket_no = s.ticket_no AND f.flight_id = s.flight_id +); + +ANALYZE dds.fact_flight_sales; +``` + +### 5.5. Point-in-time lookup для SCD2 dim_routes + +Текущая реализация использует `rte.valid_to IS NULL` (текущая версия маршрута). +Для полного point-in-time lookup (определение версии маршрута на момент вылета): + +```sql +LEFT JOIN dds.dim_routes AS rte + ON rte.route_bk = flt.route_no + AND flt.scheduled_departure::DATE >= rte.valid_from + AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to) +``` + +Это усложнение оставляем как задачу на будущее — в текущей реализации берём текущую версию. + +### 5.6. Идемпотентность паттернов + +- **dim_calendar**: `WHERE NOT EXISTS` — повторный запуск не создаёт дублей. +- **SCD1 измерения**: `UPDATE + INSERT WHERE NOT EXISTS` — натурально идемпотентно (как в ODS). +- **SCD2 dim_routes**: `UPDATE WHERE hashdiff <>` + `INSERT WHERE NOT EXISTS (bk + valid_to IS NULL + hashdiff =)` — повторный запуск с теми же данными ODS не создаёт дублей и не закрывает версии повторно. +- **fact_flight_sales**: `UPDATE + INSERT WHERE NOT EXISTS` — идемпотентно по зерну. + +--- + +## 6) DQ-проверки + +Каждый DQ-скрипт: PL/pgSQL `DO $$` блок, `RAISE EXCEPTION` при нарушении (как в ODS). + +### 6.1. Обязательные проверки по типам + +**Все измерения (кроме calendar):** +1. Таблица не пуста +2. Нет дублей по `_sk` +3. Нет дублей по `_bk` (для SCD1; для SCD2 — нет дублей по `_bk` WHERE `valid_to IS NULL`) +4. Покрытие ODS: все ключи из ODS присутствуют в DDS +5. Обязательные поля не NULL/пустые + +**dim_calendar:** +1. Не менее 1000 строк +2. Нет дублей по `calendar_sk` и `date_actual` +3. Обязательные поля не NULL + +**dim_routes (SCD2 специфика):** +1. Не более одной текущей версии на `route_bk` (`WHERE valid_to IS NULL` — уникальность) +2. `hashdiff` не NULL/пустой +3. `valid_from` не NULL +4. Покрытие: все `route_no` из ODS имеют хотя бы одну версию в DDS + +**fact_flight_sales:** +1. Таблица не пуста +2. Нет дублей по зерну `(ticket_no, flight_id)` +3. Количество строк = `COUNT(*)` из `ods.segments` +4. `passenger_sk IS NULL` = 0 (не должно быть) +5. `tariff_sk IS NULL` = 0 (не должно быть) +6. Обязательные поля: `book_ref`, `ticket_no`, `flight_id`, `is_boarded` не NULL + +### 6.2. Пример DQ для dim_routes (SCD2) + +```sql +DO $$ +DECLARE + v_row_count BIGINT; + v_dup_sk BIGINT; + v_dup_current BIGINT; + v_missing_count BIGINT; + v_null_count BIGINT; +BEGIN + -- Таблица не пуста + SELECT COUNT(*) INTO v_row_count FROM dds.dim_routes; + IF v_row_count = 0 THEN + RAISE EXCEPTION 'DQ FAILED: dds.dim_routes пуста.'; + END IF; + + -- Нет дублей по SK + SELECT COUNT(*) - COUNT(DISTINCT route_sk) INTO v_dup_sk FROM dds.dim_routes; + IF v_dup_sk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes найдены дубликаты route_sk: %', v_dup_sk; + END IF; + + -- SCD2: не более одной текущей версии на route_bk + SELECT COUNT(*) INTO v_dup_current + FROM ( + SELECT route_bk + FROM dds.dim_routes + WHERE valid_to IS NULL + GROUP BY route_bk + HAVING COUNT(*) > 1 + ) AS d; + IF v_dup_current <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes найдены route_bk с > 1 текущей версией: %', + v_dup_current; + END IF; + + -- Покрытие ODS (все route_no имеют хотя бы одну версию) + SELECT COUNT(*) INTO v_missing_count + FROM (SELECT DISTINCT route_no FROM ods.routes) AS o + WHERE NOT EXISTS ( + SELECT 1 FROM dds.dim_routes d WHERE d.route_bk = o.route_no + ); + IF v_missing_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes отсутствуют маршруты из ODS: %', v_missing_count; + END IF; + + -- Обязательные поля + SELECT COUNT(*) INTO v_null_count + FROM dds.dim_routes + WHERE route_sk IS NULL + OR route_bk IS NULL OR route_bk = '' + OR departure_airport IS NULL OR departure_airport = '' + OR arrival_airport IS NULL OR arrival_airport = '' + OR airplane_code IS NULL OR airplane_code = '' + OR hashdiff IS NULL OR hashdiff = '' + OR valid_from IS NULL + OR created_at IS NULL + OR updated_at IS NULL + OR _load_id IS NULL OR _load_id = '' + OR _load_ts IS NULL; + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes найдены NULL обязательные поля: %', v_null_count; + END IF; + + RAISE NOTICE 'DQ PASSED: dds.dim_routes ок, строк=% (версий)', v_row_count; +END $$; +``` + +### 6.3. Пример DQ для fact_flight_sales + +```sql +DO $$ +DECLARE + v_row_count BIGINT; + v_ods_count BIGINT; + v_dup_count BIGINT; + v_null_passenger BIGINT; + v_null_tariff BIGINT; + v_null_required BIGINT; +BEGIN + -- Таблица не пуста + SELECT COUNT(*) INTO v_row_count FROM dds.fact_flight_sales; + IF v_row_count = 0 THEN + RAISE EXCEPTION 'DQ FAILED: dds.fact_flight_sales пуста.'; + END IF; + + -- Покрытие: количество строк = ods.segments + SELECT COUNT(*) INTO v_ods_count FROM ods.segments; + IF v_row_count <> v_ods_count THEN + RAISE EXCEPTION + 'DQ FAILED: dds.fact_flight_sales (%) <> ods.segments (%). Потеряны строки.', + v_row_count, v_ods_count; + END IF; + + -- Нет дублей по зерну + SELECT COUNT(*) INTO v_dup_count + FROM ( + SELECT ticket_no, flight_id + FROM dds.fact_flight_sales + GROUP BY ticket_no, flight_id + HAVING COUNT(*) > 1 + ) AS d; + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.fact_flight_sales дубликаты (ticket_no, flight_id): %', + v_dup_count; + END IF; + + -- Ссылочная целостность: passenger_sk + SELECT COUNT(*) INTO v_null_passenger + FROM dds.fact_flight_sales WHERE passenger_sk IS NULL; + IF v_null_passenger <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales строки без passenger_sk: %', v_null_passenger; + END IF; + + -- Ссылочная целостность: tariff_sk + SELECT COUNT(*) INTO v_null_tariff + FROM dds.fact_flight_sales WHERE tariff_sk IS NULL; + IF v_null_tariff <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales строки без tariff_sk: %', v_null_tariff; + END IF; + + -- Обязательные поля + SELECT COUNT(*) INTO v_null_required + FROM dds.fact_flight_sales + WHERE book_ref IS NULL OR book_ref = '' + OR ticket_no IS NULL OR ticket_no = '' + OR flight_id IS NULL + OR is_boarded IS NULL + OR _load_id IS NULL OR _load_id = '' + OR _load_ts IS NULL; + IF v_null_required <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales NULL обязательные поля: %', v_null_required; + END IF; + + RAISE NOTICE 'DQ PASSED: dds.fact_flight_sales ок, строк=%', v_row_count; +END $$; +``` + +--- + +## 7) Структура файлов + +```text +sql/dds/ (21 SQL-файл) +├── dim_calendar_ddl.sql +├── dim_calendar_load.sql +├── dim_calendar_dq.sql +├── dim_airports_ddl.sql +├── dim_airports_load.sql +├── dim_airports_dq.sql +├── dim_airplanes_ddl.sql +├── dim_airplanes_load.sql +├── dim_airplanes_dq.sql +├── dim_tariffs_ddl.sql +├── dim_tariffs_load.sql +├── dim_tariffs_dq.sql +├── dim_passengers_ddl.sql +├── dim_passengers_load.sql +├── dim_passengers_dq.sql +├── dim_routes_ddl.sql +├── dim_routes_load.sql +├── dim_routes_dq.sql +├── fact_flight_sales_ddl.sql +├── fact_flight_sales_load.sql +└── fact_flight_sales_dq.sql + +airflow/dags/ (2 новых DAG) +├── bookings_dds_ddl.py +└── bookings_to_gp_dds.py + +sql/ddl_gp.sql (+ \i dds/*_ddl.sql в конец) +tests/test_dags_smoke.py (+ 2 smoke-теста) +docs/bookings_to_gp_dds.md (документация для студентов) +docs/internal/bookings_dds_design.md (этот план) +docs/internal/db_schema.md (обновить: добавить dim_routes, статус DDS) +``` + +--- + +## 8) DAG `bookings_dds_ddl` + +По аналогии с `bookings_ods_ddl.py` (`airflow/dags/bookings_ods_ddl.py`). + +**Ключевые параметры:** +- `dag_id = "bookings_dds_ddl"` +- `schedule = None` +- `template_searchpath = "/sql"` +- `tags = ["demo", "greenplum", "ddl", "bookings", "dds"]` +- `description = "Учебный DDL DAG: создаёт/обновляет dds.* для bookings"` + +**Задачи (линейная цепочка из 7 задач):** +1. `apply_dds_dim_calendar_ddl` — `dds/dim_calendar_ddl.sql` +2. `apply_dds_dim_airports_ddl` — `dds/dim_airports_ddl.sql` +3. `apply_dds_dim_airplanes_ddl` — `dds/dim_airplanes_ddl.sql` +4. `apply_dds_dim_tariffs_ddl` — `dds/dim_tariffs_ddl.sql` +5. `apply_dds_dim_passengers_ddl` — `dds/dim_passengers_ddl.sql` +6. `apply_dds_dim_routes_ddl` — `dds/dim_routes_ddl.sql` +7. `apply_dds_fact_flight_sales_ddl` — `dds/fact_flight_sales_ddl.sql` + +--- + +## 9) DAG `bookings_to_gp_dds`: граф зависимостей + +По аналогии с `bookings_to_gp_ods.py` (`airflow/dags/bookings_to_gp_ods.py`). + +**Ключевые параметры:** +- `dag_id = "bookings_to_gp_dds"` +- `schedule = None`, `max_active_runs = 1` +- `_load_id = {{ run_id }}` (не нужен `resolve_stg_batch_id`) + +### 9.1. Граф + +```text +load_dds_dim_calendar -> dq_dds_dim_calendar + | + v (после calendar — параллельно 5 измерений) +load_dds_dim_airports -> dq_dds_dim_airports +load_dds_dim_airplanes -> dq_dds_dim_airplanes +load_dds_dim_tariffs -> dq_dds_dim_tariffs +load_dds_dim_passengers -> dq_dds_dim_passengers +load_dds_dim_routes -> dq_dds_dim_routes + | + v (факт после ВСЕХ 6 измерений) +load_dds_fact_flight_sales -> dq_dds_fact_flight_sales -> finish_dds_summary +``` + +Задач: 7 load + 7 dq + 1 finish = **15 задач**. + +### 9.2. Зависимости (Python) + +```python +load_dds_dim_calendar >> dq_dds_dim_calendar + +# 5 измерений параллельно после calendar +dq_dds_dim_calendar >> [ + load_dds_dim_airports, load_dds_dim_airplanes, + load_dds_dim_tariffs, load_dds_dim_passengers, + load_dds_dim_routes +] + +load_dds_dim_airports >> dq_dds_dim_airports +load_dds_dim_airplanes >> dq_dds_dim_airplanes +load_dds_dim_tariffs >> dq_dds_dim_tariffs +load_dds_dim_passengers >> dq_dds_dim_passengers +load_dds_dim_routes >> dq_dds_dim_routes + +# Факт после всех измерений +[dq_dds_dim_airports, dq_dds_dim_airplanes, + dq_dds_dim_tariffs, dq_dds_dim_passengers, + dq_dds_dim_routes] >> load_dds_fact_flight_sales + +load_dds_fact_flight_sales >> dq_dds_fact_flight_sales >> finish_dds_summary +``` + +### 9.3. Почему calendar первая + +Факт ссылается на `calendar_sk`. Calendar — статическая таблица, заполняется один раз. +Но если DDS запускается впервые, calendar должна быть заполнена до загрузки факта. +Остальные 5 измерений не зависят друг от друга в DDS (FK-зависимости уже проверены в ODS). + +--- + +## 10) Smoke-тесты + +Добавить в `tests/test_dags_smoke.py` два теста: + +### test_bookings_dds_ddl_dag_structure +- 7 задач: `apply_dds_dim_{calendar,airports,airplanes,tariffs,passengers,routes}_ddl`, `apply_dds_fact_flight_sales_ddl` +- Линейная цепочка: каждая задача reachable от предыдущей + +### test_bookings_to_gp_dds_dag_structure +- 15 задач (7 load + 7 dq + `finish_dds_summary`) +- `load → dq` для каждого объекта (direct edge) +- `dq_dds_dim_calendar` → все 5 остальных load-измерений +- airports и airplanes не зависят друг от друга (параллельность) +- факт reachable от всех 6 dq измерений (через `[...] >> load_dds_fact`) +- `finish_dds_summary` reachable от `dq_dds_fact_flight_sales` + +--- + +## 11) Порядок реализации + +1. DDL: 7 файлов `sql/dds/*_ddl.sql` (calendar, airports, airplanes, tariffs, passengers, routes, fact) +2. Подключить DDL в `sql/ddl_gp.sql` (добавить `\i dds/*_ddl.sql`) +3. DAG `airflow/dags/bookings_dds_ddl.py` +4. Load SQL: 7 файлов `sql/dds/*_load.sql` +5. DQ SQL: 7 файлов `sql/dds/*_dq.sql` +6. DAG `airflow/dags/bookings_to_gp_dds.py` +7. Smoke-тесты в `tests/test_dags_smoke.py` (+2 теста) +8. Документация `docs/bookings_to_gp_dds.md` +9. Обновить `docs/internal/db_schema.md` — отразить `dim_routes` и актуальный статус DDS + +Итого: **21 SQL-файл** + **2 DAG** + **обновления 3 существующих файлов** + **1 новый doc-файл**. + +--- + +## 12) Критические файлы-образцы (patterns to follow) + +| Что реализуем | Образец в репозитории | +|---------------|---------| +| DDS DDL DAG | `airflow/dags/bookings_ods_ddl.py` | +| DDS ETL DAG | `airflow/dags/bookings_to_gp_ods.py` | +| DDL SQL | `sql/ods/airports_ddl.sql` | +| SCD1 UPSERT SQL | `sql/ods/airports_load.sql`, `sql/ods/bookings_load.sql` | +| DQ SQL (PL/pgSQL) | `sql/ods/airports_dq.sql`, `sql/ods/segments_dq.sql` | +| Smoke-тесты | `tests/test_dags_smoke.py` (тесты ODS DAG) | +| Подключение DDL | `sql/ddl_gp.sql` (секция ODS `\i` директивы) | + +--- + +## 13) Критерии готовности (Definition of Done) + +1. Оба новых DAG парсятся и проходят smoke-тесты (`make test`) +2. `make ddl-gp` создаёт STG+ODS+DDS без ошибок +3. DAG `bookings_to_gp_dds` завершается успешно после ODS +4. Все DQ-задачи зелёные +5. В DDS нет дублей по SK и BK +6. `fact_flight_sales` содержит столько строк, сколько в `ods.segments` +7. Нейминг консистентен: `_bk`, `_sk`, `valid_from`/`valid_to`, `hashdiff`, `_load_id`, `_load_ts`, `created_at`/`updated_at` +8. `make fmt` / `make lint` проходят +9. `dim_routes` демонстрирует SCD2 с реальными версиями + +--- + +## 14) Как проверять вручную + +```bash +make up +make ddl-gp # создать STG+ODS+DDS-объекты +# Trigger bookings_to_gp_stage (загрузить STG) +# Trigger bookings_to_gp_ods (загрузить ODS) +# Trigger bookings_to_gp_dds (загрузить DDS) +make gp-psql +``` + +Проверочные SQL: + +```sql +-- 1) Количество строк в измерениях и факте +SELECT 'dim_calendar' AS tbl, COUNT(*) FROM dds.dim_calendar +UNION ALL +SELECT 'dim_airports', COUNT(*) FROM dds.dim_airports +UNION ALL +SELECT 'dim_airplanes', COUNT(*) FROM dds.dim_airplanes +UNION ALL +SELECT 'dim_tariffs', COUNT(*) FROM dds.dim_tariffs +UNION ALL +SELECT 'dim_passengers', COUNT(*) FROM dds.dim_passengers +UNION ALL +SELECT 'dim_routes', COUNT(*) FROM dds.dim_routes +UNION ALL +SELECT 'fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales; + +-- 2) Покрытие факта: должно совпадать с ods.segments +SELECT + (SELECT COUNT(*) FROM dds.fact_flight_sales) AS fact_rows, + (SELECT COUNT(*) FROM ods.segments) AS ods_rows; + +-- 3) SCD2 dim_routes: версии маршрутов +SELECT route_bk, COUNT(*) AS versions +FROM dds.dim_routes +GROUP BY route_bk +HAVING COUNT(*) > 1 +ORDER BY versions DESC; + +-- 4) NULL суррогатные ключи в факте (потенциальные аномалии) +SELECT + SUM(CASE WHEN calendar_sk IS NULL THEN 1 ELSE 0 END) AS null_calendar, + SUM(CASE WHEN departure_airport_sk IS NULL THEN 1 ELSE 0 END) AS null_dep_airport, + SUM(CASE WHEN arrival_airport_sk IS NULL THEN 1 ELSE 0 END) AS null_arr_airport, + SUM(CASE WHEN airplane_sk IS NULL THEN 1 ELSE 0 END) AS null_airplane, + SUM(CASE WHEN tariff_sk IS NULL THEN 1 ELSE 0 END) AS null_tariff, + SUM(CASE WHEN passenger_sk IS NULL THEN 1 ELSE 0 END) AS null_passenger, + SUM(CASE WHEN route_sk IS NULL THEN 1 ELSE 0 END) AS null_route +FROM dds.fact_flight_sales; + +-- 5) Пример аналитического запроса: выручка по тарифам +SELECT + t.fare_conditions, + COUNT(*) AS segments, + SUM(f.price) AS total_revenue, + AVG(f.price) AS avg_price +FROM dds.fact_flight_sales AS f +JOIN dds.dim_tariffs AS t ON t.tariff_sk = f.tariff_sk +GROUP BY t.fare_conditions +ORDER BY total_revenue DESC; + +-- 6) Пример запроса с SCD2: маршруты и их версии +SELECT + r.route_bk, + r.departure_airport, + r.arrival_airport, + r.airplane_code, + r.valid_from, + r.valid_to, + COUNT(f.ticket_no) AS fact_rows +FROM dds.dim_routes AS r +LEFT JOIN dds.fact_flight_sales AS f ON f.route_sk = r.route_sk +GROUP BY 1, 2, 3, 4, 5, 6 +ORDER BY r.route_bk, r.valid_from; +``` + +--- + +## 15) Что будет следующим шагом + +- Data Mart (витрина) поверх DDS +- Point-in-time lookup для `dim_routes` в факте (`BETWEEN valid_from AND valid_to`) +- `dim_calendar.is_holiday` (если появится источник) From 06a5b7bc4c4f15be74e0389b4798e86df92ee79f Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Mon, 23 Feb 2026 23:05:16 +0300 Subject: [PATCH 33/38] =?UTF-8?q?fix(dwh):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=205=20=D0=B7=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=87=D0=B0=D0=BD=D0=B8=D0=B9=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0=20DDS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - устранить противоречия и пробелы в плане DDS перед реализацией. - Что: - HIGH: факт теперь использует point-in-time SCD2 lookup для routes, UPDATE факта не перезаписывает dimension SK (историчность сохранена). - MEDIUM: явная политика NULL FK в факте (3 группы: обязательные, зависят от маршрута, зависят от расписания) + DQ с порогом 1%. - MEDIUM: добавлены DQ-проверки SCD2 интервалов (valid_from < valid_to, нет перекрытий версий). - MEDIUM: DQ факта теперь проверяет calendar_sk и route_sk IS NULL с порогом. - LOW: добавлен стабильный tie-breaker (ticket_no) при дедупликации passengers. - добавлена секция backfill/reprocess политики. - Проверка: - cat docs/internal/bookings_dds_design.md. Co-Authored-By: Claude Opus 4.6 --- docs/internal/bookings_dds_design.md | 178 +++++++++++++++------------ 1 file changed, 102 insertions(+), 76 deletions(-) diff --git a/docs/internal/bookings_dds_design.md b/docs/internal/bookings_dds_design.md index 8b41b14..89912e5 100644 --- a/docs/internal/bookings_dds_design.md +++ b/docs/internal/bookings_dds_design.md @@ -166,7 +166,17 @@ _load_ts TIMESTAMP NOT NULL DEFAULT now() DISTRIBUTED BY (ticket_no) ``` -FK суррогатные ключи допускают NULL (LEFT JOIN при аномалиях данных). DQ ловит NULL там, где их быть не должно. +### 3.8. Политика NULL FK в факте + +FK суррогатные ключи разделены на три группы: + +| Группа | FK | NULL допустим? | Причина | +|--------|-----|---------------|---------| +| **Обязательные** | `tariff_sk`, `passenger_sk` | Нет | Данные всегда есть в ODS (segments, tickets). NULL = баг загрузки. | +| **Зависят от маршрута** | `route_sk`, `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk` | Нет (в норме) | Маршрут должен быть в ODS. NULL = аномалия данных, DQ предупреждает. | +| **Зависят от расписания** | `calendar_sk` | Допустим (редко) | `scheduled_departure` может быть NULL в ODS. DQ считает и логирует, но не фейлит. | + +DQ-проверки явно контролируют каждую группу (см. секцию 6). --- @@ -276,7 +286,7 @@ ANALYZE dds.dim_airports; **dim_tariffs** — аналогично, но источник: `SELECT DISTINCT fare_conditions FROM ods.segments WHERE fare_conditions IS NOT NULL AND fare_conditions <> ''`. -**dim_passengers** — аналогично, но с дедупликацией: `ROW_NUMBER() OVER (PARTITION BY passenger_id ORDER BY event_ts DESC NULLS LAST, _load_ts DESC)`, берём `rn = 1`. +**dim_passengers** — аналогично, но с дедупликацией: `ROW_NUMBER() OVER (PARTITION BY passenger_id ORDER BY event_ts DESC NULLS LAST, _load_ts DESC, ticket_no DESC)`, берём `rn = 1`. Третий ключ `ticket_no DESC` — стабильный tie-breaker при одинаковых timestamp. ### 5.3. dim_routes — SCD2 с hashdiff @@ -375,65 +385,27 @@ ANALYZE dds.dim_routes; ### 5.4. fact_flight_sales — инкрементальный UPSERT ```sql --- Statement 1: UPDATE существующих строк факта (если метрики/FK изменились) -WITH fact_src AS ( - SELECT - seg.ticket_no, - seg.flight_id, - cal.calendar_sk, - dep.airport_sk AS departure_airport_sk, - arr.airport_sk AS arrival_airport_sk, - ap.airplane_sk, - tar.tariff_sk, - pax.passenger_sk, - rte.route_sk, - tkt.book_ref, - bkg.book_date::DATE AS book_date, - bp.seat_no, - seg.segment_amount AS price, - (bp.ticket_no IS NOT NULL) AS is_boarded - FROM ods.segments AS seg - JOIN ods.tickets AS tkt ON tkt.ticket_no = seg.ticket_no - JOIN ods.bookings AS bkg ON bkg.book_ref = tkt.book_ref - JOIN ods.flights AS flt ON flt.flight_id = seg.flight_id - LEFT JOIN dds.dim_calendar AS cal ON cal.date_actual = flt.scheduled_departure::DATE - LEFT JOIN dds.dim_routes AS rte - ON rte.route_bk = flt.route_no AND rte.valid_to IS NULL - LEFT JOIN dds.dim_airports AS dep ON dep.airport_bk = rte.departure_airport - LEFT JOIN dds.dim_airports AS arr ON arr.airport_bk = rte.arrival_airport - LEFT JOIN dds.dim_airplanes AS ap ON ap.airplane_bk = rte.airplane_code - LEFT JOIN dds.dim_tariffs AS tar ON tar.fare_conditions = seg.fare_conditions - LEFT JOIN dds.dim_passengers AS pax ON pax.passenger_bk = tkt.passenger_id - LEFT JOIN ods.boarding_passes AS bp - ON bp.ticket_no = seg.ticket_no AND bp.flight_id = seg.flight_id -) +-- Statement 1: UPDATE существующих строк факта. +-- ВАЖНО: обновляем ТОЛЬКО мутабельные поля (is_boarded, seat_no, price). +-- Dimension SK (route_sk, airport_sk, airplane_sk и т.д.) НЕ перезаписываем — +-- они зафиксированы на момент INSERT и отражают историческое состояние. UPDATE dds.fact_flight_sales AS f -SET calendar_sk = s.calendar_sk, - departure_airport_sk = s.departure_airport_sk, - arrival_airport_sk = s.arrival_airport_sk, - airplane_sk = s.airplane_sk, - tariff_sk = s.tariff_sk, - passenger_sk = s.passenger_sk, - route_sk = s.route_sk, - book_ref = s.book_ref, - book_date = s.book_date, - seat_no = s.seat_no, - price = s.price, - is_boarded = s.is_boarded, - _load_id = '{{ run_id }}', - _load_ts = now() -FROM fact_src AS s -WHERE f.ticket_no = s.ticket_no - AND f.flight_id = s.flight_id - AND (f.is_boarded IS DISTINCT FROM s.is_boarded - OR f.price IS DISTINCT FROM s.price - OR f.seat_no IS DISTINCT FROM s.seat_no - OR f.route_sk IS DISTINCT FROM s.route_sk - OR f.departure_airport_sk IS DISTINCT FROM s.departure_airport_sk - OR f.arrival_airport_sk IS DISTINCT FROM s.arrival_airport_sk - OR f.airplane_sk IS DISTINCT FROM s.airplane_sk); +SET seat_no = bp.seat_no, + price = seg.segment_amount, + is_boarded = (bp.ticket_no IS NOT NULL), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM ods.segments AS seg +LEFT JOIN ods.boarding_passes AS bp + ON bp.ticket_no = seg.ticket_no AND bp.flight_id = seg.flight_id +WHERE f.ticket_no = seg.ticket_no + AND f.flight_id = seg.flight_id + AND (f.is_boarded IS DISTINCT FROM (bp.ticket_no IS NOT NULL) + OR f.price IS DISTINCT FROM seg.segment_amount + OR f.seat_no IS DISTINCT FROM bp.seat_no); --- Statement 2: INSERT новых строк факта +-- Statement 2: INSERT новых строк факта. +-- Dimension SK фиксируются на момент вставки (point-in-time для SCD2 routes). WITH fact_src AS ( SELECT seg.ticket_no, @@ -454,9 +426,12 @@ WITH fact_src AS ( JOIN ods.tickets AS tkt ON tkt.ticket_no = seg.ticket_no JOIN ods.bookings AS bkg ON bkg.book_ref = tkt.book_ref JOIN ods.flights AS flt ON flt.flight_id = seg.flight_id + -- SCD2 point-in-time: версия маршрута, актуальная на дату вылета + LEFT JOIN dds.dim_routes AS rte + ON rte.route_bk = flt.route_no + AND flt.scheduled_departure::DATE >= rte.valid_from + AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to) LEFT JOIN dds.dim_calendar AS cal ON cal.date_actual = flt.scheduled_departure::DATE - LEFT JOIN dds.dim_routes AS rte - ON rte.route_bk = flt.route_no AND rte.valid_to IS NULL LEFT JOIN dds.dim_airports AS dep ON dep.airport_bk = rte.departure_airport LEFT JOIN dds.dim_airports AS arr ON arr.airport_bk = rte.arrival_airport LEFT JOIN dds.dim_airplanes AS ap ON ap.airplane_bk = rte.airplane_code @@ -486,21 +461,27 @@ WHERE NOT EXISTS ( ANALYZE dds.fact_flight_sales; ``` -### 5.5. Point-in-time lookup для SCD2 dim_routes +### 5.5. Модель историчности факта -Текущая реализация использует `rte.valid_to IS NULL` (текущая версия маршрута). -Для полного point-in-time lookup (определение версии маршрута на момент вылета): +Dimension SK фиксируются **при INSERT** и не перезаписываются: +- `route_sk` — версия маршрута на дату `scheduled_departure` (point-in-time SCD2 lookup); +- `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk` — из той же версии маршрута; +- `calendar_sk`, `tariff_sk`, `passenger_sk` — из текущих SCD1-измерений на момент INSERT. -```sql -LEFT JOIN dds.dim_routes AS rte - ON rte.route_bk = flt.route_no - AND flt.scheduled_departure::DATE >= rte.valid_from - AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to) -``` +UPDATE факта обновляет только **мутабельные поля**: `is_boarded`, `seat_no`, `price` +(появился посадочный, изменилась цена). Это гарантирует, что аналитика по историческим +периодам использует правильные версии измерений. -Это усложнение оставляем как задачу на будущее — в текущей реализации берём текущую версию. +### 5.6. Политика backfill/reprocess -### 5.6. Идемпотентность паттернов +- **Повторный запуск** с теми же данными ODS — безопасен (идемпотентно). +- **Повторный запуск после изменения маршрутов в ODS**: dim_routes создаст новую SCD2-версию; + уже вставленные строки факта сохранят старый `route_sk` (историчность). + Новые строки факта получат актуальный `route_sk` через point-in-time lookup. +- **Полная пересборка факта**: если нужна — `TRUNCATE dds.fact_flight_sales` и повторный + запуск DAG. Все SK будут пересчитаны через point-in-time lookup. + +### 5.7. Идемпотентность паттернов - **dim_calendar**: `WHERE NOT EXISTS` — повторный запуск не создаёт дублей. - **SCD1 измерения**: `UPDATE + INSERT WHERE NOT EXISTS` — натурально идемпотентно (как в ODS). @@ -531,15 +512,18 @@ LEFT JOIN dds.dim_routes AS rte 1. Не более одной текущей версии на `route_bk` (`WHERE valid_to IS NULL` — уникальность) 2. `hashdiff` не NULL/пустой 3. `valid_from` не NULL -4. Покрытие: все `route_no` из ODS имеют хотя бы одну версию в DDS +4. Корректность интервалов: `valid_from < valid_to` для всех закрытых версий +5. Нет перекрытий версий: для одного `route_bk` интервалы `[valid_from, valid_to)` не пересекаются +6. Покрытие: все `route_no` из ODS имеют хотя бы одну версию в DDS **fact_flight_sales:** 1. Таблица не пуста 2. Нет дублей по зерну `(ticket_no, flight_id)` 3. Количество строк = `COUNT(*)` из `ods.segments` -4. `passenger_sk IS NULL` = 0 (не должно быть) -5. `tariff_sk IS NULL` = 0 (не должно быть) -6. Обязательные поля: `book_ref`, `ticket_no`, `flight_id`, `is_boarded` не NULL +4. **Обязательные FK**: `passenger_sk IS NULL` = 0, `tariff_sk IS NULL` = 0 +5. **FK маршрута**: `route_sk IS NULL` — допустимо при аномалиях, считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк +6. **Calendar**: `calendar_sk IS NULL` — допустимо если `scheduled_departure IS NULL` в ODS; считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк +7. Обязательные поля: `book_ref`, `ticket_no`, `flight_id`, `is_boarded` не NULL ### 6.2. Пример DQ для dim_routes (SCD2) @@ -565,6 +549,16 @@ BEGIN 'DQ FAILED: в dds.dim_routes найдены дубликаты route_sk: %', v_dup_sk; END IF; + -- SCD2: корректность интервалов (valid_from < valid_to для закрытых версий) + SELECT COUNT(*) INTO v_null_count + FROM dds.dim_routes + WHERE valid_to IS NOT NULL AND valid_from >= valid_to; + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes найдены версии с valid_from >= valid_to: %', + v_null_count; + END IF; + -- SCD2: не более одной текущей версии на route_bk SELECT COUNT(*) INTO v_dup_current FROM ( @@ -670,6 +664,38 @@ BEGIN 'DQ FAILED: в fact_flight_sales строки без tariff_sk: %', v_null_tariff; END IF; + -- FK маршрута: route_sk (допустимо при аномалиях, фейлим если > 1%) + DECLARE v_null_route BIGINT; + SELECT COUNT(*) INTO v_null_route + FROM dds.fact_flight_sales WHERE route_sk IS NULL; + IF v_null_route > 0 THEN + IF v_null_route * 100 / v_row_count > 1 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales слишком много строк без route_sk: % (>1%%)', + v_null_route; + ELSE + RAISE NOTICE + 'DQ WARNING: в fact_flight_sales строк без route_sk: % (<=1%%, допустимо)', + v_null_route; + END IF; + END IF; + + -- Calendar: calendar_sk (допустимо если scheduled_departure IS NULL) + DECLARE v_null_calendar BIGINT; + SELECT COUNT(*) INTO v_null_calendar + FROM dds.fact_flight_sales WHERE calendar_sk IS NULL; + IF v_null_calendar > 0 THEN + IF v_null_calendar * 100 / v_row_count > 1 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales слишком много строк без calendar_sk: % (>1%%)', + v_null_calendar; + ELSE + RAISE NOTICE + 'DQ WARNING: в fact_flight_sales строк без calendar_sk: % (<=1%%, допустимо)', + v_null_calendar; + END IF; + END IF; + -- Обязательные поля SELECT COUNT(*) INTO v_null_required FROM dds.fact_flight_sales From e98821f28d9e2fa78e78cfccb6dc933c26d72df3 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 24 Feb 2026 00:03:37 +0300 Subject: [PATCH 34/38] =?UTF-8?q?fix(dwh):=20=D0=B4=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=D0=BD=20DDS-=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF=D0=BE=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - закрыть оставшиеся риски и противоречия в проектировании DDS перед реализацией. - Что: - добавлена обработка исчезнувших маршрутов в SCD2 (`UPDATE missing`) и уточнён паттерн идемпотентности. - уточнены правила SCD2 на DATE-гранулярности (`valid_from <= valid_to`) и добавлена DQ-проверка current-среза DDS vs ODS. - расширены DQ-требования (покрытие календаря по датам flights, route-related FK), исправлены критерии готовности и текстовые несоответствия. - Проверка: - git diff -- docs/internal/bookings_dds_design.md --- docs/internal/bookings_dds_design.md | 120 ++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 22 deletions(-) diff --git a/docs/internal/bookings_dds_design.md b/docs/internal/bookings_dds_design.md index 89912e5..b383a1c 100644 --- a/docs/internal/bookings_dds_design.md +++ b/docs/internal/bookings_dds_design.md @@ -328,6 +328,27 @@ WHERE s.rn = 1 AND d.valid_to IS NULL -- только текущая версия AND d.hashdiff <> s.hashdiff; -- атрибуты изменились +-- Statement 1.1: Закрыть "исчезнувшие" маршруты +-- (есть в текущем срезе DDS, но отсутствуют в текущем состоянии ODS). +WITH src AS ( + SELECT + route_no, + ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn + FROM ods.routes +) +UPDATE dds.dim_routes AS d +SET valid_to = CURRENT_DATE, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +WHERE d.valid_to IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM src AS s + WHERE s.rn = 1 + AND s.route_no = d.route_bk + ); + -- Statement 2: Вставить новые версии (для изменённых и совсем новых route_no) WITH src AS ( SELECT @@ -367,7 +388,15 @@ SELECT s.departure_time, s.duration, s.hashdiff, - CURRENT_DATE, -- valid_from = сегодня + -- valid_from: для совсем новых route_no — sentinel '1900-01-01' + -- (чтобы point-in-time lookup покрыл все исторические рейсы); + -- для обновлённых (уже были в DDS, но hashdiff изменился) — CURRENT_DATE. + CASE + WHEN EXISTS ( + SELECT 1 FROM dds.dim_routes d2 WHERE d2.route_bk = s.route_no + ) THEN CURRENT_DATE + ELSE '1900-01-01'::DATE + END AS valid_from, NULL, -- valid_to = NULL (текущая версия) now(), now(), '{{ run_id }}', now() FROM src AS s @@ -382,6 +411,10 @@ WHERE s.rn = 1 ANALYZE dds.dim_routes; ``` +Примечание: `valid_from`/`valid_to` имеют дневную гранулярность (`DATE`). +Если маршрут меняется несколько раз в один день, допускается закрытая версия с +`valid_from = valid_to` (нулевой интервал), чтобы не терять факт изменения. + ### 5.4. fact_flight_sales — инкрементальный UPSERT ```sql @@ -485,7 +518,7 @@ UPDATE факта обновляет только **мутабельные по - **dim_calendar**: `WHERE NOT EXISTS` — повторный запуск не создаёт дублей. - **SCD1 измерения**: `UPDATE + INSERT WHERE NOT EXISTS` — натурально идемпотентно (как в ODS). -- **SCD2 dim_routes**: `UPDATE WHERE hashdiff <>` + `INSERT WHERE NOT EXISTS (bk + valid_to IS NULL + hashdiff =)` — повторный запуск с теми же данными ODS не создаёт дублей и не закрывает версии повторно. +- **SCD2 dim_routes**: `UPDATE changed` + `UPDATE missing` + `INSERT WHERE NOT EXISTS (bk + valid_to IS NULL + hashdiff =)` — повторный запуск с теми же данными ODS не создаёт дублей и не закрывает версии повторно. - **fact_flight_sales**: `UPDATE + INSERT WHERE NOT EXISTS` — идемпотентно по зерну. --- @@ -507,21 +540,23 @@ UPDATE факта обновляет только **мутабельные по 1. Не менее 1000 строк 2. Нет дублей по `calendar_sk` и `date_actual` 3. Обязательные поля не NULL +4. Покрывает диапазон дат из `ods.flights.scheduled_departure` (для NOT NULL) **dim_routes (SCD2 специфика):** 1. Не более одной текущей версии на `route_bk` (`WHERE valid_to IS NULL` — уникальность) 2. `hashdiff` не NULL/пустой 3. `valid_from` не NULL -4. Корректность интервалов: `valid_from < valid_to` для всех закрытых версий +4. Корректность интервалов: `valid_from <= valid_to` для всех закрытых версий (DATE-гранулярность) 5. Нет перекрытий версий: для одного `route_bk` интервалы `[valid_from, valid_to)` не пересекаются 6. Покрытие: все `route_no` из ODS имеют хотя бы одну версию в DDS +7. Текущий срез DDS консистентен с ODS: `route_bk` с `valid_to IS NULL` есть в `ods.routes` **fact_flight_sales:** 1. Таблица не пуста 2. Нет дублей по зерну `(ticket_no, flight_id)` 3. Количество строк = `COUNT(*)` из `ods.segments` 4. **Обязательные FK**: `passenger_sk IS NULL` = 0, `tariff_sk IS NULL` = 0 -5. **FK маршрута**: `route_sk IS NULL` — допустимо при аномалиях, считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк +5. **FK маршрута**: NULL в любом из `route_sk`, `departure_airport_sk`, `arrival_airport_sk`, `airplane_sk` — допустимо при аномалиях, считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк 6. **Calendar**: `calendar_sk IS NULL` — допустимо если `scheduled_departure IS NULL` в ODS; считаем и логируем (`RAISE NOTICE`); фейлим если > 1% строк 7. Обязательные поля: `book_ref`, `ticket_no`, `flight_id`, `is_boarded` не NULL @@ -533,7 +568,9 @@ DECLARE v_row_count BIGINT; v_dup_sk BIGINT; v_dup_current BIGINT; + v_overlap_count BIGINT; v_missing_count BIGINT; + v_orphan_current BIGINT; v_null_count BIGINT; BEGIN -- Таблица не пуста @@ -549,16 +586,33 @@ BEGIN 'DQ FAILED: в dds.dim_routes найдены дубликаты route_sk: %', v_dup_sk; END IF; - -- SCD2: корректность интервалов (valid_from < valid_to для закрытых версий) + -- SCD2: корректность интервалов (valid_from <= valid_to для закрытых версий) SELECT COUNT(*) INTO v_null_count FROM dds.dim_routes - WHERE valid_to IS NOT NULL AND valid_from >= valid_to; + WHERE valid_to IS NOT NULL AND valid_from > valid_to; IF v_null_count <> 0 THEN RAISE EXCEPTION - 'DQ FAILED: в dds.dim_routes найдены версии с valid_from >= valid_to: %', + 'DQ FAILED: в dds.dim_routes найдены версии с valid_from > valid_to: %', v_null_count; END IF; + -- SCD2: нет перекрытий интервалов для одного route_bk + SELECT COUNT(*) INTO v_overlap_count + FROM ( + SELECT 1 + FROM dds.dim_routes d1 + JOIN dds.dim_routes d2 + ON d1.route_bk = d2.route_bk + AND d1.route_sk < d2.route_sk + AND d1.valid_from < COALESCE(d2.valid_to, DATE '9999-12-31') + AND d2.valid_from < COALESCE(d1.valid_to, DATE '9999-12-31') + ) AS overlaps; + IF v_overlap_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes найдены перекрытия SCD2-интервалов: %', + v_overlap_count; + END IF; + -- SCD2: не более одной текущей версии на route_bk SELECT COUNT(*) INTO v_dup_current FROM ( @@ -585,6 +639,24 @@ BEGIN 'DQ FAILED: в dds.dim_routes отсутствуют маршруты из ODS: %', v_missing_count; END IF; + -- SCD2: current-срез DDS не содержит route_bk, которых нет в ODS + SELECT COUNT(*) INTO v_orphan_current + FROM ( + SELECT DISTINCT route_bk + FROM dds.dim_routes + WHERE valid_to IS NULL + ) AS d + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.routes AS o + WHERE o.route_no = d.route_bk + ); + IF v_orphan_current <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в current-срезе dds.dim_routes есть route_bk вне ODS: %', + v_orphan_current; + END IF; + -- Обязательные поля SELECT COUNT(*) INTO v_null_count FROM dds.dim_routes @@ -618,6 +690,8 @@ DECLARE v_dup_count BIGINT; v_null_passenger BIGINT; v_null_tariff BIGINT; + v_null_route_related BIGINT; + v_null_calendar BIGINT; v_null_required BIGINT; BEGIN -- Таблица не пуста @@ -664,28 +738,30 @@ BEGIN 'DQ FAILED: в fact_flight_sales строки без tariff_sk: %', v_null_tariff; END IF; - -- FK маршрута: route_sk (допустимо при аномалиях, фейлим если > 1%) - DECLARE v_null_route BIGINT; - SELECT COUNT(*) INTO v_null_route - FROM dds.fact_flight_sales WHERE route_sk IS NULL; - IF v_null_route > 0 THEN - IF v_null_route * 100 / v_row_count > 1 THEN + -- FK маршрута: route-related группа (допустимо при аномалиях, фейлим если > 1%) + SELECT COUNT(*) INTO v_null_route_related + FROM dds.fact_flight_sales + WHERE route_sk IS NULL + OR departure_airport_sk IS NULL + OR arrival_airport_sk IS NULL + OR airplane_sk IS NULL; + IF v_null_route_related > 0 THEN + IF v_null_route_related * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN RAISE EXCEPTION - 'DQ FAILED: в fact_flight_sales слишком много строк без route_sk: % (>1%%)', - v_null_route; + 'DQ FAILED: в fact_flight_sales слишком много строк с NULL в route-related FK: % (>1%%)', + v_null_route_related; ELSE RAISE NOTICE - 'DQ WARNING: в fact_flight_sales строк без route_sk: % (<=1%%, допустимо)', - v_null_route; + 'DQ WARNING: в fact_flight_sales строк с NULL в route-related FK: % (<=1%%, допустимо)', + v_null_route_related; END IF; END IF; -- Calendar: calendar_sk (допустимо если scheduled_departure IS NULL) - DECLARE v_null_calendar BIGINT; SELECT COUNT(*) INTO v_null_calendar FROM dds.fact_flight_sales WHERE calendar_sk IS NULL; IF v_null_calendar > 0 THEN - IF v_null_calendar * 100 / v_row_count > 1 THEN + IF v_null_calendar * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN RAISE EXCEPTION 'DQ FAILED: в fact_flight_sales слишком много строк без calendar_sk: % (>1%%)', v_null_calendar; @@ -882,7 +958,7 @@ load_dds_fact_flight_sales >> dq_dds_fact_flight_sales >> finish_dds_summary | SCD1 UPSERT SQL | `sql/ods/airports_load.sql`, `sql/ods/bookings_load.sql` | | DQ SQL (PL/pgSQL) | `sql/ods/airports_dq.sql`, `sql/ods/segments_dq.sql` | | Smoke-тесты | `tests/test_dags_smoke.py` (тесты ODS DAG) | -| Подключение DDL | `sql/ddl_gp.sql` (секция ODS `\i` директивы) | +| Подключение DDL | `sql/ddl_gp.sql` (секция DDS `\i` директивы) | --- @@ -892,7 +968,7 @@ load_dds_fact_flight_sales >> dq_dds_fact_flight_sales >> finish_dds_summary 2. `make ddl-gp` создаёт STG+ODS+DDS без ошибок 3. DAG `bookings_to_gp_dds` завершается успешно после ODS 4. Все DQ-задачи зелёные -5. В DDS нет дублей по SK и BK +5. В DDS нет дублей по SK; для SCD1 нет дублей по BK, для SCD2 не более одной current-версии BK 6. `fact_flight_sales` содержит столько строк, сколько в `ods.segments` 7. Нейминг консистентен: `_bk`, `_sk`, `valid_from`/`valid_to`, `hashdiff`, `_load_id`, `_load_ts`, `created_at`/`updated_at` 8. `make fmt` / `make lint` проходят @@ -983,5 +1059,5 @@ ORDER BY r.route_bk, r.valid_from; ## 15) Что будет следующим шагом - Data Mart (витрина) поверх DDS -- Point-in-time lookup для `dim_routes` в факте (`BETWEEN valid_from AND valid_to`) +- Unknown-member стратегия (`*_sk = 0`) для late-arriving dimensions - `dim_calendar.is_holiday` (если появится источник) From 9dbfc3572db6991048ba7fd3a67aed0f1d4a6253 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 24 Feb 2026 00:09:14 +0300 Subject: [PATCH 35/38] =?UTF-8?q?docs(dwh):=20=D0=B0=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=20db=5Fschema=20=D0=B4=D0=BB=D1=8F=20ODS=20=D0=B8=20DDS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - синхронизировать db_schema.md с фактическим состоянием репозитория и текущим DDS-планом. - Что: - обновлён статус слоёв: STG/ODS отмечены как реализованные, DDS как слой в проектировании. - приведены к актуальному виду DDS-объекты и нейминг (`dds.dim_*`, `dds.fact_flight_sales`, `dds.dim_routes` SCD2). - обновлены mermaid-диаграмма, обучающие пояснения, TODO, связанные документы и история изменений. - Проверка: - git diff -- docs/internal/db_schema.md --- docs/internal/db_schema.md | 76 ++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/docs/internal/db_schema.md b/docs/internal/db_schema.md index af7c3df..4800b5c 100644 --- a/docs/internal/db_schema.md +++ b/docs/internal/db_schema.md @@ -1,6 +1,6 @@ # Схема БД DWH (Bookings → Greenplum) -> **Статус:** Проект в разработке. Реализован STG слой полностью (все 9 таблиц). +> **Статус:** Проект в разработке. Реализованы STG и ODS (по 9 таблиц). DDS зафиксирован как дизайн и готовится к реализации. ## Обзор @@ -17,8 +17,9 @@ ### Ключевые договорённости - **Источник**: используем основные таблицы схемы `bookings` (табличные данные, не `VIEW`) -- **Зерно факта `fact.flight_sales`**: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`, источник: `segments`) +- **Зерно факта `dds.fact_flight_sales`**: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`, источник: `segments`) - **Обязательная связь для аэропортов и самолёта**: `flights.route_no → routes → (departure_airport, arrival_airport, airplane_code)` +- **Маршруты в DDS**: используем `dds.dim_routes` (SCD2), в факт пишем `route_sk` через point-in-time lookup на дату вылета - **Даты**: как минимум различаем `book_date` (дата покупки) и `scheduled_departure` (дата/время вылета) - **Инкремент в STG**: для `tickets` опорная дата берётся из `bookings.book_date`, потому что в `tickets` нет собственного поля времени изменения - **DQ-проверки**: проверки качества данных выполняем SQL-скриптами, но **не сохраняем результаты в отдельные таблицы/слой DQ** (при проблемах падаем с понятной ошибкой и останавливаем пайплайн) @@ -30,8 +31,8 @@ |------|--------|-------------| | **Source** | ✅ Готово | Демо-БД bookings (Postgres) | | **STG** | ✅ Готово | 9 из 9 таблиц (bookings, tickets, airports, airplanes, routes, seats, flights, segments, boarding_passes) | -| **ODS** | ❌ Не реализован | Планируется | -| **DDS** | ❌ Не реализован | Планируется | +| **ODS** | ✅ Готово | 9 из 9 таблиц + DAG `bookings_ods_ddl` и `bookings_to_gp_ods` | +| **DDS** | ⚙️ В проектировании | Подготовлен дизайн `docs/internal/bookings_dds_design.md`, реализация запланирована | ### Архитектура слоёв @@ -49,28 +50,31 @@ #### ODS (Operational Data Store) - **Назначение**: Очищенные данные в 3NF, готовые для аналитики - **Хранение**: Heap для частых чтений и обновлений -- **Трансформации**: Очистка, приведение типов, нормализация +- **Трансформации**: Очистка, приведение типов, нормализация, SCD1 UPSERT - **Связи**: Все связи через бизнес-ключи (без суррогатных ключей) +- **Текущий статус**: Реализован (9 таблиц, SQL DQ, DAG загрузки) #### DDS (Data Delivery System) - **Назначение**: Star Schema для аналитики и отчётности - **Хранение**: Heap или AO-CO (Append-Only Column-oriented) для аналитических запросов - **Структура**: Измерения (Dimensions) + Факты (Facts) - **Ключи**: Суррогатные ключи (SK) для измерений, FK в фактах +- **Текущий статус**: Зафиксирован детальный дизайн (см. `docs/internal/bookings_dds_design.md`) ### Измерения DDS (Dimensions) | Измерение | Бизнес-ключ | Суррогатный ключ | Атрибуты | |-----------|-------------|------------------|----------| -| `dim.calendar` | `date DATE` | `calendar_sk INT` | `year`, `month`, `day`, `day_of_week`, `is_holiday` (опционально) | -| `dim.airports` | `airport_code CHAR(3)` | `airport_sk INT` | `airport_name`, `city`, `timezone`, `coordinates` | -| `dim.airplanes` | `airplane_code CHAR(3)` | `airplane_sk INT` | `model`, `total_seats`, `range_km` | -| `dim.tariffs` | `fare_conditions TEXT` | `tariff_sk INT` | `fare_conditions` (Economy/Comfort/Business) | -| `dim.passengers` | `passenger_id TEXT` | `passenger_sk INT` | `passenger_name` (SCD Type 1) | +| `dds.dim_calendar` | `date_actual` | `calendar_sk` | `year_actual`, `month_actual`, `day_actual`, `day_of_week`, `day_name`, `is_weekend` | +| `dds.dim_airports` | `airport_code` (`airport_bk`) | `airport_sk` | `airport_name`, `city`, `country`, `timezone`, `coordinates` | +| `dds.dim_airplanes` | `airplane_code` (`airplane_bk`) | `airplane_sk` | `model`, `range_km`, `speed_kmh`, `total_seats` | +| `dds.dim_tariffs` | `fare_conditions` | `tariff_sk` | `fare_conditions` | +| `dds.dim_passengers` | `passenger_id` (`passenger_bk`) | `passenger_sk` | `passenger_name` (SCD1) | +| `dds.dim_routes` | `route_no` (`route_bk`) | `route_sk` | `departure_airport`, `arrival_airport`, `airplane_code`, `hashdiff`, `valid_from`, `valid_to` (SCD2) | ### Факт DDS (Fact) -`fact.flight_sales`: +`dds.fact_flight_sales`: - **Зерно**: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`) - **FK на измерения**: - `calendar_sk` — ссылка на дату вылета @@ -79,6 +83,7 @@ - `airplane_sk` — самолёт - `tariff_sk` — тариф - `passenger_sk` — пассажир + - `route_sk` — версия маршрута (SCD2, point-in-time) - **Метрики**: - `price NUMERIC` — стоимость сегмента - `is_boarded BOOLEAN` — сел ли пассажир в самолёт (из boarding_passes) @@ -297,14 +302,15 @@ graph LR direction TB %% Dimensions - DIM_Calendar[dim.calendar]:::dim - DIM_Airports[dim.airports]:::dim - DIM_Airplanes[dim.airplanes]:::dim - DIM_Tariffs[dim.tariffs]:::dim - DIM_Passengers[dim.passengers]:::dim + DIM_Calendar[dds.dim_calendar]:::dim + DIM_Airports[dds.dim_airports]:::dim + DIM_Airplanes[dds.dim_airplanes]:::dim + DIM_Tariffs[dds.dim_tariffs]:::dim + DIM_Passengers[dds.dim_passengers]:::dim + DIM_Routes[dds.dim_routes SCD2]:::dim %% Fact - FACT_Sales[fact.flight_sales]:::fact + FACT_Sales[dds.fact_flight_sales]:::fact end %% Transformations ODS to DDS @@ -318,13 +324,13 @@ graph LR ODS_Segments -.->|Extract distinct| DIM_Tariffs ODS_Tickets -->|Extract Unique| DIM_Passengers + ODS_Routes -->|SCD2 with hashdiff| DIM_Routes - %% Fact assembly (Main process) + %% Fact assembly (Main process + route point-in-time) ODS_Segments -->|Main Stream| FACT_Sales ODS_Tickets -->|Join book_ref passenger_id| FACT_Sales ODS_Bookings -->|Join book_date| FACT_Sales - ODS_Flights -->|Join Times Status Route| FACT_Sales - ODS_Routes -->|Join Dep/Arr Airplane| FACT_Sales + ODS_Flights -->|Join Times Status Route No| FACT_Sales ODS_Boarding -->|LEFT JOIN Seat No| FACT_Sales %% Link dimensions to fact @@ -334,6 +340,7 @@ graph LR DIM_Airplanes -->|airplane_sk| FACT_Sales DIM_Tariffs -->|tariff_sk| FACT_Sales DIM_Passengers -->|passenger_sk| FACT_Sales + DIM_Routes -->|route_sk| FACT_Sales ``` --- @@ -344,7 +351,7 @@ graph LR | Термин | Объяснение | |--------|-----------| -| **Зерно факта (Fact Grain)** | Минимальная единица измерения в факте. Для `fact.flight_sales` — это один сегмент билета. | +| **Зерно факта (Fact Grain)** | Минимальная единица измерения в факте. Для `dds.fact_flight_sales` — это один сегмент билета. | | **Суррогатный ключ (Surrogate Key, SK)** | Технический ключ (обычно INT), который генерируется в DWH и не зависит от бизнес-ключа. | | **Бизнес-ключ (Business Key)** | Ключ из источника (например, `airport_code`, `passenger_id`). | | **Star Schema** | Модель данных, где факт в центре, а измерения вокруг него (как звезда). | @@ -360,19 +367,21 @@ graph LR #### 1. Ветка справочников (Reference Data) -**`seats` + `airplanes` → `dim.airplanes`**: Здесь мы показываем пример **обогащения**. Таблица `seats` сама по себе в аналитике редко нужна отдельной сущностью. Мы используем её в ODS, чтобы посчитать общее количество мест (`total_seats`) и добавить это как атрибут в измерение самолётов (`dim.airplanes`). +**`seats` + `airplanes` → `dds.dim_airplanes`**: Здесь мы показываем пример **обогащения**. Таблица `seats` сама по себе в аналитике редко нужна отдельной сущностью. Мы используем её в ODS, чтобы посчитать общее количество мест (`total_seats`) и добавить это как атрибут в измерение самолётов (`dds.dim_airplanes`). -**`airports` → `dim.airports`**: Простой перенос (1-в-1), но в DDS мы можем добавить, например, поле `city_ru` и `city_en` как отдельные колонки, убрав JSON, который есть в источнике. +**`airports` → `dds.dim_airports`**: Простой перенос (1-в-1), но в DDS мы можем добавить, например, поле `city_ru` и `city_en` как отдельные колонки, убрав JSON, который есть в источнике. #### 2. Ветка генерации измерений (Dimension Generation) -**`tickets` → `dim.passengers`**: Это самая сложная трансформация для измерения. В источнике нет таблицы "Пассажиры". Мы должны объяснить студентам, что мы "майним" пассажиров из билетов. Важно: один и тот же пассажир может иметь разные записи с разными именами (опечатки, изменение фамилии), поэтому в проде часто делают логику SCD Type 2 для отслеживания изменений. +**`tickets` → `dds.dim_passengers`**: Это самая сложная трансформация для измерения. В источнике нет таблицы "Пассажиры". Мы должны объяснить студентам, что мы "майним" пассажиров из билетов. Важно: один и тот же пассажир может иметь разные записи с разными именами (опечатки, изменение фамилии), поэтому в проде часто делают логику SCD Type 2 для отслеживания изменений. - Для домашки (и первого эталонного решения) обычно достаточно **SCD Type 1**: одна актуальная запись на `passenger_id`, а SCD2 можно оставить как усложнение. -**`segments` → `dim.tariffs`**: Таблицы тарифов физически нет в источнике, она хранится строкой (`fare_conditions`: Economy/Comfort/Business) в таблице `segments`. Мы выносим её в отдельный справочник (нормализация), чтобы в факте хранить маленький `INT` ключ, а не длинную строку. +**`segments` → `dds.dim_tariffs`**: Таблицы тарифов физически нет в источнике, она хранится строкой (`fare_conditions`: Economy/Comfort/Business) в таблице `segments`. Мы выносим её в отдельный справочник (нормализация), чтобы в факте хранить маленький `INT` ключ, а не длинную строку. -#### 3. Сборка Факта (`fact.flight_sales`) +**`routes` → `dds.dim_routes` (SCD2)**: Это отдельный учебный пример историзации. По `route_no` храним версии маршрута с `valid_from/valid_to` и `hashdiff`, чтобы показать студентам паттерн SCD2 на практике. + +#### 3. Сборка Факта (`dds.fact_flight_sales`) Это центр звезды. Мы собираем его из шести ODS таблиц: @@ -380,18 +389,18 @@ graph LR 2. **`ods.tickets`**: Приджойниваем, чтобы получить `book_ref` и `passenger_id`. 3. **`ods.bookings`**: Приджойниваем по `book_ref`, чтобы получить `book_date` (дата покупки). 4. **`ods.flights`**: Приджойниваем, чтобы получить расписание/факт времени и статус рейса, а также `route_no` (связка на маршруты). -5. **`ods.routes`**: Приджойниваем по `route_no`, чтобы получить аэропорты вылета/прилёта и `airplane_code` (в `flights` этих полей нет напрямую). +5. **`dds.dim_routes`**: По `route_no` и дате вылета подбираем версию маршрута (point-in-time) и получаем `route_sk`. 6. **`ods.boarding_passes`**: Приджойниваем (LEFT JOIN), чтобы узнать, **сел ли пассажир реально в самолёт** и на какое место (`seat_no`). Это важный бизнес-аспект: билет куплен, но посадочный не выдан = пассажир не летел. -#### 4. Почему нет `dim.bookings`? +#### 4. Почему нет `dds.dim_bookings`? В классической Star Schema измерения — это справочники (airports, airplanes, passengers), а факты — транзакции/события (sales, bookings). -`bookings` — это транзакционная таблица, а не справочник. Вместо отдельного измерения `dim.bookings` мы храним: +`bookings` — это транзакционная таблица, а не справочник. Вместо отдельного измерения `dds.dim_bookings` мы храним: - `book_ref` — бизнес-ключ бронирования (в факте) - `book_date` — дата бронирования (в факте, берём из `ods.bookings` по `book_ref`) -Это позволяет отвечать на вопросы типа: *"За сколько дней до вылета люди обычно покупают билеты?"* (разница между `book_date` и датой вылета из `dim.calendar`). +Это позволяет отвечать на вопросы типа: *"За сколько дней до вылета люди обычно покупают билеты?"* (разница между `book_date` и датой вылета из `dds.dim_calendar`). #### 5. Суррогатные ключи (Surrogate Keys) @@ -408,6 +417,8 @@ graph LR ## Связанные документы - [`docs/internal/bookings_stg_design.md`](bookings_stg_design.md) — Детальный дизайн STG слоя для bookings +- [`docs/internal/bookings_ods_design.md`](bookings_ods_design.md) — Детальный дизайн ODS слоя (SCD1, batch contract, DQ) +- [`docs/internal/bookings_dds_design.md`](bookings_dds_design.md) — План реализации DDS слоя (Star Schema, SCD2 для routes) - [`docs/internal/bookings_stg_code_review.md`](bookings_stg_code_review.md) — Ревью решения и рекомендации по улучшению - [`docs/internal/bookings_tz.md`](bookings_tz.md) — Работа с часовыми поясами в источнике - [`docs/internal/pxf_bookings.md`](pxf_bookings.md) — Настройка PXF для чтения из bookings-db @@ -419,6 +430,7 @@ graph LR | Дата | Версия | Описание изменений | |------|--------|-------------------| +| 2026-02-23 | 2.1 | Актуализирован статус: STG+ODS реализованы. Обновлены DDS-объекты (`dds.dim_*`, `dds.fact_flight_sales`), добавлен `dds.dim_routes` (SCD2), исправлены диаграмма и TODO. | | 2025-01-17 | 2.0 | Удалён слой DQ для упрощения учебного стенда. Добавлены спецификации для LLM и обучающие материалы для студентов. Добавлен глоссарий терминов. | | 2025-01-17 | 1.1 | Исправлены названия таблиц (`aircrafts_data` → `airplanes_data`, `ticket_flights` → `segments`), удалено `dim.bookings`, добавлены суррогатные ключи, добавлен слой DQ, исправлены связи | | 2025-01-XX | 1.0 | Первоначальная версия | @@ -428,7 +440,7 @@ graph LR ## TODO - [x] Реализовать STG слой полностью (все 9 таблиц) -- [ ] Реализовать ODS слой +- [x] Реализовать ODS слой - [ ] Реализовать DDS слой (измерения и факт) -- [ ] Создать DAG для загрузки ODS +- [x] Создать DAG для загрузки ODS - [ ] Создать DAG для загрузки DDS From d25c753eb45409517d3baa8667bc513720bd049e Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Wed, 25 Feb 2026 23:05:43 +0300 Subject: [PATCH 36/38] =?UTF-8?q?feat(dds):=20=D1=80=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20=D1=81=D0=BB=D0=BE=D0=B9?= =?UTF-8?q?=20dds=20=D0=B4=D0=BB=D1=8F=20bookings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - подготовлен учебный Star Schema слой для перехода от ODS к аналитике и витринам. - Что: - добавлены 21 SQL-файл для DDS (DDL/LOAD/DQ) с SCD1/SCD2 и фактом `fact_flight_sales`. - добавлены DAG `bookings_dds_ddl` и `bookings_to_gp_dds`, а также smoke-тесты структуры DAG. - обновлены `sql/ddl_gp.sql` и документация (`README`, `docs/*`, `db_schema`) под поток `stg -> ods -> dds`. - Проверка: - make test. --- README.md | 23 ++-- TESTING.md | 4 +- airflow/dags/bookings_dds_ddl.py | 84 +++++++++++++++ airflow/dags/bookings_to_gp_dds.py | 162 +++++++++++++++++++++++++++++ docs/README.md | 2 + docs/bookings_to_gp_dds.md | 72 +++++++++++++ docs/internal/db_schema.md | 12 ++- sql/ddl_gp.sql | 9 ++ sql/dds/dim_airplanes_ddl.sql | 17 +++ sql/dds/dim_airplanes_dq.sql | 83 +++++++++++++++ sql/dds/dim_airplanes_load.sql | 92 ++++++++++++++++ sql/dds/dim_airports_ddl.sql | 18 ++++ sql/dds/dim_airports_dq.sql | 88 ++++++++++++++++ sql/dds/dim_airports_load.sql | 60 +++++++++++ sql/dds/dim_calendar_ddl.sql | 15 +++ sql/dds/dim_calendar_dq.sql | 87 ++++++++++++++++ sql/dds/dim_calendar_load.sql | 29 ++++++ sql/dds/dim_passengers_ddl.sql | 14 +++ sql/dds/dim_passengers_dq.sql | 100 ++++++++++++++++++ sql/dds/dim_passengers_load.sql | 82 +++++++++++++++ sql/dds/dim_routes_ddl.sql | 22 ++++ sql/dds/dim_routes_dq.sql | 149 ++++++++++++++++++++++++++ sql/dds/dim_routes_load.sql | 130 +++++++++++++++++++++++ sql/dds/dim_tariffs_ddl.sql | 13 +++ sql/dds/dim_tariffs_dq.sql | 98 +++++++++++++++++ sql/dds/dim_tariffs_load.sql | 36 +++++++ sql/dds/fact_flight_sales_ddl.sql | 23 ++++ sql/dds/fact_flight_sales_dq.sql | 151 +++++++++++++++++++++++++++ sql/dds/fact_flight_sales_load.sql | 111 ++++++++++++++++++++ tests/test_dags_smoke.py | 85 +++++++++++++++ 30 files changed, 1856 insertions(+), 15 deletions(-) create mode 100644 airflow/dags/bookings_dds_ddl.py create mode 100644 airflow/dags/bookings_to_gp_dds.py create mode 100644 docs/bookings_to_gp_dds.md create mode 100644 sql/dds/dim_airplanes_ddl.sql create mode 100644 sql/dds/dim_airplanes_dq.sql create mode 100644 sql/dds/dim_airplanes_load.sql create mode 100644 sql/dds/dim_airports_ddl.sql create mode 100644 sql/dds/dim_airports_dq.sql create mode 100644 sql/dds/dim_airports_load.sql create mode 100644 sql/dds/dim_calendar_ddl.sql create mode 100644 sql/dds/dim_calendar_dq.sql create mode 100644 sql/dds/dim_calendar_load.sql create mode 100644 sql/dds/dim_passengers_ddl.sql create mode 100644 sql/dds/dim_passengers_dq.sql create mode 100644 sql/dds/dim_passengers_load.sql create mode 100644 sql/dds/dim_routes_ddl.sql create mode 100644 sql/dds/dim_routes_dq.sql create mode 100644 sql/dds/dim_routes_load.sql create mode 100644 sql/dds/dim_tariffs_ddl.sql create mode 100644 sql/dds/dim_tariffs_dq.sql create mode 100644 sql/dds/dim_tariffs_load.sql create mode 100644 sql/dds/fact_flight_sales_ddl.sql create mode 100644 sql/dds/fact_flight_sales_dq.sql create mode 100644 sql/dds/fact_flight_sales_load.sql diff --git a/README.md b/README.md index a63ecac..ef25906 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ - работы с Airflow и Greenplum. В курсовой у нас один источник данных — демо‑БД **bookings**. В стенде уже есть готовые учебные примеры -загрузки **bookings → stg** и **stg -> ods** в Greenplum, чтобы вы могли сфокусироваться на DWH‑части -(ODS/DDS/DM) и не тратить время на инфраструктуру. +загрузки **bookings → stg**, **stg -> ods** и **ods -> dds** в Greenplum, чтобы вы могли сфокусироваться +на DWH‑части (ODS/DDS/DM) и не тратить время на инфраструктуру. ## Что внутри @@ -44,7 +44,7 @@ Про PXF и технические детали стенда: [docs/stack.md](docs/stack.md). -## Быстрый старт (основной сценарий: bookings -> stg -> ods) +## Быстрый старт (основной сценарий: bookings -> stg -> ods -> dds) 1) Скопируйте настройки: @@ -65,16 +65,18 @@ make up make bookings-init ``` -4) Подготовьте STG/ODS-объекты в Greenplum (выберите один вариант): +4) Подготовьте STG/ODS/DDS-объекты в Greenplum (выберите один вариант): -- Учебный вариант: в Airflow UI запустите DAG `bookings_stg_ddl`, затем `bookings_ods_ddl`; -- Технический шорткат: `make ddl-gp` (применяет DDL для STG и ODS разом вручную). +- Учебный вариант: в Airflow UI запустите DAG `bookings_stg_ddl`, затем `bookings_ods_ddl`, затем `bookings_dds_ddl`; +- Технический шорткат: `make ddl-gp` (применяет DDL для STG, ODS и DDS разом вручную). 5) Запустите основной DAG `bookings_to_gp_stage`. 6) Запустите DAG `bookings_to_gp_ods`. -7) Проверьте результат в Greenplum: +7) Запустите DAG `bookings_to_gp_dds`. + +8) Проверьте результат в Greenplum: ```bash make gp-psql @@ -84,6 +86,8 @@ SELECT COUNT(*) FROM stg.tickets; SELECT * FROM stg.bookings ORDER BY src_created_at_ts DESC LIMIT 10; SELECT COUNT(*) FROM ods.bookings; SELECT COUNT(*) FROM ods.tickets; +SELECT COUNT(*) FROM dds.dim_routes; +SELECT COUNT(*) FROM dds.fact_flight_sales; ``` Подробнее про логику DAG и проверки — `docs/bookings_to_gp_stage.md`. @@ -97,6 +101,8 @@ SELECT COUNT(*) FROM ods.tickets; - `bookings_to_gp_stage` — генерирует учебный день в `bookings-db`, затем загружает данные в STG и выполняет DQ‑проверки. - `bookings_ods_ddl` — создаёт/обновляет ODS-таблицы по домену bookings. - `bookings_to_gp_ods` — загружает данные из STG в ODS (SCD1 UPSERT) и выполняет DQ‑проверки. +- `bookings_dds_ddl` — создаёт/обновляет DDS-таблицы (`dim_*`, `fact_flight_sales`) по домену bookings. +- `bookings_to_gp_dds` — загружает данные из ODS в DDS (SCD1/SCD2 + факт) и выполняет DQ‑проверки. Вспомогательные (побочный трек с CSV): @@ -111,7 +117,7 @@ make up # поднять стек make logs # логи airflow-webserver и airflow-scheduler make gp-psql # psql в Greenplum make bookings-psql # psql в демо-БД bookings (Postgres) -make ddl-gp # применить DDL STG+ODS к Greenplum вручную (вместо DDL-DAG) +make ddl-gp # применить DDL STG+ODS+DDS к Greenplum вручную (вместо DDL-DAG) make down # остановить и удалить контейнеры/сети (volumes сохраняются) make clean # полный reset: удалить контейнеры/сети и volumes (данные будут потеряны) ``` @@ -142,6 +148,7 @@ make clean # полный reset: удалить контейнер - План тестирования/проверок и негативные кейсы: `TESTING.md`. - Дополнительные заметки и технические детали: `docs/README.md`. - Детали по ODS DAG: `docs/bookings_to_gp_ods.md`. +- Детали по DDS DAG: `docs/bookings_to_gp_dds.md`. ## Типичные проблемы и решения diff --git a/TESTING.md b/TESTING.md index 3e58938..fb5f99d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -38,7 +38,7 @@ - Проверить, что все 5 задач Success и логи содержат `Проверка пройдена`. - DAG `bookings_to_gp_stage` (полная проверка цепочки bookings → Greenplum STG): - - предварительно выполнить один раз: `make bookings-init` (установка демобазы `demo` в контейнере `bookings-db`) и `make ddl-gp` (создаёт STG слой в Greenplum, включая внешние `*_ext` через PXF); + - предварительно выполнить один раз: `make bookings-init` (установка демобазы `demo` в контейнере `bookings-db`) и `make ddl-gp` (создаёт STG/ODS/DDS слои в Greenplum, включая внешние `*_ext` через PXF); - перед Trigger проверить, что в source реально есть данные (все значения должны быть `> 0`): - `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.bookings;"` - `docker compose exec bookings-db psql -U bookings -d demo -At -c "SELECT COUNT(*) FROM bookings.airports_data;"` @@ -90,6 +90,6 @@ - При необходимости сохранить данные: скопировать CSV из `data/` и сделать дампы до `make clean`. ## Текущий статус (пример успешного прогона) -- `uv run pytest -q` — 11 passed, 2 smoke-теста DAG пропущены (Airflow не установлен в venv). +- `uv run pytest -q` — 14 passed, 9 smoke-тестов DAG пропущены (Airflow не установлен в venv). - `make lint` — проходит (DAG‑файлы отформатированы black/isort). - Docker-стенд не запускался в рамках этой сессии; ожидается, что инструкции выше обеспечат полноценную проверку. diff --git a/airflow/dags/bookings_dds_ddl.py b/airflow/dags/bookings_dds_ddl.py new file mode 100644 index 0000000..f1e00b7 --- /dev/null +++ b/airflow/dags/bookings_dds_ddl.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +""" +Учебный DAG: создаёт/обновляет слой dds в Greenplum для домена bookings. + +Запускается вручную перед DAG загрузки `bookings_to_gp_dds` или после изменения DDS DDL. +Создаёт 7 DDS-таблиц: dim_calendar, dim_airports, dim_airplanes, +dim_tariffs, dim_passengers, dim_routes, fact_flight_sales. +""" + +from datetime import timedelta + +import pendulum +from airflow.providers.postgres.operators.postgres import PostgresOperator + +from airflow import DAG + +GREENPLUM_CONN_ID = "greenplum_conn" + +default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)} + +with DAG( + dag_id="bookings_dds_ddl", + start_date=pendulum.datetime(2024, 1, 1, tz="UTC"), + schedule=None, + catchup=False, + template_searchpath="/sql", + default_args=default_args, + tags=["demo", "greenplum", "ddl", "bookings", "dds"], + description="Учебный DDL DAG: создаёт/обновляет dds.* для bookings", +) as dag: + apply_dds_dim_calendar_ddl = PostgresOperator( + task_id="apply_dds_dim_calendar_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_calendar_ddl.sql", + ) + + apply_dds_dim_airports_ddl = PostgresOperator( + task_id="apply_dds_dim_airports_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_airports_ddl.sql", + ) + + apply_dds_dim_airplanes_ddl = PostgresOperator( + task_id="apply_dds_dim_airplanes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_airplanes_ddl.sql", + ) + + apply_dds_dim_tariffs_ddl = PostgresOperator( + task_id="apply_dds_dim_tariffs_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_tariffs_ddl.sql", + ) + + apply_dds_dim_passengers_ddl = PostgresOperator( + task_id="apply_dds_dim_passengers_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_passengers_ddl.sql", + ) + + apply_dds_dim_routes_ddl = PostgresOperator( + task_id="apply_dds_dim_routes_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_routes_ddl.sql", + ) + + apply_dds_fact_flight_sales_ddl = PostgresOperator( + task_id="apply_dds_fact_flight_sales_ddl", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/fact_flight_sales_ddl.sql", + ) + + # DDL применяем последовательно, чтобы порядок был понятным для новичков, + # а ошибки — воспроизводимыми (в логах сразу видно, на каком объекте упали). + ( + apply_dds_dim_calendar_ddl + >> apply_dds_dim_airports_ddl + >> apply_dds_dim_airplanes_ddl + >> apply_dds_dim_tariffs_ddl + >> apply_dds_dim_passengers_ddl + >> apply_dds_dim_routes_ddl + >> apply_dds_fact_flight_sales_ddl + ) diff --git a/airflow/dags/bookings_to_gp_dds.py b/airflow/dags/bookings_to_gp_dds.py new file mode 100644 index 0000000..a194b5f --- /dev/null +++ b/airflow/dags/bookings_to_gp_dds.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +""" +Учебный DAG: загрузка из ODS в DDS (Greenplum) по домену bookings. + +Ключевая идея: +- DDS читает текущее состояние ODS; +- для каждой сущности выполняем пару задач load -> dq; +- для dim_routes применяем SCD2, для остальных измерений — SCD1 UPSERT; +- факт грузим инкрементальным UPSERT по зерну (ticket_no, flight_id). +""" + +from datetime import timedelta +from logging import getLogger + +import pendulum +from airflow.operators.python import PythonOperator +from airflow.providers.postgres.operators.postgres import PostgresOperator + +from airflow import DAG + +GREENPLUM_CONN_ID = "greenplum_conn" + +log = getLogger(__name__) + +default_args = { + "owner": "airflow", + "retries": 1, + "retry_delay": timedelta(seconds=30), +} + + +def _finish_summary() -> None: + """Логирует краткий итог выполнения DDS-ветки.""" + log.info("DAG bookings_to_gp_dds завершён. Подробности смотрите в логах задач.") + + +with DAG( + dag_id="bookings_to_gp_dds", + start_date=pendulum.datetime(2024, 1, 1, tz="UTC"), + schedule=None, + catchup=False, + max_active_runs=1, + template_searchpath="/sql", + default_args=default_args, + tags=["demo", "bookings", "greenplum", "dds"], + description="Учебный DAG: загрузка ODS -> DDS (Star Schema) + DQ проверки", +) as dag: + load_dds_dim_calendar = PostgresOperator( + task_id="load_dds_dim_calendar", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_calendar_load.sql", + ) + + dq_dds_dim_calendar = PostgresOperator( + task_id="dq_dds_dim_calendar", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_calendar_dq.sql", + ) + + load_dds_dim_airports = PostgresOperator( + task_id="load_dds_dim_airports", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_airports_load.sql", + ) + + dq_dds_dim_airports = PostgresOperator( + task_id="dq_dds_dim_airports", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_airports_dq.sql", + ) + + load_dds_dim_airplanes = PostgresOperator( + task_id="load_dds_dim_airplanes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_airplanes_load.sql", + ) + + dq_dds_dim_airplanes = PostgresOperator( + task_id="dq_dds_dim_airplanes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_airplanes_dq.sql", + ) + + load_dds_dim_tariffs = PostgresOperator( + task_id="load_dds_dim_tariffs", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_tariffs_load.sql", + ) + + dq_dds_dim_tariffs = PostgresOperator( + task_id="dq_dds_dim_tariffs", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_tariffs_dq.sql", + ) + + load_dds_dim_passengers = PostgresOperator( + task_id="load_dds_dim_passengers", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_passengers_load.sql", + ) + + dq_dds_dim_passengers = PostgresOperator( + task_id="dq_dds_dim_passengers", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_passengers_dq.sql", + ) + + load_dds_dim_routes = PostgresOperator( + task_id="load_dds_dim_routes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_routes_load.sql", + ) + + dq_dds_dim_routes = PostgresOperator( + task_id="dq_dds_dim_routes", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/dim_routes_dq.sql", + ) + + load_dds_fact_flight_sales = PostgresOperator( + task_id="load_dds_fact_flight_sales", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/fact_flight_sales_load.sql", + ) + + dq_dds_fact_flight_sales = PostgresOperator( + task_id="dq_dds_fact_flight_sales", + postgres_conn_id=GREENPLUM_CONN_ID, + sql="dds/fact_flight_sales_dq.sql", + ) + + finish_dds_summary = PythonOperator( + task_id="finish_dds_summary", + python_callable=_finish_summary, + ) + + load_dds_dim_calendar >> dq_dds_dim_calendar + + dq_dds_dim_calendar >> [ + load_dds_dim_airports, + load_dds_dim_airplanes, + load_dds_dim_tariffs, + load_dds_dim_passengers, + load_dds_dim_routes, + ] + + load_dds_dim_airports >> dq_dds_dim_airports + load_dds_dim_airplanes >> dq_dds_dim_airplanes + load_dds_dim_tariffs >> dq_dds_dim_tariffs + load_dds_dim_passengers >> dq_dds_dim_passengers + load_dds_dim_routes >> dq_dds_dim_routes + + [ + dq_dds_dim_airports, + dq_dds_dim_airplanes, + dq_dds_dim_tariffs, + dq_dds_dim_passengers, + dq_dds_dim_routes, + ] >> load_dds_fact_flight_sales + + load_dds_fact_flight_sales >> dq_dds_fact_flight_sales >> finish_dds_summary diff --git a/docs/README.md b/docs/README.md index 8fa0215..fc12a28 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ - [План тестирования и проверки](../TESTING.md) - [Главный учебный DAG: bookings → stg](bookings_to_gp_stage.md) - [Учебный DAG: stg -> ods](bookings_to_gp_ods.md) +- [Учебный DAG: ods -> dds](bookings_to_gp_dds.md) ## Технические детали (опционально) @@ -17,4 +18,5 @@ - [PXF в этом проекте (проектная реализация)](internal/pxf_bookings.md) - [Дизайн stg для bookings (черновик)](internal/bookings_stg_design.md) - [Дизайн ods для bookings (черновик)](internal/bookings_ods_design.md) +- [Дизайн dds для bookings (черновик)](internal/bookings_dds_design.md) - [Про время/UTC в bookings (черновик)](internal/bookings_tz.md) diff --git a/docs/bookings_to_gp_dds.md b/docs/bookings_to_gp_dds.md new file mode 100644 index 0000000..9cb0c75 --- /dev/null +++ b/docs/bookings_to_gp_dds.md @@ -0,0 +1,72 @@ +# DAG `bookings_to_gp_dds`: `ods` -> `dds` в Greenplum + +Этот DAG — учебный пример загрузки аналитического слоя **DDS** (Star Schema) из текущего состояния **ODS**. +Логика: измерения + факт, проверки качества данных после каждой загрузки. + +## Что делает DAG + +- Загружает измерения DDS: + - `dds.dim_calendar` (статическое измерение дат); + - `dds.dim_airports`, `dds.dim_airplanes`, `dds.dim_tariffs`, `dds.dim_passengers` (SCD1 UPSERT); + - `dds.dim_routes` (SCD2 с `hashdiff`, `valid_from`, `valid_to`). +- Загружает факт `dds.fact_flight_sales` инкрементальным UPSERT по зерну `(ticket_no, flight_id)`. +- Для каждой таблицы выполняет пару задач `load -> dq`. +- Использует `_load_id = {{ run_id }}` (DDS не требует `stg_batch_id`, потому что читает current state ODS). + +## Что должно быть готово перед запуском + +1) Стенд поднят: + +```bash +make up +``` + +2) STG и ODS уже загружены: + +- выполнены DAG-и `bookings_to_gp_stage` и `bookings_to_gp_ods`; +- DDL-объекты созданы (`bookings_dds_ddl` или `make ddl-gp`). + +## Как запустить + +1) Откройте Airflow UI: http://localhost:8080. +2) Если запускаете DDS впервые — выполните `bookings_dds_ddl`. +3) Запустите `bookings_to_gp_dds`. + +## Граф зависимостей (упрощённо) + +- `load_dds_dim_calendar -> dq_dds_dim_calendar` +- После calendar параллельно: + - `load_dds_dim_airports -> dq_dds_dim_airports` + - `load_dds_dim_airplanes -> dq_dds_dim_airplanes` + - `load_dds_dim_tariffs -> dq_dds_dim_tariffs` + - `load_dds_dim_passengers -> dq_dds_dim_passengers` + - `load_dds_dim_routes -> dq_dds_dim_routes` +- Факт: + - `load_dds_fact_flight_sales -> dq_dds_fact_flight_sales -> finish_dds_summary` + +## Как проверить результат + +```bash +make gp-psql +``` + +```sql +SELECT COUNT(*) FROM dds.dim_calendar; +SELECT COUNT(*) FROM dds.dim_routes; +SELECT COUNT(*) FROM dds.fact_flight_sales; + +SELECT + (SELECT COUNT(*) FROM dds.fact_flight_sales) AS fact_rows, + (SELECT COUNT(*) FROM ods.segments) AS ods_rows; +``` + +Ожидаемо: `fact_rows = ods_rows`. + +## Типичные ошибки + +- `relation "dds..." does not exist`: + - не применён DDS DDL (`bookings_dds_ddl` или `make ddl-gp`). +- DQ падает на `dim_routes`: + - проверьте согласованность `ods.routes` (дубли/аномальные версии) и перезапустите DAG. +- DQ падает на `fact_flight_sales` по coverage: + - проверьте, что ODS DAG завершился успешно без пропуска задач. diff --git a/docs/internal/db_schema.md b/docs/internal/db_schema.md index 4800b5c..1a63323 100644 --- a/docs/internal/db_schema.md +++ b/docs/internal/db_schema.md @@ -1,6 +1,6 @@ # Схема БД DWH (Bookings → Greenplum) -> **Статус:** Проект в разработке. Реализованы STG и ODS (по 9 таблиц). DDS зафиксирован как дизайн и готовится к реализации. +> **Статус:** Проект в разработке. Реализованы STG, ODS и DDS (bookings). ## Обзор @@ -32,7 +32,7 @@ | **Source** | ✅ Готово | Демо-БД bookings (Postgres) | | **STG** | ✅ Готово | 9 из 9 таблиц (bookings, tickets, airports, airplanes, routes, seats, flights, segments, boarding_passes) | | **ODS** | ✅ Готово | 9 из 9 таблиц + DAG `bookings_ods_ddl` и `bookings_to_gp_ods` | -| **DDS** | ⚙️ В проектировании | Подготовлен дизайн `docs/internal/bookings_dds_design.md`, реализация запланирована | +| **DDS** | ✅ Готово | 6 измерений + 1 факт + DAG `bookings_dds_ddl` и `bookings_to_gp_dds` | ### Архитектура слоёв @@ -59,7 +59,7 @@ - **Хранение**: Heap или AO-CO (Append-Only Column-oriented) для аналитических запросов - **Структура**: Измерения (Dimensions) + Факты (Facts) - **Ключи**: Суррогатные ключи (SK) для измерений, FK в фактах -- **Текущий статус**: Зафиксирован детальный дизайн (см. `docs/internal/bookings_dds_design.md`) +- **Текущий статус**: Реализован (6 измерений, 1 факт, SQL DQ, DAG загрузки) ### Измерения DDS (Dimensions) @@ -419,6 +419,7 @@ graph LR - [`docs/internal/bookings_stg_design.md`](bookings_stg_design.md) — Детальный дизайн STG слоя для bookings - [`docs/internal/bookings_ods_design.md`](bookings_ods_design.md) — Детальный дизайн ODS слоя (SCD1, batch contract, DQ) - [`docs/internal/bookings_dds_design.md`](bookings_dds_design.md) — План реализации DDS слоя (Star Schema, SCD2 для routes) +- [`docs/bookings_to_gp_dds.md`](../bookings_to_gp_dds.md) — Запуск и проверка DAG `bookings_to_gp_dds` - [`docs/internal/bookings_stg_code_review.md`](bookings_stg_code_review.md) — Ревью решения и рекомендации по улучшению - [`docs/internal/bookings_tz.md`](bookings_tz.md) — Работа с часовыми поясами в источнике - [`docs/internal/pxf_bookings.md`](pxf_bookings.md) — Настройка PXF для чтения из bookings-db @@ -430,6 +431,7 @@ graph LR | Дата | Версия | Описание изменений | |------|--------|-------------------| +| 2026-02-25 | 2.2 | Реализован DDS: добавлены `sql/dds/*` (DDL/LOAD/DQ), DAG `bookings_dds_ddl`, DAG `bookings_to_gp_dds`, обновлены smoke-тесты и документация. | | 2026-02-23 | 2.1 | Актуализирован статус: STG+ODS реализованы. Обновлены DDS-объекты (`dds.dim_*`, `dds.fact_flight_sales`), добавлен `dds.dim_routes` (SCD2), исправлены диаграмма и TODO. | | 2025-01-17 | 2.0 | Удалён слой DQ для упрощения учебного стенда. Добавлены спецификации для LLM и обучающие материалы для студентов. Добавлен глоссарий терминов. | | 2025-01-17 | 1.1 | Исправлены названия таблиц (`aircrafts_data` → `airplanes_data`, `ticket_flights` → `segments`), удалено `dim.bookings`, добавлены суррогатные ключи, добавлен слой DQ, исправлены связи | @@ -441,6 +443,6 @@ graph LR - [x] Реализовать STG слой полностью (все 9 таблиц) - [x] Реализовать ODS слой -- [ ] Реализовать DDS слой (измерения и факт) +- [x] Реализовать DDS слой (измерения и факт) - [x] Создать DAG для загрузки ODS -- [ ] Создать DAG для загрузки DDS +- [x] Создать DAG для загрузки DDS diff --git a/sql/ddl_gp.sql b/sql/ddl_gp.sql index 9df705b..a270a0b 100644 --- a/sql/ddl_gp.sql +++ b/sql/ddl_gp.sql @@ -48,3 +48,12 @@ FORMAT 'CUSTOM' (formatter='pxfwritable_import'); \i ods/flights_ddl.sql \i ods/segments_ddl.sql \i ods/boarding_passes_ddl.sql + +-- DDL для DDS-слоя (Star Schema, SCD1 + SCD2). +\i dds/dim_calendar_ddl.sql +\i dds/dim_airports_ddl.sql +\i dds/dim_airplanes_ddl.sql +\i dds/dim_tariffs_ddl.sql +\i dds/dim_passengers_ddl.sql +\i dds/dim_routes_ddl.sql +\i dds/fact_flight_sales_ddl.sql diff --git a/sql/dds/dim_airplanes_ddl.sql b/sql/dds/dim_airplanes_ddl.sql new file mode 100644 index 0000000..5376870 --- /dev/null +++ b/sql/dds/dim_airplanes_ddl.sql @@ -0,0 +1,17 @@ +-- DDL для DDS-слоя по таблице dim_airplanes (SCD1-измерение). + +CREATE SCHEMA IF NOT EXISTS dds; + +CREATE TABLE IF NOT EXISTS dds.dim_airplanes ( + airplane_sk INTEGER NOT NULL, + airplane_bk TEXT NOT NULL, + model TEXT NOT NULL, + range_km INTEGER, + speed_kmh INTEGER, + total_seats INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (airplane_sk); diff --git a/sql/dds/dim_airplanes_dq.sql b/sql/dds/dim_airplanes_dq.sql new file mode 100644 index 0000000..c0d01dd --- /dev/null +++ b/sql/dds/dim_airplanes_dq.sql @@ -0,0 +1,83 @@ +-- DQ для DDS dim_airplanes. + +DO $$ +DECLARE + v_row_count BIGINT; + v_dup_sk BIGINT; + v_dup_bk BIGINT; + v_missing_bk BIGINT; + v_null_count BIGINT; +BEGIN + -- Таблица не пуста. + SELECT COUNT(*) + INTO v_row_count + FROM dds.dim_airplanes; + + IF v_row_count = 0 THEN + RAISE EXCEPTION 'DQ FAILED: dds.dim_airplanes пуста.'; + END IF; + + -- Нет дублей по SK. + SELECT COUNT(*) - COUNT(DISTINCT airplane_sk) + INTO v_dup_sk + FROM dds.dim_airplanes; + + IF v_dup_sk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_airplanes найдены дубликаты airplane_sk: %', + v_dup_sk; + END IF; + + -- Нет дублей по BK. + SELECT COUNT(*) - COUNT(DISTINCT airplane_bk) + INTO v_dup_bk + FROM dds.dim_airplanes; + + IF v_dup_bk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_airplanes найдены дубликаты airplane_bk: %', + v_dup_bk; + END IF; + + -- Покрытие ODS: все airplane_code из ODS есть в DDS. + SELECT COUNT(*) + INTO v_missing_bk + FROM (SELECT DISTINCT airplane_code FROM ods.airplanes) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_airplanes AS d + WHERE d.airplane_bk = s.airplane_code + ); + + IF v_missing_bk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_airplanes отсутствуют ключи из ods.airplanes: %', + v_missing_bk; + END IF; + + -- Обязательные поля. + SELECT COUNT(*) + INTO v_null_count + FROM dds.dim_airplanes + WHERE airplane_sk IS NULL + OR airplane_bk IS NULL + OR airplane_bk = '' + OR model IS NULL + OR model = '' + OR total_seats IS NULL + OR created_at IS NULL + OR updated_at IS NULL + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_airplanes найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: dds.dim_airplanes ок, строк=%', + v_row_count; +END $$; diff --git a/sql/dds/dim_airplanes_load.sql b/sql/dds/dim_airplanes_load.sql new file mode 100644 index 0000000..6eb1344 --- /dev/null +++ b/sql/dds/dim_airplanes_load.sql @@ -0,0 +1,92 @@ +-- Загрузка DDS dim_airplanes: SCD1 UPSERT (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих записей (если атрибуты изменились). +WITH seats_agg AS ( + SELECT + s.airplane_code, + COUNT(*)::INTEGER AS total_seats + FROM ods.seats AS s + GROUP BY s.airplane_code +), +src AS ( + SELECT + a.airplane_code, + a.model, + a.range_km, + a.speed_kmh, + COALESCE(sa.total_seats, 0) AS total_seats + FROM ods.airplanes AS a + LEFT JOIN seats_agg AS sa + ON sa.airplane_code = a.airplane_code +) +UPDATE dds.dim_airplanes AS d +SET model = s.model, + range_km = s.range_km, + speed_kmh = s.speed_kmh, + total_seats = s.total_seats, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM src AS s +WHERE d.airplane_bk = s.airplane_code + AND ( + d.model IS DISTINCT FROM s.model + OR d.range_km IS DISTINCT FROM s.range_km + OR d.speed_kmh IS DISTINCT FROM s.speed_kmh + OR d.total_seats IS DISTINCT FROM s.total_seats + ); + +-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()). +WITH seats_agg AS ( + SELECT + s.airplane_code, + COUNT(*)::INTEGER AS total_seats + FROM ods.seats AS s + GROUP BY s.airplane_code +), +src AS ( + SELECT + a.airplane_code, + a.model, + a.range_km, + a.speed_kmh, + COALESCE(sa.total_seats, 0) AS total_seats + FROM ods.airplanes AS a + LEFT JOIN seats_agg AS sa + ON sa.airplane_code = a.airplane_code +), +max_sk AS ( + SELECT COALESCE(MAX(airplane_sk), 0) AS v + FROM dds.dim_airplanes +) +INSERT INTO dds.dim_airplanes ( + airplane_sk, + airplane_bk, + model, + range_km, + speed_kmh, + total_seats, + created_at, + updated_at, + _load_id, + _load_ts +) +SELECT + (SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.airplane_code)::INTEGER, + s.airplane_code, + s.model, + s.range_km, + s.speed_kmh, + s.total_seats, + now(), + now(), + '{{ run_id }}', + now() +FROM src AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_airplanes AS d + WHERE d.airplane_bk = s.airplane_code +); + +ANALYZE dds.dim_airplanes; diff --git a/sql/dds/dim_airports_ddl.sql b/sql/dds/dim_airports_ddl.sql new file mode 100644 index 0000000..4495aad --- /dev/null +++ b/sql/dds/dim_airports_ddl.sql @@ -0,0 +1,18 @@ +-- DDL для DDS-слоя по таблице dim_airports (SCD1-измерение). + +CREATE SCHEMA IF NOT EXISTS dds; + +CREATE TABLE IF NOT EXISTS dds.dim_airports ( + airport_sk INTEGER NOT NULL, + airport_bk TEXT NOT NULL, + airport_name TEXT NOT NULL, + city TEXT NOT NULL, + country TEXT NOT NULL, + timezone TEXT NOT NULL, + coordinates TEXT, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (airport_sk); diff --git a/sql/dds/dim_airports_dq.sql b/sql/dds/dim_airports_dq.sql new file mode 100644 index 0000000..640951d --- /dev/null +++ b/sql/dds/dim_airports_dq.sql @@ -0,0 +1,88 @@ +-- DQ для DDS dim_airports. + +DO $$ +DECLARE + v_row_count BIGINT; + v_dup_sk BIGINT; + v_dup_bk BIGINT; + v_missing_bk BIGINT; + v_null_count BIGINT; +BEGIN + -- Таблица не пуста. + SELECT COUNT(*) + INTO v_row_count + FROM dds.dim_airports; + + IF v_row_count = 0 THEN + RAISE EXCEPTION 'DQ FAILED: dds.dim_airports пуста.'; + END IF; + + -- Нет дублей по SK. + SELECT COUNT(*) - COUNT(DISTINCT airport_sk) + INTO v_dup_sk + FROM dds.dim_airports; + + IF v_dup_sk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_airports найдены дубликаты airport_sk: %', + v_dup_sk; + END IF; + + -- Нет дублей по BK. + SELECT COUNT(*) - COUNT(DISTINCT airport_bk) + INTO v_dup_bk + FROM dds.dim_airports; + + IF v_dup_bk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_airports найдены дубликаты airport_bk: %', + v_dup_bk; + END IF; + + -- Покрытие ODS: все airport_code из ODS есть в DDS. + SELECT COUNT(*) + INTO v_missing_bk + FROM (SELECT DISTINCT airport_code FROM ods.airports) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_airports AS d + WHERE d.airport_bk = s.airport_code + ); + + IF v_missing_bk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_airports отсутствуют ключи из ods.airports: %', + v_missing_bk; + END IF; + + -- Обязательные поля. + SELECT COUNT(*) + INTO v_null_count + FROM dds.dim_airports + WHERE airport_sk IS NULL + OR airport_bk IS NULL + OR airport_bk = '' + OR airport_name IS NULL + OR airport_name = '' + OR city IS NULL + OR city = '' + OR country IS NULL + OR country = '' + OR timezone IS NULL + OR timezone = '' + OR created_at IS NULL + OR updated_at IS NULL + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_airports найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: dds.dim_airports ок, строк=%', + v_row_count; +END $$; diff --git a/sql/dds/dim_airports_load.sql b/sql/dds/dim_airports_load.sql new file mode 100644 index 0000000..666e0bc --- /dev/null +++ b/sql/dds/dim_airports_load.sql @@ -0,0 +1,60 @@ +-- Загрузка DDS dim_airports: SCD1 UPSERT (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих записей (если атрибуты изменились). +UPDATE dds.dim_airports AS d +SET airport_name = s.airport_name, + city = s.city, + country = s.country, + timezone = s.timezone, + coordinates = s.coordinates, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM ods.airports AS s +WHERE d.airport_bk = s.airport_code + AND ( + d.airport_name IS DISTINCT FROM s.airport_name + OR d.city IS DISTINCT FROM s.city + OR d.country IS DISTINCT FROM s.country + OR d.timezone IS DISTINCT FROM s.timezone + OR d.coordinates IS DISTINCT FROM s.coordinates + ); + +-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()). +WITH max_sk AS ( + SELECT COALESCE(MAX(airport_sk), 0) AS v + FROM dds.dim_airports +) +INSERT INTO dds.dim_airports ( + airport_sk, + airport_bk, + airport_name, + city, + country, + timezone, + coordinates, + created_at, + updated_at, + _load_id, + _load_ts +) +SELECT + (SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.airport_code)::INTEGER, + s.airport_code, + s.airport_name, + s.city, + s.country, + s.timezone, + s.coordinates, + now(), + now(), + '{{ run_id }}', + now() +FROM ods.airports AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_airports AS d + WHERE d.airport_bk = s.airport_code +); + +ANALYZE dds.dim_airports; diff --git a/sql/dds/dim_calendar_ddl.sql b/sql/dds/dim_calendar_ddl.sql new file mode 100644 index 0000000..8611829 --- /dev/null +++ b/sql/dds/dim_calendar_ddl.sql @@ -0,0 +1,15 @@ +-- DDL для DDS-слоя по таблице dim_calendar (статическое измерение дат). + +CREATE SCHEMA IF NOT EXISTS dds; + +CREATE TABLE IF NOT EXISTS dds.dim_calendar ( + calendar_sk INTEGER NOT NULL, + date_actual DATE NOT NULL, + year_actual INTEGER NOT NULL, + month_actual INTEGER NOT NULL, + day_actual INTEGER NOT NULL, + day_of_week INTEGER NOT NULL, + day_name TEXT NOT NULL, + is_weekend BOOLEAN NOT NULL +) +DISTRIBUTED BY (calendar_sk); diff --git a/sql/dds/dim_calendar_dq.sql b/sql/dds/dim_calendar_dq.sql new file mode 100644 index 0000000..f0c2089 --- /dev/null +++ b/sql/dds/dim_calendar_dq.sql @@ -0,0 +1,87 @@ +-- DQ для DDS dim_calendar. + +DO $$ +DECLARE + v_row_count BIGINT; + v_dup_sk BIGINT; + v_dup_date BIGINT; + v_null_count BIGINT; + v_missing_flight_dates BIGINT; +BEGIN + -- Таблица должна быть достаточно заполнена. + SELECT COUNT(*) + INTO v_row_count + FROM dds.dim_calendar; + + IF v_row_count < 1000 THEN + RAISE EXCEPTION + 'DQ FAILED: dds.dim_calendar содержит слишком мало строк: %', + v_row_count; + END IF; + + -- Нет дублей по surrogate key. + SELECT COUNT(*) - COUNT(DISTINCT calendar_sk) + INTO v_dup_sk + FROM dds.dim_calendar; + + IF v_dup_sk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_calendar найдены дубликаты calendar_sk: %', + v_dup_sk; + END IF; + + -- Нет дублей по business key (date_actual). + SELECT COUNT(*) - COUNT(DISTINCT date_actual) + INTO v_dup_date + FROM dds.dim_calendar; + + IF v_dup_date <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_calendar найдены дубликаты date_actual: %', + v_dup_date; + END IF; + + -- Обязательные поля не NULL. + SELECT COUNT(*) + INTO v_null_count + FROM dds.dim_calendar + WHERE calendar_sk IS NULL + OR date_actual IS NULL + OR year_actual IS NULL + OR month_actual IS NULL + OR day_actual IS NULL + OR day_of_week IS NULL + OR day_name IS NULL + OR day_name = '' + OR is_weekend IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_calendar найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + -- Календарь покрывает даты вылета из ODS (где scheduled_departure не NULL). + SELECT COUNT(*) + INTO v_missing_flight_dates + FROM ( + SELECT DISTINCT f.scheduled_departure::DATE AS departure_date + FROM ods.flights AS f + WHERE f.scheduled_departure IS NOT NULL + ) AS src + WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_calendar AS c + WHERE c.date_actual = src.departure_date + ); + + IF v_missing_flight_dates <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: dds.dim_calendar не покрывает даты вылета из ods.flights: %', + v_missing_flight_dates; + END IF; + + RAISE NOTICE + 'DQ PASSED: dds.dim_calendar ок, строк=%', + v_row_count; +END $$; diff --git a/sql/dds/dim_calendar_load.sql b/sql/dds/dim_calendar_load.sql new file mode 100644 index 0000000..1c9e31f --- /dev/null +++ b/sql/dds/dim_calendar_load.sql @@ -0,0 +1,29 @@ +-- Загрузка DDS dim_calendar: статическое измерение (генерация дат). +-- Заполняем только если таблица пуста (идемпотентно). + +INSERT INTO dds.dim_calendar ( + calendar_sk, + date_actual, + year_actual, + month_actual, + day_actual, + day_of_week, + day_name, + is_weekend +) +SELECT + ROW_NUMBER() OVER (ORDER BY d.date_actual)::INTEGER AS calendar_sk, + d.date_actual, + EXTRACT(YEAR FROM d.date_actual)::INTEGER AS year_actual, + EXTRACT(MONTH FROM d.date_actual)::INTEGER AS month_actual, + EXTRACT(DAY FROM d.date_actual)::INTEGER AS day_actual, + EXTRACT(ISODOW FROM d.date_actual)::INTEGER AS day_of_week, + TO_CHAR(d.date_actual, 'FMDay') AS day_name, + EXTRACT(ISODOW FROM d.date_actual) IN (6, 7) AS is_weekend +FROM ( + SELECT generate_series('2016-01-01'::DATE, '2030-12-31'::DATE, '1 day'::INTERVAL)::DATE + AS date_actual +) AS d +WHERE NOT EXISTS (SELECT 1 FROM dds.dim_calendar LIMIT 1); + +ANALYZE dds.dim_calendar; diff --git a/sql/dds/dim_passengers_ddl.sql b/sql/dds/dim_passengers_ddl.sql new file mode 100644 index 0000000..1d1de23 --- /dev/null +++ b/sql/dds/dim_passengers_ddl.sql @@ -0,0 +1,14 @@ +-- DDL для DDS-слоя по таблице dim_passengers (SCD1-измерение). + +CREATE SCHEMA IF NOT EXISTS dds; + +CREATE TABLE IF NOT EXISTS dds.dim_passengers ( + passenger_sk INTEGER NOT NULL, + passenger_bk TEXT NOT NULL, + passenger_name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (passenger_sk); diff --git a/sql/dds/dim_passengers_dq.sql b/sql/dds/dim_passengers_dq.sql new file mode 100644 index 0000000..8cbcaa5 --- /dev/null +++ b/sql/dds/dim_passengers_dq.sql @@ -0,0 +1,100 @@ +-- DQ для DDS dim_passengers. + +DO $$ +DECLARE + v_row_count BIGINT; + v_src_count BIGINT; + v_dup_sk BIGINT; + v_dup_bk BIGINT; + v_missing_bk BIGINT; + v_null_count BIGINT; +BEGIN + -- Для инкрементальных периодов без новых билетов допускаем пустую dim_passengers. + SELECT COUNT(*) + INTO v_row_count + FROM dds.dim_passengers; + + SELECT COUNT(DISTINCT passenger_id) + INTO v_src_count + FROM ods.tickets + WHERE passenger_id IS NOT NULL + AND passenger_id <> ''; + + IF v_row_count = 0 AND v_src_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: dds.dim_passengers пуста при непустом источнике ods.tickets (passenger_id=%).', + v_src_count; + ELSIF v_row_count = 0 AND v_src_count = 0 THEN + RAISE NOTICE + 'DQ PASSED: dds.dim_passengers пуста, т.к. в ods.tickets нет passenger_id для загрузки.'; + RETURN; + END IF; + + -- Нет дублей по SK. + SELECT COUNT(*) - COUNT(DISTINCT passenger_sk) + INTO v_dup_sk + FROM dds.dim_passengers; + + IF v_dup_sk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_passengers найдены дубликаты passenger_sk: %', + v_dup_sk; + END IF; + + -- Нет дублей по BK. + SELECT COUNT(*) - COUNT(DISTINCT passenger_bk) + INTO v_dup_bk + FROM dds.dim_passengers; + + IF v_dup_bk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_passengers найдены дубликаты passenger_bk: %', + v_dup_bk; + END IF; + + -- Покрытие ODS: все passenger_id из ods.tickets есть в DDS. + SELECT COUNT(*) + INTO v_missing_bk + FROM ( + SELECT DISTINCT passenger_id + FROM ods.tickets + WHERE passenger_id IS NOT NULL + AND passenger_id <> '' + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_passengers AS d + WHERE d.passenger_bk = s.passenger_id + ); + + IF v_missing_bk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_passengers отсутствуют passenger_id из ods.tickets: %', + v_missing_bk; + END IF; + + -- Обязательные поля. + SELECT COUNT(*) + INTO v_null_count + FROM dds.dim_passengers + WHERE passenger_sk IS NULL + OR passenger_bk IS NULL + OR passenger_bk = '' + OR passenger_name IS NULL + OR passenger_name = '' + OR created_at IS NULL + OR updated_at IS NULL + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_passengers найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: dds.dim_passengers ок, строк=%', + v_row_count; +END $$; diff --git a/sql/dds/dim_passengers_load.sql b/sql/dds/dim_passengers_load.sql new file mode 100644 index 0000000..df48c0e --- /dev/null +++ b/sql/dds/dim_passengers_load.sql @@ -0,0 +1,82 @@ +-- Загрузка DDS dim_passengers: SCD1 UPSERT (UPDATE изменившихся + INSERT новых). + +-- Statement 1: UPDATE существующих записей (если атрибуты изменились). +WITH src AS ( + SELECT + d.passenger_id, + d.passenger_name + FROM ( + SELECT + t.passenger_id, + t.passenger_name, + ROW_NUMBER() OVER ( + PARTITION BY t.passenger_id + ORDER BY t.event_ts DESC NULLS LAST, t._load_ts DESC, t.ticket_no DESC + ) AS rn + FROM ods.tickets AS t + WHERE t.passenger_id IS NOT NULL + AND t.passenger_id <> '' + AND t.passenger_name IS NOT NULL + AND t.passenger_name <> '' + ) AS d + WHERE d.rn = 1 +) +UPDATE dds.dim_passengers AS d +SET passenger_name = s.passenger_name, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM src AS s +WHERE d.passenger_bk = s.passenger_id + AND d.passenger_name IS DISTINCT FROM s.passenger_name; + +-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()). +WITH src AS ( + SELECT + d.passenger_id, + d.passenger_name + FROM ( + SELECT + t.passenger_id, + t.passenger_name, + ROW_NUMBER() OVER ( + PARTITION BY t.passenger_id + ORDER BY t.event_ts DESC NULLS LAST, t._load_ts DESC, t.ticket_no DESC + ) AS rn + FROM ods.tickets AS t + WHERE t.passenger_id IS NOT NULL + AND t.passenger_id <> '' + AND t.passenger_name IS NOT NULL + AND t.passenger_name <> '' + ) AS d + WHERE d.rn = 1 +), +max_sk AS ( + SELECT COALESCE(MAX(passenger_sk), 0) AS v + FROM dds.dim_passengers +) +INSERT INTO dds.dim_passengers ( + passenger_sk, + passenger_bk, + passenger_name, + created_at, + updated_at, + _load_id, + _load_ts +) +SELECT + (SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.passenger_id)::INTEGER, + s.passenger_id, + s.passenger_name, + now(), + now(), + '{{ run_id }}', + now() +FROM src AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_passengers AS d + WHERE d.passenger_bk = s.passenger_id +); + +ANALYZE dds.dim_passengers; diff --git a/sql/dds/dim_routes_ddl.sql b/sql/dds/dim_routes_ddl.sql new file mode 100644 index 0000000..81892d4 --- /dev/null +++ b/sql/dds/dim_routes_ddl.sql @@ -0,0 +1,22 @@ +-- DDL для DDS-слоя по таблице dim_routes (SCD2-измерение). + +CREATE SCHEMA IF NOT EXISTS dds; + +CREATE TABLE IF NOT EXISTS dds.dim_routes ( + route_sk INTEGER NOT NULL, + route_bk TEXT NOT NULL, + departure_airport TEXT NOT NULL, + arrival_airport TEXT NOT NULL, + airplane_code TEXT NOT NULL, + days_of_week TEXT, + departure_time TIME, + duration INTERVAL, + hashdiff TEXT NOT NULL, + valid_from DATE NOT NULL, + valid_to DATE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (route_sk); diff --git a/sql/dds/dim_routes_dq.sql b/sql/dds/dim_routes_dq.sql new file mode 100644 index 0000000..fe1e56f --- /dev/null +++ b/sql/dds/dim_routes_dq.sql @@ -0,0 +1,149 @@ +-- DQ для DDS dim_routes (SCD2). + +DO $$ +DECLARE + v_row_count BIGINT; + v_dup_sk BIGINT; + v_dup_current BIGINT; + v_overlap_count BIGINT; + v_missing_count BIGINT; + v_orphan_current BIGINT; + v_null_count BIGINT; +BEGIN + -- Таблица не пуста. + SELECT COUNT(*) + INTO v_row_count + FROM dds.dim_routes; + + IF v_row_count = 0 THEN + RAISE EXCEPTION 'DQ FAILED: dds.dim_routes пуста.'; + END IF; + + -- Нет дублей по SK. + SELECT COUNT(*) - COUNT(DISTINCT route_sk) + INTO v_dup_sk + FROM dds.dim_routes; + + IF v_dup_sk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes найдены дубликаты route_sk: %', + v_dup_sk; + END IF; + + -- Корректность интервалов (valid_from <= valid_to для закрытых версий). + SELECT COUNT(*) + INTO v_null_count + FROM dds.dim_routes + WHERE valid_to IS NOT NULL + AND valid_from > valid_to; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes найдены версии с valid_from > valid_to: %', + v_null_count; + END IF; + + -- Нет перекрытий интервалов для одного route_bk. + SELECT COUNT(*) + INTO v_overlap_count + FROM ( + SELECT 1 + FROM dds.dim_routes AS d1 + JOIN dds.dim_routes AS d2 + ON d1.route_bk = d2.route_bk + AND d1.route_sk < d2.route_sk + AND d1.valid_from < COALESCE(d2.valid_to, DATE '9999-12-31') + AND d2.valid_from < COALESCE(d1.valid_to, DATE '9999-12-31') + ) AS overlap_rows; + + IF v_overlap_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes найдены перекрытия SCD2-интервалов: %', + v_overlap_count; + END IF; + + -- Не более одной текущей версии на route_bk. + SELECT COUNT(*) + INTO v_dup_current + FROM ( + SELECT route_bk + FROM dds.dim_routes + WHERE valid_to IS NULL + GROUP BY route_bk + HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup_current <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes найдены route_bk с > 1 текущей версией: %', + v_dup_current; + END IF; + + -- Покрытие ODS: все route_no имеют хотя бы одну версию в DDS. + SELECT COUNT(*) + INTO v_missing_count + FROM (SELECT DISTINCT route_no FROM ods.routes) AS o + WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_routes AS d + WHERE d.route_bk = o.route_no + ); + + IF v_missing_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes отсутствуют маршруты из ODS: %', + v_missing_count; + END IF; + + -- Current-срез DDS не содержит route_bk, которых нет в ODS. + SELECT COUNT(*) + INTO v_orphan_current + FROM ( + SELECT DISTINCT route_bk + FROM dds.dim_routes + WHERE valid_to IS NULL + ) AS d + WHERE NOT EXISTS ( + SELECT 1 + FROM ods.routes AS o + WHERE o.route_no = d.route_bk + ); + + IF v_orphan_current <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в current-срезе dds.dim_routes есть route_bk вне ODS: %', + v_orphan_current; + END IF; + + -- Обязательные поля. + SELECT COUNT(*) + INTO v_null_count + FROM dds.dim_routes + WHERE route_sk IS NULL + OR route_bk IS NULL + OR route_bk = '' + OR departure_airport IS NULL + OR departure_airport = '' + OR arrival_airport IS NULL + OR arrival_airport = '' + OR airplane_code IS NULL + OR airplane_code = '' + OR hashdiff IS NULL + OR hashdiff = '' + OR valid_from IS NULL + OR created_at IS NULL + OR updated_at IS NULL + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_routes найдены NULL обязательные поля: %', + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: dds.dim_routes ок, строк=% (версий)', + v_row_count; +END $$; diff --git a/sql/dds/dim_routes_load.sql b/sql/dds/dim_routes_load.sql new file mode 100644 index 0000000..49e0585 --- /dev/null +++ b/sql/dds/dim_routes_load.sql @@ -0,0 +1,130 @@ +-- Загрузка DDS dim_routes: SCD2 с hashdiff. + +-- Statement 1: Закрыть устаревшие версии (valid_to = текущая дата). +WITH src AS ( + SELECT + route_no, + departure_airport, + arrival_airport, + airplane_code, + days_of_week, + departure_time, + duration, + md5( + COALESCE(departure_airport, '') || '|' || + COALESCE(arrival_airport, '') || '|' || + COALESCE(airplane_code, '') || '|' || + COALESCE(days_of_week, '') || '|' || + COALESCE(departure_time::TEXT, '') || '|' || + COALESCE(duration::TEXT, '') + ) AS hashdiff, + ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn + FROM ods.routes +) +UPDATE dds.dim_routes AS d +SET valid_to = CURRENT_DATE, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM src AS s +WHERE s.rn = 1 + AND d.route_bk = s.route_no + AND d.valid_to IS NULL + AND d.hashdiff <> s.hashdiff; + +-- Statement 1.1: Закрыть "исчезнувшие" маршруты. +WITH src AS ( + SELECT + route_no, + ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn + FROM ods.routes +) +UPDATE dds.dim_routes AS d +SET valid_to = CURRENT_DATE, + updated_at = now(), + _load_id = '{{ run_id }}', + _load_ts = now() +WHERE d.valid_to IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM src AS s + WHERE s.rn = 1 + AND s.route_no = d.route_bk + ); + +-- Statement 2: Вставить новые версии (для изменённых и новых route_no). +WITH src AS ( + SELECT + route_no, + departure_airport, + arrival_airport, + airplane_code, + days_of_week, + departure_time, + duration, + md5( + COALESCE(departure_airport, '') || '|' || + COALESCE(arrival_airport, '') || '|' || + COALESCE(airplane_code, '') || '|' || + COALESCE(days_of_week, '') || '|' || + COALESCE(departure_time::TEXT, '') || '|' || + COALESCE(duration::TEXT, '') + ) AS hashdiff, + ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn + FROM ods.routes +), +max_sk AS ( + SELECT COALESCE(MAX(route_sk), 0) AS v + FROM dds.dim_routes +) +INSERT INTO dds.dim_routes ( + route_sk, + route_bk, + departure_airport, + arrival_airport, + airplane_code, + days_of_week, + departure_time, + duration, + hashdiff, + valid_from, + valid_to, + created_at, + updated_at, + _load_id, + _load_ts +) +SELECT + (SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.route_no)::INTEGER, + s.route_no, + s.departure_airport, + s.arrival_airport, + s.airplane_code, + s.days_of_week, + s.departure_time, + s.duration, + s.hashdiff, + CASE + WHEN EXISTS ( + SELECT 1 + FROM dds.dim_routes AS d2 + WHERE d2.route_bk = s.route_no + ) THEN CURRENT_DATE + ELSE '1900-01-01'::DATE + END AS valid_from, + NULL, + now(), + now(), + '{{ run_id }}', + now() +FROM src AS s +WHERE s.rn = 1 + AND NOT EXISTS ( + SELECT 1 + FROM dds.dim_routes AS d + WHERE d.route_bk = s.route_no + AND d.valid_to IS NULL + AND d.hashdiff = s.hashdiff + ); + +ANALYZE dds.dim_routes; diff --git a/sql/dds/dim_tariffs_ddl.sql b/sql/dds/dim_tariffs_ddl.sql new file mode 100644 index 0000000..bd93796 --- /dev/null +++ b/sql/dds/dim_tariffs_ddl.sql @@ -0,0 +1,13 @@ +-- DDL для DDS-слоя по таблице dim_tariffs (SCD1-измерение). + +CREATE SCHEMA IF NOT EXISTS dds; + +CREATE TABLE IF NOT EXISTS dds.dim_tariffs ( + tariff_sk INTEGER NOT NULL, + fare_conditions TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (tariff_sk); diff --git a/sql/dds/dim_tariffs_dq.sql b/sql/dds/dim_tariffs_dq.sql new file mode 100644 index 0000000..925bba9 --- /dev/null +++ b/sql/dds/dim_tariffs_dq.sql @@ -0,0 +1,98 @@ +-- DQ для DDS dim_tariffs. + +DO $$ +DECLARE + v_row_count BIGINT; + v_src_count BIGINT; + v_dup_sk BIGINT; + v_dup_bk BIGINT; + v_missing_bk BIGINT; + v_null_count BIGINT; +BEGIN + -- Для инкрементальных периодов без новых сегментов допускаем пустую dim_tariffs. + SELECT COUNT(*) + INTO v_row_count + FROM dds.dim_tariffs; + + SELECT COUNT(DISTINCT fare_conditions) + INTO v_src_count + FROM ods.segments + WHERE fare_conditions IS NOT NULL + AND fare_conditions <> ''; + + IF v_row_count = 0 AND v_src_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: dds.dim_tariffs пуста при непустом источнике ods.segments (fare_conditions=%).', + v_src_count; + ELSIF v_row_count = 0 AND v_src_count = 0 THEN + RAISE NOTICE + 'DQ PASSED: dds.dim_tariffs пуста, т.к. в ods.segments нет тарифов для загрузки.'; + RETURN; + END IF; + + -- Нет дублей по SK. + SELECT COUNT(*) - COUNT(DISTINCT tariff_sk) + INTO v_dup_sk + FROM dds.dim_tariffs; + + IF v_dup_sk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_tariffs найдены дубликаты tariff_sk: %', + v_dup_sk; + END IF; + + -- Нет дублей по BK. + SELECT COUNT(*) - COUNT(DISTINCT fare_conditions) + INTO v_dup_bk + FROM dds.dim_tariffs; + + IF v_dup_bk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_tariffs найдены дубликаты fare_conditions: %', + v_dup_bk; + END IF; + + -- Покрытие ODS: все fare_conditions из ods.segments есть в DDS. + SELECT COUNT(*) + INTO v_missing_bk + FROM ( + SELECT DISTINCT fare_conditions + FROM ods.segments + WHERE fare_conditions IS NOT NULL + AND fare_conditions <> '' + ) AS s + WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_tariffs AS d + WHERE d.fare_conditions = s.fare_conditions + ); + + IF v_missing_bk <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_tariffs отсутствуют значения fare_conditions из ods.segments: %', + v_missing_bk; + END IF; + + -- Обязательные поля. + SELECT COUNT(*) + INTO v_null_count + FROM dds.dim_tariffs + WHERE tariff_sk IS NULL + OR fare_conditions IS NULL + OR fare_conditions = '' + OR created_at IS NULL + OR updated_at IS NULL + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.dim_tariffs найдены NULL/пустые обязательные поля: %', + v_null_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: dds.dim_tariffs ок, строк=%', + v_row_count; +END $$; diff --git a/sql/dds/dim_tariffs_load.sql b/sql/dds/dim_tariffs_load.sql new file mode 100644 index 0000000..ebe514f --- /dev/null +++ b/sql/dds/dim_tariffs_load.sql @@ -0,0 +1,36 @@ +-- Загрузка DDS dim_tariffs: SCD1 UPSERT (INSERT новых тарифов). + +WITH src AS ( + SELECT DISTINCT + s.fare_conditions + FROM ods.segments AS s + WHERE s.fare_conditions IS NOT NULL + AND s.fare_conditions <> '' +), +max_sk AS ( + SELECT COALESCE(MAX(tariff_sk), 0) AS v + FROM dds.dim_tariffs +) +INSERT INTO dds.dim_tariffs ( + tariff_sk, + fare_conditions, + created_at, + updated_at, + _load_id, + _load_ts +) +SELECT + (SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.fare_conditions)::INTEGER, + s.fare_conditions, + now(), + now(), + '{{ run_id }}', + now() +FROM src AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM dds.dim_tariffs AS d + WHERE d.fare_conditions = s.fare_conditions +); + +ANALYZE dds.dim_tariffs; diff --git a/sql/dds/fact_flight_sales_ddl.sql b/sql/dds/fact_flight_sales_ddl.sql new file mode 100644 index 0000000..1b087d4 --- /dev/null +++ b/sql/dds/fact_flight_sales_ddl.sql @@ -0,0 +1,23 @@ +-- DDL для DDS-слоя по таблице fact_flight_sales. + +CREATE SCHEMA IF NOT EXISTS dds; + +CREATE TABLE IF NOT EXISTS dds.fact_flight_sales ( + calendar_sk INTEGER, + departure_airport_sk INTEGER, + arrival_airport_sk INTEGER, + airplane_sk INTEGER, + tariff_sk INTEGER, + passenger_sk INTEGER, + route_sk INTEGER, + book_ref TEXT NOT NULL, + ticket_no TEXT NOT NULL, + flight_id INTEGER NOT NULL, + book_date DATE, + seat_no TEXT, + price NUMERIC(10,2), + is_boarded BOOLEAN NOT NULL, + _load_id TEXT NOT NULL, + _load_ts TIMESTAMP NOT NULL DEFAULT now() +) +DISTRIBUTED BY (ticket_no); diff --git a/sql/dds/fact_flight_sales_dq.sql b/sql/dds/fact_flight_sales_dq.sql new file mode 100644 index 0000000..c4fa53c --- /dev/null +++ b/sql/dds/fact_flight_sales_dq.sql @@ -0,0 +1,151 @@ +-- DQ для DDS fact_flight_sales. + +DO $$ +DECLARE + v_row_count BIGINT; + v_ods_count BIGINT; + v_dup_count BIGINT; + v_null_passenger BIGINT; + v_null_tariff BIGINT; + v_null_route_related BIGINT; + v_null_calendar BIGINT; + v_null_required BIGINT; +BEGIN + -- Для пустого инкрементального окна (ods.segments) допускаем пустой факт. + SELECT COUNT(*) + INTO v_ods_count + FROM ods.segments; + + SELECT COUNT(*) + INTO v_row_count + FROM dds.fact_flight_sales; + + IF v_ods_count = 0 THEN + IF v_row_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: ods.segments пустая, но в dds.fact_flight_sales есть строки: %', + v_row_count; + END IF; + + RAISE NOTICE + 'DQ PASSED: ods.segments и dds.fact_flight_sales пустые (инкрементальное окно без сегментов).'; + RETURN; + END IF; + + IF v_row_count = 0 THEN + RAISE EXCEPTION + 'DQ FAILED: dds.fact_flight_sales пуста при непустом источнике ods.segments (%).', + v_ods_count; + END IF; + + -- Покрытие: количество строк = ods.segments. + IF v_row_count <> v_ods_count THEN + RAISE EXCEPTION + 'DQ FAILED: dds.fact_flight_sales (%) <> ods.segments (%). Потеряны строки.', + v_row_count, + v_ods_count; + END IF; + + -- Нет дублей по зерну. + SELECT COUNT(*) + INTO v_dup_count + FROM ( + SELECT ticket_no, flight_id + FROM dds.fact_flight_sales + GROUP BY ticket_no, flight_id + HAVING COUNT(*) > 1 + ) AS d; + + IF v_dup_count <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в dds.fact_flight_sales дубликаты (ticket_no, flight_id): %', + v_dup_count; + END IF; + + -- Ссылочная целостность: passenger_sk. + SELECT COUNT(*) + INTO v_null_passenger + FROM dds.fact_flight_sales + WHERE passenger_sk IS NULL; + + IF v_null_passenger <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales строки без passenger_sk: %', + v_null_passenger; + END IF; + + -- Ссылочная целостность: tariff_sk. + SELECT COUNT(*) + INTO v_null_tariff + FROM dds.fact_flight_sales + WHERE tariff_sk IS NULL; + + IF v_null_tariff <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales строки без tariff_sk: %', + v_null_tariff; + END IF; + + -- Route-related FK: допустимо при аномалиях, фейлим если > 1%. + SELECT COUNT(*) + INTO v_null_route_related + FROM dds.fact_flight_sales + WHERE route_sk IS NULL + OR departure_airport_sk IS NULL + OR arrival_airport_sk IS NULL + OR airplane_sk IS NULL; + + IF v_null_route_related > 0 THEN + IF v_null_route_related * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales слишком много строк с NULL в route-related FK: % (>1%%)', + v_null_route_related; + ELSE + RAISE NOTICE + 'DQ WARNING: в fact_flight_sales строк с NULL в route-related FK: % (<=1%%, допустимо)', + v_null_route_related; + END IF; + END IF; + + -- Calendar: допустимо если scheduled_departure IS NULL, фейлим если > 1%. + SELECT COUNT(*) + INTO v_null_calendar + FROM dds.fact_flight_sales + WHERE calendar_sk IS NULL; + + IF v_null_calendar > 0 THEN + IF v_null_calendar * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales слишком много строк без calendar_sk: % (>1%%)', + v_null_calendar; + ELSE + RAISE NOTICE + 'DQ WARNING: в fact_flight_sales строк без calendar_sk: % (<=1%%, допустимо)', + v_null_calendar; + END IF; + END IF; + + -- Обязательные поля. + SELECT COUNT(*) + INTO v_null_required + FROM dds.fact_flight_sales + WHERE book_ref IS NULL + OR book_ref = '' + OR ticket_no IS NULL + OR ticket_no = '' + OR flight_id IS NULL + OR is_boarded IS NULL + OR _load_id IS NULL + OR _load_id = '' + OR _load_ts IS NULL; + + IF v_null_required <> 0 THEN + RAISE EXCEPTION + 'DQ FAILED: в fact_flight_sales NULL обязательные поля: %', + v_null_required; + END IF; + + RAISE NOTICE + 'DQ PASSED: dds.fact_flight_sales ок, строк=%', + v_row_count; +END $$; diff --git a/sql/dds/fact_flight_sales_load.sql b/sql/dds/fact_flight_sales_load.sql new file mode 100644 index 0000000..053cfaf --- /dev/null +++ b/sql/dds/fact_flight_sales_load.sql @@ -0,0 +1,111 @@ +-- Загрузка DDS fact_flight_sales: инкрементальный UPSERT по (ticket_no, flight_id). + +-- Statement 1: UPDATE существующих строк факта. +-- Обновляем только мутабельные поля; SK измерений не перезаписываем. +UPDATE dds.fact_flight_sales AS f +SET seat_no = bp.seat_no, + price = seg.segment_amount, + is_boarded = (bp.ticket_no IS NOT NULL), + _load_id = '{{ run_id }}', + _load_ts = now() +FROM ods.segments AS seg +LEFT JOIN ods.boarding_passes AS bp + ON bp.ticket_no = seg.ticket_no + AND bp.flight_id = seg.flight_id +WHERE f.ticket_no = seg.ticket_no + AND f.flight_id = seg.flight_id + AND ( + f.is_boarded IS DISTINCT FROM (bp.ticket_no IS NOT NULL) + OR f.price IS DISTINCT FROM seg.segment_amount + OR f.seat_no IS DISTINCT FROM bp.seat_no + ); + +-- Statement 2: INSERT новых строк факта. +-- Dimension SK фиксируются на момент вставки (point-in-time для SCD2 routes). +WITH fact_src AS ( + SELECT + seg.ticket_no, + seg.flight_id, + cal.calendar_sk, + dep.airport_sk AS departure_airport_sk, + arr.airport_sk AS arrival_airport_sk, + ap.airplane_sk, + tar.tariff_sk, + pax.passenger_sk, + rte.route_sk, + tkt.book_ref, + bkg.book_date::DATE AS book_date, + bp.seat_no, + seg.segment_amount AS price, + (bp.ticket_no IS NOT NULL) AS is_boarded + FROM ods.segments AS seg + JOIN ods.tickets AS tkt + ON tkt.ticket_no = seg.ticket_no + JOIN ods.bookings AS bkg + ON bkg.book_ref = tkt.book_ref + JOIN ods.flights AS flt + ON flt.flight_id = seg.flight_id + LEFT JOIN dds.dim_routes AS rte + ON rte.route_bk = flt.route_no + AND flt.scheduled_departure::DATE >= rte.valid_from + AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to) + LEFT JOIN dds.dim_calendar AS cal + ON cal.date_actual = flt.scheduled_departure::DATE + LEFT JOIN dds.dim_airports AS dep + ON dep.airport_bk = rte.departure_airport + LEFT JOIN dds.dim_airports AS arr + ON arr.airport_bk = rte.arrival_airport + LEFT JOIN dds.dim_airplanes AS ap + ON ap.airplane_bk = rte.airplane_code + LEFT JOIN dds.dim_tariffs AS tar + ON tar.fare_conditions = seg.fare_conditions + LEFT JOIN dds.dim_passengers AS pax + ON pax.passenger_bk = tkt.passenger_id + LEFT JOIN ods.boarding_passes AS bp + ON bp.ticket_no = seg.ticket_no + AND bp.flight_id = seg.flight_id +) +INSERT INTO dds.fact_flight_sales ( + calendar_sk, + departure_airport_sk, + arrival_airport_sk, + airplane_sk, + tariff_sk, + passenger_sk, + route_sk, + book_ref, + ticket_no, + flight_id, + book_date, + seat_no, + price, + is_boarded, + _load_id, + _load_ts +) +SELECT + s.calendar_sk, + s.departure_airport_sk, + s.arrival_airport_sk, + s.airplane_sk, + s.tariff_sk, + s.passenger_sk, + s.route_sk, + s.book_ref, + s.ticket_no, + s.flight_id, + s.book_date, + s.seat_no, + s.price, + s.is_boarded, + '{{ run_id }}', + now() +FROM fact_src AS s +WHERE NOT EXISTS ( + SELECT 1 + FROM dds.fact_flight_sales AS f + WHERE f.ticket_no = s.ticket_no + AND f.flight_id = s.flight_id +); + +ANALYZE dds.fact_flight_sales; diff --git a/tests/test_dags_smoke.py b/tests/test_dags_smoke.py index 2117dd9..2fbe4f3 100644 --- a/tests/test_dags_smoke.py +++ b/tests/test_dags_smoke.py @@ -297,3 +297,88 @@ def test_bookings_to_gp_ods_dag_structure(): # Финальная сводка должна ждать обе ветки. _assert_reachable(dag, "dq_ods_boarding_passes", "finish_ods_summary") _assert_reachable(dag, "dq_ods_seats", "finish_ods_summary") + + +def test_bookings_dds_ddl_dag_structure(): + """Проверка структуры DAG bookings_dds_ddl.""" + dag = _load_dag("airflow.dags.bookings_dds_ddl") + + expected_tasks = { + "apply_dds_dim_calendar_ddl", + "apply_dds_dim_airports_ddl", + "apply_dds_dim_airplanes_ddl", + "apply_dds_dim_tariffs_ddl", + "apply_dds_dim_passengers_ddl", + "apply_dds_dim_routes_ddl", + "apply_dds_fact_flight_sales_ddl", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + _assert_reachable(dag, "apply_dds_dim_calendar_ddl", "apply_dds_dim_airports_ddl") + + for task_id in expected_tasks - {"apply_dds_dim_calendar_ddl"}: + _assert_reachable(dag, "apply_dds_dim_calendar_ddl", task_id) + + +def test_bookings_to_gp_dds_dag_structure(): + """Проверка структуры DAG bookings_to_gp_dds.""" + dag = _load_dag("airflow.dags.bookings_to_gp_dds") + + expected_tasks = { + "load_dds_dim_calendar", + "dq_dds_dim_calendar", + "load_dds_dim_airports", + "dq_dds_dim_airports", + "load_dds_dim_airplanes", + "dq_dds_dim_airplanes", + "load_dds_dim_tariffs", + "dq_dds_dim_tariffs", + "load_dds_dim_passengers", + "dq_dds_dim_passengers", + "load_dds_dim_routes", + "dq_dds_dim_routes", + "load_dds_fact_flight_sales", + "dq_dds_fact_flight_sales", + "finish_dds_summary", + } + assert expected_tasks.issubset(dag.task_dict.keys()) + + load_to_dq = [ + ("load_dds_dim_calendar", "dq_dds_dim_calendar"), + ("load_dds_dim_airports", "dq_dds_dim_airports"), + ("load_dds_dim_airplanes", "dq_dds_dim_airplanes"), + ("load_dds_dim_tariffs", "dq_dds_dim_tariffs"), + ("load_dds_dim_passengers", "dq_dds_dim_passengers"), + ("load_dds_dim_routes", "dq_dds_dim_routes"), + ("load_dds_fact_flight_sales", "dq_dds_fact_flight_sales"), + ] + for load_task_id, dq_task_id in load_to_dq: + _assert_direct_edge(dag, load_task_id, dq_task_id) + + # После calendar все остальные измерения должны быть reachable. + _assert_reachable(dag, "dq_dds_dim_calendar", "load_dds_dim_airports") + _assert_reachable(dag, "dq_dds_dim_calendar", "load_dds_dim_airplanes") + _assert_reachable(dag, "dq_dds_dim_calendar", "load_dds_dim_tariffs") + _assert_reachable(dag, "dq_dds_dim_calendar", "load_dds_dim_passengers") + _assert_reachable(dag, "dq_dds_dim_calendar", "load_dds_dim_routes") + + # Параллельность: airports и airplanes не должны зависеть друг от друга. + airports = dag.get_task("load_dds_dim_airports") + airplanes = dag.get_task("load_dds_dim_airplanes") + assert airplanes not in airports.get_flat_relatives( + upstream=False + ), "dds airports не должен быть upstream для airplanes" + assert airports not in airplanes.get_flat_relatives( + upstream=False + ), "dds airplanes не должен быть upstream для airports" + + # Факт должен стартовать только после всех измерений. + _assert_reachable(dag, "dq_dds_dim_calendar", "load_dds_fact_flight_sales") + _assert_reachable(dag, "dq_dds_dim_airports", "load_dds_fact_flight_sales") + _assert_reachable(dag, "dq_dds_dim_airplanes", "load_dds_fact_flight_sales") + _assert_reachable(dag, "dq_dds_dim_tariffs", "load_dds_fact_flight_sales") + _assert_reachable(dag, "dq_dds_dim_passengers", "load_dds_fact_flight_sales") + _assert_reachable(dag, "dq_dds_dim_routes", "load_dds_fact_flight_sales") + + # Финальная сводка должна ждать DQ факта. + _assert_reachable(dag, "dq_dds_fact_flight_sales", "finish_dds_summary") From 8fd5ae10690d7348147bac882b88e39a94ab53ff Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Wed, 25 Feb 2026 23:12:27 +0300 Subject: [PATCH 37/38] =?UTF-8?q?fix(ods):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=B0=20flights=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=81=D1=81=D1=8B=D0=BB=D0=BE=D0=BA=20=D0=B8=D0=B7=20segments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - dq_ods_segments падал на непустых батчах из-за orphan flight_id в ods.segments. - Что: - доработан sql/ods/flights_load.sql: добавлено добирание рейсов из истории stg.flights для flight_id из stg.segments текущего batch. - добавлен контрактный тест в tests/test_ods_sql_contract.py на покрытие flight_id из segments. - обновлена документация DAG в docs/bookings_to_gp_ods.md. - Проверка: - make test. - airflow dags trigger bookings_to_gp_ods -c '{"stg_batch_id":"manual__2026-01-18T18:47:18.316091+00:00"}'. --- docs/bookings_to_gp_ods.md | 3 + sql/ods/flights_load.sql | 142 +++++++++++++++++++++++++++++---- tests/test_ods_sql_contract.py | 12 +++ 3 files changed, 141 insertions(+), 16 deletions(-) diff --git a/docs/bookings_to_gp_ods.md b/docs/bookings_to_gp_ods.md index e1db94f..53c7259 100644 --- a/docs/bookings_to_gp_ods.md +++ b/docs/bookings_to_gp_ods.md @@ -15,6 +15,9 @@ - На загрузке использует дедупликацию внутри батча + UPSERT (SCD1). - Для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) дополнительно синхронизирует ключи (удаляет из ODS записи, отсутствующие в выбранном STG-батче). +- Для `flights` дополнительно добирает рейсы из истории `stg.flights`, если на них + ссылаются `stg.segments` выбранного батча (чтобы сохранить ссылочную целостность + `segments.flight_id -> flights.flight_id`). ## Что должно быть готово перед запуском diff --git a/sql/ods/flights_load.sql b/sql/ods/flights_load.sql index ff33580..bd30210 100644 --- a/sql/ods/flights_load.sql +++ b/sql/ods/flights_load.sql @@ -1,22 +1,79 @@ -- Загрузка ODS по flights: SCD1 (UPDATE изменившихся + INSERT новых). -- Statement 1: UPDATE существующих строк. -WITH src AS ( +WITH segment_flights AS ( + -- В segments текущего batch могут быть flight_id не только из stg.flights этого batch. + -- Поэтому заранее собираем список flight_id из segments текущего batch. + SELECT DISTINCT + NULLIF(s.flight_id, '')::INTEGER AS flight_id + FROM stg.segments AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text + AND s.flight_id IS NOT NULL + AND s.flight_id <> '' +), +stg_flights_typed AS ( SELECT - NULLIF(s.flight_id, '')::INTEGER AS flight_id, + NULLIF(s.flight_id, '')::INTEGER AS flight_id, s.route_no, s.status, NULLIF(s.scheduled_departure, '')::TIMESTAMP WITH TIME ZONE AS scheduled_departure, NULLIF(s.scheduled_arrival, '')::TIMESTAMP WITH TIME ZONE AS scheduled_arrival, NULLIF(s.actual_departure, '')::TIMESTAMP WITH TIME ZONE AS actual_departure, NULLIF(s.actual_arrival, '')::TIMESTAMP WITH TIME ZONE AS actual_arrival, - s.src_created_at_ts AS event_ts, - ROW_NUMBER() OVER ( - PARTITION BY s.flight_id - ORDER BY s.src_created_at_ts DESC NULLS LAST, s.load_dttm DESC - ) AS rn + s.src_created_at_ts AS event_ts, + s.load_dttm, + s.batch_id FROM stg.flights AS s - WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text + WHERE s.flight_id IS NOT NULL + AND s.flight_id <> '' +), +src_union AS ( + -- 1) Рейсы из текущего batch. + SELECT + f.flight_id, + f.route_no, + f.status, + f.scheduled_departure, + f.scheduled_arrival, + f.actual_departure, + f.actual_arrival, + f.event_ts, + f.load_dttm + FROM stg_flights_typed AS f + WHERE f.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text + + UNION ALL + + -- 2) Добираем из истории STG рейсы, на которые ссылаются segments текущего batch. + SELECT + f.flight_id, + f.route_no, + f.status, + f.scheduled_departure, + f.scheduled_arrival, + f.actual_departure, + f.actual_arrival, + f.event_ts, + f.load_dttm + FROM stg_flights_typed AS f + JOIN segment_flights AS sf + ON sf.flight_id = f.flight_id +), +src AS ( + SELECT + u.flight_id, + u.route_no, + u.status, + u.scheduled_departure, + u.scheduled_arrival, + u.actual_departure, + u.actual_arrival, + u.event_ts, + ROW_NUMBER() OVER ( + PARTITION BY u.flight_id + ORDER BY u.event_ts DESC NULLS LAST, u.load_dttm DESC + ) AS rn + FROM src_union AS u ) UPDATE ods.flights AS o SET route_no = s.route_no, @@ -42,22 +99,75 @@ WHERE s.rn = 1 ); -- Statement 2: INSERT новых строк. -WITH src AS ( +WITH segment_flights AS ( + SELECT DISTINCT + NULLIF(s.flight_id, '')::INTEGER AS flight_id + FROM stg.segments AS s + WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text + AND s.flight_id IS NOT NULL + AND s.flight_id <> '' +), +stg_flights_typed AS ( SELECT - NULLIF(s.flight_id, '')::INTEGER AS flight_id, + NULLIF(s.flight_id, '')::INTEGER AS flight_id, s.route_no, s.status, NULLIF(s.scheduled_departure, '')::TIMESTAMP WITH TIME ZONE AS scheduled_departure, NULLIF(s.scheduled_arrival, '')::TIMESTAMP WITH TIME ZONE AS scheduled_arrival, NULLIF(s.actual_departure, '')::TIMESTAMP WITH TIME ZONE AS actual_departure, NULLIF(s.actual_arrival, '')::TIMESTAMP WITH TIME ZONE AS actual_arrival, - s.src_created_at_ts AS event_ts, - ROW_NUMBER() OVER ( - PARTITION BY s.flight_id - ORDER BY s.src_created_at_ts DESC NULLS LAST, s.load_dttm DESC - ) AS rn + s.src_created_at_ts AS event_ts, + s.load_dttm, + s.batch_id FROM stg.flights AS s - WHERE s.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text + WHERE s.flight_id IS NOT NULL + AND s.flight_id <> '' +), +src_union AS ( + SELECT + f.flight_id, + f.route_no, + f.status, + f.scheduled_departure, + f.scheduled_arrival, + f.actual_departure, + f.actual_arrival, + f.event_ts, + f.load_dttm + FROM stg_flights_typed AS f + WHERE f.batch_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text + + UNION ALL + + SELECT + f.flight_id, + f.route_no, + f.status, + f.scheduled_departure, + f.scheduled_arrival, + f.actual_departure, + f.actual_arrival, + f.event_ts, + f.load_dttm + FROM stg_flights_typed AS f + JOIN segment_flights AS sf + ON sf.flight_id = f.flight_id +), +src AS ( + SELECT + u.flight_id, + u.route_no, + u.status, + u.scheduled_departure, + u.scheduled_arrival, + u.actual_departure, + u.actual_arrival, + u.event_ts, + ROW_NUMBER() OVER ( + PARTITION BY u.flight_id + ORDER BY u.event_ts DESC NULLS LAST, u.load_dttm DESC + ) AS rn + FROM src_union AS u ) INSERT INTO ods.flights ( flight_id, diff --git a/tests/test_ods_sql_contract.py b/tests/test_ods_sql_contract.py index 0471bda..c93596d 100644 --- a/tests/test_ods_sql_contract.py +++ b/tests/test_ods_sql_contract.py @@ -36,3 +36,15 @@ def test_ods_batch_resolver_uses_consistent_snapshot_batches() -> None: assert "INTERSECT" in dag_code assert "candidate_batches" in dag_code + + +def test_flights_load_covers_segment_flight_ids_from_stg_history() -> None: + """ + Загрузка flights должна подтягивать рейсы из истории STG, + если на них ссылаются segments текущего батча. + """ + sql = _read("sql/ods/flights_load.sql") + + assert "segment_flights" in sql + assert "FROM stg.segments AS s" in sql + assert "JOIN segment_flights AS sf" in sql From e986222d46fc32442883a8aac3cecea31a7d78cf Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Thu, 26 Feb 2026 23:37:26 +0300 Subject: [PATCH 38/38] =?UTF-8?q?docs(dm):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD?= =?UTF-8?q?-=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=20DM-=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - зафиксировать архитектуру 5 витрин DM перед реализацией и отдать на ревью. - Что: - создан docs/internal/bookings_dm_design.md с описанием всех витрин, DAG-структуры, DQ-проверок и порядка реализации. - Проверка: - cat docs/internal/bookings_dm_design.md. Co-Authored-By: Claude Opus 4.6 --- docs/internal/bookings_dm_design.md | 351 ++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/internal/bookings_dm_design.md diff --git a/docs/internal/bookings_dm_design.md b/docs/internal/bookings_dm_design.md new file mode 100644 index 0000000..b5dd18f --- /dev/null +++ b/docs/internal/bookings_dm_design.md @@ -0,0 +1,351 @@ +# План: DM-слой (Data Mart) для учебного стенда Bookings + +## Context + +DWH-стенд уже имеет полностью реализованные слои STG (9 таблиц) -> ODS (9 таблиц, SCD1) -> DDS (6 измерений + 1 факт, Star Schema). DM-слой — финальный аналитический слой, который: +- даёт студентам опыт построения витрин поверх Star Schema; +- демонстрирует реалистичные паттерны (UPSERT, full rebuild, AO Column Store); +- служит основой для будущего Superset-дашборда. + +**1 витрина — эталонная** (показывается студенту), **4 остальные — демо/задания**. + +--- + +## 5 витрин DM + +### 1. `dm.sales_report` (ЭТАЛОННАЯ) + +**Бизнес-вопрос**: "Какова выручка, кол-во билетов и boarding rate по направлениям/тарифам за каждый день?" + +**Зерно**: `(flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk)` + +**Поля**: +``` +-- Ключ +flight_date DATE NOT NULL +departure_airport_sk INTEGER NOT NULL +arrival_airport_sk INTEGER NOT NULL +tariff_sk INTEGER NOT NULL +-- Денормализованные атрибуты +departure_city TEXT NOT NULL +departure_airport_bk TEXT NOT NULL +arrival_city TEXT NOT NULL +arrival_airport_bk TEXT NOT NULL +fare_conditions TEXT NOT NULL +day_of_week INTEGER NOT NULL +day_name TEXT NOT NULL +is_weekend BOOLEAN NOT NULL +-- Метрики +tickets_sold INTEGER NOT NULL +passengers_boarded INTEGER NOT NULL +total_revenue NUMERIC(15,2) NOT NULL +avg_price NUMERIC(10,2) NOT NULL +min_price NUMERIC(10,2) +max_price NUMERIC(10,2) +boarding_rate NUMERIC(5,4) NOT NULL -- boarded / sold +-- Служебные +created_at, updated_at, _load_id, _load_ts +``` + +**Источники**: `fact_flight_sales` JOIN `dim_calendar`, `dim_airports` (x2), `dim_tariffs` + +**Загрузка**: инкрементальный UPSERT (UPDATE изменившихся + INSERT новых по ключу) + +**Хранение**: `DISTRIBUTED BY (flight_date)`, heap (нужен UPDATE) + +**Учит**: денормализация измерений, GROUP BY + агрегация, UPSERT по составному ключу, IS DISTINCT FROM + +--- + +### 2. `dm.route_performance` + +**Бизнес-вопрос**: "Какие маршруты самые прибыльные? Каков load factor (заполняемость)?" + +**Зерно**: `(route_bk)` — одна строка на бизнес-ключ маршрута + +**Поля**: +``` +route_bk TEXT NOT NULL -- бизнес-ключ (GROUP BY по нему, чтобы учесть все SCD2-версии) +route_sk INTEGER -- SK текущей версии (для денормализации) +departure_airport_bk, departure_city -- из текущей версии dim_routes +arrival_airport_bk, arrival_city +airplane_bk, airplane_model, total_seats -- из dim_airplanes (через текущую версию маршрута) +-- Метрики +total_flights INTEGER NOT NULL -- COUNT(DISTINCT flight_id) +total_tickets INTEGER NOT NULL +total_boarded INTEGER NOT NULL +total_revenue NUMERIC(15,2) NOT NULL +avg_ticket_price NUMERIC(10,2) +avg_boarding_rate NUMERIC(5,4) NOT NULL +avg_load_factor NUMERIC(5,4) -- AVG(boarded_per_flight / total_seats) +first_flight_date, last_flight_date DATE +-- Служебные +created_at, updated_at, _load_id, _load_ts +``` + +**Источники**: `fact_flight_sales` JOIN `dim_routes` (все версии по route_sk), `dim_airports`, `dim_airplanes`, `dim_calendar` + +**Нюанс SCD2**: Факт содержит `route_sk`, привязанный к конкретной версии. Агрегируем по `route_bk` (через JOIN dim_routes), чтобы собрать метрики **всех** версий. Атрибуты берём из текущей версии (`valid_to IS NULL`). + +**Загрузка**: full rebuild (TRUNCATE + INSERT) — таблица маленькая (~1000 строк) + +**Хранение**: `DISTRIBUTED BY (route_bk)`, **AO Column** (нет UPDATE, чисто аналитические чтения — демонстрация отличия от heap) + +**Учит**: TRUNCATE + INSERT как альтернатива UPSERT, AO Column Store, load factor, агрегация по SCD2 через route_bk, подзапрос для двухуровневой агрегации + +--- + +### 3. `dm.passenger_loyalty` + +**Бизнес-вопрос**: "Кто наши частые пассажиры, сколько тратят, каков их любимый тариф?" + +**Зерно**: `(passenger_sk)` + +**Поля**: +``` +passenger_sk INTEGER NOT NULL +passenger_bk TEXT NOT NULL +passenger_name TEXT NOT NULL +-- Метрики +total_bookings INTEGER NOT NULL -- COUNT(DISTINCT book_ref) +total_flights INTEGER NOT NULL -- COUNT(*) +total_boarded INTEGER NOT NULL +total_spent NUMERIC(15,2) NOT NULL +avg_ticket_price NUMERIC(10,2) +favorite_tariff TEXT -- самый частый тариф (MODE) +unique_routes INTEGER NOT NULL -- COUNT(DISTINCT route_sk) +first_flight_date, last_flight_date DATE +days_as_customer INTEGER -- last - first +-- Служебные +created_at, updated_at, _load_id, _load_ts +``` + +**Источники**: `fact_flight_sales` JOIN `dim_passengers`, `dim_tariffs`, `dim_calendar` + +**Загрузка**: UPSERT (664K пассажиров — full rebuild дорогой) + +**Хранение**: `DISTRIBUTED BY (passenger_sk)`, heap + +**Учит**: DISTINCT ON / ROW_NUMBER для "самого частого", COUNT(DISTINCT) по нескольким полям, RFM-подобные метрики, UPSERT на большой таблице + +--- + +### 4. `dm.airport_traffic` + +**Бизнес-вопрос**: "Каков ежедневный пассажиропоток аэропорта? Сколько вылетов vs прилётов?" + +**Зерно**: `(traffic_date, airport_sk)` + +**Поля**: +``` +traffic_date DATE NOT NULL +airport_sk INTEGER NOT NULL +airport_bk TEXT NOT NULL +airport_name TEXT NOT NULL +city TEXT NOT NULL +-- Метрики вылета +departures_flights INTEGER NOT NULL DEFAULT 0 +departures_passengers INTEGER NOT NULL DEFAULT 0 +departures_revenue NUMERIC(15,2) NOT NULL DEFAULT 0 +-- Метрики прилёта +arrivals_flights INTEGER NOT NULL DEFAULT 0 +arrivals_passengers INTEGER NOT NULL DEFAULT 0 +arrivals_revenue NUMERIC(15,2) NOT NULL DEFAULT 0 +-- Итого +total_passengers INTEGER NOT NULL -- departures + arrivals +-- Служебные +created_at, updated_at, _load_id, _load_ts +``` + +**Источники**: `fact_flight_sales` JOIN `dim_calendar`, `dim_airports` (dual-role: departure + arrival через UNION ALL в CTE) + +**Ключевой паттерн**: UNION ALL для "разворота" двух ролей аэропорта: +```sql +WITH traffic AS ( + SELECT cal.date_actual, f.departure_airport_sk AS airport_sk, + 'departure' AS direction, ... + FROM fact_flight_sales f JOIN dim_calendar cal ... + UNION ALL + SELECT cal.date_actual, f.arrival_airport_sk AS airport_sk, + 'arrival' AS direction, ... + FROM fact_flight_sales f JOIN dim_calendar cal ... +) +SELECT airport_sk, date_actual, + SUM(CASE WHEN direction='departure' THEN flights END) AS departures_flights, ... +FROM traffic GROUP BY ... +``` + +**Загрузка**: UPSERT по (traffic_date, airport_sk) + +**Хранение**: `DISTRIBUTED BY (traffic_date)`, heap + +**Учит**: dual-role dimension join (UNION ALL), conditional aggregation (CASE WHEN + SUM), паттерн "unpivot → aggregate" + +--- + +### 5. `dm.monthly_overview` + +**Бизнес-вопрос**: "Каковы помесячные тренды: выручка, пассажиропоток, заполняемость по типам самолётов?" + +**Зерно**: `(year_actual, month_actual, airplane_sk)` + +**Поля**: +``` +year_actual INTEGER NOT NULL +month_actual INTEGER NOT NULL +airplane_sk INTEGER NOT NULL +airplane_bk TEXT NOT NULL +airplane_model TEXT NOT NULL +total_seats INTEGER +-- Метрики +total_flights INTEGER NOT NULL -- COUNT(DISTINCT flight_id) +total_tickets INTEGER NOT NULL +total_boarded INTEGER NOT NULL +total_revenue NUMERIC(15,2) NOT NULL +avg_ticket_price NUMERIC(10,2) +avg_load_factor NUMERIC(5,4) -- AVG(boarded_per_flight / total_seats) +unique_routes INTEGER NOT NULL +unique_passengers INTEGER NOT NULL +-- Служебные +created_at, updated_at, _load_id, _load_ts +``` + +**Источники**: `fact_flight_sales` JOIN `dim_calendar`, `dim_airplanes` + +**Загрузка**: UPSERT по (year_actual, month_actual, airplane_sk) + +**Хранение**: `DISTRIBUTED BY (year_actual)`, heap + +**Учит**: двухуровневая агрегация (сначала по рейсу для load factor, потом по месяцу), NULLIF для деления, COUNT(DISTINCT) на нескольких полях, executive-дашборд + +--- + +## DAG-структура + +### DAG `bookings_dm_ddl` (DDL) + +Линейная цепочка из 5 задач (по аналогии с `bookings_dds_ddl.py`): +``` +apply_dm_sales_report_ddl >> apply_dm_route_performance_ddl +>> apply_dm_passenger_loyalty_ddl >> apply_dm_airport_traffic_ddl +>> apply_dm_monthly_overview_ddl +``` + +### DAG `bookings_to_gp_dm` (ETL + DQ) + +Все 5 витрин **параллельно** (читают из DDS, не зависят друг от друга): +``` + load_dm_sales_report → dq_dm_sales_report ─┐ + load_dm_route_performance → dq_dm_route_performance ─┤ +start_dm ──>> load_dm_passenger_loyalty → dq_dm_passenger_loyalty ─┤── >> finish_dm_summary + load_dm_airport_traffic → dq_dm_airport_traffic ─┤ + load_dm_monthly_overview → dq_dm_monthly_overview ─┘ +``` + +Задач: 1 (start) + 5 (load) + 5 (dq) + 1 (finish) = **12 задач**. + +--- + +## Файлы для создания/изменения + +### Новые файлы (17 шт.) + +**SQL** (`sql/dm/` — 15 файлов): +1. `sql/dm/sales_report_ddl.sql` +2. `sql/dm/sales_report_load.sql` +3. `sql/dm/sales_report_dq.sql` +4. `sql/dm/route_performance_ddl.sql` +5. `sql/dm/route_performance_load.sql` +6. `sql/dm/route_performance_dq.sql` +7. `sql/dm/passenger_loyalty_ddl.sql` +8. `sql/dm/passenger_loyalty_load.sql` +9. `sql/dm/passenger_loyalty_dq.sql` +10. `sql/dm/airport_traffic_ddl.sql` +11. `sql/dm/airport_traffic_load.sql` +12. `sql/dm/airport_traffic_dq.sql` +13. `sql/dm/monthly_overview_ddl.sql` +14. `sql/dm/monthly_overview_load.sql` +15. `sql/dm/monthly_overview_dq.sql` + +**DAG** (`airflow/dags/` — 2 файла): +16. `airflow/dags/bookings_dm_ddl.py` +17. `airflow/dags/bookings_to_gp_dm.py` + +### Изменяемые файлы (3 шт.) + +18. `sql/ddl_gp.sql` — добавить `\i dm/*_ddl.sql` в конец +19. `tests/test_dags_smoke.py` — 2 новых теста (DDL DAG + ETL DAG) +20. `docs/internal/db_schema.md` — добавить DM-слой в описание/Mermaid + +### Документация (2 шт.) + +21. `docs/internal/bookings_dm_design.md` — полный дизайн-документ DM-слоя (этот файл) +22. `docs/bookings_to_gp_dm.md` — инструкция для студентов (аналог `bookings_to_gp_dds.md`) + +--- + +## Порядок реализации + +### Этап 1: Инфраструктура + эталонная витрина `dm.sales_report` +- Дизайн-документ `docs/internal/bookings_dm_design.md` +- DDL + load + DQ для sales_report +- Оба DAG (изначально с 1 витриной) +- Обновить `ddl_gp.sql` +- Smoke-тесты +- Документация для студентов + +### Этап 2: `dm.route_performance` (full rebuild + AO Column) +- DDL + load + DQ +- Расширить оба DAG и smoke-тесты + +### Этап 3: `dm.passenger_loyalty` +- DDL + load + DQ +- Расширить DAG и тесты + +### Этап 4: `dm.airport_traffic` (dual-role dimension) +- DDL + load + DQ +- Расширить DAG и тесты + +### Этап 5: `dm.monthly_overview` + финализация +- DDL + load + DQ +- Финализировать DAG и тесты +- Обновить `db_schema.md` (Mermaid lineage, статус) + +--- + +## DQ-проверки (общий паттерн для всех витрин) + +PL/pgSQL `DO $$` блоки (как в DDS): +1. Таблица не пуста +2. Нет дублей по составному ключу +3. Бизнес-инварианты (`tickets_sold >= passengers_boarded`, `boarding_rate BETWEEN 0 AND 1`, `total_revenue >= 0`) +4. Обязательные поля не NULL/пустые +5. Для route_performance: `avg_load_factor IS NULL OR avg_load_factor BETWEEN 0 AND 2` + +--- + +## Ключевые образцы для переиспользования + +| Что | Файл-образец | +|-----|--------------| +| ETL DAG (PostgresOperator, зависимости) | `airflow/dags/bookings_to_gp_dds.py` | +| DDL DAG (линейная цепочка) | `airflow/dags/bookings_dds_ddl.py` | +| UPSERT SQL (UPDATE + INSERT + CTE) | `sql/dds/fact_flight_sales_load.sql` | +| DQ PL/pgSQL (RAISE EXCEPTION/NOTICE) | `sql/dds/fact_flight_sales_dq.sql` | +| DDL (CREATE TABLE IF NOT EXISTS) | `sql/dds/dim_airports_ddl.sql` | +| Smoke-тесты DAG | `tests/test_dags_smoke.py` | +| Naming conventions | `docs/internal/naming_conventions.md` | + +--- + +## Верификация (end-to-end) + +1. `make fmt && make lint` — код проходит проверки +2. `make test` — smoke-тесты DAG зелёные (включая 2 новых) +3. `make ddl-gp` — DDL всех слоёв (STG + ODS + DDS + DM) применяется без ошибок +4. Запустить `bookings_to_gp_dm` в Airflow → все 12 задач зелёные +5. SQL-проверки в Greenplum: + - `SELECT COUNT(*) FROM dm.sales_report;` — не пусто + - `SELECT COUNT(*) FROM dm.route_performance;` — ~число текущих маршрутов + - Нет дублей по составным ключам + - `boarding_rate BETWEEN 0 AND 1` для всех строк