From 1e727762548b970f6e7c26b30faf1438577b97ba Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 22 Sep 2026 11:20:10 -0400 Subject: [PATCH] =?UTF-8?q?feat(labs):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=B0=D0=B1=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=B8=D0=B5?= =?UTF-8?q?=D0=BC=D0=B0=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20PXF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 4 + README.md | 7 +- airflow/dags/lab_pxf_airports.py | 70 ++++++ docs/README.md | 13 +- docs/assignment/README.md | 27 ++- docs/assignment/analyst_spec.md | 10 +- docs/assignment/pxf_airports.md | 317 ++++++++++++++++++++++++++ docs/bookings_to_gp_ods.md | 2 +- docs/bookings_to_gp_stage.md | 4 +- docs/dag_execution_order.md | 6 + docs/design/architecture-map.html | 4 + docs/design/architecture-map/map.json | 4 + docs/design/db_schema.md | 10 +- docs/design/reading_the_pipeline.md | 2 +- scripts/check_airports_snapshot.py | 158 +++++++++++++ tests/test_airports_snapshot_check.py | 80 +++++++ tests/test_dags_smoke.py | 16 ++ 17 files changed, 704 insertions(+), 30 deletions(-) create mode 100644 airflow/dags/lab_pxf_airports.py create mode 100644 docs/assignment/pxf_airports.md create mode 100644 scripts/check_airports_snapshot.py create mode 100644 tests/test_airports_snapshot_check.py diff --git a/Makefile b/Makefile index bd671c1..326064e 100644 --- a/Makefile +++ b/Makefile @@ -224,10 +224,14 @@ test: lint: $(UV) run black --check airflow tests $(UV) run isort --check-only airflow tests + $(UV) run black --check scripts/check_airports_snapshot.py + $(UV) run isort --check-only scripts/check_airports_snapshot.py fmt: $(UV) run black airflow tests $(UV) run isort airflow tests + $(UV) run black scripts/check_airports_snapshot.py + $(UV) run isort scripts/check_airports_snapshot.py clean-venv: python -c "import shutil; shutil.rmtree('.venv', ignore_errors=True)" diff --git a/README.md b/README.md index eacee59..9af25cc 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,9 @@ SELECT COUNT(*) FROM dm.sales_report; оно проведет от билетов и сегментов к факту и витрине. Рядом держите [интерактивную карту](docs/design/architecture-map.html) — локальный файл `docs/design/architecture-map.html` нужно открыть в браузере; в Gitea HTML -показывается как исходный текст. Затем разберите -[готовую загрузку справочника](docs/design/reading_the_pipeline.md#ods-airports) -и переходите к [первому заданию ODS](docs/assignment/README.md#первая-загрузка-ods). +показывается как исходный текст. Затем переходите к +[учебным заданиям](docs/assignment/README.md): начните с приема справочника +через PXF, после него — загрузки ODS. ## DAG-и в стенде @@ -115,6 +115,7 @@ SELECT COUNT(*) FROM dm.sales_report; - `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‑проверки. +- `lab_pxf_airports` — готовит общий снимок четырех справочников для [лабораторной PXF](docs/assignment/pxf_airports.md); использует те же SQL и DQ, без генерации транзакций. - `bookings_ods_ddl` — создаёт/обновляет ODS-таблицы по домену bookings. - `bookings_to_gp_ods` — заменяет снимки справочников и обновляет транзакционные таблицы ODS из STG; выполняет DQ-проверки. - `bookings_dds_ddl` — создаёт/обновляет DDS-таблицы (`dim_*`, `fact_flight_sales`) по домену bookings. diff --git a/airflow/dags/lab_pxf_airports.py b/airflow/dags/lab_pxf_airports.py new file mode 100644 index 0000000..373c9a6 --- /dev/null +++ b/airflow/dags/lab_pxf_airports.py @@ -0,0 +1,70 @@ +"""Готовый DAG первой лабораторной: общий снимок справочников без генерации дней.""" + +from datetime import timedelta + +import pendulum +from airflow.providers.postgres.operators.postgres import PostgresOperator + +from airflow import DAG + +with DAG( + dag_id="lab_pxf_airports", + start_date=pendulum.datetime(2017, 1, 1, tz="UTC"), + schedule=None, + catchup=False, + max_active_runs=1, + template_searchpath="/sql", + default_args={ + "owner": "airflow", + "retries": 1, + "retry_delay": timedelta(seconds=30), + }, + tags=["lab", "bookings", "pxf"], + description="Лабораторная PXF: четыре снимка STG с общим Run ID для ODS", +) as dag: + load_airports_to_stg = PostgresOperator( + task_id="load_airports_to_stg", + postgres_conn_id="greenplum_conn", + sql="stg/airports_load.sql", + ) + check_airports_dq = PostgresOperator( + task_id="check_airports_dq", + postgres_conn_id="greenplum_conn", + sql="stg/airports_dq.sql", + ) + load_airplanes_to_stg = PostgresOperator( + task_id="load_airplanes_to_stg", + postgres_conn_id="greenplum_conn", + sql="stg/airplanes_load.sql", + ) + check_airplanes_dq = PostgresOperator( + task_id="check_airplanes_dq", + postgres_conn_id="greenplum_conn", + sql="stg/airplanes_dq.sql", + ) + load_routes_to_stg = PostgresOperator( + task_id="load_routes_to_stg", + postgres_conn_id="greenplum_conn", + sql="stg/routes_load.sql", + ) + check_routes_dq = PostgresOperator( + task_id="check_routes_dq", + postgres_conn_id="greenplum_conn", + sql="stg/routes_dq.sql", + ) + load_seats_to_stg = PostgresOperator( + task_id="load_seats_to_stg", + postgres_conn_id="greenplum_conn", + sql="stg/seats_load.sql", + ) + check_seats_dq = PostgresOperator( + task_id="check_seats_dq", + postgres_conn_id="greenplum_conn", + sql="stg/seats_dq.sql", + ) + + load_airports_to_stg >> check_airports_dq + load_airplanes_to_stg >> check_airplanes_dq + # Готовим один общий батч: DQ маршрутов и мест читает связанные справочники. + [check_airports_dq, check_airplanes_dq] >> load_routes_to_stg >> check_routes_dq + check_airplanes_dq >> load_seats_to_stg >> check_seats_dq diff --git a/docs/README.md b/docs/README.md index bf7616b..2624129 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,14 +2,15 @@ ## Знакомство и первое задание -После [запуска стенда](../README.md) пройдите три шага: +После [запуска стенда](../README.md): 1. [Структура DWH](design/db_schema.md): смысл строк и связей на примере поездки. -2. [От карты к SQL загрузки](design/reading_the_pipeline.md#ods-airports): - разбор готового справочника ODS. Сборка факта понадобится позже, перед DDS. -3. [Учебные задания](assignment/README.md): файлы для работы, порядок запуска - и [самопроверка](assignment/README.md#запуск-и-проверка). - Поля и бизнес-правила находятся в [ТЗ](assignment/analyst_spec.md). +2. [Учебные задания](assignment/README.md): прием справочника через PXF, + затем загрузки ODS, измерения и витрины. Каждая работа указывает свои проверки. + +Перед заданиями ODS прочитайте [разбор готового справочника](design/reading_the_pipeline.md#ods-airports). +Сборка факта из того же руководства понадобится перед DDS. +Поля и бизнес-правила расширений находятся в [ТЗ](assignment/analyst_spec.md). ## Найти деталь во время работы diff --git a/docs/assignment/README.md b/docs/assignment/README.md index 1479c9c..bbc33da 100644 --- a/docs/assignment/README.md +++ b/docs/assignment/README.md @@ -1,8 +1,9 @@ # Учебные задания В стенде уже работает цепочка от Bookings до `dm.sales_report`. -Вы дополните ее загрузками ODS, измерениями DDS и четырьмя витринами DM. -Поля, преобразования и бизнес-правила описаны в [ТЗ](analyst_spec.md). +Сначала вы восстановите прием справочника в STG, затем дополните цепочку +загрузками ODS, измерениями DDS и четырьмя витринами DM. +Требования к этим расширениям описаны в [ТЗ](analyst_spec.md). ## Подготовка @@ -12,23 +13,31 @@ Открывайте [карту](../design/architecture-map.html) рядом с заданием; для Gitea есть [инструкция открытия локального HTML](../design/db_schema.md#как-открыть-карту). -Таблицы уже созданы, задачи Airflow подключены к SQL-файлам. -В учебных `*_load.sql` и `*_dq.sql` пока стоит `SELECT 1;`. -Вы заменяете эти заглушки; менять DAG и создавать таблицы заново не требуется. +## Прием справочника: PXF → STG + +Пройдите [«Восстановите прием справочника из источника»](pxf_airports.md). +Вы разберете готовый участок `airports`, восстановите внешнюю таблицу +и загрузку. На повторном запуске и переименовании аэропорта выясните, +как загрузка сохраняет снимки источника. ## Первая загрузка: ODS -Начните с `ods.airplanes`, затем добавьте `ods.seats`. +После приема справочника реализуйте `ods.airplanes`, затем `ods.seats`. На карте откройте [модели самолетов](../design/architecture-map.html#node=ods.airplanes) и [места](../design/architecture-map.html#node=ods.seats): источники STG уже заполнены. Позже обе таблицы понадобятся `dds.dim_airplanes` для характеристик модели и расчета ее вместимости. +Таблицы уже созданы, задачи Airflow подключены к SQL-файлам. +В учебных `*_load.sql` и `*_dq.sql` пока стоит `SELECT 1;`. +В заданиях ODS, DDS и DM вы заменяете эти заглушки; менять DAG +и создавать таблицы заново не требуется. + ### Готовый пример Пройдите [разбор stg.airports → ods.airports](../design/reading_the_pipeline.md#ods-airports). Это близкий пример полного снимка с выбором батча, преобразованиями и дедупликацией. -Для первого задания этого разбора достаточно. +Для первых загрузок ODS этого разбора достаточно. ### Самостоятельная работа @@ -46,8 +55,8 @@ ### Запуск и проверка -1. Дождитесь успешного `bookings_to_gp_stage` из подготовки. - Если STG уже заполнен, новый запуск для этой работы не нужен. +1. Используйте общий снимок после восстановления источника + в лабораторной PXF. Новый запуск STG для этой работы не нужен. 2. Запустите `bookings_to_gp_ods` целиком. Он выберет согласованный снимок в `resolve_stg_batch_id`, затем выполнит `load_ods_airplanes → dq_ods_airplanes → load_ods_seats → dq_ods_seats`. diff --git a/docs/assignment/analyst_spec.md b/docs/assignment/analyst_spec.md index cd0daba..6e5f0df 100644 --- a/docs/assignment/analyst_spec.md +++ b/docs/assignment/analyst_spec.md @@ -13,8 +13,9 @@ > > SQL-скрипты эталона лежат в `sql/stg/`, `sql/ods/`, `sql/dds/`, `sql/dm/`. -Порядок запуска и проверки первой загрузки находится во -[входе в задания](README.md#первая-загрузка-ods). В каждом разделе ниже есть +Программа начинается с [приема справочника через PXF](pxf_airports.md). +Затем переходите к расширениям из этого ТЗ; порядок их запуска и проверки +находится во [входе в задания](README.md#первая-загрузка-ods). В каждом разделе ниже есть ссылка на объект карты и близкий готовый пример. Карту открывайте [локально в браузере](../design/db_schema.md#как-открыть-карту). @@ -23,7 +24,8 @@ ## Рекомендуемый порядок выполнения > **Примечание:** STG-слой полностью реализован в эталоне (все 9 таблиц), `ods.routes` -> тоже эталонный. Ваше задание начинается с ODS: `airplanes` и `seats`. +> тоже эталонный. В первой лабораторной вы временно разбираете и восстанавливаете +> прием `airports`; расширения по этому ТЗ начинаются с ODS: `airplanes` и `seats`. 1. **ODS** — `airplanes`, `seats` (практика TRUNCATE+INSERT по аналогии с `ods.airports`) 2. **DDS** — `dim_airplanes`, `dim_passengers` (SCD1 — новые измерения) @@ -75,7 +77,7 @@ STG-слой (все 9 таблиц) и `ods.routes` полностью реал `ods.routes` тоже эталонный: факт `fact_flight_sales` использует его для резолвинга аэропортов вылета/прилёта, независимо от студенческого `dds.dim_routes`. -Для первого задания разберите [загрузку `ods.airports`](../design/reading_the_pipeline.md#ods-airports). +После лабораторной PXF разберите [загрузку `ods.airports`](../design/reading_the_pipeline.md#ods-airports). При необходимости проследить получение снимка откройте [sql/stg/airports_load.sql](../../sql/stg/airports_load.sql) или [sql/stg/airplanes_load.sql](../../sql/stg/airplanes_load.sql): оба читают полный diff --git a/docs/assignment/pxf_airports.md b/docs/assignment/pxf_airports.md new file mode 100644 index 0000000..6e0ba01 --- /dev/null +++ b/docs/assignment/pxf_airports.md @@ -0,0 +1,317 @@ +# Восстановите прием справочника из источника + +Хранилище получает справочник аэропортов из Postgres. Названия и другие +атрибуты в источнике могут меняться, поэтому загрузка должна сохранять +состояние справочника на момент приема данных. + +Вы разберете готовый участок `airports` и напишете его заново: создадите +внешнюю таблицу PXF и загрузку полного снимка в STG. Затем испытаете ее +на повторном запуске и изменении источника. Полученный снимок понадобится +следующему слою — ODS. + +## Задача + +Ваш участок на [карте](../design/architecture-map.html#node=stg.airports): +`bookings.airports_data → stg.airports_ext → stg.airports`. + +| Что написать | Где | +|---|---| +| DDL внешней таблицы `stg.airports_ext` | [airports_ddl.sql](../../sql/stg/airports_ddl.sql) | +| Загрузку полного снимка во внутреннюю `stg.airports` | [airports_load.sql](../../sql/stg/airports_load.sql) | + +Внутренняя таблица, JDBC-сервер PXF, DAG и проверки качества уже готовы. +Рядом есть пример для другого справочника: +[DDL airplanes](../../sql/stg/airplanes_ddl.sql) и +[его загрузка](../../sql/stg/airplanes_load.sql). Используйте его, учитывая +состав полей и ключ аэропорта. + +Требования к вашей загрузке: + +- Источник — `bookings.airports_data` в базе `demo`, ключ — `airport_code`. +- Все шесть полей (`airport_code`, `airport_name`, `city`, `country`, + `coordinates`, `timezone`) попадают в STG без потерь и преобразований. + Внешняя и внутренняя таблицы используют TEXT; JSON и координаты + пока хранятся как текст. +- Строки одного снимка получают `_load_id = '{{ run_id }}'`, + `_load_ts = now()` и `event_ts = now()`. Точного времени изменения + аэропорта в источнике нет, поэтому `event_ts` — время получения снимка. +- Новый запуск сохраняет новый полный снимок, оставляя предыдущие. + Повторное выполнение загрузки в том же запуске на неизменном источнике + не меняет бизнес-данные и не создает дубли ключей внутри снимка. + +## Подготовка + +Нужен свой стенд после [быстрого старта и знакомства с моделью](README.md#подготовка). +Во время работы не запускайте другие загрузки: они могут изменить данные +посреди опыта. Если запущен `bookings_validate`, дождитесь его завершения. + +Сначала освободите участок для своей реализации. Команды ниже удаляют +внешнюю таблицу и **всю историю только `stg.airports`**. Это нужно, чтобы +данные быстрого старта не скрыли отсутствие вашей загрузки. + +
+Подготовка файлов и таблиц + +Сохраните свои SQL-файлы в Git или отдельной копии. В `airports_ddl.sql` +удалите блок от `DROP EXTERNAL TABLE` до конца `CREATE EXTERNAL TABLE`; +DDL схемы и внутренней таблицы оставьте. Содержимое `airports_load.sql` +замените на `SELECT 1;`. DAG и DQ менять не нужно. + +В Greenplum (`make gp-psql`): + +```sql +DROP EXTERNAL TABLE IF EXISTS stg.airports_ext; +TRUNCATE TABLE stg.airports; +SELECT COUNT(*) FROM stg.airports; -- 0 +``` + +Остальные справочники и текущий ODS сохраняются. Пока участок +не восстановлен, ODS запускать не нужно. + +
+ +## Соберите загрузку + +Начните с внешней таблицы. Добейтесь, чтобы запрос к `stg.airports_ext` +возвращал те же поля и значения, что запрос к `bookings.airports_data` +в Postgres. Для начала достаточно рассмотреть несколько аэропортов. + +
+Ориентиры для PXF и применение DDL + +В `LOCATION` укажите таблицу источника, `PROFILE=JDBC` и `SERVER=bookings-db`. +`SERVER` — имя готового [каталога конфигурации PXF](../../pxf/servers/bookings-db/jdbc-site.xml). +Формат чтения — `FORMAT 'CUSTOM' (formatter='pxfwritable_import')`. +Логин и пароль в DDL не нужны. Если меняли учетные данные источника, +согласуйте их с PXF по [справочнику стенда](../stack.md). + +Примените свой файл из `make gp-psql`: + +```sql +\set ON_ERROR_STOP on +\i /sql/stg/airports_ddl.sql +SELECT * FROM stg.airports_ext ORDER BY airport_code LIMIT 3; +``` + +В Postgres (`make bookings-psql`) выполните такой же запрос к +`bookings.airports_data`. Сверьте состав и порядок полей с DDL. +Если чтение не работает, начните с сообщения об ошибке и +[диагностики STG](../bookings_to_gp_stage.md#если-загрузка-не-прошла). + +
+ +Теперь напишите загрузку снимка. Продумайте, как ваш SQL отличит +повторное выполнение от нового запуска и сохранит историю. +Выполнять загрузку будет готовый DAG `lab_pxf_airports`. + +Получите первый снимок и сравните его с источником готовой сверкой. +Она проверяет ключи, все бизнес-поля и служебные метки. Зеленые задачи +в Airflow показывают, что код выполнился; сверка помогает обнаружить +ошибку в самих данных. + +
+Первый запуск в Airflow и сверка с Postgres + +Для сверки нужен `uv` с Python 3.11 из +[окружения разработчика](../stack.md#локальное-окружение-разработчика-uv). +В терминале из корня репозитория сохраните исходный справочник: + +```bash +mkdir -p data/lab9 +uv run --no-project scripts/check_airports_snapshot.py export data/lab9/airports-before.json +``` + +Скрипт читает Postgres напрямую, минуя вашу внешнюю таблицу, и не перезаписывает +существующий файл. Эта копия позволит сравнить первый снимок с источником +даже после изменения названия аэропорта. + +Откройте Airflow UI по адресу из `.env` (`AIRFLOW_WEB_PORT`), войдите с +`AIRFLOW_USER` и `AIRFLOW_PASSWORD`. Включите `lab_pxf_airports`, нажмите +**Trigger DAG** и дождитесь успеха всех задач. В **Grid** выберите запуск; +его **Run ID** подставьте в команду: + +```bash +first_run='вставьте Run ID первого запуска' +uv run --no-project scripts/check_airports_snapshot.py check "$first_run" data/lab9/airports-before.json +``` + +Если задача упала, откройте ее **Log**. SQL-файлы доступны контейнеру +из рабочей папки: после правки пересобирать образ не нужно. +`{{ run_id }}` подставляет Airflow, поэтому файл загрузки не следует +выполнять напрямую через `psql`. + +
+ +## Испытайте загрузку + +### Повторное выполнение + +Представьте, что после сбоя пришлось повторить уже выполненную задачу. +Что произойдет с данными при повторном выполнении вашего SQL? За счет +чего в одном снимке не появится вторая копия каждого аэропорта? + +Повторите загрузку **в том же запуске** и снова выполните сверку первого +снимка. При ошибке исправьте SQL и повторите опыт на том же Run ID. + +
+Как повторить задачу, сохранив Run ID + +В запуске первого снимка выберите `load_airports_to_stg` → **Clear task**. +Снимите **Upstream**, **Downstream**, **Past**, **Future**, **Recursive** +и **Only Failed**, если включены. В **Affected Tasks** должна остаться +только эта задача этого запуска. Нажмите **Clear**, дождитесь успеха, +затем так же повторите `check_airports_dq`. + +Новый **Trigger DAG** создал бы другой Run ID. Здесь нужно повторное +выполнение с прежним `_load_id`, чтобы испытать защиту от дублей. + +Если загрузка уже записала дубли или неверные значения, исправление SQL +само их не удалит. +Удалите из `stg.airports` строки только этого `_load_id`, затем повторите +исправленную загрузку и DQ в том же запуске. После успешной сверки повторите +их еще раз: теперь вы проверяете защиту от дублей на заполненном снимке. + +
+ +### Изменение в источнике + +Теперь аэропорт переименовали. До новой загрузки предположите, где будет +видно новое название: в Postgres, во внешней таблице, в первом снимке STG? + +Измените русское название одного аэропорта, сохранив ключ и остальные +языки. Для опыта ниже есть готовый SQL, который также сохраняет исходное +название для восстановления. + +
+Изменить название в Postgres + +В `make bookings-psql`: + +```sql +\set ON_ERROR_STOP on +CREATE TABLE public.lab9_airport_original AS +SELECT airport_code, airport_name +FROM bookings.airports_data ORDER BY airport_code LIMIT 1; + +UPDATE bookings.airports_data AS a +SET airport_name = jsonb_set( + b.airport_name, '{ru}', to_jsonb((b.airport_name->>'ru') || ' [lab9]'), false +) +FROM public.lab9_airport_original AS b +WHERE a.airport_code = b.airport_code +RETURNING a.airport_code, b.airport_name AS original, a.airport_name AS changed; +``` + +Запрос изменяет один аэропорт. Если таблица копии уже существует после +предыдущего опыта, сначала [восстановите источник](#завершение-работы), +сверьте результат и удалите таблицу копии. Затем можно начать новый опыт. + +
+ +Найдите этот аэропорт в `stg.airports_ext` и `stg.airports` обычными +SELECT-запросами. Сопоставьте наблюдение со своим предположением. + +Запустите `lab_pxf_airports` заново и рассмотрите оба снимка в STG. +Какое название хранится в каждом? Почему одного сравнения количества +строк недостаточно, чтобы заметить переименование? + +Сверьте новый снимок с измененным источником, а первый — с исходным. +Это обнаружит, например, загрузку старых данных под новым Run ID. + +
+Сверить оба снимка + +В том же терминале, где задан `first_run`: + +```bash +uv run --no-project scripts/check_airports_snapshot.py export data/lab9/airports-after.json +next_run='вставьте Run ID нового запуска' +uv run --no-project scripts/check_airports_snapshot.py check "$next_run" data/lab9/airports-after.json +uv run --no-project scripts/check_airports_snapshot.py check "$first_run" data/lab9/airports-before.json +``` + +Если терминал закрывался, значения Run ID можно снова взять из Airflow. + +
+ +## Передайте снимок в ODS + +Запустите `bookings_to_gp_ods` без параметров `conf`. Найдите измененный +аэропорт в `ods.airports`: название должно быть новым, а `_load_id` — +совпадать с последним запуском STG. Так вы увидите, что следующий слой +использовал результат вашей загрузки. + +ODS выбирает общий батч четырех справочников: `airports`, `airplanes`, +`routes`, `seats`. Поэтому готовый `lab_pxf_airports` загружает все четыре, +используя те же SQL, что основной STG-DAG. Генератор новых дней и +транзакционные таблицы в этом опыте не участвуют. + +
+Проверить выбор снимка и данные ODS + +В Log задачи `resolve_stg_batch_id` строка `Используем stg_batch_id=...` +показывает выбранный Run ID. Он должен совпасть с `next_run`. +После завершения DAG выполните: + +```bash +uv run --no-project scripts/check_airports_snapshot.py check "$next_run" data/lab9/airports-after.json --layer ods +``` + +Сверка учитывает преобразование JSON в русские названия и требует нужный +`_load_id` у всех строк ODS. Если выбран старый батч, посмотрите, завершились +ли все загрузки и DQ последнего запуска `lab_pxf_airports`. + +
+ +## Завершение работы + +Верните исходное название и получите еще один общий снимок через +`lab_pxf_airports`. Проведите его по цепочке +`bookings_to_gp_ods` → `bookings_to_gp_dds` → `bookings_to_gp_dm`. +В `ods.airports` и `dds.dim_airports` должно вернуться исходное название. + +
+Восстановить источник и сверить результат + +В `make bookings-psql`: + +```sql +UPDATE bookings.airports_data AS a +SET airport_name = b.airport_name +FROM public.lab9_airport_original AS b +WHERE a.airport_code = b.airport_code +RETURNING a.airport_code, a.airport_name; +``` + +После нового запуска `lab_pxf_airports` и ODS сравните восстановленный +снимок с исходной копией: + +```bash +restored_run='вставьте Run ID запуска STG после восстановления' +uv run --no-project scripts/check_airports_snapshot.py check "$restored_run" data/lab9/airports-before.json +uv run --no-project scripts/check_airports_snapshot.py check "$restored_run" data/lab9/airports-before.json --layer ods +``` + +Повтор UPDATE восстановления безопасен. После успешной сверки выполните +в Postgres `DROP TABLE public.lab9_airport_original;`. +Если прервались на изменении источника, +продолжите с этого восстановления и нового запуска цепочки. + +Для повторения всей лабораторной возьмите новые имена контрольных файлов. +При полной пересборке сохраните SQL-файлы: `make clean` удаляет тома. +Затем снова пройдите быстрый старт со своим решением. + +
+ +Снимки в STG остаются историей опыта: первоначальный, с измененным названием +и восстановленный. Последний общий снимок и текущий ODS снова содержат +исходные значения; транзакционные данные остались от быстрого старта. + +Результат работы — ваши два SQL-файла и объяснение их поведения: откуда +берется каждый снимок, что защищает его от дублей при повторе и как +изменение источника попадает в следующую загрузку. + +Дальше — [загрузки `ods.airplanes` и `ods.seats`](README.md#первая-загрузка-ods). +Они используют уже подготовленный снимок, поэтому заново запускать STG +для начала следующей работы не нужно. Там вы будете преобразовывать поля +полученных данных и заменять текущее состояние в ODS. diff --git a/docs/bookings_to_gp_ods.md b/docs/bookings_to_gp_ods.md index 321a54a..3edc4bc 100644 --- a/docs/bookings_to_gp_ods.md +++ b/docs/bookings_to_gp_ods.md @@ -124,7 +124,7 @@ STG-батч. Сравните его с XCom задачи `resolve_stg_batch_id Пустой транзакционный батч допустим, пустой снимок справочника считается ошибкой. После реализации `airplanes` и `seats` пройдите -[самопроверку первого задания](assignment/README.md#запуск-и-проверка). +[самопроверку заданий ODS](assignment/README.md#запуск-и-проверка). Дальше можно переходить к [DDS](bookings_to_gp_dds.md). ## Если загрузка не прошла diff --git a/docs/bookings_to_gp_stage.md b/docs/bookings_to_gp_stage.md index eb3f8e3..637bed5 100644 --- a/docs/bookings_to_gp_stage.md +++ b/docs/bookings_to_gp_stage.md @@ -1,7 +1,9 @@ # Загрузка Bookings → STG: `bookings_to_gp_stage` DAG читает девять таблиц Bookings через внешние таблицы PXF и сохраняет -строки в Greenplum. Весь STG, включая DQ, уже реализован на `main`. +строки в Greenplum. Весь STG, включая DQ, уже реализован на `main`; +в [первой лабораторной](assignment/pxf_airports.md) вы разберете и восстановите +прием `airports`. Смысл бронирования, билета и сегмента разобран в [руководстве о структуре](design/db_schema.md); на карте начните со [stg.bookings](design/architecture-map.html#node=stg.bookings). diff --git a/docs/dag_execution_order.md b/docs/dag_execution_order.md index 8ec3890..44631e2 100644 --- a/docs/dag_execution_order.md +++ b/docs/dag_execution_order.md @@ -54,3 +54,9 @@ DDL создает структуру; для наполнения нужны о `bookings_validate` запускайте отдельно от загрузок: дождитесь всего DAG и успешного `validate_dds.scd2_restore` перед следующей записью в хранилище. Тест SCD2 временно меняет данные и восстанавливает их из копии. + +В [лабораторной PXF](assignment/pxf_airports.md) снимки справочников готовит +`lab_pxf_airports`: он выполняет только четыре справочника STG и их DQ. +Для передачи снимка и восстановления нижних слоев используйте обычные +DAG ODS → DDS → DM. Следующий учебный день по-прежнему загружается полной +цепочкой, начиная с `bookings_to_gp_stage`. diff --git a/docs/design/architecture-map.html b/docs/design/architecture-map.html index 0235035..bcec434 100644 --- a/docs/design/architecture-map.html +++ b/docs/design/architecture-map.html @@ -1222,6 +1222,10 @@ { "label": "DQ", "url": "../../sql/stg/airports_dq.sql" + }, + { + "label": "Лабораторная: прием через PXF", + "url": "../assignment/pxf_airports.md" } ], "parts": [ diff --git a/docs/design/architecture-map/map.json b/docs/design/architecture-map/map.json index ca0c341..aa409e4 100644 --- a/docs/design/architecture-map/map.json +++ b/docs/design/architecture-map/map.json @@ -1022,6 +1022,10 @@ { "label": "DQ", "url": "../../sql/stg/airports_dq.sql" + }, + { + "label": "Лабораторная: прием через PXF", + "url": "../assignment/pxf_airports.md" } ], "parts": [ diff --git a/docs/design/db_schema.md b/docs/design/db_schema.md index 92ae928..edc3ba3 100644 --- a/docs/design/db_schema.md +++ b/docs/design/db_schema.md @@ -171,7 +171,7 @@ SQL использует `LEFT JOIN`, поэтому строка с таким эталон работает до выполнения заданий. Остальные витрины видны рядом пунктирными рамками: в их карточках описан требуемый результат по ТЗ. -## Модели самолетов и места: начало задания +## Модели самолетов и места: расширение цепочки Откройте [dds.dim_airplanes](architecture-map.html#node=dds.dim_airplanes). У него два источника: `ods.airplanes` дает характеристики модели, @@ -203,13 +203,13 @@ SQL использует `LEFT JOIN`, поэтому строка с таким ## Куда идти дальше -Для первого задания разберите готовую загрузку справочника: -[от карты к SQL загрузки](reading_the_pipeline.md#ods-airports), затем откройте -[первое задание ODS](../assignment/README.md#первая-загрузка-ods). Для точечного вопроса +Начните с [приема справочника через PXF](../assignment/pxf_airports.md), +затем переходите к [заданиям ODS](../assignment/README.md#первая-загрузка-ods). +Для точечного вопроса о таблице возвращайтесь на карту: там находится каталог объектов, их источников, потребителей, SQL и DQ. -Проверяйте задание по [инструкции самопроверки](../assignment/README.md#запуск-и-проверка). +Проверки каждого этапа находятся в [учебном маршруте](../assignment/README.md). Для разбора ошибки загрузки откройте руководство нужного слоя: [STG](../bookings_to_gp_stage.md), [ODS](../bookings_to_gp_ods.md), [DDS](../bookings_to_gp_dds.md), [DM](../bookings_to_gp_dm.md). diff --git a/docs/design/reading_the_pipeline.md b/docs/design/reading_the_pipeline.md index d5d7ea2..58e05af 100644 --- a/docs/design/reading_the_pipeline.md +++ b/docs/design/reading_the_pipeline.md @@ -5,7 +5,7 @@ что меняет и по какому ключу записывает результат. Смысл строк и связей разобран в [руководстве о структуре DWH](db_schema.md). -Для первого задания достаточно примера `ods.airports` ниже. +Для заданий на снимки ODS достаточно примера `ods.airports` ниже. К сборке факта вернитесь перед заданиями DDS. Адресные ссылки на карту работают в локальном просмотре; если читаете в Gitea, используйте [инструкцию открытия карты](db_schema.md#как-открыть-карту). diff --git a/scripts/check_airports_snapshot.py b/scripts/check_airports_snapshot.py new file mode 100644 index 0000000..8441cdf --- /dev/null +++ b/scripts/check_airports_snapshot.py @@ -0,0 +1,158 @@ +"""Независимая сверка лабораторной PXF: Postgres → сохраненный файл → STG/ODS.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FIELDS = ("airport_code", "airport_name", "city", "country", "coordinates", "timezone") +JSON_FIELDS = {"airport_name", "city", "country"} + + +def read_rows(service: str, query: str) -> list[dict]: + """Читает через psql в контейнере; настройки берет из окружения Compose.""" + if service == "bookings-db": + command = ( + 'PGPASSWORD="$POSTGRES_PASSWORD" exec psql -X -qAt ' + '-v ON_ERROR_STOP=1 -h 127.0.0.1 -U "$POSTGRES_USER" -d demo' + ) + else: + command = ( + 'PGPASSWORD="$GREENPLUM_PASSWORD" exec ' + "/usr/local/greenplum-db/bin/psql -X -qAt -v ON_ERROR_STOP=1 " + '-h 127.0.0.1 -U "$GREENPLUM_USER" -d "$GREENPLUM_DATABASE_NAME"' + ) + result = subprocess.run( + ["docker", "compose", "exec", "-T", service, "sh", "-c", command], + input=f"SELECT row_to_json(r) FROM ({query}) r;\n", + text=True, + capture_output=True, + cwd=ROOT, + check=True, + ) + return [json.loads(line) for line in result.stdout.splitlines() if line.strip()] + + +def compare_snapshot( + expected: list[dict], actual: list[dict], batch_id: str, layer: str +) -> list[str]: + """Проверяет ключи, все бизнес-поля и метки; порядок строк не важен.""" + errors = [] + if not expected: + return ["Контрольный источник пуст. Сначала восстановите Bookings."] + source = {row["airport_code"]: row for row in expected} + if len(source) != len(expected) or None in source or "" in source: + return ["В контрольном источнике пустые или повторяющиеся ключи."] + keys = [row["airport_code"] for row in actual] + if len(keys) != len(set(keys)): + errors.append("Повторяются airport_code внутри снимка.") + missing = source.keys() - set(keys) + extra = set(keys) - source.keys() + if missing: + errors.append( + f"Не получены ключи: {sorted(missing)[:5]} (всего {len(missing)})." + ) + if extra: + errors.append(f"Лишние ключи: {sorted(map(str, extra))[:5]}.") + for row in actual: + key = row["airport_code"] + if row.get("_load_id") != batch_id: + errors.append( + f"{key}: _load_id не равен {batch_id}; проверьте выбор батча." + ) + timestamps = ("_load_ts", "event_ts") if layer == "stg" else ("_load_ts",) + if any(not row.get(field) for field in timestamps): + errors.append(f"{key}: не заполнены метки времени {timestamps}.") + if key not in source: + continue + for field in FIELDS: + wanted, received = source[key][field], row[field] + if field in JSON_FIELDS: + # Сравниваем весь JSON в STG, включая языки, кроме русского. + wanted = json.loads(wanted) if wanted is not None else None + if layer == "ods": + wanted = wanted.get("ru") if wanted is not None else None + elif received is not None: + try: + received = json.loads(received) + except (ValueError, TypeError): + errors.append(f"{key}: {field} не содержит исходный JSON.") + continue + if received != wanted: + errors.append(f"{key}: отличается {field}.") + return errors + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + actions = parser.add_subparsers(dest="action", required=True) + export = actions.add_parser( + "export", help="Сохранить источник напрямую из Postgres" + ) + export.add_argument( + "file", type=Path, help="Новый JSON-файл для контрольного источника" + ) + check = actions.add_parser("check", help="Сверить снимок с сохраненным источником") + check.add_argument("batch_id", help="Run ID нужного запуска STG") + check.add_argument("file", type=Path, help="Контрольный JSON-файл из export") + check.add_argument("--layer", choices=("stg", "ods"), default="stg") + args = parser.parse_args() + + try: + if args.action == "export": + fields = ", ".join(f"{field}::text AS {field}" for field in FIELDS) + rows = read_rows( + "bookings-db", + f"SELECT {fields} FROM bookings.airports_data ORDER BY airport_code", + ) + if not rows: + raise ValueError( + "Источник пуст. Пройдите инициализацию Bookings из README." + ) + rows.sort(key=lambda row: row["airport_code"]) + # Не даем случайно заменить контроль A данными измененного источника B. + with args.file.open("x", encoding="utf-8") as output: + json.dump(rows, output, ensure_ascii=False, indent=2) + output.write("\n") + print(f"Источник: {len(rows)} аэропортов сохранено в {args.file}.") + return 0 + + expected = json.loads(args.file.read_text(encoding="utf-8")) + # Run ID передается SQL-литералом, а не частью команды shell. + batch_literal = "'" + args.batch_id.replace("'", "''") + "'" + query = f"SELECT * FROM {args.layer}.airports" + if args.layer == "stg": + query += f" WHERE _load_id = {batch_literal}" + actual = read_rows("greenplum", query) + errors = compare_snapshot(expected, actual, args.batch_id, args.layer) + if errors: + print("Сверка не прошла. Проверьте SQL загрузки и выбранный Run ID:") + for error in errors[:10]: + print(f"- {error}") + return 1 + print( + f"Сверка пройдена: {args.layer}.airports, _load_id={args.batch_id}, " + f"{len(actual)} ключей; все бизнес-поля и метки совпадают с требованиями." + ) + return 0 + except subprocess.CalledProcessError as error: + print("Не удалось прочитать БД. Проверьте docker compose ps и настройки .env.") + print(error.stderr.strip()) + return 1 + except FileExistsError: + print( + "Контрольный файл уже существует. Сохраните его; для нового опыта выберите другой путь." + ) + return 1 + except (OSError, ValueError, KeyError, TypeError) as error: + print( + f"Не удалось выполнить сверку: {error}. Проверьте путь и формат контрольного файла." + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_airports_snapshot_check.py b/tests/test_airports_snapshot_check.py new file mode 100644 index 0000000..e05fc2f --- /dev/null +++ b/tests/test_airports_snapshot_check.py @@ -0,0 +1,80 @@ +"""Проверка должна отвергать ошибки, которые не видны по количеству строк.""" + +import importlib.util +from copy import deepcopy +from pathlib import Path + +import pytest + +spec = importlib.util.spec_from_file_location( + "check_airports_snapshot", + Path(__file__).resolve().parents[1] / "scripts/check_airports_snapshot.py", +) +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + +SOURCE = [ + { + "airport_code": "AAA", + "airport_name": '{"en": "Airport", "ru": "Аэропорт"}', + "city": '{"ru": "Город"}', + "country": '{"ru": "Страна"}', + "coordinates": "(1,2)", + "timezone": "Europe/Moscow", + } +] + + +def snapshot(): + """Снимок с одним ключом и обязательными метками загрузки.""" + rows = deepcopy(SOURCE) + rows[0].update( + _load_id="B", _load_ts="2026-09-22 12:00:00", event_ts="2026-09-22 12:00:00" + ) + return rows + + +def test_accepts_complete_snapshot_with_json_key_order_difference(): + rows = snapshot() + rows[0]["airport_name"] = '{"ru":"Аэропорт","en":"Airport"}' + assert checker.compare_snapshot(SOURCE, rows, "B", "stg") == [] + + +def test_accepts_different_row_order_from_segments(): + """Порядок выдачи строк Greenplum не меняет содержимое снимка.""" + expected = deepcopy(SOURCE) + deepcopy(SOURCE) + expected[1]["airport_code"] = "BBB" + rows = snapshot() + snapshot() + rows[1]["airport_code"] = "BBB" + assert checker.compare_snapshot(expected, rows[::-1], "B", "stg") == [] + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("airport_code", "BBB"), + ("airport_name", '{"ru":"Старое имя","en":"Airport"}'), + ("country", '{"ru":"Другая страна"}'), + ("coordinates", "(2,1)"), + ("_load_id", "A"), + ("event_ts", None), + ], +) +def test_rejects_wrong_values_despite_equal_row_count(field, value): + rows = snapshot() + rows[0][field] = value + assert checker.compare_snapshot(SOURCE, rows, "B", "stg") + + +def test_rejects_duplicate_and_empty_snapshot(): + rows = snapshot() + assert checker.compare_snapshot(SOURCE, rows + rows, "B", "stg") + assert checker.compare_snapshot(SOURCE, [], "B", "stg") + + +def test_ods_requires_both_transformation_and_selected_batch(): + rows = snapshot() + rows[0].update(airport_name="Аэропорт", city="Город", country="Страна") + assert checker.compare_snapshot(SOURCE, rows, "B", "ods") == [] + rows[0]["_load_id"] = "A" + assert checker.compare_snapshot(SOURCE, rows, "B", "ods") diff --git a/tests/test_dags_smoke.py b/tests/test_dags_smoke.py index cfe2555..45c216c 100644 --- a/tests/test_dags_smoke.py +++ b/tests/test_dags_smoke.py @@ -66,6 +66,22 @@ def test_bookings_stg_ddl_dag_structure(): _assert_reachable(dag, "apply_stg_bookings_ddl", task_id) +def test_lab_pxf_airports_uses_snapshot_sql_and_reference_dependencies(): + """Опыт читает четыре справочника; генератор и транзакции в него не входят.""" + dag = _load_dag("airflow.dags.lab_pxf_airports") + assert len(dag.tasks) == 8 + for entity in ("airports", "airplanes", "routes", "seats"): + load = dag.get_task(f"load_{entity}_to_stg") + dq = dag.get_task(f"check_{entity}_dq") + assert load.sql == f"stg/{entity}_load.sql" + assert dq.sql == f"stg/{entity}_dq.sql" + assert load.postgres_conn_id == dq.postgres_conn_id == "greenplum_conn" + _assert_direct_edge(dag, load.task_id, dq.task_id) + _assert_direct_edge(dag, "check_airports_dq", "load_routes_to_stg") + _assert_direct_edge(dag, "check_airplanes_dq", "load_routes_to_stg") + _assert_direct_edge(dag, "check_airplanes_dq", "load_seats_to_stg") + + def test_bookings_to_gp_stage_dag_structure(): """Проверка структуры DAG bookings_to_gp_stage.""" dag = _load_dag("airflow.dags.bookings_to_gp_stage") -- 2.54.0