Author SHA1 Message Date
ddmitry 351dbbb3c1 Merge pull request 'docs: согласованы руководства загрузок и учебный маршрут' (#7) from docs/4-loading-guides into main 2026-09-20 22:59:48 +03:00
ddadmin 35144e49f6 docs: согласованы руководства загрузок и учебный маршрут
Руководства STG/ODS/DDS/DM сверены с действующими SQL и DAG,
ожидания учебных заглушек связаны с ТЗ. Самопроверка заданий
отделена от проверок разработки и исторических планов.

Для dim_routes закреплено обновление описательных полей только
у открытых версий. SQL, DAG, DDL и настройки стенда не менялись.

Проверки: make lint; make test (4 passed, 14 skipped); карта;
292 локальные ссылки; маршрут чтения и карта в Chromium.
Живой ETL не запускался.
2026-09-20 15:53:39 -04:00
ddmitry a6fdd43e15 docs: связаны карта DWH, разбор SQL и задания (#6) 2026-09-20 22:14:28 +03:00
ddadmin 20eb7f7b84 docs: уточнен порядок загрузок после SCD2-проверки 2026-09-20 15:13:20 -04:00
ddadmin b7ecfb1bb7 docs: связаны карта DWH, разбор SQL и задания 2026-09-20 14:31:49 -04:00
ddmitry b6e950f52c docs: добавлены карта и руководство по структуре DWH (#5) 2026-09-20 20:23:59 +03:00
ddadmin ec9dd4c1a8 docs(map): ссылки на материалы перенесены в Gitea 2026-09-20 13:20:39 -04:00
ddadmin 6be0442838 docs(map): уточнены заголовки и добавлены образцы легенды 2026-09-20 13:11:44 -04:00
ddadmin b9bcfe7230 docs(map): добавлены карта и руководство по структуре DWH
Объяснены зерно таблиц, связи и путь бронирования до витрины.
Добавлена автономная карта с двумя видами связей и адресами объектов.
Состояние заглушек сверено с DDL и ТЗ; SQL и DAG не изменялись.

Проверки: сборка и ссылки, make lint, pytest (4 passed, 14 skipped),
Chromium file:// offline: выбор, связи, сброс, история и узкий экран.

Refs #2
2026-09-20 13:01:57 -04:00
ddadminandClaude Opus 5 77d72b08c6 docs(agents): настройка трекера, меток триажа и доменных доков для скиллов
- AGENTS.md: блок «Agent skills» со ссылками на docs/agents/
- docs/agents/issue-tracker.md: Gitea через tea CLI
- docs/agents/triage-labels.md: стандартные пять меток
- docs/agents/domain.md: один контекст, CONTEXT.md + docs/adr/
- CLAUDE.md: секция «Читабельность языка»

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:59:47 -04:00
ddadmin 98030f8b0d Merge branch 'docs/zoom-ladder': лесенка схем, метод чтения пайплайна и таксономия DQ 2026-07-20 14:58:56 +03:00
ddadminandClaude Fable 5 4a04a2caf6 docs: лесенка схем, метод чтения пайплайна и таксономия DQ
Одна диаграмма отвечает на один вопрос: db_schema.md перестроен в лесенку
из четырёх схем (слои, звезда, сборка факта, витрины), полная lineage-карта
перенесена в конец как справочная. Новые документы: reading_the_pipeline.md
(метод чтения FROM/JOIN load-скрипта, разбор fact_src) и dq_taxonomy.md
(три класса DQ-проверок на реальных скриптах, семантика NULL). Точки входа:
корневой README, docs/README.md, assignment/README.md, qa-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 14:56:42 +03:00
ddadminandClaude Opus 4.6 eb4cbbe730 docs(main): адаптирован qa-plan для ветки main (пометки заглушек)
- Зачем:
  - qa-plan утверждал «ВСЕ таблицы > 0», что неверно на main с 9 заглушками.
- Что:
  - добавлена секция в intro со списком пустых таблиц на main.
  - SQL-комментарии DDS/DM дополнены пометками заглушек.
  - ожидание разделено на solution (все > 0) и main (эталонные > 0).
- Проверка:
  - make test (4 passed, 14 skipped).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 11:35:48 +03:00
ddadminandClaude Opus 4.6 af27314878 docs(main): онбординг студента — гид, маркетинг, адаптация docs и docstrings
- Зачем:
  - на main docs и DAG-docstrings утверждали «все реализовано», хотя 18 заглушек;
    студент застревал после шага 6 без явного «что дальше».
- Что:
  - README: маркетинг-баннер, шаг 6 (только эталонные таблицы), шаг 7 → задания.
  - docs/assignment/README: полный гид студента (эталон → ТЗ → заглушки → validate).
  - analyst_spec: DAG-интеграция — «таски уже подключены, менять DAG не нужно».
  - docs ODS/DDS/DM: пометки заглушек, адаптация секций проверки результата.
  - 4 DAG docstrings: эталон vs задания (заглушки).
- Проверка:
  - make test (4 passed, 14 skipped), make lint (clean).
  - grep «все 5 витрин|все реализован» — ложных утверждений без оговорок нет.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 10:49:40 +03:00
ddadminandClaude Opus 4.6 5b98a5b203 docs(main): очистка docs, адаптация для студентов, починка ссылок
Шаг 5 плана main/solution split:
- Удалены внутренние документы с main: plans/, archive/, PRD,
  assignment_design, pxf_bookings, bookings_tz, benchmarks, TODO.md
- AGENTS.md: убраны упоминания plans/archive, agent-dag-testing
- Починены 19 битых markdown-ссылок во всех оставшихся файлах
- bookings_ods_design: airplanes/seats помечены как студенческие,
  обновлён DAG-граф (routes без зависимости от airplanes)
- bookings_dds_design: обновлено описание DQ факта (student SK)
- bookings_to_gp_dds: обновлена DQ-семантика для main
- qa-plan: уточнено — ods.airplanes/seats пусты by design на main

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:14:22 +03:00
ddadminandClaude Opus 4.6 5ca4e6b2ca feat(main): заглушки студенческих файлов, ослабление DQ, адаптация тестов
- fact_flight_sales_dq: student SK (passenger/route/airplane) → NOTICE,
  airport_sk → порог 1% (эталон через ods.routes)
- routes_dq: закомментирован RI airplane_code → ods.airplanes
- ODS DAG: убрана зависимость dq_ods_airplanes → load_ods_routes
- 18 студенческих файлов заменены заглушками (ODS/DDS/DM load+dq)
- test_ods_sql_contract: SNAPSHOT_ENTITIES без airplanes/seats
- test_dags_smoke: убран assert dq_ods_airplanes → dq_ods_routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:07:14 +03:00
ddadminandClaude Opus 4.6 a8148c8afa docs: обновлена документация — airport lookup через ods.routes
Описания fact_flight_sales_load обновлены в bookings_dds_design,
bookings_to_gp_dds и db_schema: departure/arrival_airport_sk теперь
разрешаются через ods.routes → dim_airports, а не через dim_routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:51:55 +03:00
ddadmin f053fe1e35 feat(etl): полный эталонный пайплайн STG→ODS→DDS→DM
# Conflicts:
#	TODO.md
2026-03-12 22:45:36 +03:00
ddadminandClaude Opus 4.6 5cb4bab010 docs(plans): добавлен план раскладки main/solution (Этап 4)
- Зачем:
  - нужна стратегия ветвления и пошаговый план для финального этапа подготовки курсовой
- Что:
  - создан `docs/plans/2026-03-12_main-solution-split.md` (v4, после 4 ревью Codex)
  - стратегия: мерж в main → общие правки → ветка solution → заглушки на main
  - solution как source of truth, однонаправленный поток solution → main
  - план очистки docs для студентов (удалить plans/archive/PRD с main)
  - открытый вопрос: детальный протокол синхронизации веток
  - добавлена пометка статуса в архивный план routes-to-reference
- Проверка:
  - просмотр `docs/plans/2026-03-12_main-solution-split.md`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:36:32 +03:00
ddadminandClaude Opus 4.6 330911c0f1 fix(sql): добавлены недостающие DM-таблицы в truncate_gp.sql
- Зачем:
  - make dwh-truncate очищал только dm.sales_report, остальные 4 витрины
    сохраняли старые SK → DQ падал при перезагрузке DDS с новыми ключами.
- Что:
  - добавлены route_performance, airport_traffic, monthly_overview,
    passenger_loyalty в TRUNCATE блок DM.
- Проверка:
  - make dwh-truncate + полный прогон пайплайна (STG→ODS→DDS→DM→validate).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:45:21 +03:00
ddadminandClaude Opus 4.6 7b6136b0fa fix(stg): переведён boarding_passes на инкрементальную загрузку
- Зачем:
  - full snapshot boarding_passes при повторных запусках создавал orphan-записи
    без соответствующих tickets/segments (инкрементальных), DQ корректно падал.
- Что:
  - boarding_passes_load.sql: HWM через book_date (JOIN tickets_ext → bookings_ext),
    аналогично segments_load.sql.
  - boarding_passes_dq.sql: подсчёт источника с фильтром по окну инкремента,
    обработка пустого окна (NOTICE + RETURN).
  - обновлена документация (4 файла): db_schema, bookings_to_gp_stage,
    bookings_ods_design, inline-комментарий в DAG.
- Проверка:
  - make test (4 passed), статический ревью Codex CLI (0 замечаний по SQL).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:11:40 +03:00
ddadmin d6e5cd56c6 docs(plans): архивирован план validation-dag после реализации 2026-03-12 12:12:57 +03:00
ddadmin a51e2a6243 feat(validate): добавлен валидационный DAG bookings_validate
- Зачем:
  - студент реализует 9 объектов DWH без автоматической обратной связи;
  - ошибки (NULL в PK, дубли BK, сломанный SCD2) обнаруживались только на слое DM
    через 2–3 слоя, где отладка многократно сложнее.
- Что:
  - создан DAG bookings_validate.py — три параллельных TaskGroup (validate_ods,
    validate_dds, validate_dm), schedule=None (только ручной trigger);
  - создано 17 SQL-скриптов в sql/validate/: ODS BK coverage vs STG-батч,
    согласованность _load_id между ods.airplanes и ods.seats, дубли BK, NULL в PK,
    покрытие ODS→DDS для измерений (dim_routes требует открытой версии valid_to IS NULL),
    SCD2 активный тест backup→mutate→student_load→check(5 инвариантов)→restore
    (trigger_rule=all_done), проверка «дыр» между SCD2-версиями,
    exists-чеки для 4 DM-витрин;
  - добавлен класс TestBookingsValidate (5 smoke-тестов) в tests/test_dags_smoke.py.
- Проверка:
  - make test — 4 passed, 14 skipped (DAG-тесты скипаются без Airflow, норма);
  - ручной trigger bookings_validate в Airflow UI на solution-ветке — все таски зелёные.
2026-03-12 12:12:44 +03:00
ddadminandClaude Opus 4.6 23cde47fe7 docs(plans): добавлен план валидационного DAG, архивирован routes-to-reference
- Зачем:
  - план routes-to-reference выполнен, нужен план следующего этапа — валидационный DAG
- Что:
  - перемещён docs/plans/2026-03-11_routes-to-reference.md → docs/archive/
  - создан docs/plans/2026-03-12_validation-dag.md: структура DAG (17 SQL + DAG + smoke-тест),
    активный тест SCD2 (цепочка тасков с безопасным откатом), прошёл 5 раундов ревью ChatGPT
- Проверка:
  - make test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:50:03 +03:00
ddadminandClaude Sonnet 4.6 4e9d8f5a1c feat(dds): перенесён lookup аэропортов на ods.routes, обновлена документация задания
- Зачем:
  - fact_flight_sales брал airport_sk через dds.dim_routes (студенческое задание SCD2);
    на ветке main без реализованного dim_routes все airport_sk были NULL,
    и эталонные витрины sales_report/airport_traffic становились бесполезны.
- Что:
  - fact_flight_sales_load.sql: добавлен subquery ods_rte (ROW_NUMBER по ods.routes),
    dep/arr airport_sk теперь через ods.routes (эталон); airplane_sk — через dim_routes (point-in-time).
  - analyst_spec.md: удалены STG-задания (1.1–1.3) и ods.routes (2.3), добавлены секции
    «Почему STG уже реализован» и «Пересчёт факта», исправлен ODS intro, перенумерованы части.
  - assignment_design.md: обновлены таблицы эталон/задание, порядок выполнения; убраны
    validate_stg и check_ods_routes_rowcount из структуры валидационного DAG.
  - db_schema.md: статус уточнён («реализовано в ветке solution»), добавлена оговорка
    о заглушках на main, DQ-контракт дополнен примечанием.
- Проверка:
  - make test → 4 passed, 9 skipped.
  - ручная проверка: TRUNCATE dds.fact_flight_sales → перезапуск DDS DAG →
    COUNT(departure_airport_sk) должен равняться COUNT(*).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 10:39:19 +03:00
ddadmin f018f1e7da docs(plans): добавлен план переноса STG в эталон и разделения main/solution
- Зачем:
  - обнаружен конфликт между дизайном БД и планом курсового задания:
    эталонный пайплайн неработоспособен на main без студенческих измерений.
- Что:
  - создан docs/plans/2026-03-11_routes-to-reference.md (v4, после 3 раундов ревью).
  - добавлен бэклог PXF-практикума в assignment_design.md (раздел 6).
- Проверка:
  - прочитать план, убедиться в согласованности формулировок.
2026-03-11 23:54:53 +03:00
ddadmin e699934230 docs(design): обновлена db_schema.md и архивирован architecture_review.md
- Зачем:
  - db_schema.md не отражала DM-слой и содержала неточности в метаданных таблиц.
  - architecture_review.md устарел: все P0–P2 выполнены, P3 отложены и покрыты другими документами.
- Что:
  - db_schema.md переработана: Mermaid-диаграмма потоков перенесена в начало, добавлен DM-слой, STG/ODS/DDS/DM представлены компактными таблицами.
  - исправлены неточности: dim_calendar (AO Column → AO Row), dim_tariffs (INSERT-only → SCD1), ODS добавлено поле event_ts.
  - architecture_review.md перенесён из docs/design/ в docs/archive/, статус обновлён на «завершён».
- Проверка:
  - открыть docs/design/db_schema.md и убедиться, что диаграмма в начале файла, DM-слой отражён.
2026-03-11 22:20:09 +03:00
ddadmin c9856f0c95 docs(design): добавлено описание активной проверки SCD2 для dim_routes
- Зачем:
  - необходимо гарантировать проверку логики SCD2 в учебном проекте, так как исходные данные статичны и не вызывают срабатывание SCD2 естественным образом.
- Что:
  - добавлен раздел "Активная проверка SCD2" в дизайн задания (assignment_design.md).
  - описан алгоритм с использованием временных таблиц для тестирования закрытия старых и открытия новых версий записей.
- Проверка:
  - визуальный осмотр обновленного раздела в docs/design/assignment_design.md.
2026-03-11 22:01:13 +03:00
ddadminandClaude Opus 4.6 84454debe3 docs(assignment): добавлено ТЗ от аналитика для курсового задания
- Зачем:
  - студенту нужен реалистичный документ-спецификация с маппингами, типами
    и бизнес-правилами для самостоятельной реализации DWH-пайплайна.
- Что:
  - создан docs/assignment/analyst_spec.md — полное ТЗ на 4 слоя (STG, ODS, DDS, DM).
  - STG: airplanes, seats, routes (snapshot, маппинг из PXF-источника).
  - ODS: airplanes, seats, routes (TRUNCATE+INSERT, приведение типов).
  - DDS: dim_airplanes, dim_passengers (SCD1), dim_routes (SCD2 с пошаговым алгоритмом).
  - DM: airport_traffic, route_performance, monthly_overview, passenger_loyalty.
  - обновлён docs/assignment/README.md — ссылка на спецификацию вместо заглушки.
- Проверка:
  - визуальная сверка маппингов с sql/stg/, sql/ods/, sql/dds/, sql/dm/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:22:40 +03:00
ddadmin 8932148c44 chore(docs): введена конвенция нейминга планов, переименованы архивные файлы
- Зачем:
  - файлы планов именовались хаотично (микс snake_case/kebab-case, без дат),
    из-за чего архив не сортировался хронологически.
- Что:
  - установлен формат YYYY-MM-DD_краткое-описание.md для docs/plans/ и docs/archive/.
  - переименованы 6 архивных файлов по новой конвенции (git mv).
  - конвенция зафиксирована в AGENTS.md (секция «Карта проекта»).
- Проверка:
  - ls docs/archive/ — все файлы начинаются с даты в kebab-case.
2026-03-11 19:34:45 +03:00
ddadmin 4ce0aa7a23 docs(etl): расширена документация ETL DAG-ов ODS, DDS, DM
- Зачем:
  - документация трёх DAG-ов была значительно беднее эталона (bookings_to_gp_stage.md):
    отсутствовал пошаговый разбор задач, SQL-пути и ASCII-графы зависимостей.
- Что:
  - добавлены секции "Как это работает внутри" с таблицами task_id → SQL-файл → паттерн.
  - добавлены ASCII-графы зависимостей в ODS и DDS (в DM уже был).
  - исправлено описание паттернов ODS: явно разделены TRUNCATE+INSERT для AO snapshot-справочников и SCD1 UPSERT для транзакционных таблиц.
  - исправлено описание fact_flight_sales: убрана неточная отсылка к late-arriving dimensions, добавлено объяснение defensive LEFT JOIN и точных DQ-правил (0% / 1%).
  - добавлена таблица grain и описание стратегий загрузки для каждой DM-витрины.
  - план улучшений перенесён в docs/archive/.
- Проверка:
  - визуально: открыть каждый docs/bookings_to_gp_*.md и убедиться, что секции присутствуют.
2026-03-11 19:26:29 +03:00
ddadmin f157c9142b docs(agents): добавлено описание структуры docs/ в карту проекта
- Зачем:
  - после реструктуризации docs/ агенты не знали о новых каталогах
    design/, reference/, plans/, archive/, assignment/.
- Что:
  - в раздел «Карта проекта» добавлена строка со структурой docs/.
- Проверка:
  - читаемость AGENTS.md, раздел 2.
2026-03-10 23:51:42 +03:00
ddadmin 21f62be01a docs(todo): исправлен порядок этапов подготовки курсовой
- Зачем:
  - Этап «подготовка main» не должен идти раньше ТЗ и валидационного DAG —
    нельзя удалять рабочий пайплайн до того, как всё создано и протестировано.
- Что:
  - Этапы 2-5 переупорядочены: сначала ТЗ от аналитика, затем валидационный DAG,
    в конце — подготовка main и создание ветки solution.
  - Этапы 2 и 5 (подготовка main + solution) объединены в один финальный этап.
  - Добавлены пояснения «Делаем пока полный пайплайн работает» к каждому этапу.
- Проверка:
  - Читаемость TODO.md.
2026-03-10 23:13:48 +03:00
ddadmin 6655326caa docs(all): реструктурирована документация — docs/internal/ заменён на design/, reference/, archive/, plans/
- Зачем:
  - docs/internal/ превратился в свалку: дизайн-документы, ревью, планы и справочники лежали вперемешку.
  - архивные планы были неотличимы от живых документов.
- Что:
  - docs/internal/ удалён; файлы распределены по docs/design/, docs/reference/, docs/archive/, docs/plans/, docs/assignment/.
  - educational-tasks.md убран из корня в архив (устарел).
  - обновлены все перекрёстные ссылки в AGENTS.md, TODO.md, README.md, docs/README.md и внутри design/reference/.
  - актуализированы architecture_review.md (статус DM-слоя), db_schema.md (DM-слой), TESTING.md, dag_execution_order.md, pxf_bookings.md.
  - добавлены заглушки docs/assignment/README.md и docs/plans/README.md.
- Проверка:
  - make test && make lint
  - rg 'docs/internal' --glob '!docs/archive/*' — должно быть пусто.
2026-03-10 23:02:41 +03:00
ddadmin 5972bcc2d8 refactor(all): унифицированы служебные поля STG — переход на канонический нейминг
- Зачем:
  - STG-слой использовал legacy-имена (batch_id, load_dttm, src_created_at_ts),
    тогда как ODS/DDS/DM уже работали с каноном (_load_id, _load_ts, event_ts).
    Студент видел разные имена для одного понятия — это убрано.
- Что:
  - переименованы колонки в 9 STG DDL: batch_id→_load_id, load_dttm→_load_ts,
    src_created_at_ts→event_ts; добавлен NOT NULL для _load_id во всех таблицах.
  - обновлены 9 STG Load, 9 STG DQ, 9 ODS Load, 9 ODS DQ (INSERT/SELECT/WHERE).
  - обновлены DAG-файлы bookings_to_gp_stage.py и bookings_to_gp_ods.py
    (встроенный SQL резолвера, комментарии; Python-идентификаторы не тронуты).
  - обновлены тесты и ~15 документов (naming_conventions, PRD, db_schema,
    design-docs, qa-plan, README, TESTING и др.).
- Проверка:
  - grep -rn 'load_dttm\|src_created_at_ts' sql/ airflow/ tests/ — 0 совпадений.
  - make test — 4 passed.
  - e2e-etl: day1 прошёл полностью, day2 стартовал без ошибок.
2026-03-10 00:07:08 +03:00
ddadmin 3de4db8aa6 docs(all): ревизия документации перед мержем в main
- Зачем:
  - ветка содержала устаревшие ссылки, артефакты CSV-пайплайна и метки «черновик»
    для полностью реализованных слоёв STG→ODS→DDS→DM.
- Что:
  - AGENTS.md: заменена фраза «в будущем» на перечисление реальных слоёв ODS/DDS/DM.
  - TESTING.md: удалены две строки про каталог data/ (артефакт CSV-пайплайна).
  - README.md: список документации заменён на кликабельные markdown-ссылки, добавлены STG и DM DAG.
  - docs/README.md: добавлен DM DAG в «Быстрый путь», DM design в «Технические детали»; убраны метки «(черновик)».
  - docs/internal/bookings_stg_design.md: убран заголовок «черновик», исправлены описания слоёв и DDL.
  - docs/internal/PRD.md: битая ссылка на analyst_spec.md заменена текстом с пометкой TODO.
  - TODO.md: ссылка на plans/ обновлена на docs/internal/bookings_db_issues.md.
  - docs/bookings_to_gp_dm.md: создан новый документ по аналогии с DDS doc (5 витрин, граф, DQ, ошибки).
  - plans/ и docs/chore/: каталоги удалены (планы выполнены, история сохранена в git).
- Проверка:
  - make lint && make test — прошло чисто.
  - grep -n "черновик|data/|в будущем|plans/" — пустой результат.
2026-03-09 20:59:09 +03:00
ddadminandClaude Opus 4.6 a207f8d281 docs(testing): усилены инструкции по управлению DAG через REST API
- Зачем:
  - агент использовал CLI (`docker exec ... airflow dags unpause`) вместо REST API,
    что приводило к зависанию DAG'ов в статусе `queued`.
- Что:
  - e2e-etl-test-protocol: явный запрет на CLI-команды, REST API как основной метод,
    шаблон unpause+trigger для каждого слоя, пример проверки статуса через API.
  - agent-dag-testing: убрана оговорка «если он новый» — unpause обязателен всегда.
  - TODO: пункт «стенд поднимается на чистой машине» отмечен выполненным.
- Проверка:
  - прочитать docs/e2e-etl-test-protocol.md и docs/agent-dag-testing.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:50:39 +03:00
ddadminandClaude Sonnet 4.6 815a248841 chore(bookings): полировка эталонного среза — код, документация, ANALYZE
- Зачем:
  - подготовка эталонного пайплайна к курсовой: код должен быть чистым,
    документация — актуальной, все слои — консистентными.
- Что:
  - добавлен ANALYZE во все 5 DM load-скриптов (был в STG/ODS/DDS, пропущен в DM).
  - README актуализирован: добавлен DM-слой в быстрый старт, список DAG'ов,
    исправлен дефолт BOOKINGS_JOBS (2→1), добавлены примеры запросов DM.
  - удалены остатки CSV-пайплайна: CSV_DIR/CSV_ROWS из docs/stack.md.
  - docs/stack.md: исправлен дефолт BOOKINGS_JOBS (2→1).
  - убран отладочный комментарий «Исправлено:» в passenger_loyalty_load.sql.
  - исправлено форматирование (black) в 4 файлах.
  - TODO.md: отмечены пройденные пункты Этапа 1.5.
- Проверка:
  - make lint && make test — проходят.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 19:37:35 +03:00
ddadmin 0ee2d81383 docs(qa-plan): исправлены SQL-запросы по результатам прогона отладки
- Зачем:
  - план содержал неверные имена полей и формулы, которые давали ошибки при выполнении.
- Что:
  - `_batch_id` → `batch_id` в запросах снапшот-таблиц STG (реальное имя колонки).
  - формула SCD2-маршрутов: `COUNT(DISTINCT route_no)` вместо `route_no || '-' || validity`
    (DDS берёт один route_no с последним validity через rn=1).
  - `ticket_price` → `price`, `boarding_seq IS NOT NULL` → `is_boarded = TRUE`
    (реальные колонки dds.fact_flight_sales).
  - Блок 2: добавлено пояснение, что STG-DAG всегда генерирует новый день,
    поэтому тест идемпотентности — только ODS/DDS/DM без повторного запуска STG.
  - Блок 5.2: уточнено, что ODS намеренно использует batch_id из STG вместо run_id
    для сквозного lineage — это паттерн, а не баг.
- Проверка:
  - все SQL-запросы из плана выполнены на стенде без ошибок.
2026-03-09 19:21:32 +03:00
ddadminandClaude Opus 4.6 c81bdb86d9 perf(bookings): BOOKINGS_JOBS=1 по умолчанию (3× быстрее на инкрементах)
- Зачем:
  - бенчмарк на i7-11800H / WSL2 показал, что jobs=1 генерирует +1 день за ~3 мин,
    а jobs=2 — за ~9 мин из-за lock contention на gen.events FOR UPDATE SKIP LOCKED.
    Тюнинг PG (shared_buffers, work_mem) и synchronous_commit=off не влияют.
- Что:
  - дефолт BOOKINGS_JOBS изменён с 2 на 1 в Makefile и .env.example.
  - добавлен docs/internal/bookings_generation_benchmark.md с полной матрицей экспериментов.
  - обновлены комментарии и bookings/README.md.
- Проверка:
  - make bookings-init && time make bookings-generate-day (~3 мин).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:06:56 +03:00
ddadminandClaude Opus 4.6 85d2776b44 fix(bookings): устранено зависание генерации при jobs>1 и убран VACUUM из инкрементов
- Зачем:
  - make bookings-generate-day зависал на 35+ мин в WHILE busy() LOOP при BOOKINGS_JOBS=2,
    а VACUUM ANALYZE всей БД внутри process_queue() добавлял минуты к каждому инкременту.
- Что:
  - заменён опрос busy() (pg_stat_activity) на dblink_is_busy() — прямая проверка
    состояния каждого dblink-соединения, без зависимости от application_name и state.
  - перед continue() удаляются VACUUM-ивенты из gen.events — они бессмысленны для +1 дня.
- Проверка:
  - make bookings-init && make bookings-generate-day BOOKINGS_JOBS=2 (~5-8 мин, без зависания).
  - DAG bookings_to_gp_stage: все 20 тасков success.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 16:30:13 +03:00
ddadminandClaude Opus 4.6 78fb8e372c fix(bookings): исправлена генерация demodb и добавлен seed-дамп
- Зачем:
  - генерация с нуля занимала часы, boarding_passes были пустыми, данные
    пропадали после docker compose down/up.
- Что:
  - обновлён demodb до коммита 866e56f, добавлен патч install_connstr_no_hardcode.
  - BOOKINGS_INIT_DAYS увеличен до 60, BOOKINGS_JOBS по умолчанию 2.
  - добавлен seed-дамп bookings/seed/demo.sql.xz (42 MB, xz вместо 7z).
  - make bookings-init восстанавливает из дампа (~18 сек) и применяет GUC из .env.
  - make bookings-generate — генерация с нуля для разработчиков.
  - generate_next_day.sql: COMMIT после continue(), pg_sleep(3) для jobs>1, synchronous_commit=on + CHECKPOINT.
  - bookings-check-jobs: добавлена валидация нечисловых значений BOOKINGS_JOBS.
  - bookings-init теперь вызывает bookings-check-jobs как prerequisite.
  - синхронизированы внутренние документы (коммит demodb, init_days=60).
- Проверка:
  - make bookings-init && make bookings-generate-day BOOKINGS_JOBS=2.
  - make test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:01:03 +03:00
ddadminandClaude Opus 4.6 ae4aee3f66 docs(internal): добавлены план отладки ETL и описание багов bookings-db
- Зачем:
  - зафиксировать найденные проблемы demodb и план системной проверки эталона
    до начала подготовки ветки main для студентов.
- Что:
  - добавлен qa-plan.md — 6 блоков проверки пайплайна (чистый прогон,
    идемпотентность, инкремент, многодневный, статика, граничные случаи).
  - добавлен bookings_db_issues.md — 3 бага demodb (gen.connstr без credentials,
    init_days=1 недостаточно, boarding_passes всегда пустая) и UX-проблема
    с временем генерации.
- Проверка:
  - документы, проверка не требуется.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 00:04:00 +03:00
ddadmin a6a3559ab9 refactor(csv): удален легаси CSV-пайплайн и связанные с ним файлы
- Зачем:
  - Пример базовой загрузки CSV перенесен в отдельный репозиторий `airflow-manual` для разделения учебных треков.
- Что:
  - удалены DAG-файлы `csv_to_greenplum` и вспомогательные скрипты `helpers/greenplum.py`, `orders_ddl.sql`.
  - из `docker-compose.yml` и `.env.example` удалены переменные и тома (`airflow_data`), необходимые для CSV.
  - очищена документация (`README.md`, `TESTING.md`, `educational-tasks.md`) и тесты (`test_dags_smoke.py`, `conftest.py`).
  - отмечен выполненным 'Этап 1' в `TODO.md`.
- Проверка:
  - `make test` проходит успешно (smoke-тесты оставшихся DAG-ов не затронуты).
2026-03-08 19:13:19 +03:00
ddadminandClaude Opus 4.6 45c1d37da5 docs(internal): вынесен план работ из PRD в TODO.md
- Зачем:
  - PRD не должен быть трекером задач, у каждого документа своя роль
- Что:
  - TODO.md реструктурирован: добавлен план подготовки курсовой (5 этапов с рекомендациями по инструментам), выполненные задачи перенесены в отдельную секцию
  - PRD.md: раздел «План работ» заменён ссылкой на TODO.md, убраны решённые вопросы
- Проверка:
  - просмотр TODO.md и docs/internal/PRD.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:48:13 +03:00
ddadminandClaude Opus 4.6 df8c2c36fa docs(internal): разделён PRD на стратегию и дизайн задания
- Зачем:
  - PRD стал перегруженным — смешаны стратегические и тактические решения
- Что:
  - PRD.md сокращён до стратегии (видение, аудитория, скоуп, критерии, риски)
  - создан assignment_design.md (эталонный срез, порядок выполнения, SCD2-подход, валидационный DAG, формат ТЗ)
  - зафиксированы решения: эталон sales_report, валидационный DAG, SCD2 как задание с подсказками
- Проверка:
  - просмотр файлов docs/internal/PRD.md и docs/internal/assignment_design.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:35:24 +03:00
ddadmin d77da5686c docs(internal): добавлена первая версия PRD для DWH-стенда
- Зачем:
  - необходим единый документ с требованиями для разработки ETL-процессов.
- Что:
  - создан файл docs/internal/PRD.md с описанием архитектуры, требований к данным и сроков.
- Проверка:
  - просмотр файла docs/internal/PRD.md.
2026-03-08 18:29:04 +03:00
ddadmin f26ed7875c docs(internal): обновлен статус задач в архитектурном обзоре
- Зачем:
  - необходимо синхронизировать документацию с фактически выполненными изменениями в коде.
- Что:
  - отмечена как выполненная задача по явному указанию storage type для всех таблиц.
  - обновлено обоснование для dds.dim_calendar (AO Row из-за малой ширины таблицы).
  - отмечен как выполненный рефакторинг hashdiff в dim_routes_load.sql.
- Проверка:
  - визуальная сверка docs/internal/architecture_review.md с файлами в sql/dds/ и sql/ods/.
2026-03-05 23:20:02 +03:00
ddadmin 583d832511 docs(airflow): обновлено руководство по программному тестированию DAG
- Зачем:
  - старая инструкция была неполной (без ODS/DDS/DM) и содержала избыточные требования.
- Что:
  - переписан docs/agent-dag-testing.md с фокусом на итеративную разработку и Airflow REST API.
  - добавлены шаги по отладке упавших задач (логгирование через CLI) и проверке DWH слоев.
- Проверка:
  - визуальная проверка текста руководства на соответствие актуальному пайплайну.
2026-03-05 23:06:11 +03:00
ddadmin d89434c3a8 feat(dds): денормализовано измерение dim_routes и упрощена витрина route_performance
- Зачем:
  - улучшение производительности аналитических запросов и упрощение витрин согласно принципам Kimball Star Schema.
- Что:
  - в dds.dim_routes добавлены денормализованные поля городов и моделей самолетов.
  - в скрипт загрузки dim_routes_load.sql добавлена фаза refresh для актуализации атрибутов.
  - загрузка dm.route_performance упрощена до 1 JOIN к измерению маршрутов.
  - обновлен DAG bookings_to_gp_dds и smoke-тесты структуры графа.
  - в документации (db_schema.md) отражены денормализация и lineage версий.
  - в dim_routes_load.sql исправлено затирание _load_id при refresh исторических версий.
- Проверка:
  - make test (smoke-тесты структуры DAG проходят успешно).
2026-03-05 23:05:59 +03:00
ddadmin 68654181d4 docs(internal): добавлен план денормализации dim_routes
- Зачем:
  - необходимо упростить витрину dm.route_performance с 4-JOIN до 1-JOIN;
  - привести измерение в соответствие с принципом Кимбалла («самодостаточное измерение»).
- Что:
  - добавлена задача в TODO.md с описанием цели денормализации;
  - создан детальный план реализации в docs/internal/dim_routes_denormalization_plan.md.
- Проверка:
  - просмотр файлов TODO.md и docs/internal/dim_routes_denormalization_plan.md.
2026-03-04 23:06:45 +03:00
ddadmin 2228312b55 feat(dm): завершено построение DM-слоя (5 витрин) и исправлены баги денормализации
- Зачем:
  - предоставить студентам полный набор аналитических витрин с примерами различных паттернов (UPSERT, Full Rebuild, UNION ALL, двухуровневая агрегация).
- Что:
  - реализованы витрины: sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview.
  - исправлен баг в route_performance_load.sql: добавлены JOIN к dim_airports и dim_airplanes для корректной денормализации атрибутов.
  - обновлен скрипт e2e_etl.sh: добавлена верификация всех 5 витрин и проверка бизнес-логики (load factor).
  - обновлены DAGи, тесты и главный DDL скрипт.
- Проверка:
  - автоматизированный прогон e2e_etl.sh через REST API Airflow.
2026-03-04 22:55:16 +03:00
ddadmin f68b055b00 feat(dm): добавлена витрина airport_traffic и внедрен паттерн dual-role dimensions
- Зачем:
  - необходимо продемонстрировать студентам работу с одной сущностью в разных ролях (вылет/прилет) через UNION ALL.
- Что:
  - созданы DDL, Load и DQ скрипты для витрины dm.airport_traffic (пассажиропоток аэропортов).
  - реализован паттерн Unpivot через UNION ALL для консолидации метрик вылета и прилета в одном разрезе.
  - внедрена инкрементальная загрузка по затронутым датам (HWM) с честным подсчетом рейсов через COUNT(DISTINCT).
  - добавлены подробные комментарии к колонкам выручки, предупреждающие о риске двойного счета.
  - обновлены DAGи bookings_dm_ddl и bookings_to_gp_dm для включения витрины в общий пайплайн.
- Проверка:
  - визуальный аудит SQL-кода на предмет использования airport_bk и корректной агрегации по ролям.
  - наличие DQ-инварианта total_passengers = departures + arrivals.
  - верификация блока UPDATE: теперь обновляются и денормализованные атрибуты (city, airport_bk).
2026-03-04 22:19:06 +03:00
ddadmin a06b52c38d feat(dm): добавлена витрина passenger_loyalty и внедрен паттерн HWM по ключу
- Зачем:
  - необходимо продемонстрировать студентам метод инкрементального пересчета "затронутых ключей" для больших справочных витрин.
- Что:
  - созданы DDL, Load и DQ скрипты для витрины dm.passenger_loyalty (лояльность пассажиров).
  - реализован расчет моды (самый частый тариф) через PostgreSQL-специфику DISTINCT ON.
  - внедрена корректная агрегация SCD2-измерений (unique_routes) по бизнес-ключу route_bk.
  - настроено Heap-хранилище (WITH appendonly=false) для эффективного выполнения UPSERT.
  - обновлены DAGи bookings_dm_ddl и bookings_to_gp_dm для включения новой витрины в конвейер.
- Проверка:
  - визуальный аудит SQL на предмет использования p.passenger_id (BK) и r.route_bk.
  - наличие учебной DQ-проверки ссылочной целостности и инварианта дат (first <= last).
  - проверка параллельности задач в Airflow DAG.
2026-03-04 22:03:26 +03:00
ddadmin 491f1e68d6 feat(dm): добавлена витрина route_performance и обновлены DM DAGи
- Зачем:
  - необходимо продемонстрировать студентам альтернативный паттерн загрузки (Full Rebuild) и использование AO Column Store в Greenplum.
- Что:
  - созданы DDL, Load и DQ скрипты для витрины dm.route_performance (эффективность маршрутов).
  - реализована агрегация по бизнес-ключу route_bk для корректной обработки SCD2-измерений.
  - настроен формат хранения AO Column Store с компрессией zstd (уровень 1).
  - обновлены DAGи bookings_dm_ddl и bookings_to_gp_dm для параллельной оркестрации новой витрины.
- Проверка:
  - визуальный аудит SQL-кода на соответствие naming_conventions.md.
  - проверка структуры DAG в Airflow (параллельные ветки load -> dq).
  - наличие бизнес-инварианта total_boarded <= total_tickets в DQ-скрипте.
2026-03-04 21:52:10 +03:00
ddadmin 22a0590e1b feat(sql): явное управление storage и автоматизированный E2E-тест через REST API
- Зачем:
  - необходимо визуализировать выбор типа хранения (Heap vs Append-Only) для учебных целей.
  - автоматизировать проверку всей цепочки DWH для исключения ручных ошибок.
  - сделать процесс отладки прозрачным и наглядным через стандартные инструменты Airflow.
- Что:
  - внедрена клауза WITH (appendonly=...) во все DDL; ODS-справочники переведены на AO Row и TRUNCATE+INSERT.
  - создан скрипт scripts/e2e_etl.sh для полного прогона ETL (DDL + 2 дня данных) через REST API.
  - исправлены баги типизации (INTEGER[]), именования полей (amount, passenger_id) и удалены фантомные колонки (contact_data).
  - обновлен e2e-etl-test-protocol.md: добавлен раздел по отладке, чтению логов и перезапуску задач через API.
  - исправлены pytest-контракты под новую логику загрузки.
- Проверка:
  - успешный прогон `make e2e-smoke` (полный цикл от очистки до витрины).
2026-03-03 22:38:26 +03:00
ddadmin ab97d6a535 chore(todo): добавлены задачи по REST API и устойчивости bookings-db
- Зачем:
  - зафиксировать новые приоритеты по тестированию ETL и стабильности источника.
- Что:
  - добавлены два новых пункта в backlog мейнтейнеров в TODO.md.
  - уточнён статус файла, чтобы он отражал наличие открытых задач.
- Проверка:
  - git diff --cached -- TODO.md.
2026-03-02 22:41:34 +03:00
ddadmin dd6274b948 chore(todo): добавлены задачи по REST API и устойчивости bookings-db
- Зачем:
  - зафиксировать новые приоритеты по тестированию ETL и стабильности источника.
- Что:
  - добавлены два новых пункта в backlog мейнтейнеров в TODO.md.
  - уточнён статус файла, чтобы он отражал наличие открытых задач.
- Проверка:
  - git diff --cached -- TODO.md.
2026-03-02 22:40:31 +03:00
ddadmin 4d793a9c11 refactor(sql): заменен тип сжатия zlib на zstd для AO-таблиц
- Зачем:
  - zstd (level 1) является современным стандартом для Greenplum 6.0+, обеспечивая более высокую скорость декомпрессии и лучшее сжатие.
- Что:
  - обновлены все DDL стейджинга (STG) и базовых таблиц.
  - обновлена архитектурная документация (ADR-3) и планы реализации.
  - исправлены примеры кода в Airflow DAG и описании ETL.
- Проверка:
  - успешное выполнение CREATE TABLE с новыми параметрами в Greenplum 6.27.1.
2026-03-01 20:26:56 +03:00
ddadmin 92f3828a45 test(e2e): протокол сквозного тестирования и инструменты очистки DWH
- Зачем:
  - формализация проверки всей цепочки ETL (STG -> ODS -> DDS -> DM).
- Что:
  - создан docs/e2e-etl-test-protocol.md и sql/truncate_gp.sql.
  - в Makefile добавлена команда dwh-truncate.
  - start_date во всех DAG изменен на 2017-01-01.
- Проверка:
  - выполнение make dwh-truncate и прогон DAG.
2026-03-01 20:08:01 +03:00
ddadmin 2937185cfa fix(ods): внедрен паттерн Temporary Table для транзакционных загрузок
- Зачем:
  - исправление критических ошибок P0 (гонка HWM, ошибки в SQL CTE, непоследовательный lineage).
  - использование временных таблиц делает код более читаемым для студентов и производительным для Greenplum.
- Что:
  - в sql/ods/ (bookings, tickets, segments, boarding_passes, flights) выборка дельты вынесена в CREATE TEMP TABLE.
  - HWM теперь вычисляется один раз, устраняя гонку между UPDATE и INSERT.
  - во всех стейтментах используется оригинальный batch_id из STG для _load_id.
  - поле _load_ts в ODS теперь берется из STG (load_dttm), что делает HWM-сравнение корректным.
- Проверка:
  - визуальный аудит SQL-логики.
2026-03-01 18:55:00 +03:00
ddadmin daeae7d612 docs(review): отмечен исправленным баг с потерей данных в ODS
- Зачем:
  - необходимо зафиксировать выполнение критической задачи (P0) в плане работ.
- Что:
  - в архитектурном ревью пункт "ODS batch resolver теряет данные" отмечен как выполненный.
- Проверка:
  - визуальная проверка docs/internal/architecture_review.md.
2026-03-01 18:39:03 +03:00
ddadmin 483ed30882 docs(review): ADR-1, ADR-2, ADR-3 — архитектурные решения
- Зачем:
  - зафиксировать принятые архитектурные решения с обоснованием для менти,
    чтобы студенты понимали границы учебных упрощений и реальную практику.
- Что:
  - ADR-1: города/страны/модели — атрибуты измерений (star vs snowflake).
  - ADR-2: партиционирование не используем (учебные объёмы), с примером
    exchange partition и контекстом импортозамещения (Teradata/Exadata → GP).
  - ADR-3: явный storage type для каждой таблицы — AO Column для write-once,
    AO Row для snapshot-справочников (TRUNCATE+INSERT), heap для UPDATE-таблиц.
  - добавлена задача P2 по реализации ADR-3, обновлена сводка трудозатрат.
- Проверка:
  - cat docs/internal/architecture_review.md | grep "ADR-".
2026-03-01 18:34:18 +03:00
ddadmin cfd20328d6 fix(ods): изменен батчевый резолвер для транзакционных таблиц
- Зачем:
  - текущая реализация ODS batch_id теряла данные транзакционных таблиц, если между запусками ODS STG успевал отработать дважды (брался только последний батч).
- Что:
  - изменены скрипты загрузки транзакционных таблиц (bookings, tickets, flights, segments, boarding_passes) для использования паттерна HWM по _load_ts вместо фильтрации по конкретному батчу.
  - обновлен комментарий в DAG bookings_to_gp_ods, объясняющий разное поведение для справочников и транзакционных данных.
  - сохранено использование оригинального batch_id из STG для поля _load_id в слое ODS для сквозного трассирования.
- Проверка:
  - запуск пайплайнов и проверка, что все батчи загружаются из STG в ODS без потерь.
2026-03-01 18:15:04 +03:00
ddadmin faf410b4e8 docs(review): добавлен P0-баг — потеря данных при двойном STG-запуске
- Зачем:
  - обнаружена проблема: ODS batch resolver берёт только последний batch,
    транзакционные данные промежуточных STG-запусков теряются навсегда.
- Что:
  - добавлен P0 в architecture_review.md с описанием сценария и решения.
  - обновлена сводная таблица трудозатрат.
- Проверка:
  - cat docs/internal/architecture_review.md | grep -A5 "batch resolver теряет".
2026-03-01 17:52:59 +03:00
ddadmin 768334453f docs(dds): добавлены комментарии и исправлены ошибки в DWH
- Зачем:
  - закрыты задачи P0 и P1 из ревью архитектуры для повышения понятности стенда для студентов.
- Что:
  - исправлен distribution key для airport_traffic в дизайн-документе.
  - добавлены комментарии о генерации SK и отсутствии SK в фактах.
  - создан документ docs/dag_execution_order.md с описанием порядка запуска DAG-ов.
  - объяснена логика late-arriving dimensions и batch resolver.
  - добавлена legacy-пометка для хелпера greenplum.py.
- Проверка:
  - визуальная проверка добавленных комментариев и новых файлов.
2026-03-01 17:50:53 +03:00
ddadmin e29a249830 docs(dds): добавлены учебные комментарии о генерации SK
- Зачем:
  - объяснить студентам, почему подход MAX() + ROW_NUMBER() безопасен только при последовательном запуске.
- Что:
  - добавлен комментарий в dim_airplanes_load.sql.
  - добавлен комментарий в dim_airports_load.sql.
  - добавлен комментарий в dim_passengers_load.sql.
  - добавлен комментарий в dim_routes_load.sql.
  - добавлен комментарий в dim_tariffs_load.sql.
- Проверка:
  - git diff HEAD~1 sql/dds/.
2026-03-01 17:42:42 +03:00
ddadmin 8870ba9088 docs(review): добавлено ревью архитектуры слоёв DWH
- Зачем:
  - зафиксировать оценку учебной ценности текущей конструкции (STG→ODS→DDS→DM)
    и сформировать backlog задач для доработки стенда.
- Что:
  - создан docs/internal/architecture_review.md с анализом 9 сильных сторон
    и приоритизированным списком замечаний (P0–P3) с чеклистом задач.
- Проверка:
  - cat docs/internal/architecture_review.md.
2026-03-01 17:36:56 +03:00
ddadmin f2f0468cb1 docs(dm): описание паттернов HWM и TEMP TABLE в дизайн-документе
- Зачем:
  - дизайн-документ описывал устаревший batch-driven подход и не фиксировал паттерн TEMP TABLE, используемый в реализации.
- Что:
  - заменено описание загрузки всех UPSERT-витрин на HWM-инкрементальность через _load_ts.
  - добавлена секция «Общий паттерн загрузки UPSERT-витрин» с SQL-скелетом и таблицей применимости.
  - в секции «Учит» каждой витрины добавлены паттерны HWM и TEMP TABLE.
- Проверка:
  - визуальная проверка docs/internal/bookings_dm_design.md.
2026-03-01 17:09:55 +03:00
ddadmin 81ff396402 refactor(dm): переход sales_report на HWM-инкрементальность через _load_ts
- Зачем:
  - фильтр `_load_id = '{{ run_id }}'` использовал run_id DM-DAG-а, который не совпадает с run_id DDS-DAG-а, записанным в факты — витрина не находила дельту.
- Что:
  - load: заменён _load_id-фильтр на HWM-подзапрос `_load_ts > MAX(_load_ts)` из dm.sales_report.
  - dq: источник затронутых дат переключён с DDS на саму витрину (где _load_id уже корректный).
- Проверка:
  - `make test` — smoke-тесты зелёные.
  - запуск `bookings_to_gp_dm` в Airflow после загрузки DDS.
2026-03-01 17:09:42 +03:00
ddadmin 84ec949ff9 refactor(dm): переход на batch-driven инкрементальность для sales_report
- Зачем:
  - жесткая привязка инкремента к логической дате Airflow ({{ ds }}) приводила к пустой витрине при обработке исторических и "опоздавших" (late-arriving) данных.
- Что:
  - изменена фильтрация в скрипте загрузки витрины: теперь динамически определяются даты, затронутые текущим батчем (через _load_id).
  - обновлены DQ-проверки для валидации только тех дат, которые были изменены в рамках запущенного батча.
  - в дизайн-документ добавлено описание паттерна работы с late-arriving facts для студентов.
- Проверка:
  - запуск пайплайна "с нуля" за логическую дату 2024-01-01 приводит к корректному расчету агрегатов для исторических данных 2017 года (>8000 строк).
2026-03-01 01:54:57 +03:00
ddadmin a8cce6cd6b refactor(dm): исправление архитектуры витрины sales_report
- Зачем:
  - исходная реализация содержала критические ошибки MPP (Load/Processing Skew) и Full Scan.
  - требуется демонстрация студентам эталонного инкрементального UPSERT.
- Что:
  - изменен ключ дистрибуции с flight_date на (departure_airport_sk, arrival_airport_sk).
  - внедрен каноничный UPSERT через TEMP TABLE (агрегация выполняется один раз).
  - добавлен инкрементальный фильтр по {{ ds }} для предотвращения Full Scan dds.fact_flight_sales.
  - проверки DQ переведены в инкрементальный режим (валидация только текущего батча).
- Проверка:
  - airflow dags test bookings_to_gp_dm 2026-02-28.
2026-02-28 23:56:31 +03:00
ddadmin 20a1270633 Merge branch 'main' into chore/bookings-etl 2026-02-28 22:53:38 +03:00
ddadmin c6ac41c3f6 docs: добавлен служебный файл GEMINI.md для контекста агентов
- Зачем:
  - файл позволяет AI-агентам быстро находить и использовать контекст из AGENTS.md
- Что:
  - создан файл GEMINI.md с ссылкой на @AGENTS.md
- Проверка:
  - cat GEMINI.md
2026-02-28 22:52:49 +03:00
ddadmin 016cc3d43d Merge remote-tracking branch 'origin/chore/bookings-etl' into chore/bookings-etl
# Conflicts:
#	docs/internal/bookings_ods_design.md
#	sql/ddl_gp.sql
#	sql/ods/airplanes_load.sql
#	sql/ods/airports_load.sql
#	tests/test_dags_smoke.py
#	tests/test_ods_snapshot_integration.py
2026-02-28 22:20:17 +03:00
ddadmin 5efb833837 feat(sql): нормализация JSON-полей в ODS для airports и airplanes
- Зачем:
  - источник хранит мультиязычные названия как JSON ({"en": "...", "ru": "..."}),
  - для упрощения downstream-логики (DDS/DM) нужны чистые строки на одном языке.
- Что:
  - добавлен парсинг JSON с извлечением поля 'ru' в sql/ods/airports_load.sql
    (airport_name, city, country).
  - добавлен парсинг JSON с извлечением поля 'ru' в sql/ods/airplanes_load.sql
    (model).
  - обновлена документация docs/internal/bookings_ods_design.md с примечаниями
    о нормализации.
  - обновлены тестовые данные в tests/test_ods_snapshot_integration.py для
    соответствия JSON-формату STG.
- Проверка:
  - uv run make test (15 passed).
  - SELECT airport_name FROM ods.airports → "Аль-Баха" (вместо JSON).
2026-02-28 22:13:11 +03:00
ddadmin 7d18b3fe5c feat(dm): добавлена эталонная витрина dm.sales_report
- Зачем:
  - требуется эталонная витрина для обучения паттернам DM-слоя
  - демонстрация UPSERT-логики с IS DISTINCT FROM для идемпотентности
- Что:
  - DDL: heap-таблица dm.sales_report с 18 полями, DISTRIBUTED BY (flight_date)
  - Load: UPSERT (UPDATE + INSERT) с JOIN dim_calendar, dim_airports (x2), dim_tariffs, fact_flight_sales
  - DQ: PL/pgSQL DO $$ с проверками непустоты, уникальности, tickets_sold >= passengers_boarded, boarding_rate BETWEEN 0 AND 1
  - DAGs: bookings_dm_ddl (DDL), bookings_to_gp_dm (ETL + DQ с параллельными ветками)
  - Tests: smoke-тесты для обоих DAG
  - sql/ddl_gp.sql: добавлен \i dm/sales_report_ddl.sql
- Проверка:
  - make fmt && make lint — passed
  - make test — 15 passed, 11 skipped
  - make ddl-gp — DDL applied successfully
  - airflow dags test bookings_to_gp_dm 2026-02-28T13:00:00 — 4 tasks SUCCESS
  - 9243 rows loaded, _load_id подставлен корректно (Jinja2 templating works)
  - DQ checks passed
2026-02-28 22:13:11 +03:00
ddadminandClaude Opus 4.6 e577f31a67 docs(dm): добавлен дизайн-документ DM-слоя
- Зачем:
  - зафиксировать архитектуру 5 витрин DM перед реализацией и отдать на ревью.
- Что:
  - создан docs/internal/bookings_dm_design.md с описанием всех витрин, DAG-структуры, DQ-проверок и порядка реализации.
- Проверка:
  - cat docs/internal/bookings_dm_design.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:13:11 +03:00
ddadmin b6b87a7a00 fix(ods): исправлена загрузка flights для ссылок из segments
- Зачем:
  - 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"}'.
2026-02-28 22:13:11 +03:00
ddadmin e592b35461 feat(dds): реализован слой dds для bookings
- Зачем:
  - подготовлен учебный 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.
2026-02-28 22:13:11 +03:00
ddadmin fc798aadd1 docs(dwh): актуализирована db_schema для ODS и DDS
- Зачем:
  - синхронизировать 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
2026-02-28 22:13:11 +03:00
ddadmin 0dfecf4cd8 fix(dwh): доработан DDS-план после повторного ревью
- Зачем:
  - закрыть оставшиеся риски и противоречия в проектировании 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
2026-02-28 22:13:11 +03:00
ddadminandClaude Opus 4.6 3ddf3cc136 fix(dwh): исправлены 5 замечаний ревью плана DDS
- Зачем:
  - устранить противоречия и пробелы в плане 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 <noreply@anthropic.com>
2026-02-28 22:13:11 +03:00
ddadminandClaude Opus 4.6 6f8dd9c7c7 docs(dwh): добавлен план реализации DDS слоя (Star Schema)
- Зачем:
  - зафиксировать архитектурные решения 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 <noreply@anthropic.com>
2026-02-28 22:13:11 +03:00
ddadmin 85fea19818 feat(ods): реализован ODS слой и DAG загрузки из STG
- Зачем:
  - подготовлена учебная реализация 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`.
2026-02-28 22:13:11 +03:00
ddadminandClaude Opus 4.6 2d629b6e80 fix(dwh): исправлены 3 замечания codex-ревью плана ODS
- Зачем:
  - 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 <noreply@anthropic.com>
2026-02-28 22:13:06 +03:00
ddadminandClaude Opus 4.6 105c4ac31c docs(dwh): исправлен план ODS после ревью (v2 → v3)
- Зачем:
  - 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 <noreply@anthropic.com>
2026-02-28 22:13:06 +03:00
ddadmin 9b1e853d99 docs(dwh): зафиксированы конвенции нейминга и обновлён план ODS
- Зачем:
  - нужен единый стандарт именования полей, чтобы новые слои не расходились с учебными материалами.
- Что:
  - добавлен единый документ с правилами нейминга `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`).
2026-02-28 22:13:06 +03:00
ddadmin 9e5ce91bcc docs(internal): добавлен план реализации ODS слоя
- Зачем:
  - зафиксировать архитектурное решение для ODS слоя перед началом разработки.
- Что:
  - создан файл docs/internal/bookings_ods_design.md.
  - описана архитектура ODS и отличия от STG слоя.
  - добавлены схемы таблиц (справочники с SCD Type 2, транзакции с UPSERT).
- Проверка:
  - git show --stat HEAD
2026-02-28 22:13:06 +03:00
ddadmin 5dfd9d3368 docs(testing): добавлены проверки source-данных перед DAG-тестами
- Зачем:
  - исключены ложные падения 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
2026-02-28 22:13:06 +03:00
ddadminandClaude Sonnet 4.6 9f8098edd5 docs(agents): добавлен гайд по программному тестированию DAG
- Зачем:
  - 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 <noreply@anthropic.com>
2026-02-28 22:13:06 +03:00
ddadmin 2c80955eb6 docs: добавлен CLAUDE.md с инструкциями для AI-ассистента
- Зачем:
  - необходимо зафиксировать контекст и правила работы с проектом для Claude AI.
- Что:
  - создан файл CLAUDE.md с описанием структуры проекта.
  - добавлены инструкции по коммитам и стилю кода.
- Проверка:
  - git show --stat HEAD
2026-02-28 22:12:22 +03:00
ddadminandClaude Opus 4.6 39ff430ed0 refactor(dags): параллелизован граф DAG bookings_to_gp_stage
- Зачем:
  - линейный граф маскировал реальные зависимости данных; для эталонного
    стенда важно показать менти параллельный граф там, где данные независимы.
- Что:
  - 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 <noreply@anthropic.com>
2026-02-28 22:12:22 +03:00
ddadmin 3dcaeb85ce Доводка стилистики документации 2026-02-28 22:12:22 +03:00
ddadmin 159ee99a32 Улучшение тестов 2026-02-28 22:12:22 +03:00
ddadmin 6ce7915806 Улучшение DQ проверки 2026-02-28 22:12:22 +03:00
ddadmin 31dde678c5 Улучшения комментариев 2026-02-28 22:12:22 +03:00
ddadmin 16e8a883b8 статусы задач 2026-02-28 22:12:22 +03:00
ddadmin d4c91c4eec Допустимость пустых данных по некоторым таблицам 2026-02-28 22:12:22 +03:00
ddadmin bb83c831d2 Исправлены критические замечания 2026-02-28 22:12:22 +03:00
ddadmin 4b119fbd7c Ревью решения 2026-02-28 22:12:22 +03:00
ddadmin 7312cdfb68 Отладка потоков 2026-02-28 22:12:22 +03:00
ddadmin 7abeb67fba Генерация dds слоя по ТЗ - без тестов 2026-02-28 22:12:22 +03:00
ddadmin 9b8a6f3ce4 Замечания от codex 2026-02-28 22:12:22 +03:00
ddadmin 733f14eee3 план работ by glm 2026-02-28 22:12:22 +03:00
ddadmin ece3873f54 Финальные доработки 2026-02-28 22:12:22 +03:00
ddadmin 1812ced796 Доработки GLM 2026-02-28 22:12:22 +03:00
ddadmin 52031c75e6 доработки плана 2026-02-28 22:12:22 +03:00
ddadmin 941601a257 план витрин до stg слоя 2026-02-28 22:12:22 +03:00
ddadmin d6fed5c7b9 Полировка кода 2026-02-28 22:12:22 +03:00
ddadmin 33110eed48 Проверка/рецензирование доработки 2026-02-28 22:12:22 +03:00
ddadmin 256d594ac8 актуализация "что делали" 2026-02-28 22:11:44 +03:00
ddadmin ad663efcea Отладка 2026-02-28 22:11:44 +03:00
ddadmin 897a76588d Первая версия dag 2026-02-28 22:11:44 +03:00
ddadmin c23bbed10c План работ 2026-02-28 22:11:44 +03:00
ddadmin ffd9585ef4 fix(bookings): добавлено явное ограничение генератора на jobs=1
- Зачем:
  - исключить нестабильные режимы генератора, в которых bookings может перестать пополняться.
  - сделать причину ошибки понятной студенту сразу при запуске команд и SQL-скриптов.
- Что:
  - добавлен precheck `bookings-check-jobs` в Makefile и подключён к `bookings-init` и `bookings-generate-day`.
  - добавлены явные проверки `bookings.jobs = 1` в `bookings/generate_next_day.sql` и `sql/src/bookings_generate_day_if_missing.sql`.
  - обновлена документация и `.env.example`: зафиксировано, что в стенде поддерживается только `BOOKINGS_JOBS=1`.
- Проверка:
  - uv run make fmt.
  - uv run make test.
  - env BOOKINGS_JOBS=2 make bookings-init.
  - env BOOKINGS_JOBS=2 make bookings-generate-day.
  - make bookings-init.
  - make bookings-generate-day.
2026-02-28 22:00:27 +03:00
ddadmin a10cbff906 fix(bookings): откатаны ошибочные изменения в инициализации demodb
- Зачем:
  - предыдущий фикс ломал make bookings-init из-за невалидного SQL в Makefile.
- Что:
  - удалены SQL-блоки постобработки reference-данных и cume_dist из bookings-init.
  - удалён случайно добавленный gitlink _vendor/demodb.
  - удалён SET LOCAL application_name из generate_next_day.sql.
- Проверка:
  - uv run make fmt.
  - uv run make test.
  - make bookings-init.
  - make bookings-generate-day.
2026-02-28 19:54:57 +03:00
ddadmin 09c940ea0e fix(bookings): исправлен бесконечный цикл busy() и генерации имён пассажиров
- Зачем:
  - генератор demodb зависал при jobs=1 из-за самоблокировки busy()
  - get_passenger_name() попадал в бесконечный цикл из-за NULL в cume_dist
- Что:
  - добавлен SET LOCAL application_name = 'psql' после вызова generate/continue
  - добавлена загрузка firstnames.dat и lastnames.dat в Makefile
  - добавлено заполнение cume_dist для firstnames, lastnames и airport_to_prob
- Проверка:
  - make bookings-generate-day выполняется успешно, создаётся ~45K бронирований
2026-02-28 19:36:48 +03:00
ddadmin 25f04fd0f0 Merge branch 'main' into chore/bookings-etl 2026-02-28 14:54:53 +03:00
ddadmin acdbb8be3e docs(agents): реструктуризация инструкций, добавлено правило баланса сложности
- Зачем:
  - документ разросся и стал неструктурированным; нужно выделить ключевое правило про баланс production-ready и KISS для начинающих Data инженеров.
- Что:
  - реструктуризация: введена нумерация разделов (1-6), удалены дублирующиеся детали команд и стиля кода.
  - добавлен раздел 1 "Главное правило генерации кода (Баланс)" с принципами Production-ready, KISS и Фокус на «Почему».
  - расширена карта проекта: добавлены все слои DWH (src/, stg/, ods/, dds/, dm/) и naming conventions.
  - добавлен раздел 6 "Порядок работы Агента" с чек-листом валидации.
- Проверка:
  - cat AGENTS.md && git log -1
2026-02-28 14:53:09 +03:00
ddadminandClaude Opus 4.6 e986222d46 docs(dm): добавлен дизайн-документ DM-слоя
- Зачем:
  - зафиксировать архитектуру 5 витрин DM перед реализацией и отдать на ревью.
- Что:
  - создан docs/internal/bookings_dm_design.md с описанием всех витрин, DAG-структуры, DQ-проверок и порядка реализации.
- Проверка:
  - cat docs/internal/bookings_dm_design.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:37:26 +03:00
ddadmin 8fd5ae1069 fix(ods): исправлена загрузка flights для ссылок из segments
- Зачем:
  - 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"}'.
2026-02-25 23:12:27 +03:00
ddadmin d25c753eb4 feat(dds): реализован слой dds для bookings
- Зачем:
  - подготовлен учебный 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.
2026-02-25 23:05:43 +03:00
ddadmin 9dbfc3572d docs(dwh): актуализирована db_schema для ODS и DDS
- Зачем:
  - синхронизировать 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
2026-02-24 00:09:14 +03:00
ddadmin e98821f28d fix(dwh): доработан DDS-план после повторного ревью
- Зачем:
  - закрыть оставшиеся риски и противоречия в проектировании 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
2026-02-24 00:03:37 +03:00
ddadminandClaude Opus 4.6 06a5b7bc4c fix(dwh): исправлены 5 замечаний ревью плана DDS
- Зачем:
  - устранить противоречия и пробелы в плане 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 <noreply@anthropic.com>
2026-02-23 23:05:16 +03:00
ddadminandClaude Opus 4.6 e6e923794c docs(dwh): добавлен план реализации DDS слоя (Star Schema)
- Зачем:
  - зафиксировать архитектурные решения 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 <noreply@anthropic.com>
2026-02-23 22:55:07 +03:00
ddadmin 03765d2808 feat(ods): реализован ODS слой и DAG загрузки из STG
- Зачем:
  - подготовлена учебная реализация 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`.
2026-02-23 01:51:40 +03:00
ddadminandClaude Opus 4.6 dc1dc9c66a fix(dwh): исправлены 3 замечания codex-ревью плана ODS
- Зачем:
  - 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 <noreply@anthropic.com>
2026-02-22 23:44:08 +03:00
ddadminandClaude Opus 4.6 7bdb5acfcb docs(dwh): исправлен план ODS после ревью (v2 → v3)
- Зачем:
  - 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 <noreply@anthropic.com>
2026-02-22 23:39:16 +03:00
ddadmin 6ceff7b027 docs(dwh): зафиксированы конвенции нейминга и обновлён план ODS
- Зачем:
  - нужен единый стандарт именования полей, чтобы новые слои не расходились с учебными материалами.
- Что:
  - добавлен единый документ с правилами нейминга `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`).
2026-02-22 22:45:53 +03:00
ddadmin d1e81099c7 docs(internal): добавлен план реализации ODS слоя
- Зачем:
  - зафиксировать архитектурное решение для ODS слоя перед началом разработки.
- Что:
  - создан файл docs/internal/bookings_ods_design.md.
  - описана архитектура ODS и отличия от STG слоя.
  - добавлены схемы таблиц (справочники с SCD Type 2, транзакции с UPSERT).
- Проверка:
  - git show --stat HEAD
2026-02-22 21:31:14 +03:00
ddadmin 26d0ab3d87 docs(testing): добавлены проверки source-данных перед DAG-тестами
- Зачем:
  - исключены ложные падения 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
2026-02-22 21:08:17 +03:00
ddadminandClaude Sonnet 4.6 2fb60e67ed docs(agents): добавлен гайд по программному тестированию DAG
- Зачем:
  - 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 <noreply@anthropic.com>
2026-02-22 20:54:02 +03:00
ddadmin cb5dd0528b docs: добавлен CLAUDE.md с инструкциями для AI-ассистента
- Зачем:
  - необходимо зафиксировать контекст и правила работы с проектом для Claude AI.
- Что:
  - создан файл CLAUDE.md с описанием структуры проекта.
  - добавлены инструкции по коммитам и стилю кода.
- Проверка:
  - git show --stat HEAD
2026-02-22 20:27:16 +03:00
ddadminandClaude Opus 4.6 69651f98ff refactor(dags): параллелизован граф DAG bookings_to_gp_stage
- Зачем:
  - линейный граф маскировал реальные зависимости данных; для эталонного
    стенда важно показать менти параллельный граф там, где данные независимы.
- Что:
  - 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 <noreply@anthropic.com>
2026-02-22 20:19:58 +03:00
ddadmin a0258fd63e Доводка стилистики документации 2026-01-18 22:52:49 +03:00
ddadmin 9e08bc181c Улучшение тестов 2026-01-18 22:32:57 +03:00
ddadmin 843500cf52 Улучшение DQ проверки 2026-01-18 22:24:06 +03:00
ddadmin e5a9a07f01 Улучшения комментариев 2026-01-18 21:56:17 +03:00
ddadmin 761cf5dd93 статусы задач 2026-01-18 21:53:28 +03:00
ddadmin 8946df4a28 Допустимость пустых данных по некоторым таблицам 2026-01-18 21:48:47 +03:00
ddadmin ba74b521dc Исправлены критические замечания 2026-01-18 21:33:46 +03:00
ddadmin 762d539d52 Ревью решения 2026-01-18 21:22:00 +03:00
ddadmin 4718b3bdb7 Отладка потоков 2026-01-18 13:47:09 +03:00
ddadmin 9c033b39aa Генерация dds слоя по ТЗ - без тестов 2026-01-18 11:04:46 +03:00
ddadmin 15081384f4 Замечания от codex 2026-01-18 10:37:59 +03:00
ddadmin 457b47e415 план работ by glm 2026-01-17 23:53:20 +03:00
ddadmin d525cf56c4 Финальные доработки 2026-01-17 23:19:37 +03:00
ddadmin 520eacbfe2 Доработки GLM 2026-01-17 23:14:49 +03:00
ddadmin fec67dfad2 доработки плана 2026-01-17 22:41:25 +03:00
ddadmin 10a36b6dfb план витрин до stg слоя 2026-01-17 22:27:05 +03:00
ddadmin 9ec7e82f5f Полировка кода 2026-01-17 21:43:02 +03:00
ddadmin f36a98497a Проверка/рецензирование доработки 2026-01-17 19:36:02 +03:00
ddadmin 93ba59c1c9 актуализация "что делали" 2026-01-17 19:11:10 +03:00
ddadmin 875746972c Отладка 2026-01-17 15:47:59 +03:00
ddadmin 464e631eea Первая версия dag 2026-01-17 15:09:08 +03:00
ddadmin 0208e6fd71 План работ 2026-01-17 15:05:11 +03:00
ddadmin 23581b4f4a Вынесена настройка портов всех сервисов 2026-01-09 20:48:25 +03:00
ddadmin 61103847aa Полировка документации 2026-01-09 20:48:05 +03:00
ddadmin cf1438fceb Merge branch 'docs/main-docs' 2026-01-09 19:46:54 +03:00
ddadmin 2d76789050 мелкие правки 2026-01-09 19:39:42 +03:00
ddadmin 5f3e32c031 Глубокая переработка документации 2026-01-09 18:48:18 +03:00
ddadmin 16da5d0aae Улучшение документации - подчистили старые задачи 2026-01-09 18:04:29 +03:00
ddadmin db0e0259d6 Переход на LocalExecutor в AirFlow 2026-01-09 17:48:41 +03:00
ddadmin 991af3b2c3 Переход на LocalExecutor 2026-01-09 17:48:17 +03:00
ddadmin ff831b6bb2 Фикс DAG загрузки данных - построение дельты 2026-01-09 14:51:21 +03:00
ddadmin 5c3723a8c1 Merge branch 'fix/greenplum-pxf' 2026-01-09 14:03:57 +03:00
181 changed files with 23716 additions and 2630 deletions

No files matched your search

+11 -7
View File
@@ -5,11 +5,15 @@ TZ=Europe/Moscow
AIRFLOW_USER=admin AIRFLOW_USER=admin
AIRFLOW_PASSWORD=admin AIRFLOW_PASSWORD=admin
AIRFLOW__WEBSERVER__SECRET_KEY=your-super-secret-key-here-replace-with-actual-random-key AIRFLOW__WEBSERVER__SECRET_KEY=your-super-secret-key-here-replace-with-actual-random-key
# Порт Airflow UI на хосте
AIRFLOW_WEB_PORT=8080
# PostgreSQL (Airflow metadata) # PostgreSQL (Airflow metadata)
PG_USER=airflow PG_USER=airflow
PG_PASSWORD=airflow PG_PASSWORD=airflow
PG_DB=airflow PG_DB=airflow
# Порт мета-БД Airflow на хосте (нужен в основном для диагностики)
PGMETA_PORT=5433
# Bookings demo database (Postgres) # Bookings demo database (Postgres)
BOOKINGS_DB_USER=bookings BOOKINGS_DB_USER=bookings
@@ -18,9 +22,11 @@ BOOKINGS_DB_NAME=bookings
BOOKINGS_DB_PORT=5434 BOOKINGS_DB_PORT=5434
# Начальная дата модельного времени для генерации демобазы # Начальная дата модельного времени для генерации демобазы
BOOKINGS_START_DATE=2017-01-01 BOOKINGS_START_DATE=2017-01-01
# Количество дней для первой генерации (держим малым, чтобы быстрее увидеть данные) # Количество дней для генерации с нуля (make bookings-generate); для make bookings-init не используется
BOOKINGS_INIT_DAYS=1 BOOKINGS_INIT_DAYS=60
# Количество параллельных джобов генератора (по умолчанию 1; при 1 работаем синхронно без dblink) # Количество параллельных джобов генератора (1 = синхронно без dblink, 2+ = через dblink).
# jobs=1 оптимален для инкрементальной генерации (+1 день ≈ 3 мин).
# jobs>1 на WSL2 в 3× медленнее из-за lock contention на gen.events.
BOOKINGS_JOBS=1 BOOKINGS_JOBS=1
# Greenplum Configuration # Greenplum Configuration
@@ -29,6 +35,8 @@ GP_PASSWORD=gpadmin
GP_DB=gp_dwh GP_DB=gp_dwh
GP_HOST=greenplum GP_HOST=greenplum
GP_PORT=5432 GP_PORT=5432
# Порт Greenplum на хосте (подключение из DBeaver/psql с ноутбука)
GP_HOST_PORT=5435
GP_CONN_ID=greenplum_conn GP_CONN_ID=greenplum_conn
GP_USE_AIRFLOW_CONN=true GP_USE_AIRFLOW_CONN=true
@@ -37,7 +45,3 @@ GP_USE_AIRFLOW_CONN=true
PXF_SEED_OVERWRITE=0 PXF_SEED_OVERWRITE=0
# PXF_SYNC_ON_START=1 — выполнять `pxf cluster sync` при старте контейнера (дольше, но гарантирует актуальные конфиги) # PXF_SYNC_ON_START=1 — выполнять `pxf cluster sync` при старте контейнера (дольше, но гарантирует актуальные конфиги)
PXF_SYNC_ON_START=0 PXF_SYNC_ON_START=0
# CSV Pipeline
CSV_DIR=/opt/airflow/data
CSV_ROWS=1000
+53 -35
View File
@@ -1,46 +1,50 @@
# Repository Guidelines (для агентa и контрибьюторов) # Agent System Instructions & Repository Guidelines
Эта репа — учебный стенд для студентов (менти), которые только начинают с Airflow/Greenplum и Python. Пожалуйста, держите решения простыми, стабильными и хорошо объяснёнными. Этот репозиторий — учебный DWH-стенд (Airflow, Greenplum, Postgres) для начинающих Data инженеров.
## Структура проекта ## 1. Главное правило генерации кода (Баланс)
- `airflow/dags/` — DAG-файлы (например, `csv_to_greenplum.py`, `csv_to_greenplum_dq.py`). Создаваемый код должен иметь учебную ценность: быть эталоном для "боевого" применения, но без избыточного усложнения (over-engineering).
- `airflow/requirements.txt` — зависимости, которые ставятся внутри контейнеров Airflow. - **Production-ready:** Учитывайте идемпотентность DAG'ов, транзакционность, отсутствие хардкода секретов.
- `sql/` — DDL и вспомогательные SQL (например, `sql/ddl_gp.sql`). - **KISS:** Не используйте сложные ООП-паттерны, метапрограммирование или избыточные абстракции, если задачу решает стандартный оператор (например, `PostgresOperator`).
- `docker-compose.yml` — Greenplum, Airflow, Postgres (мета-БД). - **Фокус на «Почему»:** При использовании специфичных паттернов DWH (например, `delete + insert` для инкремента в Greenplum вместо `merge`) — добавляйте краткий комментарий, объясняющий этот выбор студентам.
- `Makefile` — удобные команды для локальной работы.
- `.env(.example)` — настройки окружения (реальные секреты не коммитим).
## Команды (основные) ## 2. Карта проекта (Навигация для Агента)
- `make up` — поднять весь стек (Airflow инициализируется автоматически при первом старте). - `airflow/dags/` — DAG-файлы (напр. `bookings_to_gp_stage.py`).
- `make stop` — остановить контейнеры, не трогая данные. - `sql/` — DDL и SQL-скрипты. Разделены на слои: src/ (исходные системы), stg/ (стейджинг), ods/ (операционное хранилище), dds/ (детальное хранилище), dm/ (слой витрин).
- `make down` — остановить и удалить контейнеры/сети (тома сохраняются). - *Правило ИИ:* DDL таблиц хранится строго рядом с объектом (напр. `sql/stg/bookings_ddl.sql`).
- `make clean` — полный reset: остановить и удалить контейнеры/сети и тома (все данные будут потеряны). - `docs/` — документация. Подкаталоги: `design/` (дизайн, стандарты), `reference/` (справочники), `assignment/` (задание для студента).
- `make airflow-init` — вручную переинициализировать мета-БД Airflow и создать пользователя (обычно не нужно). - `docs/design/naming_conventions.md` — единый источник нейминга служебных и SCD-полей. *Правило ИИ: всегда сверяться при генерации DDL/SQL.*
- `make logs` — логи webserver и scheduler. - Полные дизайн-документы (PRD, assignment_design, планы, архив) — в ветке `solution`.
- `make ddl-gp` — применить DDL к Greenplum. - `tests/` — pytest-тесты (smoke-тесты DAG'ов и юнит-тесты).
- `make gp-psql` — открыть `psql` в контейнере Greenplum от `gpadmin`. - `.env` — Настройки окружения (все секреты `GP_*`, `AIRFLOW_*` берем только отсюда).
Пример: `make up`, затем открыть `http://localhost:8080`. ## 3. Среда и Инструменты (Терминал)
Мы используем **uv** для управления зависимостями и **make** для автоматизации.
- *Запрещено* использовать `pip install --user`. Только `uv`.
- Если нужно запустить команду в терминале, используйте `uv run ...`
- Доступные Make-таргеты (агент может вызывать их для проверок):
- `make fmt`, `make lint` — форматирование (`black`, `isort`) и линтинг кода.
- `make test` — запуск `pytest`.
- `make up` / `make stop` / `make down` — управление контейнерами docker-compose.
- `make ddl-gp` — накат DDL на Greenplum.
## Локальное Python‑окружение ## 4. Airflow + SQL Специфика
- Используем `uv`: достаточно `uv sync` (или `make dev-sync`) — подтянет Python, создаст `.venv`, установит зависимости. - В учебных DAG'ах основная логика выносится в SQL. По умолчанию используйте `PostgresOperator` + Airflow Connections (`sql='sql/stg/bookings_load.sql'`).
- `make dev-setup` полезен при смене версии Python (выполнит `uv python install` + `uv python pin` перед `uv sync`). - Сложную работу с соединениями (например, `psycopg2` напрямую в Python) используйте только там, где реально много Python-логики и это служит учебной цели.
- Проверки: `make test`, `make lint`, `make fmt` (выполняются через `uv run`).
- Не используем `pip install --user`; если что‑то попало в user‑site — удалить `pip uninstall <package>` и проверить `pip list --user`.
- В IDE выбираем интерпретатор из `.venv`.
## Стиль кода ## 5. Стиль и Оформление
- Python: PEP 8, 4 пробела, `snake_case`; `dag_id` — `lower_snake_case`. - **Язык:** Комментарии, docstrings, тексты ошибок — на **русском** языке. Ошибки должны быть дружелюбными и подсказывать студенту, что делать дальше.
- Импорты: stdlib → third‑party → local, по одному модулю в строке. - **Код:** Переменные, функции, SQL-идентификаторы (`snake_case`) — на английском.
- SQL: ключевые слова UPPERCASE, идентификаторы `snake_case`, завершаем `;`. - **Коммиты:** Придерживайтесь Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`). Если меняется логика DAG'а, в описании PR (или коммита) указывайте, что именно изменилось.
- Форматирование: `black` (88 cols) и `isort`. Если не уверены — запустите `make fmt`.
- Язык: комментарии, docstring и документацию — на русском; имена идентификаторов — на английском.
### Структура и нейминг SQL (слои DWH) ### Структура и нейминг SQL (слои DWH)
- В каталоге `sql/` придерживаемся слоёв DWH: - В каталоге `sql/` придерживаемся слоёв DWH:
- `sql/src/` — скрипты, работающие с исходными системами (например, `bookings_generate_day_if_missing.sql`); - `sql/src/` — скрипты, работающие с исходными системами (например, `bookings_generate_day_if_missing.sql`);
- `sql/stg/` — скрипты для стейджинга (`bookings_ddl.sql`, `bookings_load.sql`, `bookings_dq.sql`); - `sql/stg/` — скрипты для стейджинга (`bookings_ddl.sql`, `bookings_load.sql`, `bookings_dq.sql`);
- в будущем можно добавить `sql/ods/`, `sql/dds/`, `sql/dm/` по мере роста стенда. - `sql/ods/` — скрипты ODS (операционное хранилище);
- `sql/dds/` — скрипты DDS (детальное хранилище, star schema);
- `sql/dm/` — скрипты DM (витрины / data marts).
- Нейминг служебных полей и SCD-полей фиксирован в `docs/design/naming_conventions.md` (единый источник для всех новых слоёв).
- Именование файлов: `{объект}_{роль}.sql`, где: - Именование файлов: `{объект}_{роль}.sql`, где:
- `объект` — логическое имя сущности (`bookings`, `orders`, и т.п.); - `объект` — логическое имя сущности (`bookings`, `orders`, и т.п.);
- `роль` — `ddl` (создание/изменение объектов), `load` (загрузка/инкремент), `dq` (проверки качества данных) и т.п. - `роль` — `ddl` (создание/изменение объектов), `load` (загрузка/инкремент), `dq` (проверки качества данных) и т.п.
@@ -54,7 +58,7 @@
## Тестирование ## Тестирование
- Тесты лежат в `tests/` (pytest). Запуск: `make test`. - Тесты лежат в `tests/` (pytest). Запуск: `make test`.
- Есть юнит‑тесты для `helpers/greenplum.py` и smoke‑тесты DAG‑структуры (`tests/test_dags_smoke.py`). - Есть smoke‑тесты DAG‑структуры (`tests/test_dags_smoke.py`).
- Smoke‑тесты DAG автоматически пропускаются, если Airflow не установлен в venv. - Smoke‑тесты DAG автоматически пропускаются, если Airflow не установлен в venv.
- Для ручного прогона стенда см. `TESTING.md` (пошаговый чек‑лист для студентов). - Для ручного прогона стенда см. `TESTING.md` (пошаговый чек‑лист для студентов).
@@ -65,11 +69,25 @@
- При изменении схемы/поведения — обновляйте `README.md` и `sql/ddl_gp.sql`. - При изменении схемы/поведения — обновляйте `README.md` и `sql/ddl_gp.sql`.
## Безопасность и конфигурация ## Безопасность и конфигурация
- Все настройки — через `.env`; креды в коде не хардкодим. Частые переменные: `GP_*`, `PG_*`, `AIRFLOW_*`, `CSV_*`. - Все настройки — через `.env`; креды в коде не хардкодим. Частые переменные: `GP_*`, `PG_*`, `AIRFLOW_*`.
- `make down` удаляет тома — предупреждайте студентов, что данные пропадут. - `make clean` удаляет тома — предупреждайте студентов, что данные пропадут.
## Для агента (особенности аудитории) ## Для агента (особенности аудитории)
- Пишите простыми словами. Добавляйте короткие комментарии к нетривиальной логике. - Пишите простыми словами. Добавляйте короткие комментарии к нетривиальной логике.
- Избегайте больших рефакторингов и сложных паттернов — студенты только начинают. - Избегайте больших рефакторингов и сложных паттернов — студенты только начинают.
- Ошибки и логи — дружелюбные и понятные (лучше с подсказкой «что сделать дальше»). - Ошибки и логи — дружелюбные и понятные (лучше с подсказкой «что сделать дальше»).
- Перед релевантными правками валидируйте локально: `make up`, затем откройте DAG в UI и/или прогоните `make test`. - Перед релевантными правками валидируйте локально: `make up`, затем откройте DAG в UI и/или прогоните `make test`.
## Agent skills
### Issue tracker
Issues живут в Gitea (`git.dementev.space/ddmitry/airflow-greenplum`), работа через `tea` CLI. См. `docs/agents/issue-tracker.md`.
### Triage labels
Стандартный набор: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. См. `docs/agents/triage-labels.md`.
### Domain docs
Один контекст: `CONTEXT.md` и `docs/adr/` в корне репо. См. `docs/agents/domain.md`.
+7
View File
@@ -0,0 +1,7 @@
@AGENTS.md
# Читабельность языка
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
Please remove all mannered prose.
+8 -3
View File
@@ -5,15 +5,20 @@ LABEL maintainer="your-email@example.com"
LABEL description="Airflow with Greenplum and Pandas dependencies" LABEL description="Airflow with Greenplum and Pandas dependencies"
LABEL version="1.0" LABEL version="1.0"
# Устанавливаем psql для ручной диагностики внутри контейнера
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Копируем requirements в стандартное расположение # Копируем requirements в стандартное расположение
COPY airflow/requirements.txt /opt/airflow/requirements.txt COPY airflow/requirements.txt /opt/airflow/requirements.txt
# Устанавливаем зависимости # Устанавливаем зависимости
USER airflow
RUN pip install --no-cache-dir -r /opt/airflow/requirements.txt \ RUN pip install --no-cache-dir -r /opt/airflow/requirements.txt \
&& pip check \ && pip check \
&& rm -rf /tmp/pip-* && rm -rf /tmp/pip-*
# Переключаемся на пользователя airflow
USER airflow
WORKDIR /opt/airflow WORKDIR /opt/airflow
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+120 -9
View File
@@ -2,14 +2,16 @@ SHELL := /bin/bash
UV := uv UV := uv
PYTHON_VERSION := 3.11 PYTHON_VERSION := 3.11
DEMODB_REPO := https://github.com/postgrespro/demodb.git DEMODB_REPO := https://github.com/postgrespro/demodb.git
DEMODB_COMMIT := d68de192850237719f09b47688d5f3fc94653ca6 DEMODB_COMMIT := 866e56f7fe54596a1d2a88f5f32f4aa3b2698121
BOOKINGS_JOBS ?= 1 BOOKINGS_JOBS ?= 1
BOOKINGS_START_DATE ?= 2017-01-01 BOOKINGS_START_DATE ?= 2017-01-01
BOOKINGS_INIT_DAYS ?= 1 BOOKINGS_INIT_DAYS ?= 60
.PHONY: up stop down clean airflow-init logs gp-psql ddl-gp \ .PHONY: up stop down clean airflow-init logs gp-psql ddl-gp \
bookings-clone-demodb bookings-init bookings-psql bookings-generate-day \ bookings-check-jobs bookings-clone-demodb bookings-init bookings-generate bookings-dump \
dev-setup dev-sync dev-lock test lint fmt clean-venv build bookings-psql bookings-generate-day \
dev-setup dev-sync dev-lock test lint fmt clean-venv build e2e-smoke e2e-etl
BOOKINGS_SEED := bookings/seed/demo.sql.xz
SHELL := /bin/bash SHELL := /bin/bash
up: up:
@@ -36,6 +38,9 @@ logs:
gp-psql: gp-psql:
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -p 5432 -d gp_dwh'" docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -p 5432 -d gp_dwh'"
dwh-truncate:
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c 'cd /sql && /usr/local/greenplum-db/bin/psql -d gp_dwh -f truncate_gp.sql'"
ddl-gp: ddl-gp:
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c 'cd /sql && /usr/local/greenplum-db/bin/psql -d gp_dwh -f ddl_gp.sql'" docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c 'cd /sql && /usr/local/greenplum-db/bin/psql -d gp_dwh -f ddl_gp.sql'"
@@ -49,19 +54,35 @@ bookings-clone-demodb:
# Патчим generate/continue: при jobs=1 запускаем process_queue синхронно, без dblink # Патчим generate/continue: при jobs=1 запускаем process_queue синхронно, без dblink
if ! grep -q "Job 1 (local): ok" bookings/demodb/engine.sql; then \ if ! grep -q "Job 1 (local): ok" bookings/demodb/engine.sql; then \
if ! patch -d bookings/demodb -p1 --forward < bookings/patches/engine_jobs1_sync.patch; then \ if ! patch -d bookings/demodb -p1 --forward < bookings/patches/engine_jobs1_sync.patch; then \
echo "Не удалось применить патч engine_jobs1_sync.patch. Удалите bookings/demodb и повторите make bookings-init." >&2; \ echo "Не удалось применить патч engine_jobs1_sync.patch. Удалите bookings/demodb и повторите make bookings-generate." >&2; \
exit 1; \ exit 1; \
fi; \ fi; \
fi fi
# Делаем установку идемпотентной и принудительной: DROP DATABASE IF EXISTS demo WITH (FORCE) # Делаем установку идемпотентной и принудительной: DROP DATABASE IF EXISTS demo WITH (FORCE)
if ! grep -q "DROP DATABASE IF EXISTS demo WITH (FORCE);" bookings/demodb/install.sql; then \ if ! grep -q "DROP DATABASE IF EXISTS demo WITH (FORCE);" bookings/demodb/install.sql; then \
if ! patch -d bookings/demodb -p1 --forward < bookings/patches/install_drop_if_exists.patch; then \ if ! patch -d bookings/demodb -p1 --forward < bookings/patches/install_drop_if_exists.patch; then \
echo "Не удалось применить патч install_drop_if_exists.patch. Удалите bookings/demodb и повторите make bookings-init." >&2; \ echo "Не удалось применить патч install_drop_if_exists.patch. Удалите bookings/demodb и повторите make bookings-generate." >&2; \
exit 1; \
fi; \
fi
# Убираем хардкод gen.connstr без credentials — иначе VACUUM падает
if grep -q "gen.connstr = 'dbname=demo'" bookings/demodb/install.sql; then \
if ! patch -d bookings/demodb -p1 --forward < bookings/patches/install_connstr_no_hardcode.patch; then \
echo "Не удалось применить патч install_connstr_no_hardcode.patch. Удалите bookings/demodb и повторите make bookings-generate." >&2; \
exit 1; \ exit 1; \
fi; \ fi; \
fi fi
bookings-init: bookings-clone-demodb bookings-check-jobs:
@case "$(BOOKINGS_JOBS)" in \
''|*[!0-9]*) echo "BOOKINGS_JOBS должен быть целым числом >= 1. Текущее значение: '$(BOOKINGS_JOBS)'." >&2; exit 1;; \
esac; \
if [ "$(BOOKINGS_JOBS)" -lt 1 ]; then \
echo "BOOKINGS_JOBS должен быть >= 1. Текущее значение: $(BOOKINGS_JOBS)." >&2; \
exit 1; \
fi
bookings-generate: bookings-check-jobs bookings-clone-demodb
docker compose -f docker-compose.yml up -d bookings-db docker compose -f docker-compose.yml up -d bookings-db
docker compose -f docker-compose.yml exec bookings-db bash -lc '\ docker compose -f docker-compose.yml exec bookings-db bash -lc '\
until PGPASSWORD="$$POSTGRES_PASSWORD" pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -h localhost; do \ until PGPASSWORD="$$POSTGRES_PASSWORD" pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -h localhost; do \
@@ -80,15 +101,101 @@ bookings-init: bookings-clone-demodb
ALTER DATABASE demo SET bookings.init_days = '\''$(BOOKINGS_INIT_DAYS)'\''; \ ALTER DATABASE demo SET bookings.init_days = '\''$(BOOKINGS_INIT_DAYS)'\''; \
ALTER DATABASE demo SET bookings.jobs = '\''$(BOOKINGS_JOBS)'\'';" \ ALTER DATABASE demo SET bookings.jobs = '\''$(BOOKINGS_JOBS)'\'';" \
' '
# Генерируем первый день данных, чтобы база не оставалась пустой # Генерируем данные за $(BOOKINGS_INIT_DAYS) дней
docker compose -f docker-compose.yml exec bookings-db bash -lc '\ docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -f /bookings/generate_next_day.sql \ PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -f /bookings/generate_next_day.sql \
' '
# Валидация: ключевые таблицы не должны быть пустыми
@docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -tAc " \
SELECT format(E'\''%-25s %s'\'', t, cnt) \
FROM ( \
SELECT '\''bookings.bookings'\'' AS t, count(*) AS cnt FROM bookings.bookings \
UNION ALL \
SELECT '\''bookings.tickets'\'', count(*) FROM bookings.tickets \
UNION ALL \
SELECT '\''bookings.flights'\'', count(*) FROM bookings.flights \
UNION ALL \
SELECT '\''bookings.boarding_passes'\'', count(*) FROM bookings.boarding_passes \
) s ORDER BY t; \
" \
'
@docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d demo -tAc " \
DO \$$\$$ \
DECLARE v_cnt bigint; \
BEGIN \
SELECT count(*) INTO v_cnt FROM bookings.bookings; \
IF v_cnt = 0 THEN \
RAISE EXCEPTION '\''bookings.bookings пуста — генерация не сработала. Проверьте логи: SELECT * FROM gen.log ORDER BY at DESC LIMIT 10;'\''; \
END IF; \
SELECT count(*) INTO v_cnt FROM bookings.flights; \
IF v_cnt = 0 THEN \
RAISE EXCEPTION '\''bookings.flights пуста — попробуйте увеличить BOOKINGS_INIT_DAYS.'\''; \
END IF; \
END \$$\$$; \
" \
'
bookings-init: bookings-check-jobs
@if [ ! -f $(BOOKINGS_SEED) ]; then \
echo "Файл $(BOOKINGS_SEED) не найден. Используйте make bookings-generate для генерации с нуля." >&2; \
exit 1; \
fi
docker compose -f docker-compose.yml up -d bookings-db
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
until PGPASSWORD="$$POSTGRES_PASSWORD" pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -h localhost; do \
echo "Waiting for bookings-db to become ready..."; \
sleep 1; \
done \
'
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" \
-c "DROP DATABASE IF EXISTS demo WITH (FORCE);" \
'
@echo "Восстановление из дампа (~42 MB)..."
xz -dc $(BOOKINGS_SEED) | docker compose -f docker-compose.yml exec -T bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" \
'
# Применяем настройки из текущего окружения поверх дампа
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
CONNSTR="dbname=demo user=$$POSTGRES_USER password=$$POSTGRES_PASSWORD"; \
PGPASSWORD="$$POSTGRES_PASSWORD" psql -v ON_ERROR_STOP=1 -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -c "\
ALTER DATABASE demo SET gen.connstr='\''$$CONNSTR'\''; \
ALTER DATABASE demo SET bookings.start_date = '\''$(BOOKINGS_START_DATE)'\''; \
ALTER DATABASE demo SET bookings.init_days = '\''$(BOOKINGS_INIT_DAYS)'\''; \
ALTER DATABASE demo SET bookings.jobs = '\''$(BOOKINGS_JOBS)'\'';" \
'
@echo "Восстановление завершено. Проверяем данные..."
@docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" psql -U "$$POSTGRES_USER" -d demo -tAc " \
SELECT format(E'\''%-25s %s'\'', t, cnt) \
FROM ( \
SELECT '\''bookings.bookings'\'' AS t, count(*) AS cnt FROM bookings.bookings \
UNION ALL \
SELECT '\''bookings.tickets'\'', count(*) FROM bookings.tickets \
UNION ALL \
SELECT '\''bookings.flights'\'', count(*) FROM bookings.flights \
UNION ALL \
SELECT '\''bookings.boarding_passes'\'', count(*) FROM bookings.boarding_passes \
) s ORDER BY t; \
" \
'
bookings-dump:
@echo "Создание дампа demo → $(BOOKINGS_SEED)..."
mkdir -p bookings/seed
docker compose -f docker-compose.yml exec bookings-db bash -lc '\
PGPASSWORD="$$POSTGRES_PASSWORD" pg_dump -U "$$POSTGRES_USER" -d demo --format=plain --create \
' > /tmp/demo_dump_$$$$.sql
xz -9 < /tmp/demo_dump_$$$$.sql > $(BOOKINGS_SEED)
rm -f /tmp/demo_dump_$$$$.sql
@echo "Дамп сохранён: $$(ls -lh $(BOOKINGS_SEED) | awk '{print $$5}')"
bookings-psql: bookings-psql:
docker compose -f docker-compose.yml exec bookings-db bash -lc 'PGPASSWORD="$$POSTGRES_PASSWORD" psql -U "$$POSTGRES_USER" -d demo' docker compose -f docker-compose.yml exec bookings-db bash -lc 'PGPASSWORD="$$POSTGRES_PASSWORD" psql -U "$$POSTGRES_USER" -d demo'
bookings-generate-day: bookings-generate-day: bookings-check-jobs
docker compose -f docker-compose.yml up -d bookings-db docker compose -f docker-compose.yml up -d bookings-db
docker compose -f docker-compose.yml exec bookings-db bash -lc '\ docker compose -f docker-compose.yml exec bookings-db bash -lc '\
until PGPASSWORD="$$POSTGRES_PASSWORD" pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -h localhost; do \ until PGPASSWORD="$$POSTGRES_PASSWORD" pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" -h localhost; do \
@@ -127,3 +234,7 @@ clean-venv:
e2e-smoke: e2e-smoke:
./scripts/e2e_smoke.sh ./scripts/e2e_smoke.sh
e2e-etl:
./scripts/e2e_etl.sh
+130 -424
View File
@@ -1,480 +1,186 @@
# airflow-dwh-gp-lab # airflow-dwh-gp-lab
Учебный стенд: ETL из Postgres в Greenplum с оркестрацией в Airflow. > Этот стенд можно использовать самостоятельно, но он также является курсовой работой
> плана обучения [Data Engineering Roadmap](https://github.com/dementev-dev/de-roadmap).
> Нужна помощь? Обратитесь к автору материалов — ментору [@dementev_dev](https://t.me/dementev_dev).
Добро пожаловать в учебный стенд для изучения основ Data Engineering! Этот проект поможет вам освоить ключевые инструменты современных data pipeline: **Airflow** для оркестрации, **pandas/CSV** для подготовки данных, **Postgres** с демобазой **bookings** как источник и **Greenplum** как аналитическую базу данных. Учебный стенд для лабораторных по Data Engineering: **Airflow** оркестрирует загрузку данных из демо‑БД
**bookings** (Postgres) в **Greenplum**.
Если вы проходите стенд как серию лабораторных, смотрите также файл с заданиями: `educational-tasks.md`. ## Зачем этот стенд
## 🎯 Что вы узнаете Основная цель стенда — дать вам удобное место для лабораторных работ и будущей курсовой:
вы построите небольшое аналитическое хранилище данных (DWH) на базе **Greenplum** и закрепите навыки:
- Как настроить локальный стек данных с помощью Docker - моделирования данных (слои DWH, модели, витрины);
- Как Airflow управляет workflow и координирует задачи - построения ETL/ELT;
- Как генерировать датасеты через pandas и сохранять их в CSV - работы с Airflow и Greenplum.
- Как загружать данные в Greenplum пакетами и избегать дублей
- Как проверять качество данных в автоматизированных pipeline
- Основы проектирования ETL/ELT процессов
- Как работать с демо-БД bookings в Postgres как источником для будущего DWH
## 👩‍🎓 Для студентов (10‑минутный чек‑лист) В курсовой у нас один источник данных — демо‑БД **bookings**. В стенде уже есть готовые учебные примеры
загрузки **bookings → STG → ODS → DDS → DM** в Greenplum, чтобы вы могли сфокусироваться
на DWH‑части и не тратить время на инфраструктуру.
- Установите Docker Desktop и Git. ## Что внутри
- Скопируйте настройки: `cp .env.example .env`.
- Поднимите стенд: `make up` (или `docker compose up -d` — сервис `airflow-init` запустится автоматически при первом старте).
- Откройте UI: http://localhost:8080 (admin/admin).
- Включите и запустите DAG `csv_to_greenplum`. Дождитесь Success.
- Проверьте данные: `make gp-psql` → `SELECT COUNT(*) FROM public.orders;`.
- Дополнительно: запустите `csv_to_greenplum_dq` — все проверки должны быть зелёные.
Если что‑то не работает — смотрите «Типичные проблемы» и «Быстрый reset» ниже. - **Airflow** (UI: http://localhost:8080, по умолчанию; логин/пароль: admin/admin)
- **Greenplum** (single‑node для обучения; внешний порт по умолчанию `5435`)
- **bookings-db** (Postgres с демо‑БД `demo`; внешний порт по умолчанию `5434`)
- **PXF** как “транспорт” между Postgres и Greenplum (уже настроен в образе)
## 🚀 Быстрый старт (для новичков) ## Требования
### Шаг 1: Подготовка окружения Стенд запускается в Docker и поднимает несколько сервисов (Airflow + Greenplum + 2×Postgres),
поэтому важны ресурсы и настройки Docker.
**Требования:** | Ресурс | Минимум | Рекомендуем |
- Docker Desktop (Windows/Mac) или Docker Engine 24+ (Linux) |---|---:|---:|
- Git для клонирования репозитория | CPU | 2 ядра | 4+ ядер |
| RAM | 8 GB | 16 GB |
| Диск | 25–30 GB свободного места | 40+ GB (SSD) |
> 💡 **Совет:** Если у вас Windows, рекомендуем использовать WSL (Windows Subsystem for Linux) для лучшей совместимости. Работает на **Linux**, а на **Windows** — при использовании **WSL2**.
### Шаг 2: Настройка проекта Потребуется `make`, Docker (`docker`) и Docker Compose (`docker compose`) или Docker Desktop.
Для первого запуска нужен доступ в интернет (скачать образы и зависимости).
Порты `8080`, `5433`, `5434`, `5435` должны быть свободны (или измените их в `.env`).
Порты настраиваются в `.env`: `AIRFLOW_WEB_PORT`, `PGMETA_PORT`, `BOOKINGS_DB_PORT`, `GP_HOST_PORT`.
Про PXF и технические детали стенда: [docs/stack.md](docs/stack.md).
## Быстрый старт (основной сценарий: bookings → STG → ODS → DDS → DM)
1) Скопируйте настройки:
```bash ```bash
# Скопируйте файл настроек
cp .env.example .env cp .env.example .env
# Запустите стек (это может занять 2-3 минуты при первом запуске)
docker compose up -d
``` ```
### Шаг 3: Первый запуск pipeline 2) Поднимите стенд:
1. Откройте Airflow UI: **http://localhost:8080** (логин/пароль: admin/admin)
2. Найдите DAG с названием **csv_to_greenplum**
3. Нажмите на переключатель слева от названия DAG, чтобы включить его
4. Нажмите кнопку **Trigger** (значок воспроизведения ▶️)
🎉 **Поздравляем!** Вы только что запустили свой первый data pipeline:
- Система сгенерировала 1000 тестовых заказов при помощи pandas
- Датасет сохранился в CSV-файл в каталоге `./data`
- Airflow загрузил данные из CSV в Greenplum без дублей по `order_id`
### Шаг 4: Проверка результатов
**Проверка вручную:**
```bash
# Подключитесь к Greenplum и проверьте данные
docker compose exec greenplum bash -c "su - gpadmin -c 'psql -p 5432 -d gp_dwh'"
# Внутри psql выполните:
\dt # Показать таблицы
SELECT count(*) FROM public.orders; # Посчитать записи
# Посмотреть несколько строк
SELECT * FROM public.orders LIMIT 5;
```
CSV-файлы после выполнения DAG остаются в директории `./data`. Их можно открыть любым редактором или изучить через pandas.
### Быстрый reset
Если после изменений что‑то «сломалось»:
```bash ```bash
make down # Остановить и удалить контейнеры/сети (volumes сохраняются)
make up make up
# если make не установлен: docker compose up -d
``` ```
Если проблема связана с «грязной» остановкой и данными в томах (например, Greenplum не стартует), 3) Инициализируйте демо‑БД bookings:
используйте полный reset: `make clean && make up` (данные в Docker-томах будут потеряны).
---
## 🛠️ Подробная настройка (для уверенных пользователей)
> Если вы впервые запускаете стенд, этот раздел можно пролистать и вернуться к нему позже.
### Установка Make (опционально)
Для удобства работы с проектом рекомендуем установить `make`:
- **Linux (Debian/Ubuntu):** `sudo apt install -y make`
- **macOS:** `brew install make`
- **Windows:**
- WSL: `sudo apt install -y make`
- Chocolatey: `choco install make`
- Scoop: `scoop install make`
С `make` команды становятся короче:
```bash
make up # Запуск стека (включая airflow-init при первом старте)
make logs # Просмотр логов
make gp-psql # Подключение к Greenplum
```
### Настройка подключения к Greenplum в Airflow
По умолчанию готовые DAG используют Airflow Connections. В docker-compose они
заводятся автоматически через переменные окружения:
- `AIRFLOW_CONN_GREENPLUM_CONN` — подключение к Greenplum с `conn_id=greenplum_conn`;
- `AIRFLOW_CONN_BOOKINGS_DB` — подключение к демо-БД bookings с `conn_id=bookings_db`.
Такие подключения подхватываются из окружения и могут не отображаться в UI,
но для DAG это нормально — `PostgresOperator` найдёт их по `conn_id`.
При желании вы можете создать или отредактировать подключение вручную в UI:
1. Airflow UI → **Admin → Connections → Add a new record**
2. Заполните поля:
- **Conn Id:** `greenplum_conn`
- **Conn Type:** `Postgres`
- **Host:** `greenplum`
- **Schema:** `gp_dwh`
- **Login:** `gpadmin`
- **Password:** `gpadmin`
- **Port:** `5432`
---
### Greenplum + PXF: свой образ
Чтобы избежать проблем с правами и нестабильных запусков, Greenplum собирается
из собственного `Dockerfile.greenplum`. В образ вшиты:
- JDBC-драйвер PostgreSQL;
- конфигурация PXF-сервера `bookings-db`;
- ensure-скрипт, который при каждом старте контейнера докладывает файлы в `PXF_BASE`.
Дополнительно при старте контейнера:
- базовые конфиги PXF копируются в `PXF_BASE/conf` (если их ещё нет);
- создаются каталоги `PXF_BASE/run` и `PXF_BASE/logs`;
- `CREATE EXTENSION pxf` выполняется автоматически, когда Greenplum становится доступен (с ретраями).
Healthcheck сервиса `greenplum` учитывает не только готовность Greenplum, но и запуск PXF,
а также наличие `extension pxf` — это нужно, чтобы Airflow не стартовал раньше PXF.
Сборка и запуск:
- `make build` — собрать образ (явно);
- `make up` — поднимет стек и соберёт образ, если он ещё не создан.
Обновление PXF-конфигов:
- изменили файлы в `pxf/` → выполните `make build` и перезапустите контейнер;
- для принудительной перезаписи файлов в `PXF_BASE` используйте `PXF_SEED_OVERWRITE=1`;
- для принудительного `pxf cluster sync` при старте используйте `PXF_SYNC_ON_START=1`.
Проверка PXF:
- статус: `docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/pxf/bin/pxf cluster status'"`;
- логи: `greenplum_data:/data/pxf/logs` (внутри контейнера — `/data/pxf/logs`).
---
### Локальное окружение разработчика
Локальным окружением управляет [uv](https://docs.astral.sh/uv/) — он скачивает нужный Python и создаёт `.venv` на основе `pyproject.toml` / `uv.lock`.
```bash ```bash
uv sync make bookings-init # быстрое восстановление из seed-дампа (~18 сек), рекомендуется
# make bookings-generate # альтернатива: полная генерация с нуля (занимает часы)
``` ```
`uv sync` сам подтянет версию Python из `.python-version`/`pyproject.toml`, создаст `.venv` и установит зависимости. Для тех же действий можно использовать `make dev-sync`. Цель `make dev-setup` (или вручную `uv python install` + `uv python pin`) нужна только когда вы меняете версию Python или прогреваете кэш. 4) Подготовьте объекты DWH в Greenplum (выберите один вариант):
> Если требуется «классическое» активированное окружение, после `uv sync` выполните `.\.venv\Scripts\Activate.ps1` в PowerShell или `source .venv/bin/activate` в Unix-терминале. - Учебный вариант: в Airflow UI запустите DAG'и по порядку — `bookings_stg_ddl`, `bookings_ods_ddl`, `bookings_dds_ddl`, `bookings_dm_ddl`;
- Технический шорткат: `make ddl-gp` (применяет DDL для STG, ODS, DDS и DM разом).
Проверки и форматирование выполняем через uv: 5) Запустите DAG'и загрузки данных по порядку:
- `bookings_to_gp_stage` — загрузка в STG
- `bookings_to_gp_ods` — загрузка в ODS
- `bookings_to_gp_dds` — загрузка в DDS
- `bookings_to_gp_dm` — загрузка в DM (витрины)
6) Проверьте результат в Greenplum:
```bash ```bash
make test # uv run pytest -q make gp-psql
make lint # black/isort в режиме проверки -- внутри psql:
make fmt # автоформатирование black + isort SELECT COUNT(*) FROM stg.bookings;
SELECT COUNT(*) FROM ods.bookings;
SELECT COUNT(*) FROM dds.dim_airports;
SELECT COUNT(*) FROM dds.fact_flight_sales;
SELECT COUNT(*) FROM dm.sales_report;
-- Остальные DDS/DM-таблицы пока пусты — вы реализуете их в задании.
``` ```
#### Быстрый старт с uv Порядок повторных запусков и переходов между слоями описан в
[руководстве по запуску DAG](docs/dag_execution_order.md).
7) Разберитесь в данных и приступайте к заданию:
Стенд работает, данные загружены. Откройте
[руководство о структуре DWH](docs/design/db_schema.md): на примере поездки
оно проведет от билетов и сегментов к факту и витрине. Рядом держите
[интерактивную карту](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).
## DAG-и в стенде
Основные (для потока bookings → DWH):
- `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` — заменяет снимки справочников и обновляет транзакционные таблицы ODS из STG; выполняет DQ-проверки.
- `bookings_dds_ddl` — создаёт/обновляет DDS-таблицы (`dim_*`, `fact_flight_sales`) по домену bookings.
- `bookings_to_gp_dds` — загружает данные из ODS в DDS (SCD1/SCD2 + факт) и выполняет DQ‑проверки.
- `bookings_dm_ddl` — создаёт/обновляет DM-витрины (sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview).
- `bookings_to_gp_dm` — загружает данные из DDS в DM (агрегированные витрины).
## Полезные команды
```bash ```bash
uv sync make up # поднять стек
uv run pytest -q make logs # логи airflow-webserver и airflow-scheduler
uv run black --check airflow tests make gp-psql # psql в Greenplum
make bookings-psql # psql в демо-БД bookings (Postgres)
make ddl-gp # применить DDL STG+ODS+DDS+DM к Greenplum вручную (вместо DDL-DAG)
make down # остановить и удалить контейнеры/сети (volumes сохраняются)
make clean # полный reset: удалить контейнеры/сети и volumes (данные будут потеряны)
``` ```
> Не устанавливайте пакеты напрямую через `pip install --user ...`. Если что-то уже попало в user-site, удалите `pip uninstall <package>` и проверьте `pip list --user`. ## Подключение через DBeaver (опционально)
---
Перед подключением убедитесь, что стенд поднят (`make up`).
## 📋 Что входит в стенд
### Основные компоненты
- **Greenplum** — аналитическая база данных для хранения и анализа данных
- **Airflow** — оркестратор workflow и задач
- **Postgres** — база метаданных для Airflow
- **Postgres (bookings)** — отдельная демо-БД bookings (источник данных для будущего DWH в Greenplum)
- **pandas** — библиотека для генерации и анализа данных в формате CSV
### Готовые DAG (workflow)
- **orders_base_ddl** — создаёт базовую таблицу `public.orders` для CSV‑пайплайна
- **bookings_stg_ddl** — готовит схему `stg` и таблицы `stg.bookings_ext` / `stg.bookings`
- **csv_to_greenplum** — базовый pipeline: pandas → CSV → Greenplum
- **bookings_to_gp_stage** — пример загрузки из демо‑БД bookings в слой STG
- **csv_to_greenplum_dq** — проверки качества данных (наличие таблицы, схема, дубликаты)
> Учебный путь — триггернуть DDL‑DAG: для CSV `orders_base_ddl`, для bookings `bookings_stg_ddl`. Технический шорткат для быстрой инициализации — `make ddl-gp` (он не вызывается автоматически при старте контейнеров).
### Полезные команды
```bash
# Основные команды
make up # Запустить весь стенд (Airflow инициализируется автоматически при первом старте)
make stop # Остановить контейнеры, не трогая данные
make down # Остановить и удалить контейнеры/сети (volumes сохраняются)
make clean # Полный reset: остановить и удалить контейнеры/сети и тома (данные будут потеряны)
make airflow-init # Ручной запуск инициализации Airflow (обычно не нужен)
make ddl-gp # Применить DDL к Greenplum вручную
make gp-psql # Подключиться к Greenplum через psql
make bookings-init # Установить демобазу bookings в Postgres (по умолчанию генерирует 1 день)
make bookings-generate-day # Добавить ещё один день данных в bookings (можно вызвать несколько раз)
make bookings-psql # Подключиться к демобазе bookings (БД demo)
# Проверка данных
make logs # Следить за логами Airflow
# Логи задач Airflow сохраняются в Docker-томе `airflow_logs`
# и переживают `docker compose down`/`up` (удаляются при `docker compose down -v` / `make clean`).
# Контроль генерации bookings
docker compose -f docker-compose.yml exec bookings-db bash -lc 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U "$POSTGRES_USER" -d demo -c "SELECT busy();"'
# busy() = t — генерация ещё идёт; f — завершена. При необходимости можно вызвать CALL abort(); и запустить генерацию заново.
```
### Генерация следующего дня в bookings
- При `make bookings-init` автоматически генерируется `BOOKINGS_INIT_DAYS` суток, начиная с даты `BOOKINGS_START_DATE` (по умолчанию один день с `2017-01-01`).
- Дальше каждый вызов `make bookings-generate-day` или генерации через DAG добавляет ровно **один** следующий день после `max(book_date)` в `bookings.bookings` — генератор сам смотрит последнюю дату.
- Рекомендуемый учебный сценарий для DAG `bookings_to_gp_stage`: запускать DAG по одному дню вперёд, выбирая в форме Trigger логическую дату `Execution Date (ds)`, совпадающую с тем днём, который вы хотите загрузить (например, `2017-01-01`, затем `2017-01-02` и т.д.).
- Быстрее всего: `make bookings-generate-day` — читает GUC и сам вызывает `continue`.
- Вручную из psql/DBeaver (подзапросы в аргументах CALL не работают, поэтому через DO-блок):
```sql
DO $$
DECLARE
v_next_day timestamptz;
BEGIN
SELECT date_trunc('day', max(book_date)) + interval '1 day'
INTO v_next_day
FROM bookings.bookings;
CALL continue(v_next_day); -- или CALL continue(v_next_day, 4) для параллельности
END $$;
```
- Не вызывайте `CALL generate(...)` поверх существующих данных: она делает TRUNCATE и создаёт демобазу заново.
---
## ⚙️ Настройка через переменные окружения
Все настройки находятся в файле `.env`. Основные параметры:
### Greenplum ### Greenplum
- `GP_USER` — пользователь (по умолчанию: gpadmin)
- `GP_PASSWORD` — пароль (по умолчанию: gpadmin)
- `GP_DB` — база данных (по умолчанию: gp_dwh)
- `GP_PORT` — порт Greenplum внутри Docker-сети (по умолчанию: 5432, менять обычно не нужно; внешний порт на хосте для подключения клиентов — 5435).
### Демо-БД bookings (Postgres) - `Host`: `localhost`
- `BOOKINGS_DB_USER` — пользователь Postgres для демобазы (по умолчанию: bookings) - `Port`: значение `GP_HOST_PORT` из `.env` (по умолчанию `5435`)
- `BOOKINGS_DB_PASSWORD` — пароль пользователя (по умолчанию: bookings) - `Database`: значение `GP_DB` из `.env` (по умолчанию `gp_dwh`)
- `BOOKINGS_DB_NAME` — база данных, из которой запускается установка генератора (по умолчанию: bookings) - `Username`: значение `GP_USER` (по умолчанию `gpadmin`)
- `BOOKINGS_DB_PORT` — внешний порт для подключения к контейнеру bookings-db (по умолчанию: 5434) - `Password`: значение `GP_PASSWORD` (по умолчанию `gpadmin`)
- `BOOKINGS_START_DATE` — начальная дата модельного времени (по умолчанию: 2017-01-01)
- `BOOKINGS_INIT_DAYS` — сколько дней сгенерировать при первой инициализации (по умолчанию: 1, чтобы увидеть данные без долгого ожидания; можно менять при вызове `BOOKINGS_INIT_DAYS=... make bookings-init`)
- `BOOKINGS_JOBS` — число параллельных джобов генератора bookings (по умолчанию: 1; при 1 генерация идёт синхронно без dblink)
### CSV pipeline ### bookings-db (Postgres, демо-БД `demo`)
- `CSV_DIR` — путь к каталогу с CSV внутри контейнеров Airflow (по умолчанию: `/opt/airflow/data`)
- `CSV_ROWS` — количество строк, генерируемых DAG (по умолчанию: 1000)
### Airflow - `Host`: `localhost`
- `GP_CONN_ID` — ID подключения (по умолчанию: greenplum_conn) - `Port`: значение `BOOKINGS_DB_PORT` из `.env` (по умолчанию `5434`)
- `Database`: `demo`
- `Username`: значение `BOOKINGS_DB_USER` (по умолчанию `bookings`)
- `Password`: значение `BOOKINGS_DB_PASSWORD` (по умолчанию `bookings`)
--- ## Документация
## 🔍 Продвинутые темы - [Структура DWH: от бронирования до витрины](docs/design/db_schema.md)
- [От карты к SQL загрузки](docs/design/reading_the_pipeline.md)
- [Учебные задания: порядок работы и проверка](docs/assignment/README.md)
- [Интерактивная карта таблиц и связей](docs/design/architecture-map.html)
- [Дополнительные заметки и технические детали](docs/README.md)
- [Детали по STG DAG](docs/bookings_to_gp_stage.md)
- [Детали по ODS DAG](docs/bookings_to_gp_ods.md)
- [Детали по DDS DAG](docs/bookings_to_gp_dds.md)
- [Детали по DM DAG](docs/bookings_to_gp_dm.md)
> Этот раздел не обязателен при первом прохождении стенда; к нему удобно вернуться, когда базовый CSV‑pipeline уже понятен. Для работы над репозиторием: [проверки разработки и стенда](TESTING.md).
### Архитектура pipeline ## Типичные проблемы и решения
**Поток данных в DAG `csv_to_greenplum`:**
1. `create_orders_table` — создаёт таблицу `public.orders` в Greenplum
2. `generate_csv` — генерирует датасет при помощи pandas и сохраняет CSV в `CSV_DIR`
3. `preview_csv` — выводит предпросмотр и статистику по данным
4. `load_csv_to_greenplum` — загружает CSV во временную таблицу и переносит новые строки в `public.orders`
> 💡 **Безопасность повторного запуска:** Pipeline защищен от дубликатов, поэтому его можно запускать многократно.
### Проверка качества данных
Запустите DAG `csv_to_greenplum_dq` для автоматической проверки:
- Наличие таблицы в базе
- Соответствие схемы ожидаемой структуре
- Объем загруженных данных
- Отсутствие дубликатов записей
### Пример DAG с SQL-скриптами (bookings → stg)
> Если вы ещё не дошли до части про bookings и слои DWH, этот подраздел можно пропустить на первом чтении.
В репозитории есть учебный DAG `bookings_to_gp_stage`, который показывает «канонический» способ работы с SQL в Airflow:
- подключение к БД через Airflow Connections (`bookings_db`, `greenplum_conn`);
- бизнес-логика инкрементальной загрузки и DQ вынесена в SQL-файлы в каталоге `sql/`:
- `sql/src/bookings_generate_day_if_missing.sql` — генерация следующего учебного дня в демо-БД bookings (или нескольких стартовых дней, если база пуста);
- `sql/stg/bookings_ddl.sql` — DDL для схемы `stg` и таблиц `stg.bookings_ext` / `stg.bookings`;
- `sql/stg/bookings_load.sql` — загрузка инкремента из `stg.bookings_ext` в `stg.bookings` на основе «хвоста» после предыдущих батчей;
- `sql/stg/bookings_dq.sql` — проверка количества строк между источником и stg за то же окно.
Фрагмент DAG:
```python
load_bookings_to_stg = PostgresOperator(
task_id="load_bookings_to_stg",
postgres_conn_id="greenplum_conn",
sql="stg/bookings_load.sql",
params={"batch_id": "{{ ds_nodash }}"},
)
```
Такой подход помогает держать оркестрацию (DAG) и SQL-логику в отдельных файлах и легче сравнивать её с теорией из статьи про моделирование DWH.
### Ограничения учебного стенда
- **Greenplum** запущен в single-node режиме (для обучения)
- В продакшене Greenplum обычно разворачивают кластером на нескольких серверах
- Используется Greenplum 6 (широко доступная версия), хотя Greenplum 7 предлагает больше возможностей
---
## 🧩 Подключение к базам через DBeaver
> Необязательный раздел: нужен только если вы хотите смотреть данные через DBeaver. Для базовых заданий достаточно `make gp-psql`.
Ниже — краткая инструкция, как подключиться к Greenplum и демо-БД bookings из DBeaver. Перед этим убедитесь, что стенд запущен:
- `cp .env.example .env` (если ещё не делали)
- `make up`
- `make bookings-init`
- `make ddl-gp`
### Greenplum (аналитическая БД)
1. Откройте DBeaver → **New Database Connection**.
2. Выберите драйвер **PostgreSQL** (или **Greenplum**, если он есть в вашей версии DBeaver).
3. На вкладке **Main** заполните поля (по умолчанию):
- `Host`: `localhost`
- `Port`: `5435` (внешний порт Greenplum на хосте)
- `Database`: значение `GP_DB` (по умолчанию `gp_dwh`)
- `Username`: значение `GP_USER` (по умолчанию `gpadmin`)
- `Password`: значение `GP_PASSWORD` (по умолчанию `gpadmin`)
4. Нажмите **Test Connection** → **OK**, затем **Finish**.
После подключения:
- Основные таблицы лаба — в схеме `public` базы `gp_dwh` (например, `public.orders`).
- После настройки PXF и выполнения `make ddl-gp` станет доступна внешняя таблица `public.ext_bookings_bookings` — чтение из демо-БД bookings через PXF.
### bookings-db (демо-БД источника)
Для работы с исходными данными (демо-БД `demo`) достаточно стандартного PostgreSQL-подключения.
1. Откройте DBeaver → **New Database Connection** → драйвер **PostgreSQL**.
2. На вкладке **Main** укажите (значения по умолчанию из `.env.example`):
- `Host`: `localhost`
- `Port`: значение `BOOKINGS_DB_PORT` из `.env` (по умолчанию `5434`)
- `Database`: `demo`
- `Username`: `BOOKINGS_DB_USER` (по умолчанию `bookings`)
- `Password`: `BOOKINGS_DB_PASSWORD` (по умолчанию `bookings`)
3. Нажмите **Test Connection** → **OK**, затем **Finish**.
После подключения:
- Основные таблицы находятся в схеме `bookings` базы `demo` (например, `bookings.bookings`, `bookings.tickets`, `bookings.flights` и т.д.).
- Можно сравнивать данные:
- между `bookings.bookings` в Postgres и `public.ext_bookings_bookings` в Greenplum;
- между временем (`book_date`) в UTC в `demo` и локальным временем в Greenplum (учитывая `TZ=Europe/Moscow`).
> Если вы меняли порты или креды в `.env`, не забудьте подставить те же значения в настройках соединений в DBeaver.
---
## 🆘 Типичные проблемы и решения
| Проблема | Решение | | Проблема | Решение |
|----------|---------| |----------|---------|
| Airflow UI не открывается | Дождитесь сообщения `Listening at: http://0.0.0.0:8080` в логах (`make logs`) | | Airflow UI не открывается | Дождитесь сообщения `Listening at: http://0.0.0.0:8080` в логах (`make logs`) |
| `database "demo" does not exist` в bookings‑DAG | Вы сделали полный reset с удалением томов (`docker compose down -v` / `make clean`), поэтому демобаза bookings не установлена. Запустите `make bookings-init` и повторите DAG. | | `database "demo" does not exist` в bookings‑DAG | Вы сделали reset с удалением volumes (`make clean` / `docker compose down -v`). Запустите `make bookings-init` (быстрое восстановление из дампа, ~18 сек) и повторите DAG. |
| Ошибка подключения к Greenplum | Убедитесь, что контейнер `greenplum` стал статусом `healthy` (проверьте `docker compose ps`) | | Ошибка `bookings.jobs должен быть >= 1` | Проверьте значение `BOOKINGS_JOBS` в `.env` — оно должно быть целым числом >= 1. По умолчанию `BOOKINGS_JOBS=1` (синхронная генерация). При `BOOKINGS_JOBS>1` генерация идёт параллельно через dblink. |
| Не открывается порт 8080/5433/5434/5435 | Проверьте, что эти порты не заняты локальными сервисами; при необходимости остановите их или измените порты в `.env`/`docker-compose.yml` | | Ошибка подключения к Greenplum | Убедитесь, что контейнер `greenplum` имеет статус `healthy` (`docker compose ps`) |
| Нет файла в `./data` после запуска DAG | Проверьте логи задачи `generate_csv`, убедитесь, что `CSV_DIR` смонтирован в docker-compose |
| Команда `make` не найдена | Используйте полные команды `docker compose` или установите make |
| Greenplum не стартует/падает при старте | Попробуйте `make down && make up`. Если не помогло — полный reset: `make clean && make up` (удалит тома). |
| `protocol "pxf" does not exist` | Перезапустите `greenplum` и повторите `bookings_stg_ddl`/`make ddl-gp` — расширение `pxf` создаётся автоматически при старте контейнера. | | `protocol "pxf" does not exist` | Перезапустите `greenplum` и повторите `bookings_stg_ddl`/`make ddl-gp` — расширение `pxf` создаётся автоматически при старте контейнера. |
| PXF не отвечает (Connection refused к порту 5888) | Проверьте `pxf cluster status` в контейнере `greenplum` и перезапустите сервис `greenplum`. | | DAG `bookings_to_gp_stage` ругается на отсутствующие таблицы stg | Запустите `bookings_stg_ddl` (или выполните `make ddl-gp`), затем повторите запуск |
| PXF не подхватывает изменения конфигов | Пересоберите образ (`make build`) и перезапустите `greenplum`. Для принудительной перезаписи файлов задайте `PXF_SEED_OVERWRITE=1`. | | Порты 8080/5433/5434/5435 заняты | Остановите локальные сервисы или измените порты в `.env` |
| DAG `bookings_to_gp_stage` падает на внешней таблице/подключении к bookings | Убедитесь, что запущен контейнер `bookings-db` (`docker compose ps`, при необходимости `docker compose start bookings-db`), и выполнены `make bookings-init` и `make ddl-gp` или DAG `bookings_stg_ddl` |
| DAG `bookings_to_gp_stage` ругается на отсутствующие таблицы stg | Запустите DAG `bookings_stg_ddl` (или выполните `make ddl-gp`), затем повторите запуск |
| DAG не видит Greenplum/DEMObase по Airflow Connections | Убедитесь, что контейнеры `greenplum` и `bookings-db` запущены (`docker compose ps`). Подключения `greenplum_conn` и `bookings_db` задаются через переменные окружения `AIRFLOW_CONN_...` и могут не отображаться в UI, но `PostgresOperator` всё равно найдёт их по `conn_id`. При необходимости вы можете создать/отредактировать их вручную в разделе Connections. |
---
## 📁 Структура проекта
```
├── docker-compose.yml # Описание всех сервисов
├── Dockerfile.airflow # Образ Airflow с зависимостями
├── Dockerfile.greenplum # Образ Greenplum с интегрированным PXF
├── .env.example # Шаблон настроек
├── Makefile # Удобные команды для работы
├── README.md # Обзор стенда
├── TESTING.md # Пошаговый план проверки
├── educational-tasks.md # Учебные задания для менти
├── airflow/
│ └── dags/ # Файлы workflow (DAG)
│ ├── csv_to_greenplum.py
│ ├── csv_to_greenplum_dq.py
│ └── bookings_to_gp_stage.py
├── bookings/ # Скрипты и файлы для демобазы bookings в Postgres
├── sql/
│ └── ddl_gp.sql # Общий DDL для Greenplum (подключает stg/src-скрипты)
├── docs/ # Дополнительные документы (архитектура, bookings/STG, PXF)
├── tests/ # Автоматические тесты (pytest)
└── pxf/ # Конфигурация и файлы для PXF
```
---
## 💡 Советы для дальнейшего обучения
1. **Поэкспериментируйте с DAG** — измените параметры генерации данных или размер батча
2. **Добавьте свои проверки** — расширьте DAG `csv_to_greenplum_dq.py`
3. **Попробуйте другие источники** — замените генератор данных на чтение из файла или API
4. **Изучите Airflow deeper** — добавьте зависимости между задачами, настройте расписания
---
## ✅ Тестирование
- Локальные проверки: `make test` (pytest). Для форматирования — `make fmt`, для проверки — `make lint`.
- Пошаговый сценарий с Docker (включая негативные кейсы и reset) — см. `TESTING.md`.
- Полный smoke-тест стенда (сносит volumes!): `make e2e-smoke` — поднимает стек с нуля, прогоняет `csv_to_greenplum` и `bookings_to_gp_stage` через `airflow dags test` и проверяет, что в `public.orders` и `stg.bookings` появились строки.
---
## Благодарности ## Благодарности
- **Postgres Pro** — за демо-БД bookings и генератор данных `demodb`: https://github.com/postgrespro/demodb (лицензия MIT: https://github.com/postgrespro/demodb/blob/main/LICENSE). - **Postgres Pro** — за демо‑БД bookings и генератор данных `demodb`: https://github.com/postgrespro/demodb (лицензия MIT: https://github.com/postgrespro/demodb/blob/main/LICENSE).
- **woblerr** — за Docker-сборку Greenplum: https://github.com/woblerr/docker-greenplum (образ: `woblerr/greenplum`, лицензия MIT: https://github.com/woblerr/docker-greenplum/blob/master/LICENSE). - **woblerr** — за Docker-сборку Greenplum: https://github.com/woblerr/docker-greenplum (образ: `woblerr/greenplum`, лицензия MIT: https://github.com/woblerr/docker-greenplum/blob/master/LICENSE).
Удачи в изучении Data Engineering! 🚀
+104 -74
View File
@@ -1,88 +1,118 @@
# План тестирования (для студентов) # Проверка изменений репозитория
Этот документ — пошаговый чек‑лист, как проверить, что всё работает: от «быстрых локальных проверок» до запуска стенда в Docker и просмотра данных в Greenplum. Подходит начинающим: просто выполняйте шаги по порядку. Этот документ предназначен для работы над кодом, документацией и стендом.
Если вы выполняете учебное задание, начните с
[его самопроверки](docs/assignment/README.md#запуск-и-проверка): там объяснены
`*_dq.sql`, группы `bookings_validate` и порядок восстановления после SCD2-теста.
Если что‑то пошло не так, смотрите раздел «Быстрый reset» ниже. ## Локальные проверки
## 1. Быстрая проверка окружения Из корня репозитория:
- `uv sync` — подтягиваем Python и зависимости из `pyproject.toml`/`uv.lock`.
- Проверяем версию uv: `uv --version` (ожидаем ≥ 0.9).
- Убедитесь, что `docker compose version` доступна и Docker запущен.
## 2. Локальные автоматические проверки (без Docker) ```bash
- `make test` — короткие unit-тесты (`tests/test_greenplum_helpers.py`, `tests/test_dags_smoke.py`). uv sync
- Smoke-тесты DAG автоматически `skip`, если Airflow не установлен в venv, поэтому прогонится за миллисекунды. make lint
- `make lint` — black/isort в режиме проверки (после `make fmt` должен проходить без ошибок). make test
- `make fmt` — автоисправление форматирования; полезно запускать перед пушем. ```
- (опционально) `uv run pytest -q -k dags_smoke` — только DAG smoke.
## 3. Подготовка Docker-стенда `make lint` проверяет Black и isort для `airflow/` и `tests/`.
- `cp .env.example .env` (если файла ещё нет) и проверьте переменные: `make fmt` исправляет форматирование в этих каталогах.
- `GP_PORT` — внутренний порт Greenplum в Docker-сети (по умолчанию 5432, менять не нужно); внешний порт для подключения с хоста фиксирован на `5435`, поэтому локальный PostgreSQL на 5432 не помешает. Тесты находятся в трех файлах:
- `GP_USE_AIRFLOW_CONN=true` при желании использовать Airflow Connection; `false` — fallback на ENV.
- `make up` — поднимаем все сервисы. Важно дождаться статуса `healthy` у `pgmeta` и `greenplum` (`docker compose ps`); `greenplum` считается `healthy` только когда поднят и Greenplum, и PXF.
- `make logs` — следим, пока webserver и scheduler не перейдут в рабочее состояние (`Listening at: http://0.0.0.0:8080`).
## 4. Smoke тесты DAG в Airflow UI - [test_ods_sql_contract.py](tests/test_ods_sql_contract.py) проверяет
1. Открыть http://localhost:8080 (admin/admin). контракты SQL справочников ODS по исходным файлам.
2. (опционально) Зайти в Admin → Connections и убедиться, что DAG’и видят подключения: - [test_dags_smoke.py](tests/test_dags_smoke.py) проверяет структуру DAG.
- `greenplum_conn` и `bookings_db` задаются через переменные `AIRFLOW_CONN_...` в docker-compose и могут не отображаться в списке, но `airflow connections get greenplum_conn` / `bookings_db` внутри контейнера должны отрабатывать без ошибок. Если импорт `airflow` не предоставляет `DAG`, проверки пропускаются.
3. DAG `csv_to_greenplum`: Смотрите причины пропусков: `uv run pytest -q -rs`.
- Включить переключатель. - [test_ods_snapshot_integration.py](tests/test_ods_snapshot_integration.py)
- Нажать «Trigger DAG». по умолчанию пропускается; отдельный запуск описан ниже.
- Контроль: все таски Success, в `data/` появился CSV, в логах `load_csv_to_greenplum` видно `INSERT`.
- В Greenplum (см. п.5) убедиться в наличии строк `(SELECT COUNT(*) ...)`.
4. DAG `csv_to_greenplum_dq`:
- Запустить вручную после первого DAG.
- Проверить, что все 5 задач Success и логи содержат `Проверка пройдена`.
- DAG `bookings_to_gp_stage` (полная проверка цепочки bookings → Greenplum STG): Успешный pytest с пропущенными smoke-тестами не подтверждает работоспособность
- предварительно выполнить один раз: `make bookings-init` (установка демобазы `demo` в контейнере `bookings-db`) и `make ddl-gp` (создаёт `stg.bookings_ext` и `stg.bookings` в Greenplum); DAG в Airflow. Число выполненных и пропущенных тестов указывайте в отчете.
- важно: 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;
- при желании проверить данные: в `bookings-db` появился новый день, а в Greenplum в `stg.bookings` — строки с актуальным `batch_id` (см. пример запросов в разделе 5).
- (опционально, для менторов/разработчиков) Smoke-тест DAG через Airflow CLI без UI: Для документации проверьте относительные ссылки и якоря, имена файлов,
- `docker compose -f docker-compose.yml exec airflow-webserver airflow dags test bookings_to_gp_stage 2024-01-01` — прогоняет `bookings_to_gp_stage` целиком в «off-line» режиме; таблиц и задач. Для карты выполните:
- `docker compose -f docker-compose.yml exec airflow-webserver airflow dags trigger bookings_to_gp_stage` — создаёт реальный запуск DAG (логи и статус можно смотреть либо через UI, либо командой `airflow tasks list`/`airflow tasks logs` внутри контейнера).
## 5. Проверка данных в Greenplum ```bash
- `make gp-psql` — запустить psql в контейнере от имени `gpadmin`. uv run docs/design/architecture-map/render.py --check
- (опционально) Проверить, что PXF действительно запущен: git diff --check
- `docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/pxf/bin/pxf cluster status'"` ```
- Команды внутри psql:
- `\dt public.*` — таблицы схему public.
- `SELECT COUNT(*) FROM public.orders;` — оценка объёма.
- `SELECT * FROM public.orders LIMIT 5;` — визуальная проверка.
- `SELECT order_id FROM public.orders GROUP BY 1 HAVING COUNT(*) > 1;` — поиск дублей.
- (после настройки PXF) `SELECT COUNT(*) FROM public.ext_bookings_bookings;` — проверка чтения из демо-БД bookings через PXF.
- (после настройки PXF) `SELECT * FROM public.ext_bookings_bookings LIMIT 5;` — визуальное сравнение с таблицей `bookings.bookings` в исходной БД.
- Завершить `\q`.
## 6. Негативные сценарии и fallback Сборщик сверяет карту с DDL, загрузками и ссылками. Действия в браузере
- **Пустая таблица**: запустить `csv_to_greenplum_dq` до `csv_to_greenplum`. Ожидается ошибка на таске `check_orders_has_rows`. проверяются отдельно по [чек-листу карты](docs/design/architecture-map/README.md#проверка-в-браузере).
- **Проблемы с подключением**: временно изменить `GP_HOST` или `GP_PORT` на несуществующий, перезапустить `make up`, убедиться, что DAG падает с понятной ошибкой (`psycopg2.OperationalError`).
- **Fallback без Airflow Connection**: установить `GP_USE_AIRFLOW_CONN=false`, перезапустить стек (`make down && make up`), удостовериться, что загрузка и DQ работают через ENV.
- **Дубликаты**: дважды вызвать `csv_to_greenplum` — ожидаем, что количество строк в `public.orders` не увеличится на размер CSV, а DAG `csv_to_greenplum_dq` не найдёт дублей.
- **PXF и демобаза bookings** (после настройки PXF и выполнения `make ddl-gp`): временно остановить `bookings-db` (`docker compose stop bookings-db`) и попробовать выполнить `SELECT COUNT(*) FROM public.ext_bookings_bookings;` в `make gp-psql` — ожидается ошибка подключения. Затем запустить `bookings-db` (`docker compose start bookings-db`) и убедиться, что запрос снова работает.
## 7. Быстрый reset (если «что-то сломалось») ## Проверка на стенде
- Перезапустить стенд:
- Мягкий вариант (сохранить данные): `make stop`, затем `make up`.
- Полный reset (очистить данные в Docker-томах): `make clean`, затем `make up` (Greenplum/Airflow/bookings будут подняты и инициализированы с нуля).
- Иногда Greenplum не стартует после «грязных» остановок (из‑за старых внутренних файлов). Лечение: всегда делайте `make down` перед повторным `make up`.
## 8. Снятие метрик и мониторинг Подготовьте стенд по [README](README.md) и проверьте его состояние:
- Контейнеры: `docker compose ps`, `docker stats` (по желанию).
- Логи задач: в Airflow UI → конкретный таск → Log.
- Хостовые CSV: каталог `data/` (можно открыть любой файл и убедиться в структуре).
## 9. Завершение работы ```bash
- `make down` — выключает сервисы и удаляет тома (перезапишет данные в Greenplum!). make up
- При необходимости сохранить данные: скопировать CSV из `data/` и дампы из контейнера до `make down`. docker compose ps
```
## Текущий статус (пример успешного прогона) Порты берутся из `.env`: `AIRFLOW_WEB_PORT`, `GP_HOST_PORT`, `BOOKINGS_DB_PORT`.
- `uv run pytest -q` — 11 passed, 2 smoke-теста DAG пропущены (Airflow не установлен в venv). Для локальных подключений используйте их, а для Greenplum внутри Docker-сети
- `make lint` — проходит (DAG‑файлы отформатированы black/isort). применяется `GP_PORT` (по умолчанию 5432).
- Docker-стенд не запускался в рамках этой сессии; ожидается, что инструкции выше обеспечат полноценную проверку. Загрузки используют Airflow Connections из Compose;
подробности находятся в [справочнике стенда](docs/stack.md).
Если изменились `Dockerfile.airflow` или `airflow/requirements.txt`, перед
`make up` выполните `make build`. Инициализация Bookings нужна при создании
стенда: `make bookings-init` пересоздает исходную базу из seed-дампа.
Не используйте ее для обычной проверки SQL на существующих данных.
Выполните DDL и загрузки по [порядку DAG](docs/dag_execution_order.md).
В Airflow откройте Graph и Log задач измененного слоя; в Greenplum
(`make gp-psql`) выполните проверки из его руководства:
[STG](docs/bookings_to_gp_stage.md#проверка-результата),
[ODS](docs/bookings_to_gp_ods.md#проверка-результата),
[DDS](docs/bookings_to_gp_dds.md#проверка-результата),
[DM](docs/bookings_to_gp_dm.md#проверка-результата).
Для проверки чтения через PXF используйте действующую внешнюю таблицу:
```sql
SELECT COUNT(*) FROM stg.bookings_ext;
SELECT * FROM stg.bookings_ext LIMIT 5;
```
Сравнить источник можно через `make bookings-psql`.
При ошибке соединения начните с `docker compose ps` и логов нужного сервиса,
затем проверьте настройки из `docs/stack.md`.
## Дополнительные прогоны
Интеграционный тест снимка ODS требует работающего Greenplum и базы `gp_dwh`:
```bash
RUN_ODS_INTEGRATION=1 uv run pytest -q tests/test_ods_snapshot_integration.py
```
Он создает, затем удаляет служебные таблицы
`public.it_stg_airports_ods` и `public.it_ods_airports_ods`.
Используйте тестовый стенд, где эти имена не заняты нужными данными.
Тест проверяет замену снимка, преобразование названий и DQ готового `airports`;
задания `airplanes` и `seats` он не проверяет.
`make e2e-etl` запускает DDL и два прохода STG → ODS → DDS → DM через API Airflow.
Скрипт использует `localhost:8080`; при другом `AIRFLOW_WEB_PORT` проходите
цепочку вручную. Каждый STG добавляет учебный день. Финальные SQL выводят
счетчики, но не превращают все неверные значения в ошибку завершения скрипта:
результаты нужно прочитать с учетом заглушек `main`.
`make e2e-smoke` начинает с `make clean` и **удаляет все Docker-тома стенда**,
затем инициализирует Bookings и выполняет E2E. Запускайте его только там,
где данные можно потерять. Обычный `make down` сохраняет тома;
`make stop`, затем `make up` подходят для перезапуска с сохранением данных.
[Исторический QA-план](docs/reference/qa-plan.md) сохраняет многодневные,
негативные и граничные сценарии полной реализации. Это материал для
подготовки отдельной диагностики: перед выполнением сверяйте его SQL
с текущей веткой и учитывайте операции очистки данных.
## Что указать в отчете
Укажите коммит, команды и результаты, включая пропуски тестов.
Разделите чтение исходников, проверки ссылок, действия в браузере и реальные
загрузки. Для запуска на стенде сохраните Run ID и логи проверенных задач.
Если ETL или интерфейс не проверялись, укажите это прямо.
-41
View File
@@ -1,41 +0,0 @@
# TODO (maintainers / mentors)
Этот файл собирает идеи по доработке стенда, которые не критичны для текущих задач менти,
но улучшат стабильность и удобство сопровождения.
- Собрать свой образ Airflow поверх `apache/airflow:2.9.2`:
- вынести установку Python‑зависимостей из runtime (`pip install ...` при старте контейнеров)
в отдельный `Dockerfile`;
- переключить `docker-compose.yml` на использование этого образа для `airflow-webserver`,
`airflow-scheduler` и `airflow-init`;
- обновить документацию (README/TESTING) под новую схему сборки.
- Переключить Airflow с `SequentialExecutor` (SequentialScheduler) на `LocalExecutor`
для docker‑стенда:
- проверить, какие параметры достаточно поменять в env/конфиге (`AIRFLOW__CORE__EXECUTOR`)
для образа `apache/airflow:2.9.2`;
- убедиться, что примерные DAG’и (`csv_to_greenplum`, `bookings_to_gp_stage`) ведут себя
предсказуемо в режиме параллельного исполнения;
- при необходимости скорректировать тесты и документацию (README/TESTING) с учётом нового executor’а.
- Разобрать и стабилизировать интеграцию с Greenplum/PXF:
- убедиться, что PXF в контейнере `greenplum` всегда корректно инициализируется
(нет ошибок вида `protocol "pxf" does not exist` при первом запуске `make ddl-gp`);
- при необходимости доработать init‑скрипты в `pxf/init/` и/или документацию,
чтобы порядок действий для ментей был однозначным и воспроизводимым;
- добавить краткий раздел в README/TESTING о типичных ошибках PXF/Greenplum и шагах по их устранению.
- диагностика текущего кейса: `docs/internal/pxf_bookings.md` (раздел «Известная проблема»).
- Разобраться с генератором demodb:
- после `make bookings-init` таблица `bookings.bookings` остаётся пустой;
- патчи `bookings/patches/engine_jobs1_sync.patch` и `bookings/patches/install_drop_if_exists.patch`
падают при применении (hunk failed / garbage in patch);
- из‑за этого DAG `bookings_to_gp_stage` валится на проверках (источник пустой).
- план: `plans/bookings-demodb-bugfix-plan.md`
- Добавить раздел «Благодарности» в `README.md`:
- явно поблагодарить Postgres Pro за демо‑БД bookings (репозиторий `postgrespro/demodb`);
- указать автора Docker‑сборки Greenplum (`woblerr/docker-greenplum`, образ `woblerr/greenplum`);
- при необходимости сослаться на соответствующие лицензии/README исходных проектов.
- Добавить в образ Airflow установку `psql`, чтобы тестировать загрузку CSV из CLI внутри контейнера (без root и дополнительных зависимостей на хосте).
+1
Submodule _vendor/demodb added at d68de19285.
+84
View File
@@ -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(2017, 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
)
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
"""
Учебный DAG: создаёт/обновляет DM-слой (Data Mart) в Greenplum для домена bookings.
Запускается вручную перед DAG загрузки `bookings_to_gp_dm` или после изменения DM DDL.
Создаёт 5 DM-витрин: sales_report, route_performance, passenger_loyalty,
airport_traffic, monthly_overview.
DDL создаёт все 5 витрин. Эталон: sales_report. Остальные — задания.
"""
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_dm_ddl",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "greenplum", "ddl", "bookings", "dm"],
description="Учебный DDL DAG: создаёт/обновляет dm.* для bookings",
) as dag:
# Эталонная витрина: sales_report
apply_dm_sales_report_ddl = PostgresOperator(
task_id="apply_dm_sales_report_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/sales_report_ddl.sql",
)
# Витрина: Эффективность маршрутов (Этап 2)
apply_dm_route_performance_ddl = PostgresOperator(
task_id="apply_dm_route_performance_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/route_performance_ddl.sql",
)
# Витрина: Лояльность пассажиров (Этап 3)
apply_dm_passenger_loyalty_ddl = PostgresOperator(
task_id="apply_dm_passenger_loyalty_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/passenger_loyalty_ddl.sql",
)
# Витрина: Пассажиропоток аэропортов (Этап 4)
apply_dm_airport_traffic_ddl = PostgresOperator(
task_id="apply_dm_airport_traffic_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/airport_traffic_ddl.sql",
)
# Витрина: Помесячная сводка (Этап 5)
apply_dm_monthly_overview_ddl = PostgresOperator(
task_id="apply_dm_monthly_overview_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/monthly_overview_ddl.sql",
)
# Линейная цепочка
(
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
)
+98
View File
@@ -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(2017, 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
)
+73 -5
View File
@@ -1,12 +1,16 @@
from __future__ import annotations from __future__ import annotations
""" """
Учебный DAG: создаёт схему stg и таблицы bookings_ext/bookings в Greenplum. Учебный DAG: создаёт/обновляет слой stg в Greenplum для демо-источника bookings.
Запускается вручную перед DAG загрузки bookings_to_gp_stage или после изменения DDL.
Запускается вручную перед DAG загрузки `bookings_to_gp_stage` или после изменения DDL.
Создаёт внешние таблицы PXF (`*_ext`) и внутренние таблицы STG (9 таблиц: bookings, tickets,
airports, airplanes, routes, seats, flights, segments, boarding_passes).
""" """
from datetime import datetime, timedelta from datetime import timedelta
import pendulum
from airflow.providers.postgres.operators.postgres import PostgresOperator from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow import DAG from airflow import DAG
@@ -17,16 +21,80 @@ default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(secon
with DAG( with DAG(
dag_id="bookings_stg_ddl", dag_id="bookings_stg_ddl",
start_date=datetime(2024, 1, 1), start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None, schedule=None,
catchup=False, catchup=False,
template_searchpath="/sql", template_searchpath="/sql",
default_args=default_args, default_args=default_args,
tags=["demo", "greenplum", "ddl", "bookings", "stg"], tags=["demo", "greenplum", "ddl", "bookings", "stg"],
description="Создаёт/обновляет stg.bookings_ext и stg.bookings для учебного DAG", description="Учебный DDL DAG: создаёт/обновляет stg.* (PXF external + internal STG) для bookings",
) as dag: ) as dag:
apply_stg_bookings_ddl = PostgresOperator( apply_stg_bookings_ddl = PostgresOperator(
task_id="apply_stg_bookings_ddl", task_id="apply_stg_bookings_ddl",
postgres_conn_id=GREENPLUM_CONN_ID, postgres_conn_id=GREENPLUM_CONN_ID,
sql="stg/bookings_ddl.sql", 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",
)
# 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",
)
# DDL применяем последовательно, чтобы порядок был понятным для новичков,
# а ошибки — воспроизводимыми (в логах сразу видно, на каком объекте упали).
(
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
)
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
"""
Учебный DAG: загрузка из ODS в DDS (Greenplum) по домену bookings.
Ключевая идея:
- DDS читает текущее состояние ODS;
- для каждой сущности выполняем пару задач load -> dq;
- для dim_routes применяем SCD2, для остальных измерений — SCD1 UPSERT;
- факт грузим инкрементальным UPSERT по зерну (ticket_no, flight_id).
Эталон: dim_airports, dim_tariffs, dim_calendar, fact_flight_sales. dim_routes, dim_passengers, dim_airplanes — задания (заглушки).
"""
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(2017, 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,
]
# dim_routes зависит от airports и airplanes (денормализация).
[dq_dds_dim_airports, dq_dds_dim_airplanes] >> 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
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
"""
Учебный DAG: загрузка из DDS в DM (Greenplum) по домену bookings.
Ключевая идея:
- DM читает из DDS (Star Schema);
- для каждой витрины выполняем пару задач load -> dq;
- все витрины загружаются параллельно (не зависят друг от друга);
- sales_report использует UPSERT (heap-таблица), route_performance — Full Rebuild (AO Column).
Эталон: sales_report. Остальные 4 витрины — задания (заглушки SELECT 1;).
"""
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:
"""Логирует краткий итог выполнения DM-ветки."""
log.info("DAG bookings_to_gp_dm завершён. Подробности смотрите в логах задач.")
with DAG(
dag_id="bookings_to_gp_dm",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
max_active_runs=1,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "bookings", "greenplum", "dm"],
description="Учебный DAG: загрузка DDS -> DM (Data Mart) + DQ проверки",
) as dag:
# start-задача (для структуры, вдруг понадобятся pre-checks)
start_dm = PythonOperator(
task_id="start_dm",
python_callable=lambda: log.info("Начало загрузки DM-слоя..."),
)
# === Эталонная витрина: sales_report ===
load_dm_sales_report = PostgresOperator(
task_id="load_dm_sales_report",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/sales_report_load.sql",
)
dq_dm_sales_report = PostgresOperator(
task_id="dq_dm_sales_report",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/sales_report_dq.sql",
)
# === Витрина: Эффективность маршрутов (Этап 2) ===
load_dm_route_performance = PostgresOperator(
task_id="load_dm_route_performance",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/route_performance_load.sql",
)
dq_dm_route_performance = PostgresOperator(
task_id="dq_dm_route_performance",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/route_performance_dq.sql",
)
# === Витрина: Лояльность пассажиров (Этап 3) ===
load_dm_passenger_loyalty = PostgresOperator(
task_id="load_dm_passenger_loyalty",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/passenger_loyalty_load.sql",
)
dq_dm_passenger_loyalty = PostgresOperator(
task_id="dq_dm_passenger_loyalty",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/passenger_loyalty_dq.sql",
)
# === Витрина: Пассажиропоток аэропортов (Этап 4) ===
load_dm_airport_traffic = PostgresOperator(
task_id="load_dm_airport_traffic",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/airport_traffic_load.sql",
)
dq_dm_airport_traffic = PostgresOperator(
task_id="dq_dm_airport_traffic",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/airport_traffic_dq.sql",
)
# === Витрина: Помесячная сводка (Этап 5) ===
load_dm_monthly_overview = PostgresOperator(
task_id="load_dm_monthly_overview",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/monthly_overview_load.sql",
)
dq_dm_monthly_overview = PostgresOperator(
task_id="dq_dm_monthly_overview",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dm/monthly_overview_dq.sql",
)
# finish-задача
finish_dm_summary = PythonOperator(
task_id="finish_dm_summary",
python_callable=_finish_summary,
)
# Зависимости: параллельные ветки load -> dq
start_dm >> [
load_dm_sales_report,
load_dm_route_performance,
load_dm_passenger_loyalty,
load_dm_airport_traffic,
load_dm_monthly_overview,
]
# Связываем dq с finish
load_dm_sales_report >> dq_dm_sales_report >> finish_dm_summary
load_dm_route_performance >> dq_dm_route_performance >> finish_dm_summary
load_dm_passenger_loyalty >> dq_dm_passenger_loyalty >> finish_dm_summary
load_dm_airport_traffic >> dq_dm_airport_traffic >> finish_dm_summary
load_dm_monthly_overview >> dq_dm_monthly_overview >> finish_dm_summary
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
"""
Учебный DAG: загрузка из STG в ODS (Greenplum) по домену bookings.
Ключевая идея:
- весь запуск ODS работает с одним stg_batch_id;
- для каждой сущности выполняем пару задач load -> dq;
- загрузка реализована как SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
Эталон: airports, routes и 5 инкрементальных сущностей. airplanes, seats — задания (заглушки).
"""
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 или вычисляет последний согласованный батч.
ВНИМАНИЕ: Это значение используется ТОЛЬКО для загрузки snapshot-справочников
(airports, airplanes, routes, seats).
Для инкрементальных транзакционных таблиц (bookings, tickets, flights, segments,
boarding_passes) этот батч НЕ используется. Вместо этого они грузят все новые
записи по HWM: WHERE _load_ts > (SELECT MAX(_load_ts) FROM ods.table).
Это сделано для того, чтобы не потерять инкременты, если STG-DAG запускался
несколько раз до запуска ODS-DAG'а.
Зачем нужна согласованность (INTERSECT по всем справочникам)?
Чтобы ODS загружал только те данные, для которых уже приехали ВСЕ связанные
справочники. Это защищает от рассинхрона данных, когда часть измерений в текущем
батче обновилась, а часть — упала или не доехала, что могло бы привести к
потере ссылочной целостности при сборке витрин.
"""
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 _load_id
FROM stg.airports
WHERE _load_id IS NOT NULL AND _load_id <> ''
GROUP BY _load_id
INTERSECT
SELECT _load_id
FROM stg.airplanes
WHERE _load_id IS NOT NULL AND _load_id <> ''
GROUP BY _load_id
INTERSECT
SELECT _load_id
FROM stg.routes
WHERE _load_id IS NOT NULL AND _load_id <> ''
GROUP BY _load_id
INTERSECT
SELECT _load_id
FROM stg.seats
WHERE _load_id IS NOT NULL AND _load_id <> ''
GROUP BY _load_id
),
batch_ready AS (
SELECT
c._load_id,
GREATEST(
COALESCE(
(SELECT MAX(_load_ts) FROM stg.airports a WHERE a._load_id = c._load_id),
TIMESTAMP '1900-01-01 00:00:00'
),
COALESCE(
(SELECT MAX(_load_ts) FROM stg.airplanes a WHERE a._load_id = c._load_id),
TIMESTAMP '1900-01-01 00:00:00'
),
COALESCE(
(SELECT MAX(_load_ts) FROM stg.routes r WHERE r._load_id = c._load_id),
TIMESTAMP '1900-01-01 00:00:00'
),
COALESCE(
(SELECT MAX(_load_ts) FROM stg.seats s WHERE s._load_id = c._load_id),
TIMESTAMP '1900-01-01 00:00:00'
)
) AS ready_dttm
FROM candidate_batches c
)
SELECT _load_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(2017, 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
# На main routes не зависит от airplanes (ods.airplanes — студенческая заглушка).
# На ветке solution: [dq_ods_airports, dq_ods_airplanes] >> load_ods_routes
dq_ods_airports >> 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
+153 -6
View File
@@ -12,11 +12,31 @@ from __future__ import annotations
Каждый запуск DAG работает как «шаг по времени вперёд»: Каждый запуск DAG работает как «шаг по времени вперёд»:
- генератор в демо-БД bookings добавляет следующий учебный день после max(book_date); - генератор в демо-БД bookings добавляет следующий учебный день после max(book_date);
- загрузка в Greenplum берёт все строки, появившиеся после предыдущих батчей; - загрузка в Greenplum берёт все строки, появившиеся после предыдущих батчей;
- логическая дата запуска (`ds`) используется как удобная метка запуска (через `ds_nodash` в `batch_id`, в логах и DQ). - `run_id` используется как метка запуска (в `_load_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 datetime, timedelta from datetime import timedelta
import pendulum
from airflow.operators.python import PythonOperator from airflow.operators.python import PythonOperator
from airflow.providers.postgres.operators.postgres import PostgresOperator from airflow.providers.postgres.operators.postgres import PostgresOperator
@@ -48,13 +68,14 @@ def _finish_summary() -> None:
with DAG( with DAG(
dag_id="bookings_to_gp_stage", dag_id="bookings_to_gp_stage",
start_date=datetime(2024, 1, 1), start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None, schedule=None,
catchup=False, catchup=False,
max_active_runs=1,
template_searchpath="/sql", template_searchpath="/sql",
default_args=default_args, default_args=default_args,
tags=["demo", "bookings", "greenplum", "stg"], tags=["demo", "bookings", "greenplum", "stg"],
description="Учебный DAG: загрузка из bookings-db в stg.bookings (Greenplum)", description="Учебный DAG: загрузка из bookings-db в слой stg (Greenplum) + DQ проверки",
) as dag: ) as dag:
# 1. Генерируем один (или несколько стартовых) учебный день в демо-БД bookings # 1. Генерируем один (или несколько стартовых) учебный день в демо-БД bookings
generate_bookings_day = PostgresOperator( generate_bookings_day = PostgresOperator(
@@ -78,10 +99,136 @@ with DAG(
sql="stg/bookings_dq.sql", 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",
)
# Загрузка справочников (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/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",
)
# Финальный лог/сводка
finish_summary = PythonOperator( finish_summary = PythonOperator(
task_id="finish_summary", task_id="finish_summary",
python_callable=_finish_summary, python_callable=_finish_summary,
) )
generate_bookings_day >> load_bookings_to_stg >> check_row_counts >> finish_summary # === Этап 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
# === Этап 2. Справочники (параллельно, где данные независимы) ===
# airports и airplanes не зависят друг от друга — грузим параллельно.
check_tickets_dq >> load_airports_to_stg >> check_airports_dq
check_tickets_dq >> load_airplanes_to_stg >> check_airplanes_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, check_seats_dq] >> finish_summary
+150
View File
@@ -0,0 +1,150 @@
"""
Валидационный DAG: самопроверка студенческих заданий.
Запускается вручную в Airflow UI после реализации заданий.
Таски сгруппированы по слоям — студент видит, где именно проблема.
Структура:
validate_ods — проверки ODS: rowcount, дубли BK, NULL в PK
validate_dds — проверки DDS: SCD1-измерения, SCD2 dim_routes (активный тест!)
validate_dm — проверки DM-витрин: не пусты, нет NULL в ключах
Все три группы запускаются параллельно — инкрементальная обратная связь:
студент может проверить только ODS, пока DDS/DM ещё не реализованы.
"""
from datetime import timedelta
from logging import getLogger
import pendulum
from airflow.providers.postgres.operators.postgres import PostgresOperator
from airflow.utils.task_group import TaskGroup
from airflow import DAG
GREENPLUM_CONN_ID = "greenplum_conn"
log = getLogger(__name__)
default_args = {
"owner": "airflow",
"retries": 0, # Без ретраев — студент должен увидеть ошибку сразу
"retry_delay": timedelta(seconds=10),
}
with DAG(
dag_id="bookings_validate",
start_date=pendulum.datetime(2017, 1, 1, tz="UTC"),
schedule=None, # Только ручной запуск
catchup=False,
max_active_runs=1,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "bookings", "greenplum", "validate"],
description="Валидация студенческих заданий: ODS, DDS, DM",
) as dag:
with TaskGroup("validate_ods") as validate_ods:
check_ods_airplanes = PostgresOperator(
task_id="check_ods_airplanes_rowcount",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_airplanes_rowcount.sql",
)
check_ods_seats = PostgresOperator(
task_id="check_ods_seats_rowcount",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_seats_rowcount.sql",
)
check_ods_dup_bk = PostgresOperator(
task_id="check_ods_no_dup_bk",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_no_dup_bk.sql",
)
check_ods_pks = PostgresOperator(
task_id="check_ods_no_null_pks",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/ods_no_null_pks.sql",
)
with TaskGroup("validate_dds") as validate_dds:
check_dim_airplanes = PostgresOperator(
task_id="check_dim_airplanes_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_airplanes_exists.sql",
)
check_dim_passengers = PostgresOperator(
task_id="check_dim_passengers_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_passengers_exists.sql",
)
check_dim_passengers_dup = PostgresOperator(
task_id="check_dim_passengers_no_dup_bk",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_passengers_no_dup_bk.sql",
)
check_dim_routes = PostgresOperator(
task_id="check_dim_routes_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_exists.sql",
)
# SCD2 активный тест: цепочка backup → mutate → load → check → restore
scd2_backup = PostgresOperator(
task_id="scd2_backup",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_backup.sql",
)
scd2_mutate = PostgresOperator(
task_id="scd2_mutate",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_mutate.sql",
)
scd2_run_load = PostgresOperator(
task_id="scd2_run_student_load",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="dds/dim_routes_load.sql", # студенческий load-скрипт!
)
scd2_check = PostgresOperator(
task_id="scd2_check",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_check.sql",
)
scd2_restore = PostgresOperator(
task_id="scd2_restore",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_scd2_restore.sql",
trigger_rule="all_done", # Откат ВСЕГДА, даже если check упал
)
check_dim_routes_gaps = PostgresOperator(
task_id="check_dim_routes_no_gaps",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/dim_routes_no_gaps.sql",
)
# SCD2 цепочка
scd2_backup >> scd2_mutate >> scd2_run_load >> scd2_check >> scd2_restore
# no_gaps запускается после restore (на чистых данных)
scd2_restore >> check_dim_routes_gaps
with TaskGroup("validate_dm") as validate_dm:
check_airport_traffic = PostgresOperator(
task_id="check_airport_traffic_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/airport_traffic_exists.sql",
)
check_route_performance = PostgresOperator(
task_id="check_route_performance_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/route_performance_exists.sql",
)
check_monthly_overview = PostgresOperator(
task_id="check_monthly_overview_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/monthly_overview_exists.sql",
)
check_passenger_loyalty = PostgresOperator(
task_id="check_passenger_loyalty_exists",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="validate/passenger_loyalty_exists.sql",
)
-167
View File
@@ -1,167 +0,0 @@
from __future__ import annotations
import logging
import os
import random
from datetime import datetime, timedelta
from pathlib import Path
from typing import List
import pandas as pd
from airflow.operators.python import PythonOperator
from helpers.greenplum import get_gp_conn
from airflow import DAG
CSV_DIR = Path(os.getenv("CSV_DIR", "/opt/airflow/data"))
CSV_ROWS = int(os.getenv("CSV_ROWS", "1000"))
def _create_table() -> None:
"""Создаёт таблицу public.orders, если она ещё не существует."""
ddl = """
CREATE TABLE IF NOT EXISTS public.orders (
order_id BIGINT,
order_ts TIMESTAMP NOT NULL,
customer_id BIGINT NOT NULL,
amount NUMERIC(12,2) NOT NULL
)
WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1)
DISTRIBUTED BY (order_id);
"""
with get_gp_conn() as conn, conn.cursor() as cur:
cur.execute(ddl)
conn.commit()
def _generate_csv(rows: int, csv_dir: Path) -> str:
"""Генерирует CSV c заказами с помощью pandas и сохраняет на диск."""
csv_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.utcnow().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)
# Создаём DataFrame с использованием pandas методов
df = pd.DataFrame(
{
# Уникальные order_id начиная с базового значения
"order_id": pd.Series(
range(base_order_id, base_order_id + rows), dtype="int64"
),
# Временные метки с интервалом в 1 секунду в обратном порядке
"order_ts": pd.date_range(
end=datetime.utcnow(), periods=rows, freq="1S"
).sort_values(ascending=False),
# Случайные customer_id от 1 до 1000
"customer_id": pd.Series(
random.choices(range(1, 1001), k=rows), dtype="int64"
),
# Случайные суммы от 10 до 500 с округлением до 2 знаков
"amount": pd.Series(
[round(random.uniform(10, 500), 2) for _ in range(rows)],
dtype="float64",
),
}
)
# Сохраняем CSV без индекса
df.to_csv(csv_path, index=False)
logging.info("CSV сохранён: %s (строк: %s)", csv_path, len(df))
return str(csv_path)
def _preview_csv(csv_path: str, sample_rows: int = 5) -> None:
"""Отображает предпросмотр CSV через pandas (head и describe)."""
df = pd.read_csv(csv_path)
df["order_ts"] = pd.to_datetime(df["order_ts"], errors="coerce")
logging.info(
"Первые %s строк:\n%s", sample_rows, df.head(sample_rows).to_string(index=False)
)
numeric_summary = df.describe(include="number")
logging.info("Числовая статистика:\n%s", numeric_summary.to_string())
if df["order_ts"].notna().any():
logging.info(
"Диапазон order_ts: %s → %s",
df["order_ts"].min().isoformat(),
df["order_ts"].max().isoformat(),
)
def _load_csv(csv_path: str) -> None:
"""Загружает CSV в Greenplum через временную таблицу и anti-join."""
csv_file = Path(csv_path)
if not csv_file.exists():
raise FileNotFoundError(f"CSV не найден: {csv_file}")
with (
get_gp_conn() as conn,
conn.cursor() as cur,
csv_file.open("r", encoding="utf-8") as f,
):
cur.execute(
"CREATE TEMP TABLE tmp_orders (LIKE public.orders INCLUDING DEFAULTS) ON COMMIT DROP;"
)
cur.copy_expert(
"COPY tmp_orders (order_id, order_ts, customer_id, amount) FROM STDIN WITH CSV HEADER",
f,
)
cur.execute("SELECT COUNT(*) FROM tmp_orders")
tmp_rows = cur.fetchone()[0]
cur.execute(
"""
INSERT INTO public.orders(order_id, order_ts, customer_id, amount)
SELECT t.order_id, t.order_ts, t.customer_id, t.amount
FROM tmp_orders t
LEFT JOIN public.orders o ON o.order_id = t.order_id
WHERE o.order_id IS NULL
"""
)
inserted = cur.rowcount if cur.rowcount != -1 else 0
conn.commit()
logging.info("Загружено строк: %s (прочитано из CSV: %s)", inserted, tmp_rows)
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
with DAG(
dag_id="csv_to_greenplum",
start_date=datetime(2024, 1, 1),
schedule=None,
catchup=False,
default_args=default_args,
tags=["demo", "greenplum", "csv"],
) as dag:
create_table = PythonOperator(
task_id="create_orders_table",
python_callable=_create_table,
)
generate_csv = PythonOperator(
task_id="generate_csv",
python_callable=_generate_csv,
op_kwargs={"rows": CSV_ROWS, "csv_dir": CSV_DIR},
)
preview_csv = PythonOperator(
task_id="preview_csv",
python_callable=_preview_csv,
op_kwargs={
"csv_path": "{{ ti.xcom_pull(task_ids='generate_csv') }}",
"sample_rows": 5,
},
)
load_csv = PythonOperator(
task_id="load_csv_to_greenplum",
python_callable=_load_csv,
op_kwargs={
"csv_path": "{{ ti.xcom_pull(task_ids='generate_csv') }}",
},
)
create_table >> generate_csv >> preview_csv >> load_csv
-97
View File
@@ -1,97 +0,0 @@
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from airflow.operators.python import PythonOperator
from helpers.greenplum import (
assert_orders_have_rows,
assert_orders_no_duplicates,
assert_orders_schema,
assert_orders_table_exists,
get_gp_conn,
)
from airflow import DAG
def _run_check(check_callable):
"""
Оборачивает проверку качества данных в контекст подключения к Greenplum.
Этот DAG предназначен для автоматической проверки качества данных
после CSV-пайплайна в таблице public.orders:
1. Проверяет существование таблицы
2. Проверяет соответствие схемы
3. Проверяет наличие данных
4. Проверяет отсутствие дубликатов
Args:
check_callable: Функция проверки, принимающая подключение к БД
"""
# Получаем имя функции для логов
check_name = check_callable.__name__.replace("assert_", "")
logging.info("🚀 Запуск проверки: %s", check_name)
with get_gp_conn() as conn:
check_callable(conn)
logging.info("✅ Проверка пройдена: %s", check_name)
def _log_dq_summary():
"""
Логирует итоговую сводку по качеству данных.
Эта задача выполняется после всех проверок и показывает общий результат.
"""
logging.info("🎉 Все проверки качества данных пройдены успешно!")
logging.info("📊 Качество данных в таблице orders соответствует требованиям.")
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
with DAG(
dag_id="csv_to_greenplum_dq",
start_date=datetime(2024, 1, 1),
schedule=None,
catchup=False,
default_args=default_args,
tags=["demo", "greenplum", "quality", "csv", "dq"],
description="Проверки качества данных после CSV → public.orders в Greenplum",
) as dag:
# Задача 1: Проверка существования таблицы
check_exists = PythonOperator(
task_id="check_orders_table_exists",
python_callable=_run_check,
op_args=[assert_orders_table_exists],
)
# Задача 2: Проверка соответствия схемы таблицы
check_schema = PythonOperator(
task_id="check_orders_schema",
python_callable=_run_check,
op_args=[assert_orders_schema],
)
# Задача 3: Проверка наличия данных
check_has_rows = PythonOperator(
task_id="check_orders_has_rows",
python_callable=_run_check,
op_args=[assert_orders_have_rows],
)
# Задача 4: Проверка отсутствия дубликатов
check_no_duplicates = PythonOperator(
task_id="check_order_duplicates",
python_callable=_run_check,
op_args=[assert_orders_no_duplicates],
)
# Задача 5: Итоговая сводка
dq_summary = PythonOperator(
task_id="data_quality_summary",
python_callable=_log_dq_summary,
)
# Определяем последовательность выполнения задач
check_exists >> check_schema >> check_has_rows >> check_no_duplicates >> dq_summary
-32
View File
@@ -1,32 +0,0 @@
from __future__ import annotations
"""
Учебный DAG: применяет DDL для базовой таблицы orders в Greenplum.
Запускается вручную перед CSV‑пайплайном или после изменения схемы.
"""
from datetime import datetime, timedelta
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="orders_base_ddl",
start_date=datetime(2024, 1, 1),
schedule=None,
catchup=False,
template_searchpath="/sql",
default_args=default_args,
tags=["demo", "greenplum", "ddl", "orders"],
description="Создаёт/обновляет базовую таблицу orders в схеме public",
) as dag:
apply_orders_ddl = PostgresOperator(
task_id="apply_orders_ddl",
postgres_conn_id=GREENPLUM_CONN_ID,
sql="base/orders_ddl.sql",
)
-216
View File
@@ -1,216 +0,0 @@
from __future__ import annotations
import logging
import os
from typing import List, Sequence, Tuple
import psycopg2
# Настройки для подключения к Greenplum. По умолчанию используем Airflow Connection,
# но при проблемах можно переключиться на ENV-подключение, установив GP_USE_AIRFLOW_CONN=false.
GP_CONN_ID = os.getenv("GP_CONN_ID", "greenplum_conn")
GP_USE_AIRFLOW_CONN = os.getenv("GP_USE_AIRFLOW_CONN", "true").lower() in (
"1",
"true",
"yes",
)
# Ожидаемая схема таблицы orders для проверки качества данных
EXPECTED_ORDERS_SCHEMA: List[Tuple[str, str]] = [
("order_id", "bigint"),
("order_ts", "timestamp without time zone"),
("customer_id", "bigint"),
("amount", "numeric"),
]
def get_gp_conn():
"""
Возвращает psycopg2 connection к Greenplum.
Приоритет подключения:
1. Через Airflow Connection (если настроено и доступно)
2. Прямое подключение по переменным окружения (фоллбек)
Returns:
psycopg2 connection object
"""
if GP_USE_AIRFLOW_CONN:
try:
from airflow.providers.postgres.hooks.postgres import PostgresHook
hook = PostgresHook(postgres_conn_id=GP_CONN_ID)
conn = hook.get_conn()
logging.info("✅ Подключение через Airflow Connection успешно")
return conn
except Exception as e:
logging.warning("⚠️ Не удалось подключиться через Airflow Connection: %s", e)
logging.info("🔄 Переключаемся на прямое подключение по ENV переменным")
# Фоллбек на прямое подключение по переменным окружения.
# Прямое подключение по переменным окружения
conn_params = {
"dbname": os.getenv("GP_DB", "gp_dwh"),
"user": os.getenv("GP_USER", "gpadmin"),
"password": os.getenv("GP_PASSWORD", ""),
"host": os.getenv("GP_HOST", "greenplum"),
"port": int(os.getenv("GP_PORT", "5432")),
}
logging.info(
"🔗 Подключение к Greenplum: %s:%s/%s",
conn_params["host"],
conn_params["port"],
conn_params["dbname"],
)
return psycopg2.connect(**conn_params)
def assert_orders_table_exists(conn) -> None:
"""
Проверяет наличие таблицы orders в схеме public.
Args:
conn: Подключение к Greenplum
Raises:
ValueError: Если таблица не найдена
"""
logging.info("🔍 Проверяем существование таблицы public.orders...")
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1
FROM pg_catalog.pg_tables
WHERE schemaname = 'public' AND tablename = 'orders'
"""
)
if cur.fetchone() is None:
raise ValueError(
"❌ Таблица public.orders не найдена; запусти DAG csv_to_greenplum."
)
logging.info("✅ Таблица public.orders существует")
def fetch_orders_schema(conn) -> Sequence[Tuple[str, str]]:
"""
Получает схему таблицы orders из information_schema.
Args:
conn: Подключение к Greenplum
Returns:
Список кортежей (имя_колонки, тип_данных)
"""
with conn.cursor() as cur:
cur.execute(
"""
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'orders'
ORDER BY ordinal_position
"""
)
return cur.fetchall()
def assert_orders_schema(conn) -> None:
"""
Проверяет, что схема таблицы orders соответствует ожидаемой.
Args:
conn: Подключение к Greenplum
Raises:
ValueError: Если схема не соответствует ожидаемой
"""
logging.info("📋 Проверяем схему таблицы orders...")
schema = fetch_orders_schema(conn)
logging.info("📊 Фактическая схема: %s", list(schema))
logging.info("📊 Ожидаемая схема: %s", EXPECTED_ORDERS_SCHEMA)
if list(schema) != EXPECTED_ORDERS_SCHEMA:
raise ValueError(
f"❌ Неожиданная схема orders: {schema}. Ожидали {EXPECTED_ORDERS_SCHEMA}."
)
logging.info("✅ Схема таблицы orders соответствует ожиданиям")
def fetch_orders_count(conn) -> int:
"""
Получает количество строк в таблице orders.
Args:
conn: Подключение к Greenplum
Returns:
Количество строк в таблице
"""
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM public.orders")
return cur.fetchone()[0]
def assert_orders_have_rows(conn) -> None:
"""
Проверяет, что таблица orders не пустая.
Args:
conn: Подключение к Greenplum
Raises:
ValueError: Если таблица пустая
"""
logging.info("📊 Проверяем наличие данных в таблице orders...")
row_count = fetch_orders_count(conn)
logging.info("📈 Количество строк в orders: %s", row_count)
if row_count <= 0:
raise ValueError(
"❌ Таблица public.orders пустая — запусти DAG csv_to_greenplum перед проверкой."
)
logging.info("✅ Таблица orders содержит данные (%s строк)", row_count)
def fetch_orders_duplicates(conn) -> int:
"""
Подсчитывает количество дубликатов по order_id.
Args:
conn: Подключение к Greenplum
Returns:
Количество дублирующихся order_id
"""
with conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) FROM (
SELECT order_id
FROM public.orders
GROUP BY order_id
HAVING COUNT(*) > 1
) d
"""
)
return cur.fetchone()[0]
def assert_orders_no_duplicates(conn) -> None:
"""
Проверяет, что в таблице нет дублей по order_id.
Args:
conn: Подключение к Greenplum
Raises:
ValueError: Если обнаружены дубликаты
"""
logging.info("🔍 Проверяем отсутствие дубликатов по order_id...")
duplicates = fetch_orders_duplicates(conn)
logging.info("📊 Найдено дубликатов: %s", duplicates)
if duplicates:
raise ValueError(
f"❌ Обнаружены дубли по order_id ({duplicates} шт.) — проверь загрузку данных."
)
logging.info("✅ Дубликаты не обнаружены")
+16 -6
View File
@@ -4,28 +4,38 @@
На первом этапе мы: На первом этапе мы:
- поднимаем отдельный контейнер `bookings-db` с Postgres; - поднимаем отдельный контейнер `bookings-db` с Postgres;
- устанавливаем в нём генератор демобазы `demodb` (репозиторий `postgrespro/demodb`); - инициализируем демобазу одним из двух способов (см. ниже);
- генерируем данные «день за днём» с помощью `make`‑команд. - генерируем данные «день за днём» с помощью `make`‑команд.
Основные команды см. в корневом `Makefile` (`bookings-init`, `bookings-generate-day`, `bookings-psql`) и в `README.md` проекта. ### Два способа инициализации
| Команда | Что делает | Время | Для кого |
|---------|-----------|-------|----------|
| `make bookings-init` | Быстрое восстановление из seed-дампа | ~18 сек | **Студенты** (рекомендуется по умолчанию) |
| `make bookings-generate` | Полная генерация с нуля через генератор demodb | часы | Разработчики, пересоздание дампа |
Основные команды см. в корневом `Makefile` (`bookings-init`, `bookings-generate`, `bookings-generate-day`, `bookings-psql`) и в `README.md` проекта.
## Источник и версия ## Источник и версия
- Репозиторий демобазы: `postgrespro/demodb`. - Репозиторий демобазы: `postgrespro/demodb`.
- Закреплённый коммит: `d68de192850237719f09b47688d5f3fc94653ca6` (см. `DEMODB_COMMIT` в корневом `Makefile`). - Закреплённый коммит: `866e56f7` (см. `DEMODB_COMMIT` в корневом `Makefile`).
## Что мы патчим в demodb ## Что мы патчим в demodb
- `install.sql`: `DROP DATABASE IF EXISTS demo WITH (FORCE)` — установка не падает, даже если демобазу держат активные сессии (например, из Airflow). - `install.sql`: `DROP DATABASE IF EXISTS demo WITH (FORCE)` — установка не падает, даже если демобазу держат активные сессии (например, из Airflow).
- `engine.sql`: два изменения в `engine_jobs1_sync.patch`: - `engine.sql`: два изменения в `engine_jobs1_sync.patch`:
- `busy()` игнорирует свой `pid`, чтобы не считать собственное подключение занятым; - `busy()` игнорирует свой `pid`, чтобы не считать собственное подключение занятым;
- `continue()` при `jobs=1` вызывает `process_queue` синхронно (без `dblink`), иначе генерация обрывается при выходе из `psql` и данных не появляется. - `continue()` при `jobs=1` вызывает `process_queue` синхронно (без `dblink`), иначе генерация обрывается при выходе из `psql` и данных не появляется.
- Патчи применяются автоматически в `make bookings-init`. Если что-то пошло не так, их можно накатить вручную: - `install.sql`: удалён хардкод `gen.connstr` без credentials (`install_connstr_no_hardcode.patch`).
- Дефолт: `BOOKINGS_JOBS=1` (синхронно, без dblink — оптимально для +1 дня, ~3 мин). При `jobs>1` — через dblink, но на WSL2 в 3× медленнее из-за lock contention на `gen.events`.
- Патчи применяются автоматически в `make bookings-generate` (генерация с нуля). Если что-то пошло не так, их можно накатить вручную:
``` ```
patch -d bookings/demodb -p1 --forward < bookings/patches/install_drop_if_exists.patch patch -d bookings/demodb -p1 --forward < bookings/patches/install_drop_if_exists.patch
patch -d bookings/demodb -p1 --forward < bookings/patches/engine_jobs1_sync.patch patch -d bookings/demodb -p1 --forward < bookings/patches/engine_jobs1_sync.patch
``` ```
## Быстрая проверка после init/обновления ## Быстрая проверка после init/обновления
- `make bookings-init` должен завершиться без ошибок; в `bookings.bookings` ожидаем >0 строк (примерно 15k). - `make bookings-init` (восстановление из дампа) должен завершиться без ошибок; в `bookings.bookings` ожидаем >0 строк (примерно 15k).
- `make bookings-generate` (генерация с нуля) тоже должен дать >0 строк, но занимает значительно больше времени.
- `make bookings-generate-day` добавляет следующий день после `max(book_date)`. - `make bookings-generate-day` добавляет следующий день после `max(book_date)`.
- Ручной вызов генерации из psql/DBeaver — только через DO-блок (подзапрос в аргументах `CALL` не работает): - Ручной вызов генерации из psql/DBeaver — только через DO-блок (подзапрос в аргументах `CALL` не работает):
```sql ```sql
@@ -36,6 +46,6 @@
SELECT date_trunc('day', max(book_date)) + interval '1 day' SELECT date_trunc('day', max(book_date)) + interval '1 day'
INTO v_next_day INTO v_next_day
FROM bookings.bookings; FROM bookings.bookings;
CALL continue(v_next_day); -- или CALL continue(v_next_day, 4) CALL continue(v_next_day, 1);
END $$; END $$;
``` ```
+34 -3
View File
@@ -11,7 +11,11 @@ DECLARE
BEGIN BEGIN
-- Проверяем, что демобаза установлена -- Проверяем, что демобаза установлена
IF to_regclass('bookings.bookings') IS NULL THEN IF to_regclass('bookings.bookings') IS NULL THEN
RAISE EXCEPTION 'Таблица bookings.bookings не найдена. Сначала выполните make bookings-init.'; RAISE EXCEPTION 'Таблица bookings.bookings не найдена. Сначала выполните make bookings-init или make bookings-generate.';
END IF;
IF v_jobs < 1 THEN
RAISE EXCEPTION 'bookings.jobs должен быть >= 1. Текущее значение: %.', v_jobs;
END IF; END IF;
-- Ищем последнюю сгенерированную дату -- Ищем последнюю сгенерированную дату
@@ -31,13 +35,32 @@ BEGIN
CALL generate(v_start_date, v_end_date, v_jobs); CALL generate(v_start_date, v_end_date, v_jobs);
ELSE ELSE
v_end_date := v_start_date + interval '1 day'; v_end_date := v_start_date + interval '1 day';
-- Убираем VACUUM-ивенты из очереди: генератор demodb кладёт
-- VACUUM ANALYZE всей БД каждую неделю модельного времени.
-- На 500k+ строках это занимает минуты и бессмысленно для +1 дня.
DELETE FROM gen.events WHERE type = 'VACUUM';
CALL continue(v_end_date, v_jobs); CALL continue(v_end_date, v_jobs);
END IF; END IF;
-- Ждём завершения фоновых джобов генератора, чтобы данные успели записаться -- continue() делает TRUNCATE gen.stat_jobs → AccessExclusiveLock.
WHILE busy() LOOP -- Без COMMIT воркеры не могут INSERT INTO gen.stat_jobs → deadlock.
COMMIT;
-- Ждём завершения каждого воркера через dblink_is_busy().
-- Раньше опрашивали busy() (pg_stat_activity + application_name),
-- но это ненадёжно: воркер может обрабатывать VACUUM ANALYZE (десятки минут),
-- или зависнуть в пустой очереди — а busy() не отличает «полезную работу»
-- от «бесконечного pg_sleep(1) при пустом gen.events».
-- dblink_is_busy() проверяет состояние конкретного dblink-соединения напрямую.
IF v_jobs > 1 THEN
FOR i IN 1 .. v_jobs LOOP
WHILE dblink_is_busy('job' || i) = 1 LOOP
PERFORM pg_sleep(1); PERFORM pg_sleep(1);
END LOOP; END LOOP;
END LOOP;
END IF;
PERFORM dblink_disconnect(unnest(dblink_get_connections())); PERFORM dblink_disconnect(unnest(dblink_get_connections()));
-- Если данных нет, останавливаемся с понятной ошибкой -- Если данных нет, останавливаемся с понятной ошибкой
@@ -45,4 +68,12 @@ BEGIN
IF v_bookings_cnt = 0 THEN IF v_bookings_cnt = 0 THEN
RAISE EXCEPTION 'Генератор demodb завершился, но bookings.bookings пустая. Проверьте применение патчей и логи генератора.'; RAISE EXCEPTION 'Генератор demodb завершился, но bookings.bookings пустая. Проверьте применение патчей и логи генератора.';
END IF; END IF;
-- Генерация закончена — возвращаем synchronous_commit = on и сбрасываем
-- буферы на диск. Без этого docker compose down может убить PostgreSQL
-- до записи WAL → данные пропадут (особенно на WSL2).
ALTER DATABASE demo SET synchronous_commit = on;
SET synchronous_commit = on;
END $$; END $$;
CHECKPOINT;
@@ -0,0 +1,14 @@
--- a/install.sql
+++ b/install.sql
@@ -24,8 +24,9 @@
-- use UTC to avoid daylight-saving problems
ALTER DATABASE demo SET timezone = 'Etc/UTC';
--- connection string for dblink
-ALTER DATABASE demo SET gen.connstr = 'dbname=demo';
+-- Строку подключения для dblink (gen.connstr) задаёт Makefile
+-- с credentials текущего пользователя БД. Не хардкодим здесь,
+-- иначе VACUUM и другие dblink-вызовы падают без пароля.
-- airlines company name
ALTER DATABASE demo SET gen.airlines_name = 'PostgresPro';
Binary file not shown.
+6 -9
View File
@@ -1,6 +1,8 @@
x-airflow-common-env: &airflow-env x-airflow-common-env: &airflow-env
TZ: ${TZ:-Europe/Moscow} TZ: ${TZ:-Europe/Moscow}
AIRFLOW__CORE__LOAD_EXAMPLES: "False" AIRFLOW__CORE__LOAD_EXAMPLES: "False"
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__API__AUTH_BACKENDS: "airflow.api.auth.backend.basic_auth,airflow.api.auth.backend.session"
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB} AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW__WEBSERVER__SECRET_KEY} AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW__WEBSERVER__SECRET_KEY}
AIRFLOW_CONN_GREENPLUM_CONN: postgresql://${GP_USER}:${GP_PASSWORD}@greenplum:${GP_PORT:-5432}/${GP_DB} AIRFLOW_CONN_GREENPLUM_CONN: postgresql://${GP_USER}:${GP_PASSWORD}@greenplum:${GP_PORT:-5432}/${GP_DB}
@@ -9,7 +11,6 @@ x-airflow-common-env: &airflow-env
x-airflow-common-volumes: &airflow-volumes x-airflow-common-volumes: &airflow-volumes
- ./airflow/dags:/opt/airflow/dags - ./airflow/dags:/opt/airflow/dags
- ./sql:/sql:ro - ./sql:/sql:ro
- airflow_data:/opt/airflow/data
- airflow_logs:/opt/airflow/logs - airflow_logs:/opt/airflow/logs
x-airflow-common-depends: &airflow-depends x-airflow-common-depends: &airflow-depends
@@ -32,7 +33,7 @@ services:
POSTGRES_PASSWORD: ${PG_PASSWORD} POSTGRES_PASSWORD: ${PG_PASSWORD}
POSTGRES_DB: ${PG_DB} POSTGRES_DB: ${PG_DB}
ports: ports:
- "5433:5432" - "${PGMETA_PORT:-5433}:5432"
volumes: volumes:
- pgmeta:/var/lib/postgresql/data - pgmeta:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -79,7 +80,7 @@ services:
PXF_SYNC_ON_START: ${PXF_SYNC_ON_START:-0} PXF_SYNC_ON_START: ${PXF_SYNC_ON_START:-0}
# Порты: внешний 5435 (на хосте) # Порты: внешний 5435 (на хосте)
ports: ports:
- "5435:5432" - "${GP_HOST_PORT:-5435}:5432"
volumes: volumes:
- ./sql:/sql:ro - ./sql:/sql:ro
- greenplum_data:/data - greenplum_data:/data
@@ -104,11 +105,10 @@ services:
<<: *airflow-env <<: *airflow-env
command: bash -lc "rm -f /opt/airflow/airflow-webserver.pid && exec airflow webserver" command: bash -lc "rm -f /opt/airflow/airflow-webserver.pid && exec airflow webserver"
ports: ports:
- "8080:8080" - "${AIRFLOW_WEB_PORT:-8080}:8080"
volumes: volumes:
- ./airflow/dags:/opt/airflow/dags - ./airflow/dags:/opt/airflow/dags
- ./sql:/sql:ro - ./sql:/sql:ro
- airflow_data:/opt/airflow/data
- airflow_logs:/opt/airflow/logs - airflow_logs:/opt/airflow/logs
- ./airflow/requirements.txt:/opt/airflow/requirements.txt - ./airflow/requirements.txt:/opt/airflow/requirements.txt
healthcheck: healthcheck:
@@ -133,7 +133,6 @@ services:
volumes: volumes:
- ./airflow/dags:/opt/airflow/dags - ./airflow/dags:/opt/airflow/dags
- ./sql:/sql:ro - ./sql:/sql:ro
- airflow_data:/opt/airflow/data
- airflow_logs:/opt/airflow/logs - airflow_logs:/opt/airflow/logs
- ./airflow/requirements.txt:/opt/airflow/requirements.txt - ./airflow/requirements.txt:/opt/airflow/requirements.txt
depends_on: depends_on:
@@ -152,12 +151,11 @@ services:
volumes: volumes:
- ./airflow/dags:/opt/airflow/dags - ./airflow/dags:/opt/airflow/dags
- ./sql:/sql:ro - ./sql:/sql:ro
- airflow_data:/opt/airflow/data
- airflow_logs:/opt/airflow/logs - airflow_logs:/opt/airflow/logs
command: > command: >
bash -lc " bash -lc "
set -e; set -e;
mkdir -p /opt/airflow/data /opt/airflow/logs && chown -R airflow:root /opt/airflow/data /opt/airflow/logs; mkdir -p /opt/airflow/logs && chown -R airflow:root /opt/airflow/logs;
# Дожидаемся готовности БД ретрая миграции # Дожидаемся готовности БД ретрая миграции
for i in {1..30}; do for i in {1..30}; do
su -s /bin/bash airflow -c \"PATH='/home/airflow/.local/bin:$${PATH}' airflow db migrate\" && break || echo 'waiting for pgmeta' && sleep 3; su -s /bin/bash airflow -c \"PATH='/home/airflow/.local/bin:$${PATH}' airflow db migrate\" && break || echo 'waiting for pgmeta' && sleep 3;
@@ -173,5 +171,4 @@ volumes:
pgmeta: pgmeta:
bookings_data: bookings_data:
greenplum_data: greenplum_data:
airflow_data:
airflow_logs: airflow_logs:
+52
View File
@@ -0,0 +1,52 @@
# Документация
## Знакомство и первое задание
После [запуска стенда](../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).
## Найти деталь во время работы
[Интерактивная карта](design/architecture-map.html) ведет от таблицы к ее
источникам, потребителям, SQL и пункту задания.
Откройте локальный файл `docs/design/architecture-map.html` в браузере;
просмотр исходного HTML в Gitea не показывает карту.
[Руководство](design/db_schema.md#как-открыть-карту) объясняет открытие и адресные ссылки.
| Вопрос | Основной документ |
|---|---|
| В каком порядке запускать слои? | [Порядок запуска DAG](dag_execution_order.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) |
| Какие поля и преобразования нужны в моем задании? | [ТЗ](assignment/analyst_spec.md) |
| Как называются служебные поля и что означают метки времени? | [Соглашения об именах](design/naming_conventions.md) |
| Какие проверки включить в DQ? | [Классы DQ-проверок](reference/dq_taxonomy.md) |
| Как устроены контейнеры, Connections и настройки? | [Справочник стенда](stack.md) |
## Проверки разработки и диагностика
[TESTING.md](../TESTING.md) описывает проверки изменений репозитория:
pytest, линтеры, проверку карты и прогоны стенда. Для выполнения первого
задания достаточно самопроверки из его инструкции.
Для углубленного разбора доступны
[исторический QA-план](reference/qa-plan.md) с многодневными и негативными
сценариями полной реализации и [заметки о генераторе Bookings](reference/bookings_db_issues.md).
QA-план требует отдельного тестового стенда и сверки с текущими SQL.
## История разработки
Планы слоев сохранены по прежним адресам:
[STG](design/bookings_stg_design.md), [ODS](design/bookings_ods_design.md),
[DDS](design/bookings_dds_design.md), [DM](design/bookings_dm_design.md).
В них остаются исходные обоснования, варианты SQL и последовательность
создания файлов. В начале каждого плана есть переход к действующему руководству.
Для реализации учебных загрузок используйте ТЗ и готовые SQL-примеры.
Полные материалы проектирования (PRD, дизайн заданий, архитектурные решения)
находятся в ветке `solution`.
+51
View File
@@ -0,0 +1,51 @@
# Доменная документация
Как скиллам читать доменную документацию этого репо перед работой с кодом.
## Что читать перед началом
- **`CONTEXT.md`** в корне репо, или
- **`CONTEXT-MAP.md`** в корне, если он есть: указывает на отдельный `CONTEXT.md` для каждого контекста. Читайте те, что относятся к теме.
- **`docs/adr/`**: читайте ADR, которые касаются области, с которой собираетесь работать. В мультиконтекстных репо также проверяйте `src/<context>/docs/adr/`.
Если каких-то из этих файлов нет — **работайте молча**. Не отмечайте их отсутствие и не предлагайте создать заранее. Скилл `/domain-modeling` (через `/grill-with-docs` и `/improve-codebase-architecture`) создаёт их по мере того, как термины и решения реально фиксируются.
## Структура файлов
Один контекст (этот репо и большинство других):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Несколько контекстов (признак — `CONTEXT-MAP.md` в корне):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← решения уровня системы
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← решения этого контекста
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Используйте словарь глоссария
Когда в выводе называете доменное понятие (в заголовке issue, предложении по рефакторингу, гипотезе, имени теста), берите термин из `CONTEXT.md`. Не съезжайте на синонимы, которых глоссарий избегает.
Если нужного понятия в глоссарии нет — это сигнал: либо вы придумываете слова, которых проект не использует (пересмотрите), либо в глоссарии пробел (отметьте для `/domain-modeling`).
## Отмечайте конфликты с ADR
Если ваш вывод противоречит существующему ADR, скажите об этом явно, а не переопределяйте молча:
> _Противоречит ADR-0007 (event-sourced orders), но стоит пересмотреть, потому что…_
+51
View File
@@ -0,0 +1,51 @@
# Трекер задач: Gitea
Issues и спеки этого репо живут в Gitea:
`https://git.dementev.space/ddmitry/airflow-greenplum`.
Все операции — через CLI [`tea`](https://gitea.com/gitea/tea).
`gh` и `glab` с этим хостом не работают.
`tea` сам определяет репо и логин по `git remote -v`, если запущен внутри клона
(логин `dementev`, пользователь `ddmitry`). Для машиночитаемого вывода
добавляйте `-o json` к любой команде `list`.
## Команды
- **Создать issue**: `tea issues create --title "..." --description "..."`. Для многострочного описания используйте heredoc или `"$(cat file.md)"`. `--labels a,b` вешает метки сразу при создании.
- **Прочитать issue**: `tea issues <номер> --comments`.
- **Список issues**: `tea issues list --state open -o json --fields index,title,state,labels,assignees,body`. Фильтры: `--labels`, `--state all|open|closed`, `--keyword`, `--assignee`.
- **Прокомментировать**: `tea comments add <номер> "..."` (короткая форма: `tea comment <номер> "..."`).
- **Добавить / снять метки**: `tea issues edit <номер> --add-labels "a,b"` / `--remove-labels "a,b"`. Метка должна существовать в репо: `tea labels create --name "..." --color "..." --description "..."`.
- **Закрыть**: `tea issues close <номер>`. Комментарий при закрытии команда не принимает — сначала `tea comments add`, потом `close`.
- **Pull requests**: `tea pulls create`, `tea pulls <номер>`, `tea pulls list`, `tea pulls merge` — тот же набор, что у `tea issues`. Комментарии к PR — через тот же `tea comments add <номер>`.
## PR как источник запросов
**PR как источник запросов: нет.** _(Поставьте `да`, если внешние PR нужно рассматривать как feature request; `/triage` читает этот флаг.)_
Если стоит `да`, PR проходят те же метки и состояния, что и issues, через `tea pulls`:
- **Прочитать PR**: `tea pulls <номер> --comments`; diff — `tea pulls checkout <номер>` и `git diff main...`.
- **Список внешних PR для триажа**: `tea pulls list --state open -o json --fields index,title,author,labels,body`, дальше оставить только PR от авторов, которые не являются участниками репо.
- **Комментарий / метки / закрытие**: `tea comments add`, `tea issues edit --add-labels`/`--remove-labels` (работает и для номеров PR), `tea pulls close`.
В Gitea issues и PR нумеруются в одном пространстве: `#42` может быть и тем, и другим. `tea issues 42` показывает поле `kind`; ещё можно смотреть `tea issues list --kind all`.
## Когда скилл говорит «опубликовать в трекер»
Создать issue в Gitea: `tea issues create`.
## Когда скилл говорит «взять тикет»
Выполнить `tea issues <номер> --comments`.
## Операции wayfinding
Используется `/wayfinder`. **Карта** — одна issue, **дочерние** issues — тикеты.
- **Карта**: одна issue с меткой `wayfinder:map`, в теле — Notes / Decisions-so-far / Fog. `tea issues create --labels wayfinder:map`.
- **Дочерний тикет**: issue со строкой `Part of #<карта>` в начале описания и меткой `wayfinder:<тип>` (`research`/`prototype`/`grilling`/`task`). После взятия в работу тикет назначается на исполнителя.
- **Блокировки**: в Gitea есть встроенные зависимости между issues, но `tea` их не поддерживает. Используйте строку `Blocked by: #<n>, #<n>` в начале описания дочернего тикета. Тикет разблокирован, когда все блокирующие закрыты.
- **Поиск фронтира**: `tea issues list --state open --labels wayfinder:task -o json` (повторить для каждого типа), оставить только детей этой карты, отбросить те, у кого есть открытый блокер в `Blocked by` или назначенный исполнитель; первый по порядку в карте.
- **Взять в работу**: `tea issues edit <n> --add-assignees ddmitry` — первая запись в сессии.
- **Закрыть**: `tea comments add <n> "<ответ>"`, затем `tea issues close <n>`, затем дописать указатель на контекст (суть + ссылка) в Decisions-so-far карты.
+18
View File
@@ -0,0 +1,18 @@
# Метки триажа
Скиллы оперируют пятью ролями триажа. Эта таблица сопоставляет роли с реальными метками в трекере этого репо.
| Роль в mattpocock/skills | Метка в нашем трекере | Значение |
| ------------------------ | --------------------- | ---------------------------------------------- |
| `needs-triage` | `needs-triage` | Мейнтейнеру нужно оценить issue |
| `needs-info` | `needs-info` | Ждём уточнений от автора |
| `ready-for-agent` | `ready-for-agent` | Полностью описано, можно отдавать AFK-агенту |
| `ready-for-human` | `ready-for-human` | Требует реализации человеком |
| `wontfix` | `wontfix` | Делать не будем |
Когда скилл упоминает роль (например, «повесить метку готовности для агента»), берите строку из правой колонки.
Правую колонку можно менять под свой словарь.
В Gitea-репо меток пока нет. При первом `/triage` создайте их:
`tea labels create --name needs-triage --color "#e4e669" --description "..."` и так далее для каждой строки таблицы.
+100
View File
@@ -0,0 +1,100 @@
# Учебные задания
В стенде уже работает цепочка от Bookings до `dm.sales_report`.
Вы дополните ее загрузками ODS, измерениями DDS и четырьмя витринами DM.
Поля, преобразования и бизнес-правила описаны в [ТЗ](analyst_spec.md).
## Подготовка
Пройдите шаги 1–6 [быстрого старта](../../README.md#быстрый-старт-основной-сценарий-bookings--stg--ods--dds--dm):
поднимите стенд, создайте таблицы, запустите эталонную цепочку и проверьте данные.
Затем прочитайте [руководство о структуре DWH](../design/db_schema.md).
Открывайте [карту](../design/architecture-map.html) рядом с заданием;
для Gitea есть [инструкция открытия локального HTML](../design/db_schema.md#как-открыть-карту).
Таблицы уже созданы, задачи Airflow подключены к SQL-файлам.
В учебных `*_load.sql` и `*_dq.sql` пока стоит `SELECT 1;`.
Вы заменяете эти заглушки; менять DAG и создавать таблицы заново не требуется.
## Первая загрузка: ODS
Начните с `ods.airplanes`, затем добавьте `ods.seats`.
На карте откройте [модели самолетов](../design/architecture-map.html#node=ods.airplanes)
и [места](../design/architecture-map.html#node=ods.seats): источники STG уже заполнены.
Позже обе таблицы понадобятся `dds.dim_airplanes` для характеристик модели
и расчета ее вместимости.
### Готовый пример
Пройдите [разбор stg.airports → ods.airports](../design/reading_the_pipeline.md#ods-airports).
Это близкий пример полного снимка с выбором батча, преобразованиями и дедупликацией.
Для первого задания этого разбора достаточно.
### Самостоятельная работа
Откройте [часть 1 ТЗ](analyst_spec.md#часть-1-ods-слой-operational-data-store).
Сверяйте с ней поля и правила каждой таблицы:
| Объект и требования | Файлы для реализации | DDL для сверки |
|---|---|---|
| [ods.airplanes](analyst_spec.md#11-odsairplanes) | [airplanes_load.sql](../../sql/ods/airplanes_load.sql), [airplanes_dq.sql](../../sql/ods/airplanes_dq.sql) | [airplanes_ddl.sql](../../sql/ods/airplanes_ddl.sql) |
| [ods.seats](analyst_spec.md#12-odsseats) | [seats_load.sql](../../sql/ods/seats_load.sql), [seats_dq.sql](../../sql/ods/seats_dq.sql) | [seats_ddl.sql](../../sql/ods/seats_ddl.sql) |
Пример проверок: [airports_dq.sql](../../sql/ods/airports_dq.sql).
Учтите разницу ключей: у модели это `airplane_code`, у места -
`(airplane_code, seat_no)`.
### Запуск и проверка
1. Дождитесь успешного `bookings_to_gp_stage` из подготовки.
Если STG уже заполнен, новый запуск для этой работы не нужен.
2. Запустите `bookings_to_gp_ods` целиком. Он выберет согласованный снимок
в `resolve_stg_batch_id`, затем выполнит
`load_ods_airplanes → dq_ods_airplanes → load_ods_seats → dq_ods_seats`.
SQL использует XCom этого запуска, поэтому отдельно от выбора батча
загрузку запускать не стоит.
3. В Greenplum посмотрите строки обеих таблиц: модели, типы числовых полей,
ключи мест и метки загрузки. Повторите ODS без нового запуска STG:
бизнес-строки и количество ключей должны сохраниться без дублей.
4. После реализации обеих таблиц запустите `bookings_validate` и откройте
группу `validate_ods`. Она проверит наличие данных, совпадение ключей
с выбранным STG-батчем, отсутствие дублей и NULL в ключах,
а также общий `_load_id` двух таблиц.
В `bookings_validate` автоматически запускаются все три группы.
Пока готовы только задания ODS, оценивайте `validate_ods`: ошибки DDS и DM
на этом этапе ожидаемы. После выполнения всех заданий должны пройти все группы.
Перед следующей загрузкой дождитесь завершения всего `bookings_validate`
и убедитесь, что `validate_dds.scd2_restore` завершился успешно.
SCD2-тест временно меняет данные маршрута, запускает вашу загрузку
и восстанавливает таблицы из копии. Параллельная загрузка может потерять
свои изменения при восстановлении. Если `scd2_restore` упал, сначала
разберите его лог.
`*_dq.sql` тоже нужно реализовать: успешный `SELECT 1;` ничего не проверяет.
## Следующие этапы
| Этап | Требования | Готовый пример рядом |
|---|---|---|
| DDS: `dim_airplanes`, `dim_passengers` | [Модели](analyst_spec.md#21-ddsdim_airplanes-scd1), [пассажиры](analyst_spec.md#22-ddsdim_passengers-scd1) | [dim_airports_load.sql](../../sql/dds/dim_airports_load.sql) |
| DDS: `dim_routes` | [Версии маршрута SCD2](analyst_spec.md#23-ddsdim_routes-scd2) | [Разбор сборки факта](../design/reading_the_pipeline.md#fact-flight-sales) показывает, как используется версия |
| DM: `airport_traffic` → `route_performance` → `monthly_overview` → `passenger_loyalty` | [Часть 3 ТЗ](analyst_spec.md#часть-3-dm-слой-data-marts--витрины) | [sales_report_load.sql](../../sql/dm/sales_report_load.sql) |
Перед DDS разберите [загрузку факта](../design/reading_the_pipeline.md#fact-flight-sales).
После реализации измерений выполните [пересчет факта и витрин](analyst_spec.md#пересчёт-факта-после-реализации-измерений).
Проверяйте готовый слой своей группой в `bookings_validate`.
## Если застряли
Вернитесь к карточке объекта на карте: там есть DDL, загрузка, DQ и пункт ТЗ.
Для конкретного вопроса используйте [соглашения об именах](../design/naming_conventions.md),
[справочник DQ](../reference/dq_taxonomy.md) и руководства DAG:
[ODS](../bookings_to_gp_ods.md), [DDS](../bookings_to_gp_dds.md), [DM](../bookings_to_gp_dm.md).
Полную реализацию можно посмотреть в ветке `solution`.
## Для менторов
Дизайн заданий и педагогическая логика находятся в ветке `solution`,
в `docs/design/assignment_design.md`.
+711
View File
@@ -0,0 +1,711 @@
# Техническое задание: расширение DWH авиаперевозок
> **Роль:** Вы — Data Engineer. Это ТЗ подготовлено аналитиком на основе
> бизнес-потребностей. Ваша задача — реализовать описанные таблицы и загрузки,
> опираясь на эталонный срез (витрина `dm.sales_report` и вся её цепочка).
>
> **Эталон для изучения:**
> - STG: весь слой — `bookings`, `tickets`, `flights`, `segments`, `boarding_passes`,
> `airports`, `airplanes`, `seats`, `routes` (реализован как образец)
> - ODS: `bookings`, `tickets`, `segments`, `flights`, `boarding_passes`, `airports`, `routes`
> - DDS: `dim_airports`, `dim_tariffs`, `dim_calendar`, `fact_flight_sales`
> - DM: `sales_report`
>
> SQL-скрипты эталона лежат в `sql/stg/`, `sql/ods/`, `sql/dds/`, `sql/dm/`.
Порядок запуска и проверки первой загрузки находится во
[входе в задания](README.md#первая-загрузка-ods). В каждом разделе ниже есть
ссылка на объект карты и близкий готовый пример. Карту открывайте
[локально в браузере](../design/db_schema.md#как-открыть-карту).
---
## Рекомендуемый порядок выполнения
> **Примечание:** STG-слой полностью реализован в эталоне (все 9 таблиц), `ods.routes`
> тоже эталонный. Ваше задание начинается с ODS: `airplanes` и `seats`.
1. **ODS** — `airplanes`, `seats` (практика TRUNCATE+INSERT по аналогии с `ods.airports`)
2. **DDS** — `dim_airplanes`, `dim_passengers` (SCD1 — новые измерения)
3. **DDS** — `dim_routes` (SCD2 — ключевой вызов курсовой)
4. **DM** — `airport_traffic` (простая витрина, похожа на `sales_report`)
5. **DM** — `route_performance` (Full Rebuild, работа с SCD2-измерением)
6. **DM** — `monthly_overview` (двухуровневая агрегация)
7. **DM** — `passenger_loyalty` (самая сложная, пересчёт истории)
Порядок выстроен от простого к сложному. Каждый шаг опирается на опыт
предыдущего. Вы можете двигаться в ином порядке, но убедитесь, что зависимости
слоёв соблюдены (ODS → DDS → DM).
---
## Общие правила
- **Нейминг полей:** см. [соглашения об именах](../design/naming_conventions.md) — единый источник
истины для служебных полей (`_load_id`, `_load_ts`, `created_at`, `updated_at`,
`valid_from`, `valid_to`, `hashdiff`, суффиксы `_bk` / `_sk`).
- **SQL-файлы:** располагайте в `sql/{слой}/{объект}_{роль}.sql`
(например, `sql/stg/airplanes_ddl.sql`, `sql/stg/airplanes_load.sql`).
- **DAG-интеграция:** таски для всех заданий уже подключены в DAG-файлах
соответствующего слоя (`PostgresOperator` + путь к SQL-файлу).
Вам нужно только заменить содержимое SQL-файлов — DAG менять не нужно.
- **Идемпотентность:** каждый скрипт загрузки должен быть безопасен
при повторном запуске (не создавать дубликатов).
- **Метки загрузки:** в ODS-снимках `airplanes` и `seats` сохраняйте в `_load_id`
идентификатор исходного STG-батча из `resolve_stg_batch_id`, как в `ods.airports`.
Так проверка сможет сопоставить ODS с тем снимком, из которого он загружен.
В заданиях DDS и DM используйте `'{{ run_id }}'` текущего запуска слоя.
---
## Почему STG-слой уже реализован
STG-слой (все 9 таблиц) и `ods.routes` полностью реализованы в эталоне. Причина:
Эталонный пайплайн использует **batch resolver** — механизм, который находит
согласованный набор данных во всех четырёх snapshot-таблицах (`stg.airports`,
`stg.airplanes`, `stg.routes`, `stg.seats`) по одному `_load_id`. Если любая
из этих таблиц пуста — batch resolver не найдёт общего батча, и весь ODS pipeline
не запустится.
Это означает: без заполненного STG невозможна загрузка ODS и всей последующей
цепочки (DDS, DM). Поэтому весь STG реализован как эталон — чтобы пайплайн
работал с первого запуска.
`ods.routes` тоже эталонный: факт `fact_flight_sales` использует его для резолвинга
аэропортов вылета/прилёта, независимо от студенческого `dds.dim_routes`.
Для первого задания разберите [загрузку `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): оба читают полный
снимок источника. `NOT EXISTS` защищает от повторной вставки ключа в том же `_load_id`.
---
## Часть 1. ODS-слой (Operational Data Store)
> **Цель:** Привести данные из STG к целевым типам, очистить, дедуплицировать.
> Справочники (`airplanes`, `seats`) загружаются стратегией TRUNCATE + INSERT
> из последнего согласованного батча STG. (`ods.routes` реализован в эталоне.)
>
> **Аналог для изучения:** [DDL airports](../../sql/ods/airports_ddl.sql),
> [загрузка airports](../../sql/ods/airports_load.sql) и [ее разбор](../design/reading_the_pipeline.md#ods-airports).
### 1.1. ods.airplanes
[На карте: модель самолета и ее источник](../design/architecture-map.html#node=ods.airplanes).
Готовый пример полного снимка: [ods.airports](../design/reading_the_pipeline.md#ods-airports).
**Описание:** Очищенный справочник моделей воздушных судов с правильными типами.
**Источник:** `stg.airplanes`
| Поле | Тип | Описание | Маппинг из STG |
|------|-----|----------|----------------|
| `airplane_code` | TEXT NOT NULL | Код модели (PK) | `airplane_code` |
| `model` | TEXT NOT NULL | Наименование модели | `model` (парсинг JSON: `model::JSON->>'ru'` — если JSON, иначе `model` как есть) |
| `range_km` | INTEGER | Дальность полёта, км | `range::INTEGER` |
| `speed_kmh` | INTEGER | Крейсерская скорость, км/ч | `speed::INTEGER` |
| `_load_id` | TEXT NOT NULL | Идентификатор исходного STG-батча | `'{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (текущее состояние справочника, TRUNCATE + INSERT).
**Стратегия загрузки:** TRUNCATE + INSERT (полный снимок из STG).
**Бизнес-правила:**
- Привести `range` и `speed` из TEXT в INTEGER.
- Если `model` хранится в JSON-формате — извлечь русское название (`->>'ru'`).
Изучите, как это сделано для `airports` в эталоне.
**Тип хранения Greenplum:** Append-Only Row.
**Distribution Key:** `airplane_code`
---
### 1.2. ods.seats
[На карте: места и связь с моделью](../design/architecture-map.html#node=ods.seats).
Пример выбора снимка и дедупликации: [ods.airports](../design/reading_the_pipeline.md#ods-airports).
**Описание:** Карта посадочных мест с корректными типами.
**Источник:** `stg.seats`
| Поле | Тип | Описание | Маппинг из STG |
|------|-----|----------|----------------|
| `airplane_code` | TEXT NOT NULL | Код модели (PK, часть 1) | `airplane_code` |
| `seat_no` | TEXT NOT NULL | Номер места (PK, часть 2) | `seat_no` |
| `fare_conditions` | TEXT NOT NULL | Класс обслуживания | `fare_conditions` |
| `_load_id` | TEXT NOT NULL | Идентификатор исходного STG-батча | `'{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (текущее состояние справочника, TRUNCATE + INSERT).
**Стратегия загрузки:** TRUNCATE + INSERT.
**Бизнес-правила:**
- Составной PK: `(airplane_code, seat_no)`.
- Значения `fare_conditions` ограничены: `Economy`, `Business`, `Comfort`.
**Тип хранения Greenplum:** Append-Only Row.
**Distribution Key:** `airplane_code`
---
---
## Часть 2. DDS-слой (Detailed Data Store) — Измерения
> **Цель:** Построить измерения звёздной схемы (Star Schema) с суррогатными
> ключами. SCD1-измерения обновляют атрибуты «на месте». SCD2-измерение
> хранит историю изменений через версионирование.
>
> **Аналог для SCD1:** [DDL dim_airports](../../sql/dds/dim_airports_ddl.sql),
> [загрузка dim_airports](../../sql/dds/dim_airports_load.sql).
### 2.1. dds.dim_airplanes (SCD1)
[На карте: характеристики и вместимость модели](../design/architecture-map.html#node=dds.dim_airplanes).
Пример SCD1: [dim_airports_load.sql](../../sql/dds/dim_airports_load.sql).
**Описание:** Измерение моделей самолётов. Содержит технические характеристики
и рассчитанное общее количество мест (обогащение из `ods.seats`).
**Источники:** `ods.airplanes` + `ods.seats`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `airplane_sk` | INTEGER NOT NULL | Суррогатный ключ | Генерация: `MAX(airplane_sk) + ROW_NUMBER()` |
| `airplane_bk` | TEXT NOT NULL | Бизнес-ключ (код модели) | `ods.airplanes.airplane_code` |
| `model` | TEXT NOT NULL | Название модели | `ods.airplanes.model` |
| `range_km` | INTEGER | Дальность полёта, км | `ods.airplanes.range_km` |
| `speed_kmh` | INTEGER | Скорость, км/ч | `ods.airplanes.speed_kmh` |
| `total_seats` | INTEGER | Общее кол-во мест | `COUNT(ods.seats.*) по airplane_code` |
| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT |
| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** SCD1 (обновление атрибутов без версионирования).
**Гранулярность:** Одна строка = одна модель самолёта.
**Стратегия загрузки:** UPSERT (UPDATE существующих + INSERT новых).
- UPDATE: если атрибуты (`model`, `range_km`, `speed_kmh`, `total_seats`)
изменились (проверка через `IS DISTINCT FROM`).
- INSERT: если `airplane_bk` ещё не существует в `dim_airplanes`.
**Обогащение:** Поле `total_seats` рассчитывается как количество строк
в `ods.seats` для данного `airplane_code` (LEFT JOIN).
**Генерация суррогатного ключа:** `MAX(airplane_sk) + ROW_NUMBER()`.
Загрузки одной таблицы должны выполняться последовательно. В DDS DAG
параллельные запуски ограничены параметром `max_active_runs=1`.
**Distribution Key:** `airplane_sk`
---
### 2.2. dds.dim_passengers (SCD1)
[На карте: пассажир из билетов](../design/architecture-map.html#node=dds.dim_passengers).
Пример обновления атрибутов и вставки новых ключей: [dim_airports_load.sql](../../sql/dds/dim_airports_load.sql).
**Описание:** Измерение пассажиров. Извлекается из таблицы билетов — каждый
уникальный `passenger_id` становится строкой измерения.
**Источник:** `ods.tickets`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `passenger_sk` | INTEGER NOT NULL | Суррогатный ключ | Генерация: `MAX(passenger_sk) + ROW_NUMBER()` |
| `passenger_id` | TEXT NOT NULL | Идентификатор пассажира (BK) | `ods.tickets.passenger_id` |
| `passenger_name` | TEXT NOT NULL | ФИО пассажира | `ods.tickets.passenger_name` |
| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT |
| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** SCD1.
**Гранулярность:** Одна строка = один уникальный пассажир.
**Стратегия загрузки:** UPSERT.
- Из `ods.tickets` один пассажир может встречаться в нескольких билетах.
Необходима дедупликация: берём последнее (актуальное) имя.
**Подсказка:** используйте `ROW_NUMBER() OVER (PARTITION BY passenger_id ORDER BY event_ts DESC NULLS LAST, _load_ts DESC, ticket_no DESC)`
для детерминированного выбора самой свежей записи.
- UPDATE: если `passenger_name` изменилось.
- INSERT: если `passenger_id` ещё не существует.
**Бизнес-правила:**
- `passenger_id` — бизнес-ключ. Один пассажир может иметь несколько билетов,
но в измерении должна быть ровно одна строка.
- Имя обновляется, если изменилось в последнем билете (SCD1).
**Distribution Key:** `passenger_sk`
---
### 2.3. dds.dim_routes (SCD2)
[На карте: версии маршрута](../design/architecture-map.html#node=dds.dim_routes).
В [разборе готового факта](../design/reading_the_pipeline.md#fact-joins) видно,
как загрузчик выбирает версию на дату рейса. Алгоритм самой SCD2-загрузки дан ниже.
> **Это ключевой вызов курсовой.** Реализация SCD Type 2 — обязательный навык
> для Data Engineer. Ниже — алгоритм текстом; SQL вы пишете самостоятельно.
> Если застряли — сверьтесь с веткой `solution`.
**Описание:** Измерение авиамаршрутов с полной историей изменений. Если у маршрута
меняется самолёт, аэропорт или расписание — создаётся новая версия, а старая
закрывается. Это позволяет видеть, какой маршрут действовал на момент конкретного
рейса.
**Источники:** `ods.routes` + `dds.dim_airports` + `dds.dim_airplanes`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `route_sk` | INTEGER NOT NULL | Суррогатный ключ | Генерация: `MAX(route_sk) + ROW_NUMBER()` |
| `route_bk` | TEXT NOT NULL | Бизнес-ключ маршрута | `ods.routes.route_no` |
| `departure_airport` | TEXT NOT NULL | Код аэропорта вылета | `ods.routes.departure_airport` |
| `arrival_airport` | TEXT NOT NULL | Код аэропорта прилёта | `ods.routes.arrival_airport` |
| `airplane_code` | TEXT NOT NULL | Код модели самолёта | `ods.routes.airplane_code` |
| `departure_city` | TEXT NOT NULL | Город вылета (денормализация) | `dds.dim_airports.city` по `departure_airport` |
| `arrival_city` | TEXT NOT NULL | Город прилёта (денормализация) | `dds.dim_airports.city` по `arrival_airport` |
| `airplane_model` | TEXT NOT NULL | Модель самолёта (денормализация) | `dds.dim_airplanes.model` |
| `total_seats` | INTEGER NOT NULL | Кол-во мест (денормализация) | `dds.dim_airplanes.total_seats` |
| `days_of_week` | TEXT | Дни недели | `ods.routes.days_of_week` (приведение к TEXT) |
| `departure_time` | TIME | Время вылета | `ods.routes.departure_time` |
| `duration` | INTERVAL | Длительность полёта | `ods.routes.duration` |
| `hashdiff` | TEXT NOT NULL | Хэш версионируемых атрибутов | См. формулу ниже |
| `valid_from` | DATE NOT NULL | Начало действия версии | Первая версия: `'1900-01-01'`; последующие: `CURRENT_DATE` |
| `valid_to` | DATE | Конец действия версии (NULL = текущая) | `NULL` для актуальных, `CURRENT_DATE` при закрытии |
| `created_at` | TIMESTAMP NOT NULL | Дата создания версии | `now()` при INSERT |
| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** SCD2 (версионирование с полуоткрытым интервалом).
**Гранулярность:** Одна строка = одна версия маршрута.
#### Формула hashdiff
```sql
md5(concat_ws('|',
departure_airport,
arrival_airport,
airplane_code,
days_of_week,
departure_time,
duration
))
```
Хэш считается по атрибутам, изменение которых означает «новую версию маршрута».
Денормализованные поля (`departure_city`, `arrival_city`, `airplane_model`,
`total_seats`) **не входят** в `hashdiff` — они обновляются отдельно (SCD1-refresh),
не создавая новую версию.
#### Алгоритм SCD2 (пошагово)
1. **Подготовка:** Рассчитайте `hashdiff` для каждого маршрута из `ods.routes`.
2. **Найдите изменения:** Сравните `hashdiff` текущих версий
(`valid_to IS NULL`) с новыми значениями из ODS.
3. **Закройте устаревшие версии:** Для маршрутов, у которых `hashdiff` изменился,
выполните UPDATE: `valid_to = CURRENT_DATE`, `updated_at = now()`.
4. **Закройте исчезнувшие маршруты:** Если маршрут был в DDS (`valid_to IS NULL`),
но отсутствует в ODS — тоже закройте: `valid_to = CURRENT_DATE`.
5. **Вставьте новые версии:** INSERT строки с `valid_to = NULL`
для изменённых и новых маршрутов. Значение `valid_from` зависит от того,
встречался ли маршрут в DDS ранее:
- **Первая версия** (маршрут ещё не было в DDS): `valid_from = '1900-01-01'`.
Sentinel-дата покрывает всю историю полётов, чтобы point-in-time JOIN
фактов на исторические рейсы находил корректный `route_sk`.
- **Последующие версии** (маршрут уже был): `valid_from = CURRENT_DATE`.
6. **SCD1-обновление денормализованных полей:** Обновите `departure_city`,
`arrival_city`, `airplane_model`, `total_seats` только у открытых версий
(`valid_to IS NULL`), даже если `hashdiff` не менялся. Новая версия при этом
не создается. Закрытые версии сохраняют значения на момент закрытия.
**Интервалы версий:** полуоткрытые `[valid_from, valid_to)`.
Текущая (актуальная) версия: `valid_to IS NULL`.
**Distribution Key:** `route_sk`
---
## Часть 3. DM-слой (Data Marts) — Витрины
> **Цель:** Построить аналитические витрины поверх DDS.
> Каждая витрина отвечает на конкретный бизнес-вопрос.
>
> **Аналог для изучения:** [DDL sales_report](../../sql/dm/sales_report_ddl.sql),
> [загрузка sales_report](../../sql/dm/sales_report_load.sql).
### 3.1. dm.airport_traffic
[На карте: аэропорт за день](../design/architecture-map.html#node=dm.airport_traffic).
Пример пересчета затронутых дат: [sales_report_load.sql](../../sql/dm/sales_report_load.sql).
Две роли аэропорта проследите в [готовом факте](../design/reading_the_pipeline.md#fact-joins).
**Бизнес-вопрос:** «Какой пассажиропоток и выручка у каждого аэропорта по дням?»
**Описание:** Ежедневная статистика по каждому аэропорту: сколько рейсов
вылетело/прилетело, сколько пассажиров, какая выручка. Аэропорт выступает
в двойной роли — и как точка вылета, и как точка прилёта.
**Источники:** `dds.fact_flight_sales` + `dds.dim_airports` + `dds.dim_calendar`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `traffic_date` | DATE NOT NULL | Дата (зерно, часть 1) | `dim_calendar.date_actual` |
| `airport_sk` | INTEGER NOT NULL | Суррогатный ключ аэропорта (зерно, часть 2) | `fact.departure_airport_sk` / `fact.arrival_airport_sk` (UNION ALL) |
| `airport_bk` | TEXT NOT NULL | Код аэропорта (денормализация) | `dim_airports.airport_bk` |
| `city` | TEXT NOT NULL | Город (денормализация) | `dim_airports.city` |
| `departures_flights` | INTEGER NOT NULL | Кол-во рейсов на вылет | `COUNT(DISTINCT flight_id)` роль «departure» |
| `departures_passengers` | INTEGER NOT NULL | Пассажиры на вылет | `SUM(is_boarded)` роль «departure» |
| `departures_revenue` | NUMERIC(15,2) NOT NULL | Выручка по вылетам | `SUM(price)` роль «departure» |
| `arrivals_flights` | INTEGER NOT NULL | Кол-во рейсов на прилёт | `COUNT(DISTINCT flight_id)` роль «arrival» |
| `arrivals_passengers` | INTEGER NOT NULL | Пассажиры на прилёт | `SUM(is_boarded)` роль «arrival» |
| `arrivals_revenue` | NUMERIC(15,2) NOT NULL | Выручка по прилётам | `SUM(price)` роль «arrival» |
| `total_passengers` | INTEGER NOT NULL | Общий пассажиропоток (вылет + прилёт) | `SUM(is_boarded)` обе роли |
| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT |
| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (UPSERT — текущее состояние метрик за день).
**Гранулярность:** Одна строка = один аэропорт за один день.
**Стратегия загрузки:** Инкрементальный UPSERT с HWM по `fact_flight_sales._load_ts`.
**Ключевой приём — Unpivot (UNION ALL):**
Каждый факт продажи порождает два «события»: вылет (departure) и прилёт (arrival).
Используйте UNION ALL для разворота факта в два ряда — по `departure_airport_sk`
и `arrival_airport_sk`. Затем сгруппируйте по `(traffic_date, airport_sk)`.
**Метрики:**
Здесь `SUM(is_boarded)` означает число строк с признаком посадки.
Поле булево; в SQL используйте `SUM(CASE WHEN is_boarded THEN 1 ELSE 0 END)`,
как в `sales_report_load.sql`.
- `departures_flights` — `COUNT(DISTINCT flight_id)` для роли «вылет»
- `departures_passengers` — `SUM(is_boarded)` для роли «вылет»
- `departures_revenue` — `SUM(price)` для роли «вылет»
- Аналогично для прилётов
- `total_passengers` — сумма всех `is_boarded` (обе роли)
**Важно:** Выручка специально разделена на `departures_revenue` и `arrivals_revenue`.
Суммировать их нельзя — это приведёт к двойному счёту (один билет учитывается и
в аэропорту вылета, и в аэропорту прилёта).
**Тип хранения Greenplum:** Heap (для UPSERT).
**Distribution Key:** `airport_sk`
---
### 3.2. dm.route_performance
[На карте: статистика маршрута](../design/architecture-map.html#node=dm.route_performance).
Соединение факта с измерениями и агрегация показаны в
[sales_report_load.sql](../../sql/dm/sales_report_load.sql); здесь по ТЗ нужен полный пересчет.
**Бизнес-вопрос:** «Какие маршруты самые эффективные? Где высокий load factor,
а где теряем пассажиров?»
**Описание:** Сводная статистика эффективности каждого маршрута за всё время.
Агрегация идёт по бизнес-ключу маршрута (`route_bk`), чтобы собрать данные
со всех исторических версий (SCD2).
**Источники:** `dds.fact_flight_sales` + `dds.dim_routes` + `dds.dim_calendar`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `route_bk` | TEXT NOT NULL | Бизнес-ключ маршрута (зерно) | `dim_routes.route_bk` (GROUP BY) |
| `route_sk` | INTEGER NOT NULL | SK актуальной версии маршрута | `dim_routes.route_sk` WHERE `valid_to IS NULL` |
| `departure_airport_bk` | TEXT NOT NULL | Код аэропорта вылета (денормализация) | `dim_routes.departure_airport` (актуальная версия) |
| `departure_city` | TEXT NOT NULL | Город вылета (денормализация) | `dim_routes.departure_city` (актуальная версия) |
| `arrival_airport_bk` | TEXT NOT NULL | Код аэропорта прилёта (денормализация) | `dim_routes.arrival_airport` (актуальная версия) |
| `arrival_city` | TEXT NOT NULL | Город прилёта (денормализация) | `dim_routes.arrival_city` (актуальная версия) |
| `airplane_bk` | TEXT NOT NULL | Код модели самолёта (денормализация) | `dim_routes.airplane_code` (актуальная версия) |
| `airplane_model` | TEXT NOT NULL | Модель самолёта (денормализация) | `dim_routes.airplane_model` (актуальная версия) |
| `total_seats` | INTEGER NOT NULL | Кол-во мест в самолёте (денормализация) | `dim_routes.total_seats` (актуальная версия) |
| `total_flights` | INTEGER NOT NULL | Всего рейсов | `COUNT(DISTINCT fact.flight_id)` |
| `total_tickets` | INTEGER NOT NULL | Всего проданных билетов | `COUNT(*)` |
| `total_boarded` | INTEGER NOT NULL | Всего посадок | `SUM(CASE WHEN is_boarded THEN 1 ELSE 0 END)` |
| `total_revenue` | NUMERIC(15,2) NOT NULL | Суммарная выручка | `SUM(fact.price)` |
| `avg_ticket_price` | NUMERIC(10,2) | Средняя цена билета | `total_revenue / NULLIF(total_tickets, 0)` |
| `avg_boarding_rate` | NUMERIC(5,4) NOT NULL | Средняя доля посадок | `AVG(CASE WHEN is_boarded THEN 1 ELSE 0 END)` |
| `avg_load_factor` | NUMERIC(5,4) | Средняя заполняемость кресел | `total_boarded / NULLIF(total_flights * total_seats, 0)` |
| `first_flight_date` | DATE | Дата первого рейса | `MIN(dim_calendar.date_actual)` |
| `last_flight_date` | DATE | Дата последнего рейса | `MAX(dim_calendar.date_actual)` |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (Full Rebuild — полная перезагрузка каждый запуск).
**Гранулярность:** Одна строка = один маршрут (по `route_bk`).
**Стратегия загрузки:** Full Rebuild (TRUNCATE + INSERT).
Витрина небольшая (~1000 строк) — проще пересоздать, чем вычислять дельту.
**Обработка SCD2:** Факты связаны с разными версиями маршрута (`route_sk`).
Агрегируйте метрики по `route_bk` (бизнес-ключу), чтобы собрать статистику
со ВСЕХ версий. Денормализованные атрибуты берите из ТЕКУЩЕЙ версии
(`valid_to IS NULL`).
**Метрики:**
- `avg_ticket_price` — `total_revenue / total_tickets` (защита от деления на 0
через `NULLIF`)
- `avg_boarding_rate` — `AVG(CASE WHEN is_boarded THEN 1 ELSE 0 END)`
- `avg_load_factor` — `total_boarded / (total_flights * total_seats)`
**Тип хранения Greenplum:** AO Column Store (zstd), сжатое колоночное хранение.
В этом задании небольшой результат полностью пересоздается при каждом запуске.
**Distribution Key:** `route_bk`
**Служебные поля:** `created_at` / `updated_at` здесь не нужны — при Full Rebuild
все строки пересоздаются. Достаточно `_load_ts`.
---
### 3.3. dm.monthly_overview
[На карте: месяц и модель самолета](../design/architecture-map.html#node=dm.monthly_overview).
Пример отбора затронутых периодов: [sales_report_load.sql](../../sql/dm/sales_report_load.sql).
В этом задании период и уровни агрегации определены ниже.
**Бизнес-вопрос:** «Какова помесячная динамика: рейсы, выручка, load factor
в разрезе типов самолётов?»
**Описание:** Помесячная сводка с точным расчётом средней заполняемости (load factor)
через двухуровневую агрегацию. Разрез — по типу самолёта.
**Источники:** `dds.fact_flight_sales` + `dds.dim_calendar` + `dds.dim_airplanes`
+ `dds.dim_routes`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `year_actual` | INTEGER NOT NULL | Год (зерно, часть 1) | `dim_calendar.year_actual` |
| `month_actual` | INTEGER NOT NULL | Месяц (зерно, часть 2) | `dim_calendar.month_actual` |
| `airplane_sk` | INTEGER NOT NULL | SK типа самолёта (зерно, часть 3) | `fact.airplane_sk` |
| `airplane_bk` | TEXT NOT NULL | Код модели (денормализация) | `dim_airplanes.airplane_bk` |
| `airplane_model` | TEXT NOT NULL | Название модели (денормализация) | `dim_airplanes.model` |
| `total_seats` | INTEGER NOT NULL | Кол-во мест (денормализация) | `dim_airplanes.total_seats` |
| `total_flights` | INTEGER NOT NULL | Кол-во уникальных рейсов | `COUNT(DISTINCT fact.flight_id)` |
| `total_tickets` | INTEGER NOT NULL | Кол-во проданных билетов | `SUM(tickets_sold_per_flight)` (уровень 2) |
| `total_boarded` | INTEGER NOT NULL | Кол-во посадок | `SUM(boarded_per_flight)` (уровень 2) |
| `total_revenue` | NUMERIC(15,2) NOT NULL | Суммарная выручка | `SUM(revenue_per_flight)` (уровень 2) |
| `avg_ticket_price` | NUMERIC(10,2) | Средняя цена билета | `total_revenue / NULLIF(total_tickets, 0)` |
| `avg_load_factor` | NUMERIC(5,4) | Средняя заполняемость кресел | `AVG(flight_load_factor)` (уровень 2) |
| `unique_routes` | INTEGER NOT NULL | Кол-во уникальных маршрутов | `COUNT(DISTINCT dim_routes.route_bk)` |
| `unique_passengers` | INTEGER NOT NULL | Кол-во уникальных пассажиров | `COUNT(DISTINCT fact.passenger_sk)` |
| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT |
| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (UPSERT — текущее состояние метрик за месяц).
**Гранулярность:** Одна строка = один месяц + один тип самолёта.
**Стратегия загрузки:** Инкрементальный UPSERT с HWM по `fact_flight_sales._load_ts`.
Пересчитываются только затронутые месяцы.
**Ключевой приём — Двухуровневая агрегация:**
В этом задании `avg_load_factor` - среднее долей занятых мест по рейсам:
каждый рейс имеет одинаковый вес. Отношение `SUM(boarded) / SUM(seats)`
дает среднее с весом по вместимости и при разной вместимости может отличаться.
Нужный расчет:
1. **Уровень 1 (рейс):** Для каждого `flight_id` посчитайте `load_factor = boarded / total_seats`.
2. **Уровень 2 (месяц):** Возьмите `AVG(load_factor)` по всем рейсам месяца.
**Подсчёт уникальных маршрутов:** Используйте `COUNT(DISTINCT route_bk)` из
`dim_routes` (т.к. `dim_routes` — SCD2, у одного маршрута может быть несколько
`route_sk`).
**Антипаттерн (Distribution Key):**
Распределение по `(year_actual, month_actual)` — это ошибка. В MPP-системах
распределение по дате ведёт к Data Skew (весь месяц на одном сегменте).
Используйте `airplane_sk`.
**Тип хранения Greenplum:** Heap (для UPSERT).
**Distribution Key:** `airplane_sk`
---
### 3.4. dm.passenger_loyalty
[На карте: история пассажира](../design/architecture-map.html#node=dm.passenger_loyalty).
В [sales_report_load.sql](../../sql/dm/sales_report_load.sql) посмотрите отделение
затронутых дат от полной истории за эти даты. Здесь единица пересчета - пассажир.
**Бизнес-вопрос:** «Кто наши самые лояльные пассажиры? Сколько они летают,
тратят, какой класс предпочитают?»
**Описание:** Профиль лояльности каждого пассажира: накопительные метрики
за всю историю перелётов. Самая сложная витрина — требует пересчёта
полной истории для затронутых пассажиров.
**Источники:** `dds.fact_flight_sales` + `dds.dim_passengers` + `dds.dim_tariffs`
+ `dds.dim_routes` + `dds.dim_calendar`
| Поле | Тип | Описание | Маппинг |
|------|-----|----------|---------|
| `passenger_sk` | INTEGER NOT NULL | SK пассажира (зерно) | `fact.passenger_sk` |
| `passenger_bk` | TEXT NOT NULL | Идентификатор пассажира (денормализация) | `dim_passengers.passenger_id` |
| `passenger_name` | TEXT NOT NULL | ФИО (денормализация) | `dim_passengers.passenger_name` |
| `total_bookings` | INTEGER NOT NULL | Кол-во бронирований (`book_ref`) | `COUNT(DISTINCT fact.book_ref)` |
| `total_flights` | INTEGER NOT NULL | Кол-во перелётов | `COUNT(*)` |
| `total_boarded` | INTEGER NOT NULL | Кол-во успешных посадок | `SUM(CASE WHEN is_boarded THEN 1 ELSE 0 END)` |
| `total_spent` | NUMERIC(15,2) NOT NULL | Общие траты | `SUM(fact.price)` |
| `avg_ticket_price` | NUMERIC(10,2) | Средняя цена билета | `total_spent / NULLIF(total_flights, 0)` |
| `favorite_fare_conditions` | TEXT | Самый частый класс обслуживания | Мода по `dim_tariffs.fare_conditions` |
| `unique_routes` | INTEGER NOT NULL | Кол-во уникальных маршрутов | `COUNT(DISTINCT dim_routes.route_bk)` |
| `first_flight_date` | DATE | Дата первого перелёта | `MIN(dim_calendar.date_actual)` |
| `last_flight_date` | DATE | Дата последнего перелёта | `MAX(dim_calendar.date_actual)` |
| `days_as_customer` | INTEGER | Стаж клиента (дней между первым и последним) | `last_flight_date - first_flight_date` |
| `created_at` | TIMESTAMP NOT NULL | Дата создания записи | `now()` при INSERT |
| `updated_at` | TIMESTAMP NOT NULL | Дата обновления | `now()` при UPDATE |
| `_load_id` | TEXT NOT NULL | Идентификатор батча | `'{{ run_id }}'` |
| `_load_ts` | TIMESTAMP NOT NULL | Момент загрузки | `now()` |
**Тип историзации:** Нет (UPSERT — текущий профиль пассажира).
**Гранулярность:** Одна строка = один пассажир.
**Стратегия загрузки:** Инкрементальный UPSERT по «затронутым ключам».
**Ключевой приём — Метод затронутых ключей:**
Это не обычный HWM по датам. Алгоритм:
1. Найдите `passenger_sk`, чьи факты изменились (HWM по `fact._load_ts`).
2. Для этих пассажиров **пересчитайте ВСЮ историю** — все их перелёты от начала.
3. UPSERT результаты.
Почему? Потому что метрики накопительные (`total_spent`, `first_flight_date`).
Нельзя просто добавить дельту — нужен полный пересчёт для корректности.
**Метрики:**
- `total_bookings` — `COUNT(DISTINCT book_ref)` по всем перелётам пассажира
- `favorite_fare_conditions` — мода (самое частое значение).
**Подсказка:** используйте `DISTINCT ON` с `ORDER BY COUNT(*) DESC`.
- `unique_routes` — `COUNT(DISTINCT route_bk)` (не `route_sk`! т.к. `dim_routes` — SCD2)
- `days_as_customer` — `last_flight_date - first_flight_date`
- `avg_ticket_price` — `total_spent / total_flights`
**Фильтрация NULL:** В `fact_flight_sales` поле `passenger_sk` может быть NULL
(защитная фильтрация от неконсистентных фактов). Исключите такие строки:
`WHERE passenger_sk IS NOT NULL`.
**Тип хранения Greenplum:** Heap (для UPSERT).
**Distribution Key:** `passenger_sk`
---
## Пересчёт факта после реализации измерений
[Факт на карте](../design/architecture-map.html#node=dds.fact_flight_sales) и
[разбор его INSERT и UPDATE](../design/reading_the_pipeline.md#найдите-запись-результата)
помогут проследить, когда заполняются ключи измерений.
После того, как вы реализуете все DDS-измерения (`dim_airplanes`, `dim_passengers`,
`dim_routes`), нужно пересчитать факт — он загружался ещё без ваших SK:
1. Запустите загрузку своих измерений (dim_airplanes, dim_passengers, dim_routes)
2. Выполните `TRUNCATE dds.fact_flight_sales;`
3. Перезапустите загрузку факта (DAG или только таск `load_dds_fact_flight_sales`)
4. Перезапустите DM-витрины
```sql
-- Проверка: все SK заполнены
SELECT
COUNT(*) AS total,
COUNT(departure_airport_sk) AS has_dep_sk,
COUNT(arrival_airport_sk) AS has_arr_sk,
COUNT(route_sk) AS has_route_sk,
COUNT(passenger_sk) AS has_passenger_sk,
COUNT(airplane_sk) AS has_airplane_sk
FROM dds.fact_flight_sales;
```
Это стандартная практика при **late-arriving dimensions** (опаздывающих измерениях):
факт загрузился раньше, чем были готовы справочники, поэтому SK заполнены не были.
После пересчёта все SK проставятся корректно.
---
## Валидация
После реализации слоя следуйте [порядку запуска и проверки](README.md#запуск-и-проверка).
В `bookings_validate` смотрите группу своего этапа:
- `validate_ods` проверяет данные `airplanes` и `seats`, их ключи
и согласованность с выбранным снимком STG.
- `validate_dds` проверяет наличие данных в учебных измерениях, уникальность
пассажиров и сценарий изменения маршрута SCD2 с восстановлением таблиц.
- `validate_dm` проверяет, что четыре учебные витрины содержат строки.
Этот DAG дополняет ваши `*_dq.sql`. В частности, непустота витрины еще
не подтверждает формулы метрик: проверяйте их по требованиям выше и
[классам DQ](../reference/dq_taxonomy.md). Перед следующей загрузкой дождитесь
всего DAG и успешного `validate_dds.scd2_restore`.
---
## Приложение: ER-диаграмма (источник)
```
bookings.airplanes_data bookings.seats
airplane_code (PK) airplane_code (FK) ──┐
model (JSON) seat_no │
range fare_conditions │
speed │
│ │
└──────────────────────────────────────────────┘
│
bookings.routes
route_no (PK, part 1)
validity (PK, part 2)
departure_airport (FK → airports_data)
arrival_airport (FK → airports_data)
airplane_code (FK → airplanes_data)
days_of_week
scheduled_time
duration
bookings.flights
flight_id (PK)
route_no (FK → routes)
status
scheduled_departure / scheduled_arrival
actual_departure / actual_arrival
bookings.bookings ─── bookings.tickets ─── bookings.segments
book_ref (PK) ticket_no (PK) ticket_no (FK)
book_date book_ref (FK) flight_id (FK)
total_amount passenger_id fare_conditions
passenger_name price
outbound
bookings.boarding_passes
ticket_no (FK)
flight_id (FK)
seat_no
boarding_no
boarding_time
```
+144
View File
@@ -0,0 +1,144 @@
# Загрузка ODS → DDS: `bookings_to_gp_dds`
DAG наполняет измерения и собирает `dds.fact_flight_sales`.
Одна строка факта соответствует сегменту билета: `(ticket_no, flight_id)`.
У одного билета может быть несколько таких строк.
Сборка показана [на карте](design/architecture-map.html#node=dds.fact_flight_sales)
и в [разборе SQL](design/reading_the_pipeline.md#fact-flight-sales).
Карту открывайте [локально](design/db_schema.md#как-открыть-карту).
## Перед запуском
Дождитесь успешных STG и ODS. Таблицы DDS должны быть созданы через
`bookings_dds_ddl` или общий `make ddl-gp`.
Затем в Airflow UI запустите `bookings_to_gp_dds` целиком.
DDS читает состояние ODS; передавать `stg_batch_id` ему не нужно.
| Объект | Что уже выполняется на `main` |
|---|---|
| `dim_calendar` | Заполнение датами с 2016-01-01 по 2030-12-31, только если таблица пуста |
| `dim_airports` | Обновление изменившихся атрибутов и вставка новых аэропортов с постоянными SK |
| `dim_tariffs` | Добавление новых классов обслуживания из `ods.segments` |
| `fact_flight_sales` | Обновление существующих сегментов и вставка новых пар `(ticket_no, flight_id)` |
| `dim_airplanes`, `dim_passengers`, `dim_routes` | Загрузки и DQ пока содержат `SELECT 1;`; требования находятся в [части 2 ТЗ](assignment/analyst_spec.md#часть-2-dds-слой-detailed-data-store--измерения) |
До выполнения заданий три студенческих измерения остаются пустыми.
Готовый факт при этом загружается: отсутствующие ключи измерений сохраняются
как `NULL`, чтобы можно было изучить цепочку до написания собственного SQL.
## Порядок задач
В [DAG](../airflow/dags/bookings_to_gp_dds.py) сначала выполняется
`load_dds_dim_calendar → dq_dds_dim_calendar`. Затем:
1. Параллельно могут загружаться `dim_airports`, `dim_airplanes`, `dim_tariffs`
и `dim_passengers`; у каждого измерения своя следующая DQ-задача.
2. `load_dds_dim_routes` ждет `dq_dds_dim_airports` и `dq_dds_dim_airplanes`,
затем выполняется `dq_dds_dim_routes`.
3. `load_dds_fact_flight_sales` ждет DQ всех пяти измерений после календаря.
4. `dq_dds_fact_flight_sales → finish_dds_summary` завершают запуск.
Это порядок задач Airflow. Источники строк и поля соединений показаны
на карте отдельно от него.
## Как работают готовые загрузки
[dim_calendar_load.sql](../sql/dds/dim_calendar_load.sql) проверяет
`NOT EXISTS (SELECT 1 FROM dds.dim_calendar LIMIT 1)`. Если есть хотя бы одна
строка, скрипт не вставляет даты. Повторный запуск не пересоздает календарь
и не дополняет частично заполненную таблицу.
[Его DQ](../sql/dds/dim_calendar_dq.sql) проверяет также покрытие дат рейсов ODS.
[dim_airports_load.sql](../sql/dds/dim_airports_load.sql) сохраняет SK
существующего аэропорта, обновляет изменившиеся атрибуты через
`IS DISTINCT FROM` и вставляет новые бизнес-ключи.
[dim_tariffs_load.sql](../sql/dds/dim_tariffs_load.sql) только добавляет
недостающие значения `fare_conditions`.
Метки измененных строк относятся к текущему запуску DDS;
календарь не содержит `_load_id` и `_load_ts`.
В [fact_flight_sales_load.sql](../sql/dds/fact_flight_sales_load.sql)
есть две операции:
- `UPDATE` меняет цену, место, признак посадки и метки загрузки, если эти
бизнес-поля изменились. SK измерений остаются прежними.
- `INSERT` собирает данные из ODS и измерений для пар
`(ticket_no, flight_id)`, которых еще нет в факте.
Факт не отбирает дельту по HWM. Скрипт читает ODS и сопоставляет бизнес-ключи
с уже записанными строками.
Аэропорты вылета и прилета находятся через `ods.routes → dim_airports`.
Из ODS выбирается строка с наибольшим `validity` для `route_no`; сортировка
идет по TEXT, дата рейса в этом отборе не участвует.
`route_sk` определяется отдельно: по дате вылета в полуоткрытом интервале
`[valid_from, valid_to)` измерения `dim_routes`. Из найденной версии берется
код модели для поиска `airplane_sk`.
[Разбор соединений](design/reading_the_pipeline.md#fact-joins) показывает,
какие строки сохраняет `LEFT JOIN` и что происходит при отсутствии измерения.
## Учебные измерения
Начните с [моделей самолетов](assignment/analyst_spec.md#21-ddsdim_airplanes-scd1)
и [пассажиров](assignment/analyst_spec.md#22-ddsdim_passengers-scd1), затем
переходите к [версиям маршрута](assignment/analyst_spec.md#23-ddsdim_routes-scd2).
Поля, hashdiff и алгоритм SCD2 определены в ТЗ.
У маршрута изменение версионируемых атрибутов создает новую строку.
Описательные поля из других измерений обновляются только у открытых версий
(`valid_to IS NULL`), без новой версии; закрытые версии сохраняют свои значения.
Это правило также записано в [алгоритме ТЗ](assignment/analyst_spec.md#алгоритм-scd2-пошагово).
После реализации измерений выполните
[пересчет факта и витрин](assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
Обычный повторный запуск DDS не заполнит SK в старых строках факта,
поскольку его `UPDATE` эти поля не меняет.
## Проверка результата
В `make gp-psql` выполните:
```sql
SELECT COUNT(*) FROM dds.dim_calendar;
SELECT COUNT(*) FROM dds.dim_airports;
SELECT COUNT(*) FROM dds.dim_tariffs;
SELECT
(SELECT COUNT(*) FROM dds.fact_flight_sales) AS fact_rows,
(SELECT COUNT(*) FROM ods.segments) AS segment_rows;
SELECT ticket_no, flight_id, departure_airport_sk, arrival_airport_sk,
route_sk, airplane_sk, passenger_sk, price, is_boarded
FROM dds.fact_flight_sales
ORDER BY ticket_no, flight_id
LIMIT 10;
```
На подготовленном стенде готовые измерения и факт непусты.
`fact_rows` должен точно совпасть с `segment_rows`.
[Готовая DQ факта](../sql/dds/fact_flight_sales_dq.sql) также ищет дубли
по паре ключей и проверяет обязательные поля.
На учебной ветке `NULL` в `passenger_sk`, `route_sk` и `airplane_sk`
вызывает только `NOTICE`. `NULL` в `tariff_sk` вызывает ошибку;
для строк с отсутствующим аэропортом и для `calendar_sk` допустима доля
не выше 1%. Эти послабления не заменяют проверку заполнения ключей после задания.
Свои `*_dq.sql` реализуйте по ТЗ и
[справочнику DQ](reference/dq_taxonomy.md). Для дополнительной проверки DDS
используйте `validate_dds` в `bookings_validate`, соблюдая
[порядок запуска и восстановления](assignment/README.md#запуск-и-проверка).
Далее переходите к [DM](bookings_to_gp_dm.md).
## Если загрузка не прошла
- Нет `dds.*`: выполните `bookings_dds_ddl`.
- DQ календаря сообщает о непокрытых датах: сравните даты вылета ODS
с диапазоном и содержимым `dim_calendar`. Повторный запуск загрузки
непустого календаря не заполнит пропуски.
- Число строк факта отличается от ODS: проверьте ключи билета, бронирования
и рейса, затем соединения в `fact_src`. Потеря строки на `INNER JOIN`
и размножение строк из-за нескольких подходящих версий дают разные причины
одного расхождения.
- Ошибка в вашей DQ маршрутов: проверьте открытые версии, интервалы и hashdiff
по ТЗ. Успешная заглушка `SELECT 1;` этих свойств не проверяет.
+113
View File
@@ -0,0 +1,113 @@
# Загрузка DDS → DM: `bookings_to_gp_dm`
DAG собирает витрины из факта и измерений DDS. На `main` полностью работает
`dm.sales_report`; остальные четыре загрузки и их DQ содержат `SELECT 1;`.
Успешный запуск с заглушками не означает, что все витрины наполнены.
На [карте sales_report](design/architecture-map.html#node=dm.sales_report)
можно проследить источники полей. Как сегменты билетов превращаются
в строки витрины, объясняет [руководство о структуре](design/db_schema.md#агрегация-фактов-в-витрине-продаж).
Карту открывайте [локально](design/db_schema.md#как-открыть-карту).
## Перед запуском
Дождитесь успешного `bookings_to_gp_dds`. Создайте таблицы через
`bookings_dm_ddl`, если еще не запускали его или общий `make ddl-gp`.
Затем запустите `bookings_to_gp_dm` в Airflow UI.
Общий порядок находится в [инструкции запуска DAG](dag_execution_order.md).
Для готовой `sales_report` достаточно готового факта, календаря, аэропортов
и тарифов. Для своих витрин сначала заполните нужные измерения и выполните
[пересчет факта после заданий DDS](assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
## Что делает sales_report
Зерно витрины: `(flight_date, departure_airport_sk, arrival_airport_sk, tariff_sk)`.
Одна строка содержит продажи сегментов за дату вылета, пару аэропортов и тариф.
`tickets_sold = COUNT(*)` считает строки факта в этой группе.
[sales_report_load.sql](../sql/dm/sales_report_load.sql) работает в три шага:
1. Находит даты вылета фактов с `_load_ts > MAX(_load_ts)` витрины.
Для пустой витрины нижняя граница равна `1900-01-01`.
2. Для этих дат пересчитывает агрегаты по всем их фактам во временную таблицу.
В расчет входят и строки, загруженные ранее: например, новая посадка
меняет итог дня, сохраняя остальные продажи этого дня.
3. Обновляет существующие группы при изменении проверяемых полей и вставляет
новые. `_load_id` записанных строк равен текущему `run_id` DM.
Если после пропуска запуска в DDS накопились факты с более новой `_load_ts`,
следующий запуск включит их даты в пересчет. Изменение только описания
измерения само по себе не выбирает дату: отбор привязан к метке факта.
[sales_report_dq.sql](../sql/dm/sales_report_dq.sql) берет даты из строк витрины,
записанных текущим запуском. Для них проверяет непустоту при наличии фактов,
дубли ключа, обязательные поля, неотрицательную выручку и долю посадок от 0 до 1.
Если витрина в этом запуске не менялась, DQ выводит `NOTICE` и завершает работу.
## Учебные витрины
Ниже указано ожидаемое поведение заданий. Поля и формулы находятся в ТЗ;
загрузку и DQ для каждого объекта нужно написать самостоятельно.
| Задание | Одна строка | Что нужно пересчитывать |
|---|---|---|
| [airport_traffic](assignment/analyst_spec.md#31-dmairport_traffic) | Дата и аэропорт | Полные итоги затронутых дат, с учетом ролей вылета и прилета |
| [route_performance](assignment/analyst_spec.md#32-dmroute_performance) | Маршрут по `route_bk` | Всю историю при каждом запуске, объединяя версии маршрута |
| [monthly_overview](assignment/analyst_spec.md#33-dmmonthly_overview) | Год, месяц и модель по `airplane_sk` | Затронутые месяцы; сначала метрики рейса, затем месяца |
| [passenger_loyalty](assignment/analyst_spec.md#34-dmpassenger_loyalty) | Пассажир по `passenger_sk` | Всю историю пассажиров, чьи факты изменились |
Готовый пример отбора затронутых дат находится в `sales_report_load.sql`.
В заданиях меняется единица пересчета: дата, месяц, пассажир или вся таблица.
Следуйте [порядку заданий](assignment/README.md#следующие-этапы), чтобы каждый
следующий расчет опирался на уже освоенный прием.
## Порядок задач
В [DAG](../airflow/dags/bookings_to_gp_dm.py) `start_dm` запускает пять
независимых веток. Каждая имеет вид
`load_dm_<витрина> → dq_dm_<витрина> → finish_dm_summary`.
Финальная задача ждет все пять DQ. Витрины читают DDS и не читают друг друга,
поэтому между их задачами загрузки нет зависимостей.
## Проверка результата
В `make gp-psql` посмотрите готовую витрину:
```sql
SELECT COUNT(*) FROM dm.sales_report;
SELECT flight_date, departure_airport_bk, arrival_airport_bk,
fare_conditions, tickets_sold, passengers_boarded, total_revenue
FROM dm.sales_report
ORDER BY flight_date DESC, departure_airport_bk, arrival_airport_bk,
fare_conditions
LIMIT 10;
SELECT COUNT(*) AS invalid_boarding_rows
FROM dm.sales_report
WHERE tickets_sold < passengers_boarded;
```
После первого успешного запуска `sales_report` непуста,
`invalid_boarding_rows = 0`. Возьмите одну строку и найдите на карте,
откуда пришли ее дата, два аэропорта и тариф.
После реализации своих витрин запустите `bookings_validate` и посмотрите
`validate_dm`. Проверки этой группы требуют непустые таблицы;
правильность метрик должна проверять ваша DQ по ТЗ.
Соблюдайте [правила запуска валидации](assignment/README.md#запуск-и-проверка):
в том же DAG выполняется активный SCD2-тест с восстановлением данных.
## Если загрузка не прошла
- Нет `dm.*`: выполните `bookings_dm_ddl`.
- `sales_report` пуста: проверьте наличие факта и соответствий его SK
календарю, аэропортам и тарифам. Витрина соединяет их через `INNER JOIN`.
- Учебная витрина пуста: проверьте замену загрузочной заглушки и наличие
необходимых SK факта после заданий DDS.
- Ошибка метрики: разберите одну группу и сравните результат со строками факта
и формулой в ТЗ. Дополнительные примеры проверок есть в
[справочнике DQ](reference/dq_taxonomy.md).
- `finish_dm_summary` не выполняется: найдите первую упавшую задачу,
откройте ее Log и исправьте причину до следующего запуска слоя.
+139
View File
@@ -0,0 +1,139 @@
# Загрузка STG → ODS: `bookings_to_gp_ods`
DAG приводит данные STG к целевым типам и оставляет одну строку на бизнес-ключ.
Снимки справочников заменяют предыдущее состояние; транзакционные таблицы
получают новые и измененные строки. Смысл ключей показан на
[карте ODS](design/architecture-map.html#node=ods.airports),
а готовый снимок разобран в [stg.airports → ods.airports](design/reading_the_pipeline.md#ods-airports).
Карту открывайте [локально](design/db_schema.md#как-открыть-карту).
## Перед запуском
Дождитесь успешного `bookings_to_gp_stage`. Создайте ODS через
`bookings_ods_ddl`, если еще не запускали его или общий `make ddl-gp`.
Подготовка стенда описана в [README](../README.md), последовательность слоев
в [порядке запуска DAG](dag_execution_order.md).
На `main` работают `airports`, `routes`, `bookings`, `tickets`, `flights`,
`segments` и `boarding_passes`. Загрузки и DQ для `airplanes` и `seats`
содержат `SELECT 1;`. Успешный DAG с этими заглушками оставит две таблицы пустыми.
Их нужно заполнить в [первом задании](assignment/README.md#первая-загрузка-ods).
## Запуск и выбор снимка
В Airflow UI запустите `bookings_to_gp_ods` целиком.
Первая задача, `resolve_stg_batch_id`, выбирает последний общий `_load_id`
четырех справочников STG: `airports`, `airplanes`, `routes`, `seats`.
Общие батчи находятся через `INTERSECT`, последний выбирается по времени
загрузки их строк. Результат передается в SQL через XCom.
Resolver проверяет наличие батча в таблицах, а не статус запуска STG в Airflow.
Поэтому сначала дождитесь всех STG DQ. Если нужен определенный снимок,
передайте в Trigger DAG JSON с его реальным `_load_id`:
```json
{"stg_batch_id": "<Run ID успешного запуска STG>"}
```
Явно переданный идентификатор resolver принимает без проверки согласованности.
Убедитесь, что он есть во всех четырех справочниках; произвольная строка
или Run ID запуска ODS здесь не подойдут.
## Что выполняет загрузка
| Объекты | Реализация на `main` | Как отбираются данные |
|---|---|---|
| `airports`, `routes` | Готовые `TRUNCATE + INSERT` | Один снимок по `stg_batch_id`, дедупликация по бизнес-ключу |
| `airplanes`, `seats` | Заглушки; требуемый полный снимок описан в [части 1 ТЗ](assignment/analyst_spec.md#часть-1-ods-слой-operational-data-store) | По выбранному `stg_batch_id` после реализации задания |
| `bookings`, `tickets`, `flights`, `segments`, `boarding_passes` | Готовые `UPDATE + INSERT` через временную таблицу | Новые строки STG по `_load_ts` относительно `MAX(_load_ts)` соответствующей ODS-таблицы |
В [airports_load.sql](../sql/ods/airports_load.sql) и
[routes_load.sql](../sql/ods/routes_load.sql) сначала очищается таблица,
затем вставляется выбранный снимок. Вместе с заменой исчезают ключи,
которых больше нет в снимке. Это один полный пересчет небольшой AO-таблицы;
отдельного `DELETE` для синхронизации ключей нет.
Для транзакционных таблиц, например [bookings](../sql/ods/bookings_load.sql),
дельта фиксируется во временной таблице до `UPDATE` и `INSERT`.
Поэтому обе операции читают один набор строк, даже когда обновление меняет
максимальную `_load_ts` в ODS. Дельта может охватывать несколько STG-батчей;
параметр `stg_batch_id` ее не ограничивает.
[flights_load.sql](../sql/ods/flights_load.sql) дополнительно читает историю
STG для рейсов, на которые ссылаются новые сегменты. Так сегмент может получить
свой рейс, даже если тот пришел раньше текущего инкремента.
Метки загрузки имеют два варианта:
- У снимков `_load_id` сохраняет выбранный STG-батч, `_load_ts = now()`.
- У транзакционных строк обе метки переносятся из STG. Сохраненная `_load_ts`
служит границей следующего инкремента.
Названия и смысл служебных полей собраны в
[соглашениях об именах](design/naming_conventions.md).
## Порядок задач на main
В [DAG](../airflow/dags/bookings_to_gp_ods.py) после resolver одновременно
могут стартовать три ветки. В таблице указаны зависимости задач Airflow:
| Загрузка | Что должно завершиться перед ней |
|---|---|
| `load_ods_bookings`, `load_ods_airports`, `load_ods_airplanes` | `resolve_stg_batch_id` |
| `load_ods_tickets` | `dq_ods_bookings` |
| `load_ods_routes` | `dq_ods_airports` |
| `load_ods_seats` | `dq_ods_airplanes` |
| `load_ods_flights` | `dq_ods_routes` |
| `load_ods_segments` | `dq_ods_flights` и `dq_ods_tickets` |
| `load_ods_boarding_passes` | `dq_ods_segments` |
Каждый `load_ods_*` ведет к своему `dq_ods_*`.
`finish_ods_summary` ждет `dq_ods_boarding_passes` и `dq_ods_seats`.
На `main` маршруты не ждут студенческий `airplanes`; проверка ссылки
`routes.airplane_code → airplanes` в их DQ отключена.
## Проверка результата
В `make gp-psql` посмотрите строки готовых таблиц:
```sql
SELECT airport_code, airport_name, city, _load_id, _load_ts
FROM ods.airports
ORDER BY airport_code
LIMIT 10;
SELECT COUNT(*) FROM ods.bookings;
SELECT COUNT(*) FROM ods.tickets;
SELECT COUNT(*) FROM ods.segments;
SELECT book_ref, COUNT(*)
FROM ods.bookings
GROUP BY book_ref
HAVING COUNT(*) > 1;
```
После первого полного запуска готовые таблицы непусты; последний запрос
не должен вернуть строк. У `ods.airports` видны русские названия и выбранный
STG-батч. Сравните его с XCom задачи `resolve_stg_batch_id`.
Готовые DQ проверяют ключи, обязательные поля и связи. Для снимков
[airports_dq.sql](../sql/ods/airports_dq.sql) и
[routes_dq.sql](../sql/ods/routes_dq.sql) ищут и пропущенные, и лишние ключи
относительно выбранного батча. DQ транзакционных таблиц проверяют покрытие
ключей выбранного батча, хотя загрузка читает всю дельту по HWM.
Пустой транзакционный батч допустим, пустой снимок справочника считается ошибкой.
После реализации `airplanes` и `seats` пройдите
[самопроверку первого задания](assignment/README.md#запуск-и-проверка).
Дальше можно переходить к [DDS](bookings_to_gp_dds.md).
## Если загрузка не прошла
- `stg_batch_id не найден`: проверьте, что STG загрузил все четыре справочника.
Одна заполненная `stg.bookings` для выбора снимка недостаточна.
- Нет `ods.*`: выполните `bookings_ods_ddl`.
- Ошибка приведения типа: в логе найдите поле, затем сравните значение
в STG с преобразованием в `*_load.sql` и типом в DDL.
- DQ сообщает о пропущенных ключах: сопоставьте выбранный батч, SQL загрузки
и конкретную связь на карте. Для учебных таблиц проверьте, что заменены
обе заглушки: загрузка и DQ.
+107
View File
@@ -0,0 +1,107 @@
# Загрузка Bookings → STG: `bookings_to_gp_stage`
DAG читает девять таблиц Bookings через внешние таблицы PXF и сохраняет
строки в Greenplum. Весь STG, включая DQ, уже реализован на `main`.
Смысл бронирования, билета и сегмента разобран в
[руководстве о структуре](design/db_schema.md); на карте начните со
[stg.bookings](design/architecture-map.html#node=stg.bookings).
Карту открывайте [локально в браузере](design/db_schema.md#как-открыть-карту).
## Перед запуском
Выполните подготовку из [быстрого старта](../README.md#быстрый-старт-основной-сценарий-bookings--stg--ods--dds--dm):
стенд должен работать, база `demo` содержать исходные данные,
а DAG `bookings_stg_ddl` создать внутренние таблицы `stg.*` и внешние `stg.*_ext`.
Альтернатива DDL DAG: `make ddl-gp`, который создает все четыре слоя.
Подключения `bookings_db` и `greenplum_conn` заданы через `AIRFLOW_CONN_...`
в Compose и могут не отображаться в списке Connections.
Порты, учетные данные и настройка PXF описаны в [справочнике стенда](stack.md).
В Airflow UI (по умолчанию http://localhost:8080) запустите
`bookings_to_gp_stage` через Trigger DAG и дождитесь завершения всех задач.
Каждый полный запуск добавляет следующий учебный день в источник.
Дата запуска Airflow не задает день генерируемых данных.
## Какие строки загружаются
[generate_bookings_day](../sql/src/bookings_generate_day_if_missing.sql)
вызывает `continue(...)` до конца следующего дня после `MAX(book_date)`.
Для пустой установленной базы он вызывает `generate(...)`, используя настройки
`bookings.start_date`, `bookings.init_days` и `bookings.jobs`.
Эти настройки задаются при инициализации Bookings.
Внешняя таблица `*_ext` читает источник; внутренний STG хранит полученные строки.
Бизнес-поля сохраняются как TEXT. `_load_id` равен `run_id` запуска STG,
`_load_ts` фиксирует время записи. Опорное `event_ts` зависит от таблицы:
| Таблицы | Отбор строк | Готовый SQL |
|---|---|---|
| `bookings` | Новые `book_date` | [bookings_load.sql](../sql/stg/bookings_load.sql) |
| `tickets`, `segments`, `boarding_passes` | По `book_date` связанного бронирования через билеты | [tickets](../sql/stg/tickets_load.sql), [segments](../sql/stg/segments_load.sql), [boarding_passes](../sql/stg/boarding_passes_load.sql) |
| `flights` | Новые `scheduled_departure` | [flights_load.sql](../sql/stg/flights_load.sql) |
| `airports`, `airplanes`, `routes`, `seats` | Полный снимок при каждом запуске; `event_ts = now()` | [airports](../sql/stg/airports_load.sql), [airplanes](../sql/stg/airplanes_load.sql), [routes](../sql/stg/routes_load.sql), [seats](../sql/stg/seats_load.sql) |
У каждой инкрементальной таблицы своя нижняя граница:
`MAX(event_ts)` по предыдущим батчам этой таблицы. Верхней границы нет.
`NOT EXISTS` не дает повторно вставить тот же ключ в текущий `_load_id`.
У справочников сохраняются снимки разных запусков, поэтому повторение
бизнес-ключа между батчами ожидаемо.
Такой инкремент не перечитывает все старые строки источника: например,
изменение рейса с датой ниже границы само по себе в новый STG-батч не попадет.
Для проверки повторяемости загрузки учитывайте и генератор:
повторный запуск `generate_bookings_day` снова продвинет источник на день.
## Порядок задач
В [DAG](../airflow/dags/bookings_to_gp_stage.py) после каждой загрузки стоит DQ.
Ниже стрелки показывают порядок выполнения задач Airflow.
Связи строк по ключам смотрите на карте.
1. `generate_bookings_day → load_bookings_to_stg → check_row_counts`.
2. `load_tickets_to_stg → check_tickets_dq`.
3. После билетов параллельно могут выполняться загрузки аэропортов и моделей.
`load_routes_to_stg` ждет `check_airports_dq` и `check_airplanes_dq`;
`load_seats_to_stg` ждет только `check_airplanes_dq`.
4. После проверки маршрутов идет цепочка
`flights → segments → boarding_passes`, у каждой таблицы своя DQ-задача.
5. `finish_summary` ждет `check_boarding_passes_dq` и `check_seats_dq`.
Инкрементальные DQ допускают пустое окно: выводят `NOTICE` и завершаются успешно.
Пустой источник справочника считается ошибкой. Сверки выполняются для текущего
батча; примеры: [bookings_dq.sql](../sql/stg/bookings_dq.sql) и
[airports_dq.sql](../sql/stg/airports_dq.sql).
## Проверка результата
Откройте Greenplum командой `make gp-psql`:
```sql
SELECT COUNT(*) FROM stg.bookings;
SELECT book_ref, book_date, event_ts, _load_ts, _load_id
FROM stg.bookings
ORDER BY _load_ts DESC, book_ref
LIMIT 10;
```
После первого успешного запуска `stg.bookings` непуста. Сопоставьте `_load_id`
с Run ID в Airflow и объясните, чем `book_date` отличается от `_load_ts`.
Для просмотра первоисточника используйте `make bookings-psql`.
Далее запускайте [ODS](bookings_to_gp_ods.md).
Общий порядок слоев находится в [порядке запуска DAG](dag_execution_order.md).
## Если загрузка не прошла
Откройте Log первой упавшей задачи в Airflow. Если упала DQ, начните с ключа
и батча, названных в сообщении, затем откройте ее SQL из карточки таблицы.
- Нет базы `demo`: пройдите инициализацию из README. `make bookings-init`
пересоздает исходную базу из seed-дампа, поэтому не используйте его для
обычного повторного запуска уже заполненного стенда.
- Нет `stg.*` или `stg.*_ext`: выполните `bookings_stg_ddl`.
- Ошибка PXF или соединения: проверьте `docker compose ps`, логи `greenplum`
и `bookings-db`, затем настройки из [справочника стенда](stack.md).
Повторное применение DDL само по себе не исправляет недоступное подключение.
+56
View File
@@ -0,0 +1,56 @@
# Порядок запуска DAG
Все загрузочные DAG в стенде запускаются вручную (`schedule=None`).
Завершение одного слоя не запускает следующий автоматически.
Подготовка Docker и источника находится в [быстром старте](../README.md#быстрый-старт-основной-сценарий-bookings--stg--ods--dds--dm).
## Создание таблиц
Перед первой загрузкой выполните по порядку:
1. `bookings_stg_ddl` создает STG и внешние таблицы `*_ext` для чтения через PXF.
2. `bookings_ods_ddl` создает типизированные таблицы ODS.
3. `bookings_dds_ddl` создает измерения и факт DDS.
4. `bookings_dm_ddl` создает витрины DM.
Альтернатива четырем запускам: `make ddl-gp`.
DDL создает структуру; для наполнения нужны отдельные загрузки.
В заданиях таблицы и задачи уже подготовлены, поэтому перед каждым
повторным запуском загрузки применять DDL не требуется.
## Проход данных по слоям
Запускайте следующий DAG после успешного завершения всех задач предыдущего.
Эта последовательность показывает зависимости загрузок; связи строк и зерно
объектов смотрите на [карте](design/architecture-map.html), открытой
[локально](design/db_schema.md#как-открыть-карту).
| Порядок | DAG и руководство | Что меняется в данных |
|---|---|---|
| 1 | [bookings_to_gp_stage](bookings_to_gp_stage.md) | Генератор добавляет учебный день в Bookings; STG получает инкременты транзакций и полные снимки справочников с `_load_id = run_id` STG |
| 2 | [bookings_to_gp_ods](bookings_to_gp_ods.md) | Resolver выбирает общий STG-батч справочников; их снимки заменяются целиком. Транзакционные загрузки читают дельту STG по `_load_ts` и обновляют строки по бизнес-ключу |
| 3 | [bookings_to_gp_dds](bookings_to_gp_dds.md) | Сначала выполняются загрузки измерений, затем факт: вставляются новые пары `(ticket_no, flight_id)`, у существующих обновляются цена, место и признак посадки |
| 4 | [bookings_to_gp_dm](bookings_to_gp_dm.md) | `sales_report` пересчитывает полные агрегаты дат, затронутых изменениями факта; поведение остальных витрин задано в ТЗ |
На исходной `main` готовы весь STG, семь таблиц ODS, календарь, аэропорты,
тарифы, факт и `sales_report`. Загрузки и DQ остальных объектов являются
[заданиями](assignment/README.md). Успех этих задач с `SELECT 1;` оставляет
таблицы пустыми. У факта до реализации измерений могут быть `NULL` в
`passenger_sk`, `route_sk` и `airplane_sk`.
## Повторный запуск и проверка задания
Чтобы получить следующий учебный день, повторите цепочку STG → ODS → DDS → DM.
Когда проверяете правку SQL своего слоя на уже загруженных источниках,
начните с этого слоя; новый STG запустит генератор еще раз.
Подробности выбора батча и повторного запуска находятся в руководстве слоя.
После реализации измерений обычный повтор DDS не заполнит старые SK факта.
Выполните [пересчет по ТЗ](assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений),
затем обновите DM.
Для самопроверки задания откройте
[«Запуск и проверка»](assignment/README.md#запуск-и-проверка).
`bookings_validate` запускайте отдельно от загрузок: дождитесь всего DAG
и успешного `validate_dds.scd2_restore` перед следующей записью в хранилище.
Тест SCD2 временно меняет данные и восстанавливает их из копии.
File diff suppressed because it is too large. Load diff
+69
View File
@@ -0,0 +1,69 @@
# Исходники карты DWH
Готовый файл - [../architecture-map.html](../architecture-map.html).
Откройте его локально в браузере. Для самой карты сервер и интернет не требуются.
Ссылки на SQL, DAG и Markdown открывают Gitea, поэтому для переходов нужен интернет.
Учебный маршрут находится в [руководстве о структуре](../db_schema.md).
## Редактирование и сборка
- `map.json` - единственный каталог объектов, полей, связей и текстов карточек.
- `template.html` - интерфейс. SVG, карточки, постоянная раскладка и выделение
адаптированы из `clickstream-data-platform/docs/handbook/architecture-map/template.html`.
- `render.py` - сборка автономного HTML и статические проверки. Нужны uv и Python
3.11 или новее; пакеты Python и JavaScript не требуются. JSON заменяет YAML
образца, чтобы сборка обходилась стандартной библиотекой.
Из корня репозитория:
```bash
uv run docs/design/architecture-map/render.py
uv run docs/design/architecture-map/render.py --check
```
Правьте исходники, затем пересобирайте HTML. `--check` сверяет:
имена объектов с DDL/PXF, важные поля и поля связей, статусы загрузок и DQ,
входы готовых SQL со стрелками потока, файлы и разделы по ссылкам,
уникальность идентификаторов и позиций, свежесть HTML.
Проверка входов учитывает именованные таблицы в FROM/JOIN;
смысл соединений нужно сверять при изменении SQL вручную.
`repository_url` в `map.json` задает адрес Gitea с окончанием `/src/`.
В нем нет имени ветки или коммита: Gitea открывает файл из текущей ветки
по умолчанию. Пути в `links` остаются относительными к готовому HTML,
чтобы сборка проверяла файлы и разделы в локальном репозитории;
интерфейс преобразует их в адреса Gitea. Ссылки вида `#node=…` остаются
в локальной карте. Ссылка на руководство в шапке собирается из того же адреса.
Внешние `stg.*_ext` представлены в карточках STG, поскольку это входы чтения,
а не еще одна копия данных. Поля источника сверяются с контрактом PXF.
Для заглушек используются DDL и `docs/assignment/analyst_spec.md`.
Новые предположения о поведении загрузок в карточки не добавляйте.
## Адреса объектов
`../architecture-map.html#node=ods.airplanes` выбирает таблицу.
Дополнение `&view=keys` включает связи по ключам; без него показан поток данных.
Идентификатор равен полному имени таблицы и не зависит от позиции в карте.
Неизвестный адрес возвращает обзор с сообщением. Назад/вперед в браузере
восстанавливают предыдущий выбор и вид связей.
## Проверка в браузере
Статические проверки не заменяют этот проход:
1. Откройте готовый HTML с диска. Убедитесь, что видны пять слоев,
имена таблиц и пометки заданий; ошибок JavaScript нет.
2. Выберите `bookings.tickets`, затем `ods.segments` и `dds.fact_flight_sales`.
Проверьте источники, потребителей и карточки связей. Снимите выделение
повторным выбором, кнопкой "Общий вид" и клавишей Esc.
3. Для выбранных билета, факта и `dds.dim_airplanes` переключите оба вида:
выбор, позиции и прокрутка карты должны сохраниться.
4. У факта откройте обе роли `dim_airports`, путь через `ods.routes`
и выбор версии `dim_routes`. Проверьте отдельные карточки связей.
5. Откройте адрес объекта, обновите страницу, проверьте назад/вперед.
Откройте DDL, загрузку, DQ, DAG и пункт задания из карточки в Gitea.
Проверьте форматирование Markdown, подсветку кода и переход к разделу задания.
Ссылка на таблицу должна открывать локальную карту с тем же выбором.
6. Проверьте выбор клавишами Enter/пробел и вид на узком экране.
На узком экране карта прокручивается, карточка находится под ней.
File diff suppressed because it is too large. Load diff
+173
View File
@@ -0,0 +1,173 @@
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""Проверить данные карты и собрать автономный HTML без внешних зависимостей."""
from __future__ import annotations
import argparse
import json
import re
from html import escape
from pathlib import Path
from urllib.parse import unquote, urljoin, urlsplit
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[2]
OUTPUT = HERE.parent / "architecture-map.html"
MARKER = "/*__MAP_DATA__*/"
NOTICE = (
"<!-- Собрано из architecture-map/map.json и template.html. "
"Команда: uv run docs/design/architecture-map/render.py -->\n"
)
def headings(text: str) -> set[str]:
"""Якоря Markdown для ссылок на существующие разделы документации."""
result = set(re.findall(r'<a\s+id="([^"]+)"', text))
for title in re.findall(r"^#{1,6}\s+(.+)$", text, re.M):
slug = re.sub(r"[^\w\- ]", "", title.lower()).replace(" ", "-")
result.add(slug)
return result
def validate(data: dict) -> None:
"""Сверить объекты, поля, статусы, входы готовых SQL и локальные ссылки."""
repository = urlsplit(data["repository_url"])
if (
repository.scheme != "https"
or not repository.netloc
or not repository.path.endswith("/src/")
or repository.query
or repository.fragment
):
raise ValueError("Укажите HTTPS-адрес репозитория Gitea с окончанием /src/")
nodes = {node["id"]: node for node in data["nodes"]}
if len(nodes) != len(data["nodes"]):
raise ValueError("Повторяется идентификатор объекта")
edge_ids = [e["id"] for e in data["edges"]]
if len(set(edge_ids)) != len(edge_ids):
raise ValueError("Повторяется идентификатор связи")
columns, externals, tables = {}, {}, set()
for path in sorted((ROOT / "sql").glob("*/*_ddl.sql")):
text = re.sub(r"--[^\n]*", "", path.read_text(encoding="utf-8"))
pattern = r"CREATE\s+(EXTERNAL\s+)?TABLE\s+(?:IF NOT EXISTS\s+)?(\w+\.\w+)\s*\((.*?)\n\)"
for external, table, body in re.findall(pattern, text, re.S | re.I):
columns[table] = set(re.findall(r"^\s*(\w+)\s+\w+", body, re.M))
if external:
source = re.search(r"pxf://([^?]+)", text)[1]
columns[source] = columns[table]
externals[table] = source
else:
tables.add(table)
expected = tables | set(externals.values())
if set(nodes) != expected:
raise ValueError(f"Карта и DDL расходятся: {set(nodes) ^ expected}")
parts = {part for n in nodes.values() for part in n["parts"]}
if parts != set(externals):
raise ValueError("В карточках STG должны быть представлены все внешние таблицы")
layers = {layer["id"] for layer in data["layers"]}
positions = set()
for node in nodes.values():
if node["layer"] not in layers:
raise ValueError(f"Неизвестный слой: {node['id']}")
position = (node["layer"], node["order"])
if position in positions:
raise ValueError(f"Объекты перекрываются: {position}")
positions.add(position)
for field in ("what", "grain", "keys", "fields", "fill", "links"):
if not node[field]:
raise ValueError(f"Пустое поле {field}: {node['id']}")
for field in node["fields"]:
if field["name"] not in columns[node["id"]]:
raise ValueError(f"Нет поля {node['id']}.{field['name']} в DDL/PXF")
if node["layer"] == "source":
continue
layer, name = node["id"].split(".")
for role in ("load", "dq"):
path = ROOT / f"sql/{layer}/{name}_{role}.sql"
sql = re.sub(r"--[^\n]*", "", path.read_text(encoding="utf-8")).strip()
stub = sql.upper() == "SELECT 1;"
if stub != (node["status"] == "student"):
raise ValueError(
f"Статус карты не совпадает с {path.relative_to(ROOT)}"
)
if role != "load" or stub:
continue
sources = set(re.findall(r"\b(?:FROM|JOIN)\s+(\w+\.\w+)", sql, re.I))
sources = {externals.get(s, s) for s in sources if s != node["id"]}
sources &= expected
drawn = {
e["from"]
for e in data["edges"]
if e["mode"] == "flow" and e["to"] == node["id"]
}
if sources != drawn:
raise ValueError(
f"Входы SQL и карты расходятся у {node['id']}: {sources ^ drawn}"
)
for edge in data["edges"]:
if edge["from"] not in nodes or edge["to"] not in nodes:
raise ValueError(f"Неизвестный конец связи {edge['id']}")
if edge["mode"] not in ("flow", "keys") or not edge["join"] or not edge["refs"]:
raise ValueError(f"Неполное описание связи {edge['id']}")
for ref in edge["refs"]:
if (
ref["node"] not in nodes
or not set(ref["columns"]) <= columns[ref["node"]]
):
raise ValueError(f"Неизвестные поля связи {edge['id']}: {ref}")
count = 0
for item in [*nodes.values(), *data["edges"]]:
for link in item["links"]:
url = urlsplit(link["url"])
if url.scheme or url.netloc:
raise ValueError(f"Ссылка должна вести в локальный репозиторий: {link}")
path = (OUTPUT.parent / unquote(url.path)).resolve()
if not path.is_relative_to(ROOT) or not path.is_file():
raise ValueError(f"Файл по ссылке не найден: {link['url']}")
if url.fragment and unquote(url.fragment) not in headings(
path.read_text(encoding="utf-8")
):
raise ValueError(f"Раздел по ссылке не найден: {link['url']}")
count += 1
print(
f"Проверены {len(nodes)} объектов, {len(edge_ids)} связей, {count} ссылок; поля, статусы и входы SQL совпадают"
)
def main() -> int:
"""Собрать HTML либо проверить его свежесть вместе с исходными данными."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true", help="проверить без записи")
args = parser.parse_args()
try:
data = json.loads((HERE / "map.json").read_text(encoding="utf-8"))
validate(data)
template = (HERE / "template.html").read_text(encoding="utf-8")
if template.count(MARKER) != 1:
raise ValueError("В шаблоне должен быть ровно один маркер данных")
payload = json.dumps(data, ensure_ascii=False, indent=2).replace("<", "\\u003c")
guide_url = urljoin(data["repository_url"], "docs/design/db_schema.md")
result = NOTICE + template.replace(MARKER, payload).replace(
"__GUIDE_URL__", escape(guide_url, quote=True)
)
if args.check:
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != result:
raise ValueError(
"HTML устарел. Выполните uv run docs/design/architecture-map/render.py"
)
print("HTML соответствует исходникам")
else:
OUTPUT.write_text(result, encoding="utf-8")
print(f"Собрана карта: {OUTPUT.relative_to(ROOT)}")
return 0
except (ValueError, KeyError, OSError) as exc:
print(f"Не удалось собрать карту: {exc}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+481
View File
@@ -0,0 +1,481 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Карта DWH авиаперевозок</title>
<style>
:root {
color-scheme: light;
--paper: #f7f7f5;
--panel: #ffffff;
--ink: #272a2d;
--muted: #6b7177;
--line: #8c9298;
--soft: #e6e8e9;
--accent: #b54a2a;
--accent-soft: #f5ded7;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
header { padding: 22px 26px 12px; }
h1 { margin: 0 0 5px; font-size: 25px; }
.lead { margin: 0; color: var(--muted); }
.controls {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 8px 26px 16px;
}
.switcher { display: flex; flex-wrap: wrap; gap: 6px; }
button {
border: 1px solid #aeb3b7;
border-radius: 18px;
background: var(--panel);
color: var(--ink);
padding: 6px 13px;
font: inherit;
cursor: pointer;
}
button[aria-pressed="true"] {
border-color: var(--accent);
background: var(--accent-soft);
color: #71301e;
}
button:focus-visible, [tabindex="0"]:focus-visible {
outline: 3px solid #d79078;
outline-offset: 2px;
}
.legend { display: flex; flex-wrap: wrap; gap: 8px 20px; color: var(--muted); }
.legend-item { display: inline-flex; align-items: center; gap: 7px; }
.legend-icon { display: block; width: 50px; height: 20px; flex: none; }
main {
display: grid;
grid-template-columns: minmax(0, 1fr) 370px;
gap: 16px;
padding: 0 18px 24px;
align-items: start;
}
.map-scroll {
overflow-x: auto;
border: 1px solid #d7d9da;
border-radius: 10px;
background: var(--panel);
}
#map { display: block; width: 100%; min-width: 1040px; height: auto; }
.layer-title { font-size: 15px; font-weight: 700; text-anchor: middle; }
.layer-caption { font-size: 11px; fill: var(--muted); text-anchor: middle; }
.zone-caption { font-size: 12px; fill: var(--muted); }
.column-guide { stroke: #eef0f1; stroke-width: 1; }
.edge-line {
fill: none;
stroke: var(--line);
stroke-width: 1.8;
marker-end: url(#arrowhead);
transition: opacity .15s, stroke .15s, stroke-width .15s;
}
.edge-line.dashed { stroke-dasharray: 8 6; }
.edge-line.dotted { stroke-dasharray: 2 6; stroke-linecap: round; }
.edge-hit { fill: none; stroke: transparent; stroke-width: 14; cursor: pointer; }
.node { cursor: pointer; transition: opacity .15s; }
.node rect, .legend-node rect {
fill: #f1f2f2;
stroke: #858b90;
stroke-width: 1.2;
rx: 7;
transition: fill .15s, stroke .15s, stroke-width .15s;
}
.node text {
fill: var(--ink);
font: 14px ui-monospace, SFMono-Regular, Consolas, monospace;
text-anchor: middle;
pointer-events: none;
}
.node .stream-mark { fill: var(--muted); font: 11px system-ui, sans-serif; }
.is-filtered, .is-context-dim { opacity: .16; }
.is-path:not(.is-filtered), .is-hover:not(.is-filtered) { opacity: 1; }
.is-path .edge-line, .edge.is-path .edge-line {
stroke: var(--accent); stroke-width: 2.8; marker-end: url(#arrowhead-active);
}
.node.is-path rect { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
.node.is-hover rect { fill: #faefeb; stroke: #d79078; }
.edge.is-hover .edge-line { stroke: #d79078; stroke-width: 2.2; }
.node.is-selected rect { stroke-width: 3; }
.edge.is-selected .edge-line { stroke-width: 4; }
aside {
min-height: 470px; max-height: calc(100vh - 32px); overflow-y: auto; position: sticky; top: 16px;
padding: 20px;
border: 1px solid #d7d9da;
border-radius: 10px;
background: var(--panel);
}
@media (max-width: 900px) { main { grid-template-columns: minmax(0, 1fr); } aside { min-height: 0; max-height: none; position: static; } .map-scroll { max-height: 50vh; overflow: auto; } }
aside h2 { margin: 0 0 3px; font-size: 20px; overflow-wrap: anywhere; }
.meta { margin: 0 0 18px; color: var(--muted); }
.card-section { margin-top: 17px; }
.card-section h3 { margin: 0 0 5px; font-size: 13px; }
.card-section p { margin: 0; }
.card-section ul { margin: 5px 0 0; padding-left: 20px; }
.card-section li + li { margin-top: 5px; }
code { font: .92em ui-monospace, SFMono-Regular, Consolas, monospace; }
a { color: #8a351e; text-underline-offset: 2px; }
.hint { color: var(--muted); font-size: 13px; }
.edge[hidden] { display: none; }
.node.student rect, .legend-node.student rect { stroke-dasharray: 5 3; }
.node.student .stream-mark { fill: #875a14; }
.edge.expected .edge-line, .legend-arrow.expected .edge-line { stroke-dasharray: 5 4; }
.node.is-upstream rect, .legend-node.is-upstream rect { fill: #e3eff5; stroke: #307c9a; }
.node.is-downstream rect, .legend-node.is-downstream rect { fill: #e7f1e5; stroke: #527a43; }
.node.is-selected rect, .legend-node.is-selected rect { fill: var(--accent-soft); stroke: var(--accent); }
.relation-button { border-radius: 5px; padding: 5px 7px; text-align: left; width: 100%; margin-top: 5px; font-size: 13px; }
.badge { font-size: 13px; padding: 3px 8px; border-radius: 4px; background: #eef1ed; display: inline-block; }
.badge.student { background: #fff1cf; color: #775018; }
.navigation { margin: 8px 0; }
code { overflow-wrap: anywhere; }
summary { cursor: pointer; font-weight: 600; }
#view-hint { margin: 0; max-width: 1050px; }
noscript { display: block; padding: 20px; }
</style>
</head>
<body>
<header>
<h1 id="page-title"></h1>
<p class="lead" id="lead"></p>
<p class="navigation"><a href="__GUIDE_URL__" target="_blank" rel="noopener noreferrer">Руководство: от бронирования до витрины (Gitea)</a> · Учебная ветка main</p>
</header>
<div class="controls">
<div class="switcher" id="switcher" aria-label="Вид связей"></div>
<p id="view-hint" class="hint"></p>
<div class="legend" aria-label="Обозначения стрелок">
<span class="legend-item">
<svg class="legend-icon legend-arrow" viewBox="0 0 50 20" aria-hidden="true"><path class="edge-line" d="M 2 10 H 43"/></svg>
<span id="ready-edge-legend">Готовая загрузка</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-arrow expected" viewBox="0 0 50 20" aria-hidden="true"><path class="edge-line" d="M 2 10 H 43"/></svg>
<span id="expected-edge-legend">Загрузка по ТЗ</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-arrow is-path" viewBox="0 0 50 20" aria-hidden="true"><path class="edge-line" d="M 2 10 H 43"/></svg>
<span>Выделенные связи</span>
</span>
</div>
<div class="legend" aria-label="Обозначения таблиц">
<span class="legend-item">
<svg class="legend-icon legend-node" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span>Готовая таблица</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node student" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span>Учебная заглушка</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node is-upstream" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span id="upstream-legend">Источники</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node is-downstream" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span id="downstream-legend">Потребители</span>
</span>
<span class="legend-item">
<svg class="legend-icon legend-node is-selected" viewBox="0 0 50 20" aria-hidden="true"><rect x="4" y="2" width="40" height="16"/></svg>
<span>Выбранная таблица</span>
</span>
</div>
</div>
<main>
<div class="map-scroll">
<svg id="map" viewBox="0 0 1250 725" role="group" aria-label="Путь данных через слои хранилища"></svg>
</div>
<aside id="details" aria-live="polite" aria-label="Карточка выбранного объекта"></aside>
</main>
<noscript>Для интерактивной карты включите JavaScript. Объяснение модели доступно в <a href="__GUIDE_URL__">руководстве в Gitea</a>.</noscript>
<script>
const MAP = /*__MAP_DATA__*/;
const NS = "http://www.w3.org/2000/svg";
const state = { view: "flow", selected: null };
const nodeById = new Map(MAP.nodes.map(node => [node.id, node]));
const edgeById = new Map(MAP.edges.map(edge => [edge.id, edge]));
const positions = new Map();
const svg = document.getElementById("map");
const details = document.getElementById("details");
const statusLabel = {ready: "Готовая загрузка", student: "Задание · загрузка и DQ — заглушки", source: "Источник · демо-БД"};
function svgElement(name, attributes = {}) {
const element = document.createElementNS(NS, name);
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value));
return element;
}
function layerTitle(layerId) { return MAP.layers.find((layer) => layer.id === layerId)?.title ?? ""; }
function appendRichText(parent, value) {
value.split("`").forEach((part, index) => {
const element = index % 2 ? document.createElement("code") : document.createTextNode(part);
if (index % 2) element.textContent = part;
parent.append(element);
});
}
function addSection(title, value) {
if (!value || (Array.isArray(value) && !value.length)) return;
const section = document.createElement("section");
section.className = "card-section";
const heading = document.createElement("h3");
heading.textContent = title;
section.append(heading);
if (Array.isArray(value)) {
const list = document.createElement("ul");
value.forEach((item) => {
const row = document.createElement("li");
appendRichText(row, item);
list.append(row);
});
section.append(list);
} else {
const paragraph = document.createElement("p");
appendRichText(paragraph, value);
section.append(paragraph);
}
details.append(section);
}
function addLinks(links) {
if (!links?.length) return;
const section = document.createElement("section");
section.className = "card-section";
const heading = document.createElement("h3");
heading.textContent = "Ссылки";
const list = document.createElement("ul");
links.forEach((item) => {
const row = document.createElement("li");
const link = document.createElement("a");
// Выбор таблицы остается в локальной карте; файлы открываются в Gitea.
link.href = item.url.startsWith("#") ? item.url : new URL(item.url, new URL("docs/design/", MAP.repository_url)).href;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.textContent = item.label;
row.append(link);
list.append(row);
});
section.append(heading, list);
details.append(section);
}
function renderIntro(message = "") {
details.replaceChildren();
const heading = document.createElement("h2");
heading.textContent = message || "Начните с билета";
details.append(heading);
addSection("Маршрут чтения", "Один билет может включать несколько перелетов. Выберите bookings.tickets, затем проследите сегменты до факта и витрины sales_report.");
addSection("Как исследовать", "Выбор таблицы подсвечивает ее окружение: источники и потребителей в потоке данных, родителей и потомков в связях по ключам. Их положение остается прежним. Соседние связи доступны кнопками в карточке. Повторный выбор, Esc или «Общий вид» снимает выделение.");
addSection("Что показывает карта", "Стрелки описывают данные. Порядок задач Airflow смотрите отдельно по ссылке на DAG в карточке. Пунктирная стрелка означает ожидаемую загрузку из задания.");
addSection("Вход через PXF", "Внешние stg.*_ext показаны внутри карточек STG: они читают Postgres, но сами не хранят копию данных.");
addSection("Ключи", "Все ключи и связи здесь логические. DDL таблиц DWH не объявляет PRIMARY KEY, UNIQUE и FOREIGN KEY. DISTRIBUTED BY задает размещение, а не уникальность.");
addLinks([{label: "Открыть руководство о модели", url: "db_schema.md"}]);
}
function relationButtons(node) {
const section = document.createElement("section");
section.className = "card-section";
const h = document.createElement("h3"); h.textContent = "Связи рядом с таблицей";
section.append(h);
const edges = MAP.edges.filter(e => e.mode === state.view && (e.from === node.id || e.to === node.id));
edges.forEach(edge => {
const b = document.createElement("button"); b.className = "relation-button";
const neighbor = edge.from === node.id ? edge.to : edge.from;
b.textContent = `${edge.from === node.id ? "→" : "←"} ${neighbor}: ${edge.label}`;
b.dataset.edge = edge.id;
b.addEventListener("click", () => selectItem("edge", edge.id));
section.append(b);
});
if (!edges.length) {
const p = document.createElement("p"); p.textContent = "В этом виде рядом нет связей. Переключите вид, чтобы увидеть путь загрузки."; section.append(p);
}
details.append(section);
}
function renderNodeCard(node) {
details.replaceChildren();
const heading = document.createElement("h2"); heading.textContent = node.name;
const meta = document.createElement("p"); meta.className = `badge ${node.status}`; meta.textContent = statusLabel[node.status];
details.append(heading, meta);
addSection("Назначение", node.what);
addSection("Одна строка", node.grain);
addSection("Логические ключи", node.keys);
addSection(node.status === "student" ? "Ожидаемый результат по ТЗ" : "Как наполняется", node.fill);
if (node.parts.length) addSection("Вход через PXF", node.parts.map(id => `\`${id}\` — внешняя таблица для чтения Postgres, без локального хранения. Ее определение находится в том же DDL.`));
const fields = document.createElement("details"); fields.className = "card-section";
const summary = document.createElement("summary"); summary.textContent = `Важные поля (${node.fields.length})`;
const list = document.createElement("ul");
node.fields.forEach(field => {
const li = document.createElement("li"); appendRichText(li, "`" + field.name + "` — " + field.meaning); list.append(li);
});
fields.append(summary,list); details.append(fields);
relationButtons(node);
const dag = {stg:"stage",ods:"ods",dds:"dds",dm:"dm"}[node.layer];
addLinks([...node.links, ...(dag ? [{label:"DAG · порядок задач",url:`../../airflow/dags/bookings_to_gp_${dag}.py`}] : []), {label:"Ссылка на эту таблицу",url:`#node=${node.id}&view=${state.view}`}]);
}
function renderEdgeCard(edge) {
details.replaceChildren();
const heading = document.createElement("h2"); heading.textContent = edge.label; details.append(heading);
addSection("Таблицы", "`" + edge.from + "` → `" + edge.to + "`");
addSection("Вид связи", edge.mode === "flow" ? (edge.status === "student" ? "Ожидаемый поток по ТЗ; загрузка пока не реализована." : "Чтение и преобразование данных в готовом SQL.") : "Логическое сопоставление строк по ключам. Это не зависимость задач Airflow и не объявленное ограничение БД.");
addSection("Что связывает", edge.join);
addSection("Участвующие поля", edge.refs.map(r => `\`${r.node}\`: ${r.columns.map(c => "`"+c+"`").join(", ")}`));
[edge.from, edge.to].forEach(id => {
const b = document.createElement("button"); b.className = "relation-button"; b.textContent = `Открыть ${id}`;
b.addEventListener("click", () => selectItem("node", id)); details.append(b);
});
addLinks(edge.links);
}
function layoutMap() {
MAP.layers.forEach((layer, index) => {
MAP.nodes.filter(node => node.layer === layer.id).forEach(node => {
positions.set(node.id, {x: 20 + index * 250, y: 72 + node.order * 70, width: 195, height: 48});
});
});
}
function edgePath(edge, offset) {
const a = positions.get(edge.from), b = positions.get(edge.to);
const y1 = a.y + a.height / 2 + offset, y2 = b.y + b.height / 2 + offset;
if (a.x === b.x) {
const x = a.x + a.width, bend = x + 20 + Math.abs(a.y - b.y) * .08 + offset;
return `M ${x} ${y1} C ${bend} ${y1}, ${bend} ${y2}, ${x} ${y2}`;
}
const x1 = a.x + a.width, x2 = b.x, bend = Math.max(18, (x2 - x1) / 2);
return `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`;
}
function drawMap() {
const defs = svgElement("defs");
[["arrowhead", "#8c9298"], ["arrowhead-active", "#b54a2a"]].forEach(([id, fill]) => {
const marker = svgElement("marker", {id, viewBox:"0 0 8 8", refX:"7", refY:"4", markerWidth:"6", markerHeight:"6", orient:"auto"});
marker.append(svgElement("path", {d:"M 0 0 L 8 4 L 0 8 z", fill})); defs.append(marker);
});
svg.append(defs);
MAP.layers.forEach((layer,index) => {
const center = 117.5 + index * 250;
const title = svgElement("text", {x:center, y:27, class:"layer-title"}); title.textContent = layer.title;
const caption = svgElement("text", {x:center, y:47, class:"layer-caption"}); caption.textContent = layer.caption;
svg.append(title,caption);
});
MAP.edges.forEach(edge => {
const siblings = MAP.edges.filter(e => e.from === edge.from && e.to === edge.to && e.mode === edge.mode);
const offset = (siblings.indexOf(edge) - (siblings.length - 1) / 2) * 13;
const group = svgElement("g", {class:`edge ${edge.status === "student" ? "expected" : ""}`, "data-id":edge.id, tabindex:"0", role:"button", "aria-label":`${edge.label}: ${edge.from} → ${edge.to}`});
const d = edgePath(edge,offset);
group.append(svgElement("path", {d,class:"edge-line"}),svgElement("path", {d,class:"edge-hit"}));
bindSelection(group,"edge",edge.id); svg.append(group);
});
MAP.nodes.forEach(node => {
const p = positions.get(node.id);
const g = svgElement("g", {class:`node ${node.status}`, "data-id":node.id, tabindex:"0", role:"button", "aria-label":`${node.name}: ${statusLabel[node.status]}`});
g.append(svgElement("rect",p));
const label = svgElement("text", {x:p.x+p.width/2,y:p.y+21}); label.textContent = node.name;
const mark = svgElement("text", {x:p.x+p.width/2,y:p.y+38,class:"stream-mark"}); mark.textContent = {ready:"готово",student:"задание",source:"источник"}[node.status];
g.append(label,mark); bindSelection(g,"node",node.id); svg.append(g);
});
}
function bindSelection(element,type,id) {
element.addEventListener("click", () => selectItem(type,id));
element.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectItem(type,id); }
});
}
function walk(nodeId,direction) {
const nodes = new Set([nodeId]), edges = new Set(), queue = [nodeId];
while (queue.length) {
const current = queue.shift();
MAP.edges.filter(e => e.mode === state.view).forEach(edge => {
if ((direction === "in" ? edge.to : edge.from) !== current) return;
edges.add(edge.id);
const next = direction === "in" ? edge.from : edge.to;
if (!nodes.has(next)) { nodes.add(next); queue.push(next); }
});
}
return {nodes,edges};
}
function updateHighlights() {
let upstream = new Set(), downstream = new Set(), activeEdges = new Set(), activeNodes = new Set();
if (state.selected?.type === "node") {
const a = walk(state.selected.id,"in"), b = walk(state.selected.id,"out");
upstream = a.nodes; downstream = b.nodes;
activeNodes = new Set([...upstream,...downstream]); activeEdges = new Set([...a.edges,...b.edges]);
} else if (state.selected?.type === "edge") {
const edge = edgeById.get(state.selected.id);
activeNodes = new Set([edge.from,edge.to]); activeEdges.add(edge.id);
}
svg.querySelectorAll(".node").forEach(el => {
const id = el.dataset.id, selected = state.selected?.type === "node" && state.selected.id === id;
el.classList.toggle("is-context-dim", Boolean(state.selected) && !activeNodes.has(id));
el.classList.toggle("is-upstream", upstream.has(id)); el.classList.toggle("is-downstream", downstream.has(id));
el.classList.toggle("is-selected", selected); el.setAttribute("aria-pressed",String(selected));
});
svg.querySelectorAll(".edge").forEach(el => {
const edge = edgeById.get(el.dataset.id), visible = edge.mode === state.view;
el.toggleAttribute("hidden", !visible); el.setAttribute("tabindex",visible ? "0" : "-1");
el.setAttribute("aria-hidden",String(!visible));
el.classList.toggle("is-context-dim",Boolean(state.selected) && !activeEdges.has(edge.id));
el.classList.toggle("is-path",activeEdges.has(edge.id));
const selected = state.selected?.type === "edge" && state.selected.id === edge.id;
el.classList.toggle("is-selected", selected); el.setAttribute("aria-pressed",String(selected));
});
}
function renderState(message = "") {
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed",String(b.dataset.view === state.view)));
document.getElementById("upstream-legend").textContent = state.view === "flow" ? "Источники" : "Связанные родители";
document.getElementById("downstream-legend").textContent = state.view === "flow" ? "Потребители" : "Связанные потомки";
document.getElementById("ready-edge-legend").textContent = state.view === "flow" ? "Готовая загрузка" : "Связь по ключам";
document.getElementById("expected-edge-legend").textContent = state.view === "flow" ? "Загрузка по ТЗ" : "Связь с учебной заглушкой";
document.getElementById("view-hint").textContent = state.view === "flow" ? "Стрелка: откуда загрузчик читает данные и куда записывает результат. Календарь генерируется отдельно." : "Стрелка: от родительской строки к связанным строкам. Например, от бронирования к билетам. Это не порядок загрузки и не физические FK.";
if (state.selected?.type === "node") renderNodeCard(nodeById.get(state.selected.id));
else if (state.selected?.type === "edge") renderEdgeCard(edgeById.get(state.selected.id));
else renderIntro(message);
details.scrollTop = 0; updateHighlights();
}
function saveAddress() {
const p = new URLSearchParams();
if (state.selected) p.set(state.selected.type,state.selected.id);
if (state.view !== "flow") p.set("view",state.view);
const hash = p.toString();
if (location.hash.slice(1) !== hash) location.hash = hash;
renderState();
}
function selectItem(type,id) {
const repeated = state.selected?.type === type && state.selected.id === id;
state.selected = repeated ? null : {type,id}; saveAddress();
}
function readAddress() {
const p = new URLSearchParams(location.hash.slice(1));
state.view = p.get("view") === "keys" ? "keys" : "flow";
state.selected = null;
if (nodeById.has(p.get("node"))) state.selected = {type:"node",id:p.get("node")};
else if (edgeById.has(p.get("edge"))) {
const edge = edgeById.get(p.get("edge")); state.view = edge.mode; state.selected = {type:"edge",id:edge.id};
}
renderState(!state.selected && (p.has("node") || p.has("edge")) ? "Объект по ссылке не найден. Выберите таблицу на карте." : "");
}
function renderControls() {
const switcher = document.getElementById("switcher");
[["flow","Поток данных"],["keys","Связи по ключам"]].forEach(([id,label]) => {
const b = document.createElement("button"); b.textContent = label; b.dataset.view = id;
b.addEventListener("click", () => {
state.view = id;
if (state.selected?.type === "edge") state.selected = {type:"node",id:edgeById.get(state.selected.id).to};
saveAddress();
}); switcher.append(b);
});
const clear = document.createElement("button"); clear.textContent = "Общий вид"; clear.id = "clear-selection";
clear.addEventListener("click", () => {state.selected = null; saveAddress();}); switcher.append(clear);
}
document.getElementById("page-title").textContent = MAP.title;
document.getElementById("lead").textContent = MAP.lead;
layoutMap(); drawMap(); renderControls(); readAddress();
window.addEventListener("hashchange",readAddress);
document.addEventListener("keydown",event => {
if (event.key === "Escape" && state.selected) {state.selected = null; saveAddress();}
});
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+425
View File
@@ -0,0 +1,425 @@
# История проектирования DM для Bookings
> Архивный материал: исходный план создания слоя. Он сохраняет обоснования,
> варианты SQL и этапы разработки; приведенные фрагменты отражают состояние
> проекта во время проектирования.
Для работы с текущей учебной веткой откройте
[руководство загрузки DM](../bookings_to_gp_dm.md).
Смысл строк и связи показаны на [карте](architecture-map.html#node=dm.sales_report);
поля и правила самостоятельных загрузок определяет [ТЗ](../assignment/analyst_spec.md).
Готовые примеры доступны из [разбора SQL](reading_the_pipeline.md).
Ниже сохранен план "План: DM-слой (Data Mart) для учебного стенда Bookings".
Задачи по созданию файлов относятся к истории разработки, а примеры SQL
требуют сверки с действующими файлами перед использованием.
---
## 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 по High-Water Mark (`_load_ts`).
*Архитектурный нюанс (HWM-паттерн):* Витрина сравнивает свой `MAX(_load_ts)` с `_load_ts` фактов в DDS и пересчитывает агрегаты только для затронутых дат. Это делает конвейер самовосстанавливающимся: если DAG не запускался несколько дней, при следующем запуске витрина автоматически «догонит» всю накопленную дельту. При этом `_load_id` и `_load_ts` самой витрины фиксируют текущий DM-DAG run_id — для аудита и DQ-проверок.
**Хранение**: `DISTRIBUTED BY (flight_date)`, heap (нужен UPDATE)
**Учит**: HWM-инкрементальность через `_load_ts`, TEMP TABLE для однократной агрегации (канон MPP), ограничение радиуса обновления, денормализация измерений, 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 по HWM (`_load_ts`). Из дельты фактов определяем затронутых `passenger_sk`, пересчитываем агрегаты только для них. (664K пассажиров — full rebuild дорогой.)
**Хранение**: `DISTRIBUTED BY (passenger_sk)`, heap
**Учит**: HWM-инкрементальность, TEMP TABLE для однократной агрегации, 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 по HWM (`_load_ts`). Из дельты фактов определяем затронутые `(traffic_date, airport_sk)`, пересчитываем агрегаты только для них.
**Хранение**: `DISTRIBUTED BY (airport_sk)`, heap
**Учит**: HWM-инкрементальность, TEMP TABLE для однократной агрегации, 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 по HWM (`_load_ts`). Из дельты фактов определяем затронутые `(year_actual, month_actual, airplane_sk)`, пересчитываем агрегаты только для них.
**Хранение**: `DISTRIBUTED BY (year_actual)`, heap
**Учит**: HWM-инкрементальность, TEMP TABLE для однократной агрегации, двухуровневая агрегация (сначала по рейсу для 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/design/db_schema.md` — добавить DM-слой в описание/Mermaid
### Документация (2 шт.)
21. `docs/design/bookings_dm_design.md` — полный дизайн-документ DM-слоя (этот файл)
22. `docs/bookings_to_gp_dm.md` — инструкция для студентов (аналог `bookings_to_gp_dds.md`)
---
## Порядок реализации
### Этап 1: Инфраструктура + эталонная витрина `dm.sales_report`
- Дизайн-документ `docs/design/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, статус)
---
## Общий паттерн загрузки UPSERT-витрин (TEMP TABLE + HWM)
Все инкрементальные витрины (кроме `route_performance` — full rebuild) строятся по единому скелету:
```sql
-- Шаг 1: Агрегация дельты во временную таблицу.
-- Тяжёлый SELECT с JOIN-ами выполняется ОДИН раз — канон для MPP (Greenplum).
-- Без TEMP TABLE пришлось бы дублировать тот же SELECT в UPDATE и INSERT,
-- что означает двойной скан таблицы фактов.
CREATE TEMP TABLE tmp_<mart>_delta ON COMMIT DROP AS
SELECT
<grain_columns>,
<denormalized_attributes>,
<aggregate_metrics>
FROM dds.fact_flight_sales AS f
JOIN ...
WHERE <grain_date_or_key> IN (
-- HWM-фильтр: берём только зёрна, затронутые новыми фактами
SELECT DISTINCT <grain_column>
FROM dds.fact_flight_sales AS f_sq
JOIN ...
WHERE f_sq._load_ts > (
SELECT COALESCE(MAX(_load_ts), '1900-01-01'::TIMESTAMP)
FROM dm.<mart>
)
)
GROUP BY <grain_columns>, <denormalized_attributes>;
-- Шаг 2: UPDATE существующих строк (только если что-то изменилось).
UPDATE dm.<mart> AS tgt
SET <metrics> = src.<metrics>,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM tmp_<mart>_delta AS src
WHERE tgt.<grain> = src.<grain>
AND (<columns> IS DISTINCT FROM ...);
-- Шаг 3: INSERT новых строк.
INSERT INTO dm.<mart> (...)
SELECT ... FROM tmp_<mart>_delta AS src
WHERE NOT EXISTS (
SELECT 1 FROM dm.<mart> AS tgt
WHERE tgt.<grain> = src.<grain>
);
```
**Применимость по витринам:**
- `sales_report` — TEMP TABLE + HWM UPSERT (эталон, уже реализован)
- `passenger_loyalty` — TEMP TABLE + HWM UPSERT (затронутые `passenger_sk`)
- `airport_traffic` — TEMP TABLE + HWM UPSERT (затронутые `(traffic_date, airport_sk)`)
- `monthly_overview` — TEMP TABLE + HWM UPSERT (затронутые `(year_actual, month_actual, airplane_sk)`)
- `route_performance` — **не использует** (full rebuild: TRUNCATE + INSERT, один проход)
---
## 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` |
| HWM-инкремент (UPSERT по _load_ts) | `sql/dm/sales_report_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/design/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` для всех строк
+744
View File
@@ -0,0 +1,744 @@
# История проектирования ODS для Bookings
> Архивный материал: исходный план создания слоя. Он сохраняет обоснования,
> варианты SQL и этапы разработки; приведенные фрагменты отражают состояние
> проекта во время проектирования.
Для работы с текущей учебной веткой откройте
[руководство загрузки ODS](../bookings_to_gp_ods.md).
Смысл строк и связи показаны на [карте](architecture-map.html#node=ods.airports);
поля и правила самостоятельных загрузок определяет [ТЗ](../assignment/analyst_spec.md).
Готовые примеры доступны из [разбора SQL](reading_the_pipeline.md).
Ниже сохранен план "ODS Layer: эталонный учебный план реализации (v3)".
Задачи по созданию файлов относятся к истории разработки, а примеры SQL
требуют сверки с действующими файлами перед использованием.
---
## Контекст
STG-слой уже реализован как учебный эталон:
- данные из `bookings-db` читаются через PXF;
- в STG бизнес-колонки хранятся как `TEXT`;
- загрузка и DQ работают батчами (`_load_id = {{ run_id }}`).
Этот документ фиксирует **простую и каноничную** реализацию ODS для менти.
---
## 1) Что считаем эталоном для ODS
### 1.1. Роль ODS в этом стенде
ODS в учебном проекте — это:
- типизированные и очищенные данные;
- одна актуальная запись на бизнес-ключ;
- удобный слой для последующей сборки DDS/DM.
### 1.2. Что делаем, что не делаем
Делаем в ODS:
- приведение типов (`TEXT -> TIMESTAMPTZ/NUMERIC/INT/BOOLEAN/...`);
- дедупликацию внутри батча;
- `UPSERT` (SCD Type 1): обновляем текущую запись при изменении, вставляем новые.
- для snapshot-справочников (`airports`, `airplanes`, `routes`, `seats`) синхронизацию ключей:
удаляем из ODS записи, которых нет в выбранном `stg_batch_id`.
Не делаем в ODS (в базовом эталоне):
- SCD Type 2 с периодами действия;
- сложную обработку late-arriving/backdated событий;
- отдельный DQ-слой с хранением результатов.
### 1.3. Где хранится история изменений
- История «как приходили данные» уже сохраняется в STG (append + `_load_id`).
- Историзацию измерений (SCD2) показываем позже в DDS (как в учебной статье `dwh-modeling`).
Итог: **ODS = текущий слой (current state), простой и понятный**.
---
## 2) Нейминг служебных полей (консистентно с de-roadmap)
Источник правил: [`docs/design/naming_conventions.md`](naming_conventions.md).
В ODS используем такие техполя:
- `_load_id TEXT NOT NULL` — идентификатор загрузки (берём `stg_batch_id`);
- `_load_ts TIMESTAMP NOT NULL DEFAULT now()` — время загрузки в ODS;
- `event_ts TIMESTAMP` — время события из источника (если у сущности оно есть).
### 2.1. Маппинг из текущего STG
- `stg._load_id` -> `ods._load_id`
- `stg._load_ts` не переносим 1:1; в ODS пишем собственный `ods._load_ts = now()`
- `stg.event_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)`: один `route_no` может иметь несколько версий с разными периодами действия. `flights` ссылается только на `route_no` (без `validity`), поэтому при join в DDS нужно будет выбирать подходящую версию маршрута;
- `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 -- русское название, извлечено из JSON (поле 'ru')
city TEXT NOT NULL -- русское название, извлечено из JSON (поле 'ru')
country TEXT NOT NULL -- русское название, извлечено из JSON (поле 'ru')
coordinates TEXT
timezone TEXT NOT NULL
_load_id TEXT NOT NULL
_load_ts TIMESTAMP NOT NULL DEFAULT now()
DISTRIBUTED BY (airport_code)
```
> **Примечание о нормализации JSON**: Источник (`bookings.airports_data`) хранит
> мультиязычные поля (`airport_name`, `city`, `country`) как JSON-объекты вида
> `{"en": "...", "ru": "..."}`. На уровне ODS выполняется нормализация:
> извлекается только русский вариант (`->>'ru'`). Это упрощает downstream-логику
> (DDS/DM не нужно знать о внутренней структуре JSON) и соответствует принципу
> "типизированные данные" на уровне ODS.
#### `ods.airplanes`
```sql
airplane_code TEXT NOT NULL
model TEXT NOT NULL -- русское название модели, извлечено из JSON (поле 'ru')
range_km INTEGER
speed_kmh INTEGER
_load_id TEXT NOT NULL
_load_ts TIMESTAMP NOT NULL DEFAULT now()
DISTRIBUTED BY (airplane_code)
```
> **Примечание о нормализации JSON**: Источник (`bookings.aircrafts_data`) хранит
> поле `model` как JSON-объект `{"en": "...", "ru": "..."}`. На уровне ODS
> извлекается только русский вариант (`->>'ru'`) для консистентности с `airports`.
#### `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
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)
```
#### `ods.seats`
```sql
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)
```
### 4.2. Транзакционные
#### `ods.bookings`
```sql
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`
```sql
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)
```
> В 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
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`
```sql
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`
```sql
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-скрипты.
### 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 |
| все транзакционные | `event_ts` | `event_ts` | `TIMESTAMP` | Прямой перенос (канон) |
| все | `_load_id` | `_load_id` | `TEXT` | Прямой перенос (канон) |
Пример каста с переименованием в 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`
На проде ETL-оркестратор всегда явно передаёт downstream-задачам идентификатор батча, который прошёл все проверки. Это гарантирует:
- **воспроизводимость**: повторный запуск обработает тот же снимок данных;
- **изоляцию**: ODS не подхватит «сырой» батч, который ещё не прошёл DQ в STG;
- **отладку**: по `_load_id` в ODS легко найти исходные данные в STG.
### 5.2. Как resolve `stg_batch_id` в DAG
В 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 или последний согласованный snapshot-батч."""
conf = context["dag_run"].conf or {}
stg_batch_id = conf.get("stg_batch_id")
if not stg_batch_id:
# Берём _load_id, который присутствует во всех snapshot-таблицах STG:
# airports, airplanes, routes, seats. Это защищает от частично успешных запусков.
hook = PostgresHook(postgres_conn_id=GREENPLUM_CONN_ID)
result = hook.get_first(
'''
WITH candidate_batches AS (
SELECT _load_id FROM stg.airports WHERE _load_id IS NOT NULL GROUP BY _load_id
INTERSECT
SELECT _load_id FROM stg.airplanes WHERE _load_id IS NOT NULL GROUP BY _load_id
INTERSECT
SELECT _load_id FROM stg.routes WHERE _load_id IS NOT NULL GROUP BY _load_id
INTERSECT
SELECT _load_id FROM stg.seats WHERE _load_id IS NOT NULL GROUP BY _load_id
),
batch_ready AS (
SELECT
c._load_id,
GREATEST(
(SELECT MAX(_load_ts) FROM stg.airports a WHERE a._load_id = c._load_id),
(SELECT MAX(_load_ts) FROM stg.airplanes a WHERE a._load_id = c._load_id),
(SELECT MAX(_load_ts) FROM stg.routes r WHERE r._load_id = c._load_id),
(SELECT MAX(_load_ts) FROM stg.seats s WHERE s._load_id = c._load_id)
) AS ready_dttm
FROM candidate_batches c
)
SELECT _load_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 или сначала выполните bookings_to_gp_stage"
)
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 _load_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 = <stg_batch_id>` — чтобы связать ODS-запись с STG-батчом;
- `_load_ts = now()` — фактическое время загрузки в ODS.
Это простой и понятный паттерн: **один запуск ODS = один снимок STG-батча**.
---
## 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,
airport_name,
city,
country,
coordinates,
timezone
FROM stg.airports
WHERE _load_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 o.airport_code = s.airport_code
AND (
-- 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 _load_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 s
WHERE NOT EXISTS (
SELECT 1
FROM ods.airports o
WHERE o.airport_code = s.airport_code
);
-- Statement 3: DELETE ключей, которых нет в snapshot текущего батча
WITH src_keys AS (
SELECT DISTINCT airport_code
FROM stg.airports
WHERE _load_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;
```
### 6.2. Шаблон для транзакции (пример `bookings_load.sql`)
В транзакционных таблицах в одном STG-батче может быть несколько записей с одинаковым бизнес-ключом (например, обновления). Дедуплицируем через `ROW_NUMBER()` — стандартный и явный паттерн, часто встречающийся на собеседованиях и в DE-курсах.
```sql
-- Statement 1: UPDATE существующих записей
WITH src AS (
SELECT
book_ref,
book_date::TIMESTAMP WITH TIME ZONE AS book_date,
total_amount::NUMERIC(10,2) AS total_amount,
event_ts,
ROW_NUMBER() OVER (
PARTITION BY book_ref
ORDER BY event_ts DESC NULLS LAST, _load_ts DESC
) AS rn
FROM stg.bookings
WHERE _load_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
);
-- 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,
event_ts,
ROW_NUMBER() OVER (
PARTITION BY book_ref
ORDER BY event_ts DESC NULLS LAST, _load_ts DESC
) AS rn
FROM stg.bookings
WHERE _load_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 s
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. Идемпотентность паттерна
- Для инкрементальных таблиц паттерн `UPDATE + INSERT WHERE NOT EXISTS` — **натурально идемпотентен**:
повторный запуск с тем же `stg_batch_id` не создаст дублей и не потеряет данные.
- Для snapshot-справочников идемпотентность сохраняется паттерном
`UPDATE + INSERT + DELETE not in snapshot`: повторный запуск приводит ODS к тому же состоянию.
### 6.4. Поведение при пустом батче
- для инкрементальных таблиц (`bookings`, `tickets`, `flights`, `segments`, `boarding_passes`) пустой батч допустим;
- для snapshot-справочников (`airports`, `routes`) пустой батч считаем ошибкой.
На main `airplanes` и `seats` — студенческие заглушки (load/dq = `SELECT 1;`),
таблицы будут пустыми до реализации студентом.
---
## 7) DQ-проверки ODS (минимум, но строго)
Каждый DQ-скрипт должен:
- быть привязан к `stg_batch_id` (через XCom, как в load-скриптах);
- делать `RAISE EXCEPTION` при нарушении;
- давать понятную подсказку в тексте ошибки.
### 7.1. Обязательные проверки
1. **Нет дублей** по бизнес-ключу в ODS.
2. **Покрытие батча:** все ключи из STG текущего батча присутствуют в ODS.
3. **Обязательные поля** не `NULL`/не пустые.
4. **Батч не пустой** для эталонных snapshot-справочников (`airports`, `routes`): если STG-батч оказался пустым — это ошибка (источник недоступен или PXF не работает).
На main `airplanes_dq.sql` и `seats_dq.sql` — студенческие заглушки.
5. **Ссылочная целостность** в ODS:
- `tickets.book_ref -> bookings.book_ref`
- `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)`
### 7.2. Пример проверки покрытия батча
```sql
SELECT COUNT(*)
FROM (
SELECT DISTINCT book_ref
FROM stg.bookings
WHERE _load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text
) s
WHERE NOT EXISTS (
SELECT 1
FROM ods.bookings o
WHERE o.book_ref = s.book_ref
);
```
Ожидаемый результат: `0`.
---
## 8) Структура файлов
Каждый `*_ddl.sql` начинается с `CREATE SCHEMA IF NOT EXISTS ods;` (по аналогии с STG DDL).
```text
sql/ods/
├── airports_ddl.sql
├── airports_load.sql
├── airports_dq.sql
├── airplanes_ddl.sql
├── airplanes_load.sql
├── airplanes_dq.sql
├── routes_ddl.sql
├── routes_load.sql
├── routes_dq.sql
├── seats_ddl.sql
├── seats_load.sql
├── seats_dq.sql
├── bookings_ddl.sql
├── bookings_load.sql
├── bookings_dq.sql
├── tickets_ddl.sql
├── tickets_load.sql
├── tickets_dq.sql
├── flights_ddl.sql
├── 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
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 DDL)
tests/test_dags_smoke.py (+ smoke для 2 новых DAG)
```
---
## 9) DAG `bookings_to_gp_ods`: учебный граф зависимостей
Принцип: у каждой сущности строго `load -> dq`, и только после `dq` разрешаем downstream.
Корневой таск `resolve_stg_batch_id` определяет батч (см. секцию 5.2), после чего две параллельных ветки стартуют одновременно:
```text
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
```
Зависимости (по FK):
- `tickets` после `bookings` (FK: `book_ref`);
- `routes` после `airports` (FK: `departure_airport`, `arrival_airport`).
На ветке `solution` также зависит от `airplanes` (FK: `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`.
---
## 10) Порядок реализации
1. Подготовить DDL в `sql/ods/*_ddl.sql`.
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`.
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`.
---
## 11) Критерии готовности (Definition of Done)
Готово, если:
1. Оба новых DAG парсятся и проходят smoke-тесты (`make test`).
2. `make ddl-gp` создаёт объекты STG+ODS без ошибок.
3. Для тестового `stg_batch_id` ODS-загрузка завершается успешно.
4. Все DQ-задачи зелёные и реально валят DAG при искусственной ошибке.
5. В ODS нет дублей по бизнес-ключам.
6. Нейминг техполей ODS консистентен с учебной статьёй: `_load_id`, `_load_ts`, `event_ts`.
---
## 12) Как проверять вручную
```bash
make up
make ddl-gp # создать STG+ODS-объекты
# Trigger bookings_to_gp_stage (загрузить STG)
# Передать stg_batch_id в conf (рекомендуется) или дать ODS DAG выбрать
# последний согласованный batch автоматически.
# Trigger bookings_to_gp_ods с conf: {"stg_batch_id": "<значение>"}
make gp-psql
```
Проверочные SQL:
```sql
-- 1) Дубликаты в ODS (пример bookings)
SELECT book_ref, COUNT(*)
FROM ods.bookings
GROUP BY 1
HAVING COUNT(*) > 1;
-- 2) Покрытие текущего STG-батча в ODS
SELECT COUNT(*)
FROM (
SELECT DISTINCT book_ref
FROM stg.bookings
WHERE _load_id = '<stg_batch_id>'
) s
WHERE NOT EXISTS (
SELECT 1
FROM ods.bookings o
WHERE o.book_ref = s.book_ref
);
-- 3) Ссылочная целостность tickets -> bookings
SELECT COUNT(*)
FROM ods.tickets t
WHERE NOT EXISTS (
SELECT 1
FROM ods.bookings b
WHERE b.book_ref = t.book_ref
);
```
Ожидаемо: все три запроса возвращают `0` проблемных строк.
---
## 13) Что будет следующим шагом
После стабилизации ODS:
- строим DDS;
- показываем SCD2 на измерениях DDS (`valid_from`/`valid_to`, `created_at`/`updated_at`) по тому же неймингу, который уже знаком студентам из `dwh-modeling`.
@@ -1,18 +1,36 @@
# Дизайн STG для bookings в Greenplum (черновик) # История проектирования STG для Bookings
_Внутренний документ для учебного стенда. Перед итоговой сдачей можно объединить с основной документацией._ > Архивный материал: исходный план создания слоя. Он сохраняет обоснования,
> варианты SQL и этапы разработки; приведенные фрагменты отражают состояние
> проекта во время проектирования.
Для работы с текущей учебной веткой откройте
[руководство загрузки STG](../bookings_to_gp_stage.md).
Смысл строк и связи показаны на [карте](architecture-map.html#node=stg.bookings);
поля и правила самостоятельных загрузок определяет [ТЗ](../assignment/analyst_spec.md).
Готовые примеры доступны из [разбора SQL](reading_the_pipeline.md).
Ниже сохранен план "Дизайн STG для bookings в Greenplum".
Задачи по созданию файлов относятся к истории разработки, а примеры SQL
требуют сверки с действующими файлами перед использованием.
---
## 1. Цель и общий контур ## 1. Цель и общий контур
- Источник: Postgres в контейнере `bookings-db`, база `demo`, таблица `bookings.bookings` (см. `docs/internal/bookings_tz.md`). - Источник: Postgres в контейнере `bookings-db`, база `demo`, таблица `bookings.bookings`.
- Цель: показываем путь данных от операционной БД до сырого слоя DWH в Greenplum. - Цель: показываем путь данных от операционной БД до сырого слоя DWH в Greenplum.
- В этом документе описываем только часть `src (bookings-db) → STG (Greenplum)`. Слои ODS/DDS/DM студент проектирует сам по статье про моделирование DWH. - В этом документе описываем часть `src (bookings-db) → STG (Greenplum)`. STG — входной слой; далее данные обрабатываются в ODS → DDS → DM (см. соответствующие design-документы).
Примечание: в текущей версии стенда слой `stg` содержит не только `bookings`, но и остальные таблицы потока
(`tickets`, `airports`, `airplanes`, `routes`, `seats`, `flights`, `segments`, `boarding_passes`).
Ниже логика разобрана на примере `bookings`, потому что на нём проще показать принципы инкремента и батчей.
Логика на уровне слоёв (по статье): Логика на уровне слоёв (по статье):
- `src`: оперативная система (`bookings-db`, схема `bookings`). - `src`: оперативная система (`bookings-db`, схема `bookings`).
- `stg`: сырой слой в Greenplum, максимально близкий к источнику, без бизнес‑логики. - `stg`: сырой слой в Greenplum, максимально близкий к источнику, без бизнес‑логики.
- дальше по заданию менти можно строить `ods`/`dds`/`dm` поверх STG. - далее данные обрабатываются в слоях: `ods` (см. [bookings_ods_design.md](bookings_ods_design.md)), `dds` (см. [bookings_dds_design.md](bookings_dds_design.md)), `dm` (см. [bookings_dm_design.md](bookings_dm_design.md)).
## 2. Схема и таблицы в Greenplum ## 2. Схема и таблицы в Greenplum
@@ -20,8 +38,8 @@ _Внутренний документ для учебного стенда. П
- Используем одну схему `stg` в Greenplum. - Используем одну схему `stg` в Greenplum.
- В этой схеме будут: - В этой схеме будут:
- внешняя таблица PXF для чтения из `bookings-db`; - внешние таблицы PXF `*_ext` для чтения из `bookings-db`;
- внутренняя таблица STG для долговременного хранения «сырых» данных. - внутренние таблицы STG `stg.*` для долговременного хранения «сырых» данных.
### 2.2. Внешняя таблица (PXF) ### 2.2. Внешняя таблица (PXF)
@@ -31,7 +49,9 @@ _Внутренний документ для учебного стенда. П
- можем использовать «родные» типы из `bookings.bookings` (включая даты/числа); - можем использовать «родные» типы из `bookings.bookings` (включая даты/числа);
- задача внешней таблицы — корректно читать данные из источника, не заниматься приведением типов. - задача внешней таблицы — корректно читать данные из источника, не заниматься приведением типов.
DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Greenplum (примерно по шаблону из `docs/internal/pxf_bookings.md`), с `LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db')`. В текущей реализации аналогично созданы внешние таблицы `*_ext` и для остальных сущностей (см. `sql/stg/*_ddl.sql`).
DDL определён в `sql/stg/bookings_ddl.sql` и подключается из `sql/ddl_gp.sql` через `\i` (применяется через `make ddl-gp`).
### 2.3. Внутренняя таблица STG ### 2.3. Внутренняя таблица STG
@@ -44,11 +64,11 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre
Технологические колонки: Технологические колонки:
- `src_created_at_ts TIMESTAMP` — дата/время из источника, приведённая к TIMESTAMP: - `event_ts TIMESTAMP` — дата/время из источника, приведённая к TIMESTAMP:
- используется как опорная колонка для инкрементальной загрузки; - используется как опорная колонка для инкрементальной загрузки;
- заполняется из исходной даты/времени (`created_at` или аналог). - заполняется из опорной даты/времени, принятой для конкретной сущности (например, для `bookings` — из `book_date`).
- `load_dttm TIMESTAMP NOT NULL DEFAULT now()` — когда запись была загружена в STG. - `_load_ts TIMESTAMP NOT NULL DEFAULT now()` — когда запись была загружена в STG.
- `batch_id TEXT NOT NULL` — идентификатор «пачки» (например, `{{ ds_nodash }}` или `run_id` Airflow). - `_load_id TEXT NOT NULL` — идентификатор «пачки» (например, `{{ run_id }}` Airflow).
- при необходимости позже можно добавить `src_system TEXT`, если появятся другие источники. - при необходимости позже можно добавить `src_system TEXT`, если появятся другие источники.
Колонки‑бизнес‑ключи (`booking_id` и т.п.) храним как `TEXT`. В слое DDS позже можно будет ввести суррогатные ключи и нормализовать модель под витрины. Колонки‑бизнес‑ключи (`booking_id` и т.п.) храним как `TEXT`. В слое DDS позже можно будет ввести суррогатные ключи и нормализовать модель под витрины.
@@ -57,20 +77,20 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre
### 3.1. Опорное поле для инкремента ### 3.1. Опорное поле для инкремента
- Опорная колонка: `src_created_at_ts` (внутреннее имя в STG). - Опорная колонка: `event_ts` (внутреннее имя в STG).
- Источник значения: - Источник значения:
- берём из соответствующей колонки в `bookings.bookings` (например, `book_date`/`created_at` — будет уточнено при реализации); - для `bookings` используем `book_date` из `bookings.bookings` (в демо‑БД это поле естественно “шагает” по дням);
- при чтении через `stg.bookings_ext` приводим к `TIMESTAMP`. - при чтении через `stg.bookings_ext` приводим к `TIMESTAMP` и сохраняем в `stg.bookings.event_ts`.
### 3.2. Правила определения full/delta ### 3.2. Правила определения full/delta
- При первом запуске, если таблица `stg.bookings` пуста: - При первом запуске, если таблица `stg.bookings` пуста:
- считаем режим `full` — загружаем все строки из `stg.bookings_ext`. - считаем режим `full` — загружаем все строки из `stg.bookings_ext`.
- При последующих запусках: - При последующих запусках:
- читаем `max(src_created_at_ts)` из `stg.bookings` за все предыдущие загрузки; - читаем `max(event_ts)` из `stg.bookings` за все предыдущие загрузки;
- считаем, что нужно загрузить только строки, где `src_created_at_ts` больше этой максимальной метки и не позже конца текущего учебного дня. - загружаем строки, где `event_ts` больше этой максимальной метки (верхняя граница по времени не задаётся).
Таким образом, вся логика инкремента «замкнута» на один техно‑столбец `src_created_at_ts`, который студент потом сможет использовать и на следующих слоях (например, в CDC‑логике). Таким образом, вся логика инкремента «замкнута» на один техно‑столбец `event_ts`, который студент потом сможет использовать и на следующих слоях (например, в CDC‑логике).
## 4. DAG’и Airflow (логика на уровне задач) ## 4. DAG’и Airflow (логика на уровне задач)
@@ -79,8 +99,8 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre
- `dag_id`: `bookings_stg_ddl` (реализован в `airflow/dags/bookings_stg_ddl.py`). - `dag_id`: `bookings_stg_ddl` (реализован в `airflow/dags/bookings_stg_ddl.py`).
- Назначение: один раз (или при изменении схемы) создать необходимые объекты в Greenplum: - Назначение: один раз (или при изменении схемы) создать необходимые объекты в Greenplum:
- схему `stg` (если её ещё нет); - схему `stg` (если её ещё нет);
- внешнюю таблицу `stg.bookings_ext` (PXF → `bookings-db`); - внешние таблицы `*_ext` и внутренние таблицы слоя `stg` для всех сущностей потока
- внутреннюю таблицу `stg.bookings` с текстовыми колонками и тех.полями. (см. `sql/stg/*_ddl.sql`).
- Этот DAG не загружает данные, только подготавливает структуру. - Этот DAG не загружает данные, только подготавливает структуру.
- Вся DDL‑логика (CREATE/ALTER/DROP) сосредоточена здесь; рабочие DAG’и занимаются только DML (INSERT/SELECT). - Вся DDL‑логика (CREATE/ALTER/DROP) сосредоточена здесь; рабочие DAG’и занимаются только DML (INSERT/SELECT).
@@ -88,7 +108,7 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre
- `dag_id`: `bookings_to_gp_stage`. - `dag_id`: `bookings_to_gp_stage`.
- Основные параметры: - Основные параметры:
- `batch_id` (по умолчанию `{{ ds_nodash }}`) — метка батча, которая попадает в `stg.bookings.batch_id`; - `_load_id` (в текущей реализации `{{ run_id }}`) — метка батча, которая попадает в `stg.bookings._load_id`;
- подключения: - подключения:
- `bookings_db_conn_id` — Airflow connection к `bookings-db` (в коде DAG — `BOOKINGS_CONN_ID = "bookings_db"`); - `bookings_db_conn_id` — Airflow connection к `bookings-db` (в коде DAG — `BOOKINGS_CONN_ID = "bookings_db"`);
- `greenplum_conn_id` — Airflow connection к Greenplum (`GREENPLUM_CONN_ID = "greenplum_conn"`). - `greenplum_conn_id` — Airflow connection к Greenplum (`GREENPLUM_CONN_ID = "greenplum_conn"`).
@@ -104,29 +124,29 @@ DDL будет добавлен в `sql/ddl_gp.sql` в блоке DDL для Gre
2. `load_bookings_to_stg` 2. `load_bookings_to_stg`
- PostgresOperator к Greenplum; - PostgresOperator к Greenplum;
- выполняет скрипт `/sql/stg/bookings_load.sql`; - выполняет скрипт `/sql/stg/bookings_load.sql`;
- внутри SQL считается `max(src_created_at_ts)` по «старым» батчам и по нему строится окно инкремента: - внутри SQL считается `max(event_ts)` по «старым» батчам и по нему строится окно инкремента:
- первая загрузка (full) — берём все строки из `stg.bookings_ext`; - первая загрузка (full) — берём все строки из `stg.bookings_ext`;
- последующие загрузки — берём только записи, где `book_date` больше предыдущего максимума (верхняя граница по дате не задаётся явно); - последующие загрузки — берём только записи, где `book_date` больше предыдущего максимума (верхняя граница по дате не задаётся явно);
- при вставке заполняются тех.колонки `src_created_at_ts`, `load_dttm`, `batch_id`. - при вставке заполняются тех.колонки `event_ts`, `_load_ts`, `_load_id`.
3. `check_row_counts` 3. `check_row_counts`
- PostgresOperator к Greenplum; - PostgresOperator к Greenplum;
- выполняет скрипт `/sql/stg/bookings_dq.sql`; - выполняет скрипт `/sql/stg/bookings_dq.sql`;
- скрипт заново считает окно инкремента по тем же правилам, что и загрузка, и сравнивает: - скрипт заново считает окно инкремента по тем же правилам, что и загрузка, и сравнивает:
- количество строк в `stg.bookings_ext` с `book_date` позже «старого» максимума, - количество строк в `stg.bookings_ext` с `book_date` позже «старого» максимума,
- количество строк в `stg.bookings` для текущего `batch_id`; - количество строк в `stg.bookings` для текущего `_load_id`;
- при расхождении выполняет `RAISE EXCEPTION` с понятным текстом ошибки. - при расхождении выполняет `RAISE EXCEPTION` с понятным текстом ошибки.
4. `finish_summary` 4. Далее — загрузка и DQ для остальных таблиц потока (tickets, справочники, транзакции).
5. `finish_summary`
- PythonOperator, который логирует итог выполнения DAG и напоминает, где смотреть детальные логи. - PythonOperator, который логирует итог выполнения DAG и напоминает, где смотреть детальные логи.
Таким образом, вся бизнес‑логика инкремента и проверок живёт в SQL‑скриптах, а DAG отвечает за оркестрацию и подключение к нужным БД. Для менти это хороший пример разделения ответственности между SQL и Python. Таким образом, вся бизнес‑логика инкремента и проверок живёт в SQL‑скриптах, а DAG отвечает за оркестрацию и подключение к нужным БД. Для менти это хороший пример разделения ответственности между SQL и Python.
## 5. Связь с остальными документами ## 5. Связь с остальными документами
- `docs/internal/bookings_tz.md` — как готовится и генерируется источник `bookings-db`. - Детали настройки PXF и часовых поясов — в ветке `solution` (`docs/reference/`).
- `docs/internal/pxf_bookings.md` — детали настройки PXF и внешней таблицы для чтения из `bookings-db`.
- `sql/stg/bookings_ddl.sql` — DDL для схемы `stg` и таблиц `stg.bookings_ext` / `stg.bookings` (подключается из `sql/ddl_gp.sql` и применяется через `make ddl-gp`). - `sql/stg/bookings_ddl.sql` — DDL для схемы `stg` и таблиц `stg.bookings_ext` / `stg.bookings` (подключается из `sql/ddl_gp.sql` и применяется через `make ddl-gp`).
Дальнейшая модель DWH (слои ODS/DDS/DM, факт/измерения, SCD) должна быть спроектирована студентом по статье о моделировании данных, используя `stg.bookings` как входной слой. Дальнейшая обработка данных описана в design-документах ODS/DDS/DM (см. раздел 5).
## 6. Требования к читаемости и комментариям ## 6. Требования к читаемости и комментариям
+217
View File
@@ -0,0 +1,217 @@
# Как устроено хранилище авиаперевозок
Разберем одну поездку: как бронирование связано с билетами и перелетами,
какие строки попадут в факт и как сложится выручка по направлениям.
Найти эти строки в структуре хранилища поможет [интерактивная карта](architecture-map.html).
Карта хранит подробности объектов и связей, а это руководство задает маршрут чтения.
## Как открыть карту
Из локальной копии репозитория откройте `docs/design/architecture-map.html`
в браузере: через файловый менеджер или командой из корня проекта:
```bash
xdg-open docs/design/architecture-map.html
```
На Windows можно открыть этот же файл в Проводнике. Сервер, интернет и работающий
Docker для карты не нужны: все ее данные включены в HTML.
Ссылки на SQL, DAG, задание и это руководство открывают файлы в Gitea:
документы — с форматированием, код — с подсветкой синтаксиса.
Для этих переходов нужен интернет. Файлы берутся из текущей ветки репозитория
по умолчанию.
**В Gitea ссылка на HTML обычно показывает исходный текст, а не карту.**
Сначала откройте локальный HTML. Адресная ссылка вида
`architecture-map.html#node=dds.fact_flight_sales` сразу выбирает таблицу;
ее можно открыть из локального просмотра этого руководства или добавить
часть после `#` к адресу уже открытой карты.
## Бронирование, билет и перелет
Ниже **учебный пример**, придуманный для объяснения. Это не выгрузка из стенда.
Короткие обозначения `T1`, `F101`, `R1` заменяют настоящие номера.
Аня и Борис купили билеты в одном бронировании `B1` от 30 августа.
Аня летит 1 сентября из Москвы через Петербург в Казань. Борис летит
тем же первым рейсом до Петербурга. Все сегменты - Economy.
| Таблица источника | Пример строк | Что различают строки |
|---|---|---|
| `bookings.bookings` | `B1`, сумма `12000` | Одно бронирование на всю покупку |
| `bookings.tickets` | `T1 → B1, Аня`; `T2 → B1, Борис` | Два билета, по одному на пассажира |
| `bookings.segments` | `(T1, F101), 3000`; `(T1, F102), 5000`; `(T2, F101), 4000` | Три оплаченных перелета |
| `bookings.flights` | `F101 → R1`, 1 сентября; `F102 → R2`, 1 сентября | Два конкретных рейса |
| `bookings.routes` | `R1: SVO → LED`; `R2: LED → KZN` | Маршруты и варианты их расписания |
Бронирование объединяет покупку. Билет принадлежит пассажиру и может включать
несколько сегментов. Сегмент соединяет билет с конкретным рейсом и хранит цену
этого перелета. Поэтому `ticket_no` недостаточно для поиска сегмента:
нужна пара `(ticket_no, flight_id)`.
Рейс и маршрут тоже различаются. `flight_id` обозначает один вылет,
а `route_no` повторяется у рейсов в разные дни. В `routes` у одного номера
могут быть разные варианты расписания; строку различает пара
`(route_no, validity)`.
Откройте [билеты](architecture-map.html#node=bookings.tickets&view=keys)
в виде **"Связи по ключам"**. Выделены бронирование, сегменты и связанные с ними
талоны. Выберите связь билета с сегментами: в карточке виден `ticket_no`.
Для связи сегмента с талоном понадобятся оба поля ключа, иначе талон
первого рейса можно ошибочно присоединить ко второму.
<a id="как-устроен-поток-целиком"></a>
## Путь данных от источника до витрин
Оставьте билет выбранным и переключитесь на **"Поток данных"**.
Таблицы останутся на местах, выбранный билет сохранится; теперь стрелки
показывают чтение данных загрузчиками. Синий цвет отмечает источники выбранной
таблицы, зеленый - потребителей, оранжевый - сам выбор. Несвязанные объекты
остаются видны приглушенными. Повторный выбор, **"Общий вид"** или `Esc`
возвращает обзор.
Проследите путь
[сегмента](architecture-map.html#node=bookings.segments) через STG и ODS к факту:
- Источник → STG: PXF читает Postgres через внешние `stg.*_ext`.
Эти входы описаны в карточках STG и сами не хранят копию. Загрузчики
сохраняют строки в `stg.*` с бизнес-полями в TEXT и метками загрузки.
- STG → ODS: У строки появляются нужные типы. Например,
`stg.segments.price` становится `ods.segments.amount` типа NUMERIC.
ODS хранит текущее состояние строки по бизнес-ключу.
- ODS → DDS → DM: Сегмент обогащается ключами измерений в факте,
затем участвует в агрегатах витрины.
Стрелка данных не означает очередность задач Airflow. Например, календарь
генерируется без чтения ODS, хотя в DDS DAG задачи других измерений ждут
проверки календаря. Порядок выполнения смотрите по ссылке **"DAG · порядок задач"**
в карточке. Поля, SQL и проверки раскрываются там же, после выбора таблицы.
<a id="как-собирается-факт"></a>
## Сборка факта продаж
Откройте [dds.fact_flight_sales](architecture-map.html#node=dds.fact_flight_sales).
В нашем примере в нем будет **три строки**, по одной на каждый сегмент.
Важные значения до выполнения студенческих заданий выглядят так:
| ticket_no | flight_id | price | departure_airport_sk | arrival_airport_sk | route_sk | airplane_sk | passenger_sk |
|---|---|---:|---|---|---|---|---|
| T1 | F101 | 3000 | SK аэропорта SVO | SK аэропорта LED | NULL | NULL | NULL |
| T1 | F102 | 5000 | SK аэропорта LED | SK аэропорта KZN | NULL | NULL | NULL |
| T2 | F101 | 4000 | SK аэропорта SVO | SK аэропорта LED | NULL | NULL | NULL |
SK здесь обозначены словами: конкретные числа назначает загрузка измерений.
`calendar_sk` указывает на 1 сентября, `tariff_sk` - на Economy.
`book_ref = B1` повторяется во всех трех строках; `book_date` - 30 августа.
Цена хранится на уровне сегмента, а сумма `12000` из бронирования в факт
не переносится. Иначе ее можно было бы ошибочно посчитать трижды.
Начальный поток - `ods.segments`. Соединения с билетами, бронированиями
и рейсами - `INNER JOIN`: отсутствие подходящей строки исключит сегмент
из вставки факта. Это потеря покрытия, которую должна обнаружить DQ.
Посадочный талон добавляется через `LEFT JOIN` по паре `(ticket_no, flight_id)`.
Нет талона - сегмент сохраняется, `seat_no` остается `NULL`, `is_boarded = false`.
В этом стенде признак посадки означает именно наличие талона.
<a id="откуда-берутся-два-аэропорта"></a>
### Аэропорты вылета и прилета
В карточке факта откройте связи **"Коды двух аэропортов"**,
**"Аэропорт вылета"** и **"Аэропорт прилета"**:
1. Рейс дает `route_no`. Из `ods.routes` берется одна строка с наибольшим
`validity` на этот номер. В ODS поле `validity` хранится как TEXT;
этот выбор не использует дату рейса.
2. `departure_airport` и `arrival_airport` дают два кода аэропорта.
3. Один `dds.dim_airports` присоединяется дважды по `airport_bk`, в ролях
вылета и прилета. Получаются два разных поля SK в факте.
Этот путь работает без студенческого `dim_routes`. Отсутствующий аэропорт
в факте - аномалия данных, а не ожидаемый результат невыполненного задания.
SQL использует `LEFT JOIN`, поэтому строка с таким пропуском сохранится;
допустимость пропуска отдельно оценивает [DQ факта](../../sql/dds/fact_flight_sales_dq.sql).
<a id="какая-версия-маршрута-нужна-рейсу"></a>
### Версия маршрута на дату рейса
Связь **"Версия на дату рейса"** ведет к `dds.dim_routes` по `route_bk = route_no`
и дате `scheduled_departure::DATE`. Дата должна попасть в полуоткрытый
интервал `[valid_from, valid_to)`; `NULL` в конце означает открытую версию.
Рейс на границе двух версий относится к новой.
Из выбранной версии берется `route_sk`, а ее `airplane_code` нужен для
поиска `airplane_sk` в `dim_airplanes`. Поэтому одного заполненного справочника
моделей недостаточно: без подходящей версии маршрута `airplane_sk` не появится.
<a id="что-кормит-витрины"></a>
## Агрегация фактов в витрине продаж
Перейдите к [dm.sales_report](architecture-map.html#node=dm.sales_report).
Зерно меняется: день вылета × два аэропорта × класс обслуживания.
Три строки примера дадут две строки витрины:
| Дата вылета | Направление | Класс | tickets_sold | total_revenue |
|---|---|---|---:|---:|
| 1 сентября | SVO → LED | Economy | 2 | 7000 |
| 1 сентября | LED → KZN | Economy | 1 | 5000 |
`tickets_sold` здесь считает **сегменты**. Один билет может участвовать несколько раз.
Если сложить его по направлениям, получится 3 при двух билетах.
`total_revenue` складывается из цен сегментов, поэтому итог равен 12000.
Доля посадок рассчитывается по `is_boarded`.
Готовая витрина использует календарь, две роли аэропорта и тариф через
`INNER JOIN`: факт без совпадения в одном из этих измерений не попадет в агрегат.
Поля `route_sk`, `airplane_sk`, `passenger_sk` ей не требуются, поэтому
эталон работает до выполнения заданий. Остальные витрины видны рядом
пунктирными рамками: в их карточках описан требуемый результат по ТЗ.
## Модели самолетов и места: начало задания
Откройте [dds.dim_airplanes](architecture-map.html#node=dds.dim_airplanes).
У него два источника: `ods.airplanes` дает характеристики модели,
а `ods.seats` - места, по которым считается вместимость.
Строка измерения описывает **модель**, например тип самолета, а не отдельный борт.
`seat_no` уникален только вместе с `airplane_code`.
На `main` STG уже заполняется, а загрузки `ods.airplanes`, `ods.seats`
и `dds.dim_airplanes` пока содержат `SELECT 1;`. Карточки помечены
**"Задание"** и ведут к соответствующим пунктам ТЗ. Ожидаемые поля
известны из DDL, но готовой загрузки этих объектов еще нет.
## Ключевые договорённости
- Ключи на карте логические. В DDL таблиц DWH нет ограничений
`PRIMARY KEY`, `UNIQUE` и `FOREIGN KEY`; уникальность и связи проверяют SQL
и DQ. `DISTRIBUTED BY` отвечает за размещение строк.
- `_load_id` и `_load_ts` описывают загрузку. `event_ts` - ее опорное время;
у снимков справочников в STG оно равно времени получения снимка.
Подробности - в [соглашениях об именах](naming_conventions.md).
- До реализации измерений допустимы `NULL` в `passenger_sk`, `route_sk`
и `airplane_sk`. Ключи фиксируются **при вставке** факта: повторный запуск
меняет цену и сведения о посадке, но не заполняет старые SK.
После выполнения заданий следуйте разделу
["Пересчет факта после реализации измерений"](../assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
- В карточке заглушки описан результат из актуального ТЗ. Если ТЗ не определяет
тип соединения или поведение при пропуске строки, карта отмечает этот предел,
не выдает предположение за работающий SQL.
## Куда идти дальше
Для первого задания разберите готовую загрузку справочника:
[от карты к SQL загрузки](reading_the_pipeline.md#ods-airports), затем откройте
[первое задание ODS](../assignment/README.md#первая-загрузка-ods). Для точечного вопроса
о таблице возвращайтесь на карту: там находится каталог объектов,
их источников, потребителей, SQL и DQ.
Проверяйте задание по [инструкции самопроверки](../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).
[Индекс документации](../README.md) ведет к справочникам, проверкам разработки
и истории проектирования.
+81
View File
@@ -0,0 +1,81 @@
# Конвенции Нейминга DWH (Единый Источник)
> Статус: активный стандарт для новых реализаций в этом репозитории.
>
> Основа: учебные материалы `de-roadmap/dwh-modeling`.
## Зачем этот документ
Этот справочник задает имена служебных полей. Состав колонок конкретной
таблицы проверяйте по ее DDL, а требования к учебной загрузке - по
[ТЗ](../assignment/analyst_spec.md).
## 1. Базовые правила
- Имена колонок: `snake_case`, на английском.
- Бизнес-ключи источника не переименовываем без необходимости (`book_ref`, `ticket_no`, `route_no`).
- Булевы поля начинаются с `is_` (`is_outbound`, `is_boarded`).
- Денежные и количественные поля называем по смыслу (`total_amount`, `amount`, `range_km`).
## 2. Каноничные служебные поля
| Поле | Тип (рекомендация) | Смысл | Где применять |
|---|---|---|---|
| `_load_id` | `TEXT NOT NULL` | Идентификатор запуска или исходного STG-батча; зависит от слоя | STG, ODS, DDS и DM, кроме календаря |
| `_load_ts` | `TIMESTAMP NOT NULL` | Метка загрузки; в транзакционных ODS сохраняется время STG | STG, ODS, DDS и DM, кроме календаря |
| `event_ts` | `TIMESTAMP` | Опорное время источника или получения снимка | Все таблицы STG и транзакционные ODS |
| `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`).
- В существующих таблицах есть бизнес-ключи без `_bk`: `passenger_id`
у пассажиров, `fare_conditions` у тарифов, `date_actual` у календаря.
Используйте имена из DDL и ТЗ; переименовывать их в задании не нужно.
## 4. Правило времени (важно для обучения)
- В STG `event_ts` задает опору инкремента: `book_date` у бронирований,
билетов, сегментов и посадочных талонов; `scheduled_departure` у рейсов.
Например, у талона это дата бронирования, а не `boarding_time`.
- У снимков `airports`, `airplanes`, `routes`, `seats` в STG
`event_ts = now()`: точного времени изменения записи в источнике здесь нет.
- `_load_ts` относится к загрузке. Какой именно слой ее поставил, указано ниже.
Правила отбора строк описаны в руководствах [STG](../bookings_to_gp_stage.md)
и [ODS](../bookings_to_gp_ods.md).
## 5. Применение по слоям
### STG
- Все таблицы содержат `_load_id = run_id` запуска STG, `_load_ts = now()`
и `event_ts` по правилам выше.
### ODS
- Снимки `airports`, `airplanes`, `routes`, `seats`: `_load_id` выбранного
STG-батча, `_load_ts = now()`, без `event_ts`. Загрузка заменяет снимок целиком.
- Транзакционные таблицы: `_load_id`, `_load_ts` и `event_ts` переносятся из STG.
Сохраненная `_load_ts` служит границей инкремента для `UPDATE + INSERT`.
### DDS и DM
- `_load_id` измененной или вставленной строки относится к текущему запуску
своего слоя, `_load_ts` фиксирует время записи.
- Календарь статический: у `dim_calendar` нет меток загрузки.
- `created_at` и `updated_at` используем там, где они предусмотрены DDL.
Например, у факта и полностью пересчитываемой `route_performance` их нет.
- Для SCD2 используем:
- `valid_from`, `valid_to`, `hashdiff`, `created_at`, `updated_at`.
- Интервалы считаем как `[valid_from, valid_to)`, current-версия: `valid_to IS NULL`.
## 6. Что проверяем в ревью
- Нет новых техполей-синнонимов вроде `loaded_at`, `ingested_at`, `batch_key`, если уже есть канон.
- Нет смешивания `event_ts` и `_load_ts` в одном смысле.
- В SCD2 не используются альтернативы `dw_start_date/dw_end_date`, если в проекте принят `valid_from/valid_to`.
+132
View File
@@ -0,0 +1,132 @@
# От карты к SQL загрузки
На [карте](architecture-map.html) найдите таблицу, которую хотите заполнить,
и откройте ее SQL загрузки. Проследите, какие строки скрипт читает,
что меняет и по какому ключу записывает результат.
Смысл строк и связей разобран в [руководстве о структуре DWH](db_schema.md).
Для первого задания достаточно примера `ods.airports` ниже.
К сборке факта вернитесь перед заданиями DDS.
Адресные ссылки на карту работают в локальном просмотре;
если читаете в Gitea, используйте [инструкцию открытия карты](db_schema.md#как-открыть-карту).
<a id="ods-airports"></a>
## Справочник: stg.airports → ods.airports
Откройте [ods.airports на карте](architecture-map.html#node=ods.airports)
и связь [Типизация и загрузка ODS](architecture-map.html#edge=flow-stg.airports-ods.airports-13).
Здесь один источник: `stg.airports`. В STG накапливаются снимки разных запусков,
а в ODS нужна одна строка на аэропорт из выбранного снимка.
Рядом откройте [airports_load.sql](../../sql/ods/airports_load.sql).
Пройдите скрипт по порядку:
1. `TRUNCATE TABLE ods.airports` очищает предыдущий снимок.
Следующий `INSERT` заменяет его целиком, поэтому исчезнувший из нового
снимка аэропорт исчезнет и из ODS.
2. В CTE `src` найдите `FROM stg.airports` и фильтр `_load_id`:
```sql
WHERE s._load_id = '{{ ti.xcom_pull(task_ids="resolve_stg_batch_id") }}'::text
```
Задача `resolve_stg_batch_id` в [ODS DAG](../../airflow/dags/bookings_to_gp_ods.py)
выбирает общий батч четырех справочников STG или берет `stg_batch_id`
из параметров запуска. Этот фильтр оставляет один снимок из истории STG.
3. Выражения `airport_name::json->>'ru'`, `city::json->>'ru'`
и `country::json->>'ru'` извлекают русские названия. Остальные бизнес-поля
переносятся без преобразования.
4. `ROW_NUMBER()` нумерует строки внутри `airport_code` по `_load_ts DESC`,
затем `event_ts DESC NULLS LAST`. Финальный `WHERE s.rn = 1`
оставляет одну строку на код.
5. Сопоставьте колонки `INSERT INTO ods.airports (...)` с финальным `SELECT`.
В `_load_id` сохраняется идентификатор выбранного STG-батча,
в `_load_ts` записывается время загрузки ODS. Поля и типы результата
заданы в [airports_ddl.sql](../../sql/ods/airports_ddl.sql).
6. `ANALYZE` обновляет статистику таблицы для планировщика запросов.
Проверки находятся отдельно: [airports_dq.sql](../../sql/ods/airports_dq.sql)
сравнивает множества ключей STG-батча и ODS, ищет дубли и пустые обязательные поля.
Повторная загрузка того же снимка должна сохранить бизнес-строки без дублей;
время `_load_ts` при этом обновится.
Теперь переходите к [первой самостоятельной загрузке](../assignment/README.md#первая-загрузка-ods).
<a id="пример-fact_flight_sales"></a>
<a id="fact-flight-sales"></a>
## Факт: сегмент билета и его измерения
Откройте [dds.fact_flight_sales](architecture-map.html#node=dds.fact_flight_sales)
и [fact_flight_sales_load.sql](../../sql/dds/fact_flight_sales_load.sql).
Одна строка факта соответствует паре `(ticket_no, flight_id)`.
В [примере поездки](db_schema.md#сборка-факта-продаж) два билета с тремя
сегментами дают три строки факта.
<a id="метод"></a>
<a id="fact-joins"></a>
### Найдите источник новых строк
В файле сначала идет `UPDATE`, а затем CTE `fact_src` и `INSERT`.
Начните с `fact_src`: здесь виден полный набор источников новых строк.
`FROM ods.segments AS seg` задает зерно, а `seg.amount AS price`
переносит цену сегмента в факт.
| Участок `fact_src` | Что добавляется к сегменту | Связь на карте |
|---|---|---|
| `JOIN ods.tickets`, затем `JOIN ods.bookings` | Бронирование, пассажир и дата покупки | [Билет и пассажир](architecture-map.html#edge=fact-1), [дата бронирования](architecture-map.html#edge=fact-2) |
| `JOIN ods.flights` | Дата вылета и номер маршрута | [Рейс, дата и маршрут](architecture-map.html#edge=fact-3) |
| `LEFT JOIN` подзапроса из `ods.routes` | Коды аэропортов из строки с наибольшим `validity` на `route_no` | [Коды двух аэропортов](architecture-map.html#edge=fact-4) |
| Два `LEFT JOIN dds.dim_airports` как `dep` и `arr` | `departure_airport_sk` и `arrival_airport_sk` по двум кодам | [Вылет](architecture-map.html#edge=fact-7), [прилет](architecture-map.html#edge=fact-8) |
| `LEFT JOIN dds.dim_routes`, затем `dds.dim_airplanes` | Версия маршрута на дату рейса и SK ее модели самолета | [Версия маршрута](architecture-map.html#edge=fact-5), [модель](architecture-map.html#edge=fact-9) |
| `LEFT JOIN dds.dim_calendar`, `dds.dim_tariffs`, `dds.dim_passengers` | SK по дате вылета, классу обслуживания и `passenger_id` | [Дата](architecture-map.html#edge=fact-6), [тариф](architecture-map.html#edge=fact-10), [пассажир](architecture-map.html#edge=fact-11) |
| `LEFT JOIN ods.boarding_passes` по обоим полям ключа | `seat_no`; наличие талона задает `is_boarded` | [Посадка и место](architecture-map.html#edge=fact-12) |
Если билет, бронирование или рейс не найдены, `INNER JOIN` исключит сегмент
из вставки. `LEFT JOIN` сохранит его при отсутствии совпадения: например,
без талона получатся `seat_no = NULL` и `is_boarded = false`.
Обратите внимание на два разных пути от маршрута:
- Аэропорты берутся через готовый `ods.routes`. Отбор `ORDER BY validity DESC`
работает по полю TEXT и не использует дату рейса.
- `route_sk` ищется в студенческом `dim_routes` по дате вылета
в интервале `[valid_from, valid_to)`. Из этой версии берется `airplane_code`
для поиска `airplane_sk`.
До выполнения заданий `route_sk`, `airplane_sk` и `passenger_sk` могут быть
`NULL`. Путь аэропортов от студенческих измерений не зависит.
Объяснение этой части модели: [аэропорты и версия маршрута](db_schema.md#аэропорты-вылета-и-прилета).
### Найдите запись результата
Финальный `INSERT` берет поля из `fact_src`. Условие `NOT EXISTS`
проверяет пару `(ticket_no, flight_id)` и пропускает уже записанные строки.
Вернитесь к `UPDATE` в начале файла: он меняет только цену, место,
признак посадки и метки загрузки у существующих строк.
Ключи измерений он не пересчитывает.
Поэтому после реализации измерений нужен
[пересчет факта по ТЗ](../assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений).
Проверки результата находятся в [fact_flight_sales_dq.sql](../../sql/dds/fact_flight_sales_dq.sql).
## Как проверить свой слой
Перед записью SQL найдите свой объект на карте и откройте его пункт ТЗ.
Сформулируйте, что означает одна строка результата, по каким полям соединяются
источники и что произойдет при отсутствии совпадения. Затем сверяйте
`INSERT` с DDL, а преобразования и `*_dq.sql` с требованиями задания.
Близкие готовые примеры для следующих этапов:
- SCD1: [dim_airports_load.sql](../../sql/dds/dim_airports_load.sql) обновляет
изменившиеся атрибуты через `IS DISTINCT FROM` и вставляет новые бизнес-ключи.
- DM: [sales_report_load.sql](../../sql/dm/sales_report_load.sql) находит
затронутые даты по `_load_ts` факта, пересчитывает за них полные агрегаты
во временную таблицу и выполняет `UPDATE` и `INSERT`.
[Порядок заданий](../assignment/README.md#следующие-этапы) ведет к требованиям
каждого этапа. Имена служебных полей проверяйте по
[соглашениям](naming_conventions.md), состав DQ - по
[справочнику проверок](../reference/dq_taxonomy.md).
-80
View File
@@ -1,80 +0,0 @@
# Мини‑README по учебному DAG bookings_to_gp_stage (черновик)
_Внутренний файл, чтобы не забыть договорённости. Перед итоговой сдачей документацию по блоку bookings/STG нужно будет аккуратно собрать и переписать._
## 1. Что делает DAG
- DAG `bookings_to_gp_stage` показывает учебный поток:
- источник: демо‑БД `bookings-db` (Postgres, схема `bookings`, таблица `bookings.bookings`);
- при каждом запуске генерируется один учебный день данных (идемпотентно);
- данные из `bookings.bookings` переливаются в сырой слой `stg.bookings` в Greenplum через PXF‑внешнюю таблицу `stg.bookings_ext`.
- Слой `stg` задуман как «сырой»:
- все бизнес‑колонки (`book_ref`, `book_date`, `total_amount`) хранятся как `TEXT`;
- есть тех.колонки `src_created_at_ts`, `load_dttm`, `batch_id`.
Подробный дизайн описан в `docs/internal/bookings_stg_design.md`.
## 2. Что нужно, чтобы DAG завёлся
Минимальные предпосылки:
- Стенд поднят: `make up`.
- Демо‑БД bookings инициализирована: `make bookings-init`.
- В Greenplum применён DDL (созданы схема `stg` и таблицы `stg.bookings_ext` / `stg.bookings`):
- учебный вариант: запустить DAG `bookings_stg_ddl` (он использует `sql/stg/bookings_ddl.sql`);
- технический шорткат: `make ddl-gp` применяет все DDL разом вручную. Команда сама не вызывается при старте контейнеров, её нужно запустить явно.
- В Airflow есть коннекты:
- `greenplum_conn` — к Greenplum;
- `bookings_db` — к сервису `bookings-db`.
По умолчанию они задаются через переменные окружения `AIRFLOW_CONN_...` в docker-compose,
поэтому могут не отображаться в UI, но `PostgresOperator` найдёт их по `conn_id`. При желании
их можно создать/отредактировать вручную через Admin → Connections.
## 3. Последовательность задач в DAG
- `generate_bookings_day`:
- PostgresOperator к `bookings-db`;
- выполняет SQL `/sql/src/bookings_generate_day_if_missing.sql`;
- скрипт смотрит на `max(book_date)` в `bookings.bookings`:
- если база пустая — берёт стартовую дату из GUC и генерирует `bookings.init_days` суток;
- если данные уже есть — добавляет один следующий учебный день после `max(book_date)` и пишет NOTICE с интервалом генерации.
- `load_bookings_to_stg`:
- PostgresOperator к Greenplum;
- выполняет SQL `/sql/stg/bookings_load.sql`;
- считает «старый» максимум `src_created_at_ts` (по предыдущим батчам) и грузит только новые строки из `stg.bookings_ext`, заполняя `src_created_at_ts`, `load_dttm`, `batch_id={{ ds_nodash }}`.
- `check_row_counts`:
- PostgresOperator к Greenplum;
- выполняет SQL `/sql/stg/bookings_dq.sql`;
- за то же окно инкремента считает количество строк в источнике и в `stg.bookings` (по текущему `batch_id`);
- при расхождении делает `RAISE EXCEPTION` с понятным текстом ошибки.
- `finish_summary`:
- логирует итог выполнения DAG за одно срабатывание.
## 4. Как этим пользоваться студенту (черновой сценарий)
1. Поднять стенд и подготовить источники:
- `make up` (Airflow инициализируется автоматически при первом старте)
- `make bookings-init`
- `make ddl-gp`
2. Открыть Airflow UI (`http://localhost:8080`) и включить DAG `bookings_to_gp_stage`.
3. Вызвать `Trigger` DAG (дату логического запуска можно оставить по умолчанию — она используется только как метка `batch_id`).
4. Посмотреть:
- в `bookings-db` ― что появился день с бронированиями;
- в Greenplum (`make gp-psql`) — данные в `stg.bookings`:
- `SELECT * FROM stg.bookings LIMIT 10;`
- `SELECT src_created_at_ts, load_dttm, batch_id FROM stg.bookings ORDER BY src_created_at_ts DESC LIMIT 10;`
5. Перезапустить DAG ещё несколько раз и увидеть, что:
- генерация в `bookings.bookings` идёт по одному дню вперёд от текущего `max(book_date)`;
- в `stg.bookings` появляются только новые записи (delta), помеченные разными `batch_id`.
## 5. Примечания «на потом»
- Текущая документация по блоку bookings/STG разбросана:
- `README.md` (общий обзор стенда),
- `docs/internal/bookings_tz.md` (источник bookings),
- `docs/internal/pxf_bookings.md` (PXF),
- `docs/internal/bookings_stg_design.md` (дизайн STG),
- этот файл (мини‑README по DAG).
- В будущем всё это нужно будет собрать в одну понятную историю для студента:
- отдельный раздел «Учебный пример: bookings → stg → dwh»;
- скриншоты DAG, примеры запросов и типичные ошибки.
-25
View File
@@ -1,25 +0,0 @@
# Временное ТЗ по блоку bookings (для текущей разработки)
_Этот файл внутренний, удалить перед итоговой сдачей._
- Контейнер `bookings-db` — отдельный сервис Postgres из `docker-compose.yml`, база по умолчанию `demo` (из upstream demodb), без переименований.
- Доступ снаружи не блокируем (порт `5434` по умолчанию), чтобы позже читать через PXF и подключаться из Greenplum.
- Инициализация (`make bookings-init`): поднимает контейнер, клонирует demodb с закреплённым коммитом, накладывает патчи (`engine`: `jobs=1` синхронно + `busy()` игнорирует свой pid; `install.sql`: `DROP DATABASE IF EXISTS`), ждёт `pg_isready`, ставит `gen.connstr` и GUC `bookings.start_date/init_days/jobs`, затем запускает `/bookings/generate_next_day.sql` через `psql -f`. Значения по умолчанию: стартовая дата 2017-01-01, `init_days=1`, `jobs=1`.
- Генерация следующего дня: `make bookings-generate-day` прогоняет тот же SQL (читает GUC, вызывает `generate/continue`, ждёт `busy()`, закрывает dblink). При `jobs=1` всё синхронно, без dblink.
- Исходники demodb: клонируем по требованию с фиксированным хешем, кладём в `bookings/demodb/` (в `.gitignore`), патчи лежат в `bookings/patches/` и применяются автоматически.
- Документация: в README описаны команды (`bookings-init`, проверка данных, генерация дня), параметры `.env`; настройка PXF/ETL — следующий этап.
## Текущее состояние
- `Makefile` теперь автоматически применяет патчи (`engine_jobs1_sync.patch`, `install_drop_if_exists.patch`), ждёт готовности Postgres через `pg_isready`, запускает `install.sql`, выставляет `gen.connstr`/GUC и вызывает `generate_next_day.sql` через `psql -f`.
- Дефолты: `BOOKINGS_START_DATE=2017-01-01`, `BOOKINGS_INIT_DAYS=1`, `BOOKINGS_JOBS=1`. При `jobs=1` генерация идёт синхронно без dblink, `busy()` не учитывает текущую сессию.
- `.env.example`/README обновлены под новые дефолты; каталог `bookings/demodb/` в `.gitignore`.
- Патчи лежат в `bookings/patches/` и накладываются при `bookings-clone-demodb`.
## Текущее состояние тестов/проблем
- Чистый прогон `make bookings-init` (после `docker compose down -v` и удаления `bookings/demodb`) проходит за ~1,5 минуты: база ставится, `busy()` → `f`, `bookings.bookings` от `2017-01-01 00:00:18` до `2017-01-01 23:59:59`.
- Ранее зависание на `busy()` при `jobs=1` лечится патчем: `process_queue` теперь синхронный, а `busy()` игнорирует текущий backend.
- Данных пока только на 1 день по умолчанию, чтобы генерация не занимала много времени.
## Идеи/следующие шаги
- Если понадобится больше дней — увеличивать `BOOKINGS_INIT_DAYS`, но помнить, что генерация может идти долго; контролировать через `SELECT busy();`.
- Следующий этап — PXF/ETL в Greenplum; текущая задача — лишь подготовить источник bookings.
-201
View File
@@ -1,201 +0,0 @@
# PXF для bookings в учебном стенде (актуально)
Этот документ описывает **текущую реализацию** PXF в проекте: чтение данных из демо‑БД
`bookings` (Postgres, сервис `bookings-db`) в Greenplum через JDBC.
## 1. Что должно работать
- В Greenplum доступны внешние таблицы:
- `public.ext_bookings_bookings` (создаётся `make ddl-gp`);
- `stg.bookings_ext` (создаётся DAG `bookings_stg_ddl`).
- PXF должен быть готов **после каждого старта** контейнера `greenplum`.
## 2. Почему мы делаем свой образ Greenplum
Изначально PXF‑скрипты/конфиги монтировались в контейнер как bind‑mount `:ro`.
Базовый entrypoint образа Greenplum пытается делать `chown` файлов в
`/docker-entrypoint-initdb.d/`, из‑за чего контейнер иногда падал с ошибкой:
`chown: changing ownership ... Read-only file system`
Снять `:ro` тоже нежелательно — можно получить проблемы с правами на файлах хоста
(файл становится `root`, IDE перестаёт сохранять, появляются лишние изменения в git).
Решение для учебного стенда:
- собрать **свой образ** Greenplum (`Dockerfile.greenplum`);
- «вшить» в образ seed‑файлы и скрипты PXF;
- на каждом старте контейнера идемпотентно докладывать файлы в `PXF_BASE`,
который живёт на persistent volume.
## 3. Где что хранится
**Внутри образа (immutable):**
- seed для PXF: `/opt/pxf-seed/` (JDBC‑JAR и `servers/bookings-db/jdbc-site.xml`);
- скрипты:
- `/opt/pxf-scripts/ensure_pxf_bookings.sh` (подготовка `PXF_BASE`);
- `/start_greenplum_with_pxf.sh` (startup wrapper).
**На persistent volume (переживает рестарты):**
- `PXF_BASE` по умолчанию: `${GREENPLUM_DATA_DIRECTORY}/pxf` → в нашем compose это
`/data/pxf` на томе `greenplum_data`.
Важно: так как `PXF_BASE` лежит на томе, обновления seed‑файлов из нового образа
**не перезатирают** файлы в `PXF_BASE` автоматически (это сделано намеренно, чтобы
не ломать ручные правки студентов).
## 4. Что происходит при старте контейнера `greenplum`
1) Docker запускает контейнер с базовым entrypoint образа и командой
`/start_greenplum_with_pxf.sh` (она задана в `Dockerfile.greenplum` как `CMD`).
2) `/start_greenplum_with_pxf.sh` выполняет подготовку PXF:
- запускает ensure‑скрипт `/opt/pxf-scripts/ensure_pxf_bookings.sh`;
- параллельно пытается выполнить `CREATE EXTENSION IF NOT EXISTS pxf`
в базе `${GP_DB}` (по умолчанию `gp_dwh`), когда Greenplum начинает принимать
подключения.
3) Затем управление передаётся оригинальному старту Greenplum: `exec /start_gpdb.sh`.
4) Healthcheck сервиса `greenplum` ждёт и готовность Greenplum, и то, что PXF уже
запущен (`pxf cluster status`). Это нужно, чтобы Airflow не стартовал раньше PXF.
## 5. Управляющие переменные окружения
Все переменные можно задать в `.env` (см. `.env.example`):
- `PXF_SEED_OVERWRITE=1` — принудительно перезаписать seed‑файлы из образа в `PXF_BASE`
(обычно нужно после правок в каталоге `pxf/`).
- `PXF_SYNC_ON_START=1` — выполнять `pxf cluster sync` при старте контейнера
(делает старт чуть дольше, но гарантирует актуальные конфиги на хостах кластера).
## 6. Быстрая ручная проверка
1) Дождаться `healthy` у `greenplum`:
`docker compose ps`
2) Проверить статус PXF (PXF CLI запускается только под пользователем `gpadmin`):
`docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/pxf/bin/pxf cluster status'"`
3) После применения DDL (`make ddl-gp`) проверить чтение через PXF:
- `make gp-psql`
- `SELECT COUNT(*) FROM public.ext_bookings_bookings;`
## 7. Типовые ошибки
- `protocol "pxf" does not exist`
- причина: не создано расширение `pxf` в базе Greenplum;
- решение: перезапустить `greenplum` (скрипт сделает `CREATE EXTENSION IF NOT EXISTS pxf`)
или выполнить вручную `CREATE EXTENSION pxf;`.
- `Connection refused` к порту `5888`
- причина: PXF не поднялся/не успел подняться;
- решение: проверить `pxf cluster status`, посмотреть логи PXF в `/data/pxf/logs`,
перезапустить сервис `greenplum`.
- PXF «не подхватывает» изменения конфигов
- причина: файлы уже лежат в `PXF_BASE` на томе, а seed из образа по умолчанию не перетирает их;
- решение: `make build` + restart `greenplum` + (при необходимости) `PXF_SEED_OVERWRITE=1`.
## 9. Известная проблема: `protocol "pxf" does not exist` на «холодном старте» (исправлено)
Раньше (воспроизводилось в `./scripts/e2e_smoke.sh`) при первом `make ddl-gp` можно было получить:
`ERROR: protocol "pxf" does not exist`
### Почему так происходило
В базовом `/start_gpdb.sh` из образа Greenplum создание расширения `pxf` связано с проверкой
файла `${PXF_BASE}/conf/pxf-env.sh`:
- если `pxf-env.sh` **отсутствует**, скрипт выполняет `pxf cluster prepare/register` и затем
`CREATE EXTENSION IF NOT EXISTS pxf`;
- если `pxf-env.sh` **уже существует**, этот блок **пропускается**, и расширение может не появиться.
При этом наш ensure‑скрипт `pxf/init/10_pxf_bookings.sh` копировал `pxf-env.sh` в `${PXF_BASE}`
ещё до запуска Greenplum, из‑за чего базовый скрипт считал PXF “уже настроенным” и
пропускал создание расширения.
### Что изменили
- создание `extension pxf` вынесено в `start_greenplum_with_pxf.sh` и обёрнуто ретраями;
- `pxf-env.sh` по‑прежнему копируется в `PXF_BASE`, чтобы `/start_gpdb.sh` не пытался выполнять
`pxf cluster prepare` на непустом `PXF_BASE`;
- healthcheck `greenplum` ждёт не только PXF, но и наличие `extension pxf`.
- добавлен экспорт `PGPASSWORD` для `pxf cluster start`, чтобы `docker compose stop/start`
не ломал запуск из‑за `password authentication failed` для `gpadmin`.
### Если ошибка всё ещё возникает
1) Пересоберите образ и перезапустите контейнер `greenplum`:
`make build && make down && make up`
2) Проверьте наличие extension:
`docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -d gp_dwh -t -A -c \"SELECT extname FROM pg_extension WHERE extname = ''pxf'';\"'"`
## 8. Связанные файлы
- `Dockerfile.greenplum`
- `docker-compose.yml` (сервис `greenplum`: `build`, `hostname`, env, healthcheck)
- `pxf/init/10_pxf_bookings.sh` (ensure‑логика)
- `pxf/init/start_greenplum_with_pxf.sh` (старт контейнера)
- `README.md` (раздел «Greenplum + PXF: свой образ»)
## 10. Известная проблема: после `docker compose stop/start` Greenplum может упасть (auth для PXF)
### Симптом
После `docker compose stop`, затем `docker compose start` контейнер `greenplum` иногда уходит в `Exited (1)`.
В логах видно, что GPDB поднялся, но упал на старте PXF:
- `INFO - pxf cluster start`
- `ERROR: Could not connect to GPDB`
- `FATAL: password authentication failed for user "gpadmin"`
### Текущее понимание причины (почему это “иногда”)
1) При старте GPDB образ `woblerr/greenplum` генерирует/дописывает `pg_hba.conf` на persistent volume.
2) В `pg_hba.conf` присутствует trust‑правило для **конкретного IP** контейнера в docker‑сети
(пример из диагностики: `host all gpadmin 172.21.0.2/32 trust`).
3) После `docker compose stop/start` Docker может выдать контейнеру **другой IP** (например, `172.21.0.3`).
Тогда trust‑правило больше не подходит, и подключение начинает идти по `md5`.
4) `pxf cluster start` подключается к GPDB по TCP на `host=gpdbsne` (hostname контейнера),
то есть попадает именно в `pg_hba.conf` (а не в local‑auth).
5) В результате при “не совпавшем IP” получаем `md5` + пароль (возможно пустой/не тот) → падение на `28P01`.
Эта проблема выглядит флапающей, потому что IP после `stop/start` иногда совпадает с захардкоженным trust‑/32,
а иногда нет.
### Как подтвердить при следующем воспроизведении
1) Посмотреть логи `greenplum`:
`docker compose logs --tail=200 greenplum`
2) Найти реальный IP клиента в master‑логах GPDB (на томе):
`Password does not match ...` обычно содержит адрес вида `172.21.0.X`.
3) Сравнить его с trust‑строкой в `pg_hba.conf` на томе:
`/data/master/gpseg-1/pg_hba.conf`
Если IP в ошибке (например, `172.21.0.3`) **не** совпадает с trust‑/32 (например, `172.21.0.2/32`) —
это почти наверняка корень падения.
### Что с этим делать дальше (варианты решения, без реализации здесь)
Основная цель — убрать зависимость от “случайного IP после stop/start”:
- заставить `pxf cluster start` подключаться к GPDB через `127.0.0.1` (тогда работает существующий trust на localhost);
- или перестать добавлять в `pg_hba.conf` trust на конкретный `172.21.0.2/32` и заменить на более стабильное правило
(например, на подсеть docker‑сети или на `samehost`);
- или закрепить IP контейнера в compose (static IP), чтобы он не “плавал”;
- или отказаться от `stop/start` в пользу сценария, который не меняет сетевое окружение (но это хуже для UX студентов).
### Что реализовано
- В `pxf/init/start_greenplum_with_pxf.sh` добавлен шаг, который на каждом старте
обеспечивает в `pg_hba.conf` trust‑правило `host all gpadmin samehost trust`
(вставка перед `host all all 0.0.0.0/0 md5`), и делает `pg_ctl reload`, если GPDB уже запущен.
+143
View File
@@ -0,0 +1,143 @@
# Проблемы bookings-db (demodb)
> Дата обнаружения: 2026-03-08
> Зафиксированный коммит demodb: `866e56f7fe54596a1d2a88f5f32f4aa3b2698121`
---
## 1. gen.connstr без credentials — генерация обрывается
### Симптом
После `make bookings-generate` (генерация с нуля) таблицы в demo-базе существуют, но **пустые**.
Повторные `make bookings-generate-day` тоже дают 0 строк.
### Корневая причина
`install.sql` из demodb хардкодит:
```sql
ALTER DATABASE demo SET gen.connstr = 'dbname=demo';
```
Без `user` и `password`. Makefile устанавливает правильный connstr
(с `user=bookings password=bookings`) **после** `install.sql`, но при повторном
`make bookings-generate` порядок тот же: install.sql перезаписывает → Makefile
восстанавливает. Если что-то идёт не так между этими шагами, connstr остаётся
без credentials.
Далее цепочка:
1. `process_queue()` обрабатывает события (BOOKING, FLIGHT, VACUUM и т.д.)
2. Событие VACUUM вызывает `dblink_exec(gen.connstr, 'VACUUM ANALYZE')`
3. dblink без user → подключается как `postgres` → `FATAL: role "postgres" does not exist`
4. `process_queue()` ловит ошибку, логирует и **прекращает работу**
5. Генерация обрывается на 1-2 модельных днях → данных почти нет
### Диагностика
```sql
-- В psql к demo-базе:
SHOW gen.connstr;
-- Если "dbname=demo" без user → проблема подтверждена
SELECT * FROM gen.log ORDER BY at DESC LIMIT 5;
-- Искать: "could not establish connection" / "role postgres does not exist"
```
### Воркэраунд
```sql
-- Выполнить на bookings-db ПОСЛЕ install.sql (актуально при make bookings-generate):
ALTER DATABASE demo SET gen.connstr = 'dbname=demo user=bookings password=bookings';
-- Затем переподключиться к demo и запустить генерацию заново.
```
### Правильное решение
Один из вариантов:
- Патч `install.sql`, подставляющий credentials из ENV
- Обновление demodb до версии, где это исправлено
---
## 2. BOOKINGS_INIT_DAYS=1 — недостаточно для генерации данных
### Симптом
Даже при исправленном connstr, с `BOOKINGS_INIT_DAYS=1` (дефолт в Makefile)
`bookings.bookings` остаётся пустой.
### Корневая причина
Генератор demodb использует константы:
- `ROUTES_DURATION() = 1 month` — маршруты строятся на месяц вперёд
- `ROUTES_TAKE_EFFECT() = 2 months` — маршруты начинают действовать через 2 месяца
При `start_date=2017-01-01` и `init_days=1`:
- `end_date = 2017-01-02`
- Генератор успевает только INIT + BUILD ROUTES (маршруты на февраль-март)
- До создания бронирований не доходит → 0 строк
Проверено:
| init_days | bookings | boarding_passes | Время генерации |
|-----------|----------|-----------------|-----------------|
| 1 | 0 | 0 | ~1 сек |
| 30 | ~30 000 | 0 | ~1-2 мин |
| 90 | ? | ? (ожидаем >0) | ~10+ мин |
---
## 3. boarding_passes всегда пустая
### Симптом
Таблица `bookings.boarding_passes` ни разу не содержала данных.
### Корневая причина
Boarding passes создаются при событиях CHECK-IN и BOARDING, которые
генерируются при REGISTRATION рейса (~24ч до вылета). Рейсы начинаются
с 2017-02-01. Цепочка:
1. Нужны маршруты → появляются при init_days ≥ 1
2. Нужны рейсы → появляются при init_days ≥ 30
3. Нужны бронирования/сегменты → появляются при init_days ≥ 30
4. Нужна регистрация (за ~24ч до вылета) → init_days ≥ ~60
5. Нужны boarding events → init_days ≥ ~60
При init_days ≤ 30 генератор не доходит до дат регистрации.
При init_days ≥ 60 баг #1 (connstr) убивал генерацию раньше.
Возможно, есть и баг в самой версии demodb — требует проверки после
обновления.
---
## 4. UX: время генерации
Генерация данных занимает значительное время:
| init_days | Время | Достаточно для |
|-----------|------------|-----------------------|
| 30 | ~1-2 мин | bookings, но не bp |
| 60 | ~5 мин | предположительно bp |
| 90 | ~10+ мин | всех таблиц (?) |
Для студента ждать 10 мин при первом запуске — плохой UX.
### Альтернативы
1. **Готовый SQL-дамп** первых N дней (`pg_dump` → `pg_restore`, секунды)
2. **Docker image с предзаполненной БД** (ещё быстрее, 0 ожидания)
3. **Уменьшить масштаб** — найти параметры demodb для меньшего числа аэропортов/маршрутов
---
## Рекомендации (TODO)
- [x] Обновить demodb до последнего коммита (`866e56f`) — upstream только README-правки, баги не исправлены
- [x] Добавить патч для gen.connstr (`install_connstr_no_hardcode.patch`) — убирает хардкод без credentials
- [x] Увеличить BOOKINGS_INIT_DAYS до 60 (в Makefile и .env)
- [x] Добавить валидацию после init (count > 0 для bookings, flights; вывод counts всех таблиц)
- [ ] Решить проблему UX с временем генерации (дамп или prebuilt image)
- [ ] Проверить, появляются ли boarding_passes при init_days=60 + исправленном connstr (нужен запуск стенда)
+68
View File
@@ -0,0 +1,68 @@
# Классы DQ-проверок
DQ-проверка должна отвечать на конкретный вопрос. В этом проекте есть три
класса. Один `*_dq.sql` может содержать проверки нескольких классов.
Сверяйте их с полями и правилами своего объекта в [ТЗ](../assignment/analyst_spec.md).
Как запустить DQ и проверить задание, описано в
[инструкции самопроверки](../assignment/README.md#запуск-и-проверка).
На `main` учебные DQ содержат `SELECT 1;` и начнут проверять данные
после вашей реализации.
## 1. Универсальные структурные проверки
Они проверяют форму таблицы: обязательные ключи заполнены, ключ уникален, а
таблица не пуста там, где по контракту ожидаются строки. Так можно быстро поймать
сломанное зерно или незавершённую загрузку без знания бизнес-смысла данных.
Реальные примеры:
- [`sql/dds/dim_airports_dq.sql`](../../sql/dds/dim_airports_dq.sql) требует
непустую таблицу, проверяет уникальность `airport_sk` и `airport_bk`, а также
обязательные поля.
- [`sql/ods/bookings_dq.sql`](../../sql/ods/bookings_dq.sql) ищет дубли
`book_ref` и `NULL` или пустые значения в обязательных полях. Пустой
инкрементальный батч здесь допустим.
- [`sql/ods/tickets_dq.sql`](../../sql/ods/tickets_dq.sql) проверяет уникальность
`ticket_no`, обязательные поля и отсутствие билетов без `bookings.book_ref`.
## 2. Проверки бизнес-правил
Они проверяют ограничения предметной области, которых не видно из структуры
таблицы. Такая проверка нужна, чтобы заметить правдоподобную по типам, но
невозможную по смыслу строку.
В [`sql/dm/sales_report_dq.sql`](../../sql/dm/sales_report_dq.sql) для дат
текущего запуска проверяется, что `tickets_sold >= passengers_boarded`,
`boarding_rate` лежит между `0` и `1`, а `total_revenue` неотрицательна.
## 3. Межслойные сверки
Они отвечают на вопрос: согласован ли слой N+1 со слоем N для нужного батча?
Сверка находит потерянные или лишние строки на границе загрузки.
Реальные примеры используют разные способы сверки:
- [`sql/ods/airports_dq.sql`](../../sql/ods/airports_dq.sql) сравнивает множества
`airport_code`: ищет пропущенные и лишние ключи относительно текущего STG-батча.
Это сверка ключей, а не простое сравнение числа строк.
- [`sql/stg/airplanes_dq.sql`](../../sql/stg/airplanes_dq.sql) сравнивает число
строк во внешней `stg.airplanes_ext` и в текущем батче `stg.airplanes`.
- [`sql/dds/fact_flight_sales_dq.sql`](../../sql/dds/fact_flight_sales_dq.sql)
требует равенства числа строк факта и `ods.segments`, потому что зерно факта —
один сегмент билета.
## `NULL` — не всегда ошибка
В [`fact_flight_sales_load.sql`](../../sql/dds/fact_flight_sales_load.sql)
измерения присоединяются через `LEFT JOIN`. Факт может прийти раньше измерения,
поэтому `route_sk`, `airplane_sk` или `passenger_sk` временно бывают `NULL`. На
ветке `main` это также ожидаемо, пока студент не реализовал эти измерения. Такой
`NULL` сохраняет строку факта и не должен сам по себе останавливать пайплайн.
`seat_no` тоже может быть `NULL`: у билета уже есть сегмент, но записи
`boarding_passes` ещё нет. Обязательный ключ, напротив, должен быть заполнен.
Например, `NULL` в `airport_sk` измерения или в `(ticket_no, flight_id)` факта
нарушает контракт таблицы.
Архитектурное решение о том, где живут DQ-проверки, зафиксировано в разделе
[«Ключевые договорённости»](../design/db_schema.md#ключевые-договорённости).
+602
View File
@@ -0,0 +1,602 @@
# История QA: план отладки полной цепочки
Этот план сохраняет сценарии первоначальной проверки полной реализации:
повторную загрузку, несколько учебных дней и граничные случаи.
Он предназначен для подготовки углубленной диагностики на отдельном тестовом
стенде. Ожидания и SQL ниже нужно сверять с текущей веткой; учебная `main`
содержит заглушки и не обязана проходить проверки полной реализации.
Для своего задания используйте [самопроверку](../assignment/README.md#запуск-и-проверка).
Действующие команды разработки и границы автоматических проверок описаны
в [TESTING.md](../../TESTING.md), устройство DQ - в [справочнике](dq_taxonomy.md).
Сценарии ниже очищают DWH и изменяют данные. `make bookings-init`
пересоздает базу Bookings, а `make dwh-truncate` удаляет строки хранилища.
Выполняйте их только на стенде, где данные можно потерять.
Параметры доступа к Airflow берите из `.env`.
---
## Предусловия
```bash
make up # поднять стенд
make bookings-init # инициализировать bookings-db (демо-данные)
```
Все проверки выполняются через `make gp-psql` (psql к Greenplum) и Airflow REST API.
Для запуска DAG из CLI:
```bash
# Запуск DAG и получение run_id
curl -s -u admin:admin -X POST \
"http://localhost:8080/api/v1/dags/<DAG_ID>/dagRuns" \
-H "Content-Type: application/json" \
-d '{"conf":{}}' | jq .dag_run_id
# Проверка статуса
curl -s -u admin:admin \
"http://localhost:8080/api/v1/dags/<DAG_ID>/dagRuns?order_by=-start_date&limit=1" \
| jq '.dag_runs[0].state'
```
Перед началом тестов — убедиться, что `make test` проходит локально.
---
## Блок 1: Чистый прогон (Day 1)
**Цель:** убедиться, что пайплайн работает на свежих данных без ошибок.
### 1.1 Сброс и загрузка
```bash
make dwh-truncate # очистить все таблицы GP
```
Запустить DAG'и в порядке:
1. `bookings_stg_ddl` → дождаться success
2. `bookings_ods_ddl` → дождаться success
3. `bookings_dds_ddl` → дождаться success
4. `bookings_dm_ddl` → дождаться success
5. `bookings_to_gp_stage` → дождаться success
6. `bookings_to_gp_ods` → дождаться success
7. `bookings_to_gp_dds` → дождаться success
8. `bookings_to_gp_dm` → дождаться success
### 1.2 Проверки после Day 1
Все запросы выполнять в `make gp-psql`.
#### A. Непустота всех таблиц
```sql
-- STG: все 9 таблиц не пустые
SELECT 'stg.bookings' AS tbl, COUNT(*) FROM stg.bookings
UNION ALL SELECT 'stg.tickets', COUNT(*) FROM stg.tickets
UNION ALL SELECT 'stg.segments', COUNT(*) FROM stg.segments
UNION ALL SELECT 'stg.flights', COUNT(*) FROM stg.flights
UNION ALL SELECT 'stg.airports', COUNT(*) FROM stg.airports
UNION ALL SELECT 'stg.airplanes', COUNT(*) FROM stg.airplanes
UNION ALL SELECT 'stg.routes', COUNT(*) FROM stg.routes
UNION ALL SELECT 'stg.seats', COUNT(*) FROM stg.seats
UNION ALL SELECT 'stg.boarding_passes', COUNT(*) FROM stg.boarding_passes
ORDER BY 1;
-- ODS: эталонные таблицы не пустые (на main ods.airplanes и ods.seats пусты by design)
SELECT 'ods.bookings' AS tbl, COUNT(*) FROM ods.bookings
UNION ALL SELECT 'ods.tickets', COUNT(*) FROM ods.tickets
UNION ALL SELECT 'ods.segments', COUNT(*) FROM ods.segments
UNION ALL SELECT 'ods.flights', COUNT(*) FROM ods.flights
UNION ALL SELECT 'ods.airports', COUNT(*) FROM ods.airports
UNION ALL SELECT 'ods.airplanes', COUNT(*) FROM ods.airplanes
UNION ALL SELECT 'ods.routes', COUNT(*) FROM ods.routes
UNION ALL SELECT 'ods.seats', COUNT(*) FROM ods.seats
UNION ALL SELECT 'ods.boarding_passes', COUNT(*) FROM ods.boarding_passes
ORDER BY 1;
-- DDS: все 7 таблиц не пустые (на main dim_airplanes, dim_passengers, dim_routes пусты by design)
SELECT 'dds.dim_calendar' AS tbl, COUNT(*) FROM dds.dim_calendar
UNION ALL SELECT 'dds.dim_airports', COUNT(*) FROM dds.dim_airports
UNION ALL SELECT 'dds.dim_airplanes', COUNT(*) FROM dds.dim_airplanes
UNION ALL SELECT 'dds.dim_tariffs', COUNT(*) FROM dds.dim_tariffs
UNION ALL SELECT 'dds.dim_passengers', COUNT(*) FROM dds.dim_passengers
UNION ALL SELECT 'dds.dim_routes', COUNT(*) FROM dds.dim_routes
UNION ALL SELECT 'dds.fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales
ORDER BY 1;
-- DM: все 5 витрин не пустые (на main только sales_report непуста, остальные 4 — заглушки)
SELECT 'dm.sales_report' AS tbl, COUNT(*) FROM dm.sales_report
UNION ALL SELECT 'dm.route_performance', COUNT(*) FROM dm.route_performance
UNION ALL SELECT 'dm.passenger_loyalty', COUNT(*) FROM dm.passenger_loyalty
UNION ALL SELECT 'dm.airport_traffic', COUNT(*) FROM dm.airport_traffic
UNION ALL SELECT 'dm.monthly_overview', COUNT(*) FROM dm.monthly_overview
ORDER BY 1;
```
**Ожидание (solution):** ВСЕ таблицы > 0 строк. Если какая-то пустая — это баг.
**Ожидание (main):** Эталонные таблицы > 0; студенческие (см. список выше) = 0 до реализации заданий.
#### B. Сквозная сверка количеств (STG → ODS)
```sql
-- Инкрементальные таблицы: на Day 1 должно быть ODS = STG
SELECT
'bookings' AS entity,
(SELECT COUNT(*) FROM stg.bookings) AS stg_cnt,
(SELECT COUNT(*) FROM ods.bookings) AS ods_cnt
UNION ALL SELECT 'tickets',
(SELECT COUNT(*) FROM stg.tickets),
(SELECT COUNT(*) FROM ods.tickets)
UNION ALL SELECT 'segments',
(SELECT COUNT(*) FROM stg.segments),
(SELECT COUNT(*) FROM ods.segments)
UNION ALL SELECT 'flights',
(SELECT COUNT(*) FROM stg.flights),
(SELECT COUNT(*) FROM ods.flights)
UNION ALL SELECT 'boarding_passes',
(SELECT COUNT(*) FROM stg.boarding_passes),
(SELECT COUNT(*) FROM ods.boarding_passes);
```
**Ожидание Day 1:** `stg_cnt = ods_cnt` для инкрементальных таблиц.
```sql
-- Снапшот-таблицы: ODS = последний батч STG
SELECT
'airports' AS entity,
(SELECT COUNT(*) FROM stg.airports
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.airports)) AS stg_cnt,
(SELECT COUNT(*) FROM ods.airports) AS ods_cnt
UNION ALL SELECT 'airplanes',
(SELECT COUNT(*) FROM stg.airplanes
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.airplanes)),
(SELECT COUNT(*) FROM ods.airplanes)
UNION ALL SELECT 'routes',
(SELECT COUNT(*) FROM stg.routes
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.routes)),
(SELECT COUNT(*) FROM ods.routes)
UNION ALL SELECT 'seats',
(SELECT COUNT(*) FROM stg.seats
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.seats)),
(SELECT COUNT(*) FROM ods.seats);
```
**Ожидание:** `stg_cnt = ods_cnt` для снапшот-таблиц.
#### C. Сквозная сверка количеств (ODS → DDS)
```sql
-- Измерения SCD1: число уникальных бизнес-ключей
SELECT
'airports' AS entity,
(SELECT COUNT(DISTINCT airport_code) FROM ods.airports) AS ods_bk,
(SELECT COUNT(*) FROM dds.dim_airports) AS dds_cnt
UNION ALL SELECT 'airplanes',
(SELECT COUNT(DISTINCT airplane_code) FROM ods.airplanes),
(SELECT COUNT(*) FROM dds.dim_airplanes)
UNION ALL SELECT 'tariffs',
(SELECT COUNT(DISTINCT fare_conditions) FROM ods.segments),
(SELECT COUNT(*) FROM dds.dim_tariffs)
UNION ALL SELECT 'passengers',
(SELECT COUNT(DISTINCT passenger_id) FROM ods.tickets),
(SELECT COUNT(*) FROM dds.dim_passengers);
```
**Ожидание:** `ods_bk = dds_cnt` (на первый прогон, без SCD-истории).
```sql
-- Маршруты SCD2: текущих записей = уникальных бизнес-ключей в ODS
-- Примечание: бизнес-ключ = route_no (не route_no || '-' || validity).
-- Один route_no может иметь несколько периодов validity в ods.routes,
-- но DDS берёт только самый свежий (rn=1 по validity DESC).
SELECT
(SELECT COUNT(DISTINCT route_no) FROM ods.routes) AS ods_routes,
(SELECT COUNT(*) FROM dds.dim_routes WHERE valid_to IS NULL) AS dds_current,
(SELECT COUNT(*) FROM dds.dim_routes) AS dds_total;
```
**Ожидание Day 1:** `ods_routes = dds_current = dds_total` (без истории).
```sql
-- Факт: grain = segments
SELECT
(SELECT COUNT(*) FROM ods.segments) AS ods_segments,
(SELECT COUNT(*) FROM dds.fact_flight_sales) AS dds_fact;
```
**Ожидание:** `ods_segments = dds_fact`.
#### D. Сквозная сверка: DDS → DM (бизнес-метрики)
```sql
-- Общая выручка: fact vs sales_report
-- Примечание: колонка называется price (не ticket_price)
SELECT
(SELECT SUM(price) FROM dds.fact_flight_sales) AS fact_revenue,
(SELECT SUM(total_revenue) FROM dm.sales_report) AS dm_revenue;
```
**Ожидание:** `fact_revenue = dm_revenue` (или объяснимая разница из-за логики витрины).
```sql
-- Количество уникальных пассажиров: fact vs passenger_loyalty
SELECT
(SELECT COUNT(DISTINCT passenger_sk) FROM dds.fact_flight_sales
WHERE passenger_sk IS NOT NULL) AS fact_passengers,
(SELECT COUNT(*) FROM dm.passenger_loyalty) AS dm_passengers;
```
**Ожидание:** совпадение (или объяснимая разница).
```sql
-- Общее число посадок: fact vs sales_report
-- Примечание: колонка называется is_boarded (не boarding_seq)
SELECT
(SELECT COUNT(*) FROM dds.fact_flight_sales
WHERE is_boarded = TRUE) AS fact_boarded,
(SELECT SUM(passengers_boarded) FROM dm.sales_report) AS dm_boarded;
```
#### E. Целостность суррогатных ключей (FK в DDS и DM)
```sql
SELECT 'orphan_route_sk' AS check_name, COUNT(*) AS orphans
FROM dds.fact_flight_sales f
LEFT JOIN dds.dim_routes r ON f.route_sk = r.route_sk
WHERE f.route_sk IS NOT NULL AND r.route_sk IS NULL
UNION ALL
SELECT 'orphan_airport_sk', COUNT(*)
FROM dm.sales_report sr
LEFT JOIN dds.dim_airports a ON sr.airport_sk = a.airport_sk
WHERE sr.airport_sk IS NOT NULL AND a.airport_sk IS NULL
UNION ALL
SELECT 'orphan_tariff_sk', COUNT(*)
FROM dds.fact_flight_sales f
LEFT JOIN dds.dim_tariffs t ON f.tariff_sk = t.tariff_sk
WHERE f.tariff_sk IS NOT NULL AND t.tariff_sk IS NULL
UNION ALL
SELECT 'orphan_passenger_sk', COUNT(*)
FROM dds.fact_flight_sales f
LEFT JOIN dds.dim_passengers p ON f.passenger_sk = p.passenger_sk
WHERE f.passenger_sk IS NOT NULL AND p.passenger_sk IS NULL
UNION ALL
SELECT 'orphan_calendar_sk', COUNT(*)
FROM dds.fact_flight_sales f
LEFT JOIN dds.dim_calendar c ON f.calendar_sk = c.calendar_sk
WHERE f.calendar_sk IS NOT NULL AND c.calendar_sk IS NULL;
```
**Ожидание:** ВСЕ orphans = 0.
---
## Блок 2: Идемпотентность (повторный Day 1)
**Цель:** повторный прогон ODS/DDS/DM НЕ дублирует данные при неизменённом STG.
**Важно:** `bookings_to_gp_stage` ВСЕГДА генерирует следующий день при запуске
(через `generate_bookings_day`). Тест идемпотентности нужно проводить только для
`bookings_to_gp_ods`, `bookings_to_gp_dds`, `bookings_to_gp_dm` — без повторного
запуска STG. Убедитесь, что все STG-батчи уже обработаны ODS перед тестом:
```sql
SELECT COUNT(*) AS unprocessed
FROM stg.bookings
WHERE _load_ts > (SELECT COALESCE(MAX(_load_ts), '1900-01-01') FROM ods.bookings);
-- Ожидание: 0
```
### 2.1 Зафиксировать counts после Day 1
```sql
SELECT 'ods.bookings' AS tbl, COUNT(*) AS cnt FROM ods.bookings
UNION ALL SELECT 'ods.tickets', COUNT(*) FROM ods.tickets
UNION ALL SELECT 'ods.segments', COUNT(*) FROM ods.segments
UNION ALL SELECT 'ods.flights', COUNT(*) FROM ods.flights
UNION ALL SELECT 'dds.fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales
UNION ALL SELECT 'dds.dim_routes', COUNT(*) FROM dds.dim_routes
UNION ALL SELECT 'dm.sales_report', COUNT(*) FROM dm.sales_report
UNION ALL SELECT 'dm.passenger_loyalty', COUNT(*) FROM dm.passenger_loyalty
ORDER BY 1;
```
Записать результаты.
### 2.2 Повторный прогон (без генерации нового дня!)
Запустить снова: STG → ODS → DDS → DM (4 load-DAG'а).
### 2.3 Проверить, что counts не изменились
Повторить запрос из 2.1 и сравнить.
**Ожидание:**
- Инкрементальные (bookings, tickets, segments, flights, boarding_passes,
fact_flight_sales) — count НЕ увеличился
- Снапшоты (airports, airplanes, routes, seats) — count тот же
- DM (full rebuild) — count тот же
**Если count вырос — баг идемпотентности.** Записать, в какой таблице и на сколько.
---
## Блок 3: Инкремент (Day 2)
**Цель:** после генерации нового дня инкрементальные таблицы растут,
снапшоты обновляются, витрины обогащаются.
### 3.1 Генерация нового дня
```bash
make bookings-generate-day
```
### 3.2 Запуск пайплайна
Запустить STG → ODS → DDS → DM (4 load-DAG'а).
### 3.3 Проверки после Day 2
#### A. Инкрементальные таблицы выросли
```sql
SELECT 'stg.bookings' AS tbl, COUNT(*) FROM stg.bookings
UNION ALL SELECT 'ods.bookings', COUNT(*) FROM ods.bookings
UNION ALL SELECT 'ods.tickets', COUNT(*) FROM ods.tickets
UNION ALL SELECT 'ods.segments', COUNT(*) FROM ods.segments
UNION ALL SELECT 'dds.fact_flight_sales', COUNT(*) FROM dds.fact_flight_sales
ORDER BY 1;
```
**Ожидание:** все count'ы > Day 1.
#### B. STG хранит оба батча
```sql
SELECT _load_id, COUNT(*) FROM stg.bookings GROUP BY 1 ORDER BY 1;
```
**Ожидание:** 2 разных `_load_id`, оба с данными.
#### C. Снапшоты не дублировались
```sql
SELECT
'airports' AS entity,
(SELECT COUNT(*) FROM stg.airports
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.airports)) AS stg_last_batch,
(SELECT COUNT(*) FROM ods.airports) AS ods_cnt
UNION ALL SELECT 'airplanes',
(SELECT COUNT(*) FROM stg.airplanes
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.airplanes)),
(SELECT COUNT(*) FROM ods.airplanes)
UNION ALL SELECT 'routes',
(SELECT COUNT(*) FROM stg.routes
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.routes)),
(SELECT COUNT(*) FROM ods.routes)
UNION ALL SELECT 'seats',
(SELECT COUNT(*) FROM stg.seats
WHERE _load_id = (SELECT MAX(_load_id) FROM stg.seats)),
(SELECT COUNT(*) FROM ods.seats);
```
**Ожидание:** `stg_last_batch = ods_cnt`.
#### D. SCD2 dim_routes — версионность
```sql
SELECT
COUNT(*) AS total_rows,
COUNT(*) FILTER (WHERE valid_to IS NULL) AS current_rows,
COUNT(*) FILTER (WHERE valid_to IS NOT NULL) AS closed_rows
FROM dds.dim_routes;
```
**Ожидание:** `closed_rows = 0` если справочник маршрутов не менялся.
Если `closed_rows > 0` — проверить, действительно ли атрибуты изменились:
```sql
SELECT route_bk, valid_from, valid_to, hashdiff
FROM dds.dim_routes
WHERE route_bk IN (
SELECT route_bk FROM dds.dim_routes GROUP BY route_bk HAVING COUNT(*) > 1
)
ORDER BY route_bk, valid_from;
```
#### E. DM после инкремента
```sql
SELECT MIN(flight_date), MAX(flight_date), COUNT(DISTINCT flight_date)
FROM dm.sales_report;
```
**Ожидание:** диапазон дат шире, чем после Day 1.
---
## Блок 4: Многодневный прогон (Days 3-5)
**Цель:** поймать баги, которые проявляются только при накоплении данных.
### 4.1 Цикл
Повторить 3 раза:
```bash
make bookings-generate-day
# Запустить STG → ODS → DDS → DM
```
### 4.2 Проверки после 5 дней
#### A. Монотонный рост инкрементальных таблиц
```sql
SELECT _load_id, COUNT(*) FROM stg.bookings GROUP BY 1 ORDER BY 1;
```
**Ожидание:** 5 строк, все с данными.
#### B. Нет дупликатов в ODS (критически важно!)
```sql
SELECT 'ods.bookings' AS tbl,
COUNT(*) - COUNT(DISTINCT book_ref) AS dups FROM ods.bookings
UNION ALL SELECT 'ods.tickets',
COUNT(*) - COUNT(DISTINCT ticket_no) FROM ods.tickets
UNION ALL SELECT 'ods.flights',
COUNT(*) - COUNT(DISTINCT flight_id) FROM ods.flights
UNION ALL SELECT 'ods.segments',
COUNT(*) - COUNT(DISTINCT ticket_no || '-' || flight_id::text) FROM ods.segments
UNION ALL SELECT 'ods.boarding_passes',
COUNT(*) - COUNT(DISTINCT ticket_no || '-' || flight_id::text) FROM ods.boarding_passes;
```
**Ожидание:** ВСЕ dups = 0. Если > 0 — **критический баг**.
#### C. Нет дупов в DDS fact
```sql
SELECT COUNT(*) - COUNT(DISTINCT ticket_no || '-' || flight_id::text) AS dups
FROM dds.fact_flight_sales;
```
**Ожидание:** 0.
#### D. Рост витрин осмысленный
```sql
SELECT
(SELECT COUNT(*) FROM dm.sales_report) AS sales_rows,
(SELECT COUNT(*) FROM dm.route_performance) AS route_rows,
(SELECT COUNT(*) FROM dm.passenger_loyalty) AS passenger_rows,
(SELECT COUNT(*) FROM dm.airport_traffic) AS traffic_rows,
(SELECT COUNT(*) FROM dm.monthly_overview) AS monthly_rows;
```
Сравнить с Day 1. Ожидание: `sales_rows` и `traffic_rows` растут (по дням),
`route_rows` стабильны (по маршрутам), `passenger_rows` растут или стабильны.
---
## Блок 5: Валидация SQL-скриптов (статический анализ)
**Цель:** проверить качество кода без запуска стенда. Можно делать параллельно
с блоками 1-4.
### 5.1 Консистентность _load_id во всех слоях
```bash
# В STG/ODS/DDS/DM должен быть _load_id
grep -r '_load_id' sql/stg/*_load.sql sql/ods/*_load.sql sql/dds/*_load.sql sql/dm/*_load.sql | head -20
# Не должно быть старых имён batch_id, load_dttm, src_created_at_ts
grep -r '\bbatch_id\b' sql/stg/ sql/ods/ sql/dds/ sql/dm/ # ожидание: только допустимые переменные PL (v_batch_id)
grep -r 'load_dttm\|src_created_at_ts' sql/ # ожидание: пусто
```
### 5.2 Все load.sql используют шаблон {{ run_id }}
Примечание: ODS намеренно не использует `{{ run_id }}` — вместо этого в `_load_id`
сохраняется `_load_id` из STG для сквозного lineage (traceable to source batch).
Это правильный паттерн, а не баг. Проверять нужно только STG/DDS/DM.
```bash
for f in sql/stg/*_load.sql sql/dds/*_load.sql sql/dm/*_load.sql; do
if ! grep -q '{{ run_id }}\|{{ ti.xcom_pull' "$f"; then
echo "WARN: $f не содержит {{ run_id }}"
fi
done
```
### 5.3 DDL и load совпадают по набору колонок
Для каждой пары `_ddl.sql` / `_load.sql`:
- Извлечь список колонок из DDL (CREATE TABLE)
- Извлечь список колонок из INSERT в load.sql
- Сравнить
**Ожидание:** списки совпадают (за исключением SERIAL/GENERATED колонок).
Приоритетные пары для ручной проверки (сложные):
- `dds/fact_flight_sales` (много FK)
- `dds/dim_routes` (SCD2-поля)
- `dm/sales_report` (агрегаты)
### 5.4 DQ-скрипты согласованы с DDL
Для каждого `_dq.sql` проверить:
- Все NOT NULL колонки из DDL проверяются в DQ?
- Все бизнес-ключи из DDL проверяются на дупликаты?
- FK-проверки ссылаются на правильные таблицы?
---
## Блок 6: Граничные случаи
### 6.1 Пустой инкремент
Запустить STG DAG БЕЗ предварительной генерации нового дня.
**Ожидание:** DAG завершается success (не failure). Таблицы не меняются.
DQ-скрипты не падают на пустом батче.
### 6.2 DDL DAG на уже существующих таблицах
Запустить `bookings_stg_ddl` повторно (таблицы уже есть).
**Ожидание:** success. DDL использует `IF NOT EXISTS`. Данные не потеряны.
### 6.3 Служебные поля заполнены
```sql
SELECT 'ods.bookings' AS tbl,
COUNT(*) FILTER (WHERE _load_id IS NULL) AS null_load_id,
COUNT(*) FILTER (WHERE _load_ts IS NULL) AS null_load_ts
FROM ods.bookings
UNION ALL SELECT 'dds.fact_flight_sales',
COUNT(*) FILTER (WHERE _load_id IS NULL),
COUNT(*) FILTER (WHERE _load_ts IS NULL)
FROM dds.fact_flight_sales
UNION ALL SELECT 'dds.dim_routes',
COUNT(*) FILTER (WHERE _load_id IS NULL),
COUNT(*) FILTER (WHERE _load_ts IS NULL)
FROM dds.dim_routes;
```
**Ожидание:** все null_* = 0.
---
## Формат отчёта
По каждому блоку фиксировать:
| Блок | Проверка | Статус | Детали |
|------|----------|--------|--------|
| 1.2A | Непустота таблиц | OK / FAIL | какая таблица пуста |
| 1.2B | STG→ODS counts | OK / FAIL | расхождение: X vs Y |
| ... | ... | ... | ... |
Если найден баг:
1. Описать симптом (какой запрос, какой результат)
2. Локализовать (в каком SQL-файле проблема)
3. Предложить fix
4. После fix — повторить проверку
---
## Приоритеты (если время ограничено)
1. **Блок 1** (чистый прогон) — обязательно, базовый smoke
2. **Блок 2** (идемпотентность) — обязательно, частый источник багов
3. **Блок 3** (инкремент Day 2) — обязательно, проверяет главную фичу
4. **Блок 5.3** (DDL ↔ load) — высокий приоритет, ловит рассинхрон колонок
5. **Блок 4** (5 дней) — средний приоритет, ловит накопительные баги
6. **Блок 6** (граничные) — средний приоритет
7. **Блок 5.1-5.4** (статика) — можно делать параллельно без стенда
+135
View File
@@ -0,0 +1,135 @@
# Технические детали стенда (Docker / Airflow / Greenplum)
Этот документ не обязателен для прохождения лабораторных, но полезен, если вы:
- хотите понять, как устроен стенд “под капотом”;
- меняете конфиги/зависимости и пересобираете образы;
- отлаживаете проблемы со стартом Greenplum/PXF или Connections в Airflow.
## Make (опционально, но удобно)
Если `make` не установлен, можно пользоваться `docker compose ...`.
Но с `make` команды короче и проще.
Установка `make` (если нужно):
- Linux (Debian/Ubuntu): `sudo apt install -y make`
- macOS: `brew install make`
- Windows:
- WSL: `sudo apt install -y make`
- Chocolatey: `choco install make`
- Scoop: `scoop install make`
Примеры:
```bash
make up
make logs
make gp-psql
```
## Airflow Connections
Готовые DAG по умолчанию подключаются к БД через Airflow Connections:
- `greenplum_conn` — Greenplum;
- `bookings_db` — демо‑БД bookings (`bookings-db`).
В `docker-compose.yml` Connections задаются через переменные окружения `AIRFLOW_CONN_...`,
поэтому они могут не отображаться в UI — для DAG это нормально.
Если нужно завести вручную:
1) Airflow UI → Admin → Connections → Add a new record
2) Пример для Greenplum:
- Conn Id: `greenplum_conn`
- Conn Type: `Postgres`
- Host: `greenplum`
- Schema: `gp_dwh`
- Login/Password: `gpadmin` / `gpadmin`
- Port: `5432`
## PXF (в 5 строк)
PXF (Platform Extension Framework) — компонент Greenplum для работы с внешними источниками.
В этом стенде PXF используется для чтения таблицы `bookings.bookings` из Postgres прямо из Greenplum
через внешнюю таблицу `stg.bookings_ext`. Поэтому загрузка в `stg.bookings` выглядит как обычный
`INSERT ... SELECT` без промежуточных файлов.
## Greenplum + PXF: свой образ
Greenplum собирается из собственного `Dockerfile.greenplum`, чтобы:
- не ловить проблемы с правами/`chown` при bind‑mount конфигов;
- держать PXF‑конфиги и JDBC‑драйвер внутри образа;
- делать старт контейнера идемпотентным.
При старте контейнера:
- seed‑конфиги PXF докладываются в `PXF_BASE` на persistent volume;
- создаются каталоги `PXF_BASE/run` и `PXF_BASE/logs`;
- расширение `pxf` создаётся автоматически, когда Greenplum становится доступен (с ретраями).
Полезные команды:
```bash
make build # пересобрать образы
docker compose ps # проверить health
```
Перезапись seed‑файлов в `PXF_BASE`:
- `PXF_SEED_OVERWRITE=1` — перезаписать конфиги при старте;
- `PXF_SYNC_ON_START=1` — выполнить `pxf cluster sync` при старте.
Подробнее: см. ветку `solution` (`docs/reference/pxf_bookings.md`).
## Airflow: свой образ
Airflow тоже собирается из собственного `Dockerfile.airflow`, чтобы зависимости ставились при сборке,
а не во время старта контейнеров.
Если меняли `airflow/requirements.txt` или `Dockerfile.airflow`, пересоберите образы:
```bash
make build
make up
```
## Локальное окружение разработчика (uv)
Локальным окружением управляет `uv` — он создаёт `.venv` из `pyproject.toml` / `uv.lock`.
```bash
uv sync
make test
make lint
make fmt
```
Не устанавливайте зависимости через `pip install --user ...` — это часто ломает окружение и IDE.
## Переменные окружения (`.env`)
Все настройки лежат в `.env` (шаблон: `.env.example`).
### Порты на хосте
- `AIRFLOW_WEB_PORT` — Airflow UI (по умолчанию `8080`)
- `PGMETA_PORT` — мета‑БД Airflow (по умолчанию `5433`, обычно нужно только для диагностики)
- `BOOKINGS_DB_PORT` — bookings-db (по умолчанию `5434`)
- `GP_HOST_PORT` — Greenplum (по умолчанию `5435`)
### Greenplum
- `GP_USER`, `GP_PASSWORD`, `GP_DB`
- `GP_PORT` — внутренний порт в Docker-сети (обычно `5432`)
- `GP_HOST_PORT` — внешний порт на хосте (по умолчанию `5435`)
### bookings-db (Postgres)
- `BOOKINGS_DB_USER`, `BOOKINGS_DB_PASSWORD`
- `BOOKINGS_DB_PORT` — внешний порт (по умолчанию `5434`)
- `BOOKINGS_START_DATE` — стартовая дата модельного времени
- `BOOKINGS_INIT_DAYS` — сколько дней генерировать при `make bookings-generate` (генерация с нуля)
- `BOOKINGS_JOBS` — число джобов генератора (по умолчанию `1`)
-136
View File
@@ -1,136 +0,0 @@
# Учебные задания по стенду
Этот документ собирает в одном месте задания для менти.
Он разбит на блоки: от базовой работы с CSV‑pipeline до более продвинутого сценария с демо‑БД bookings и слоем STG в Greenplum.
Если вы только начинаете, выполняйте задания по порядку. К разделу про bookings можно вернуться позже.
---
## 1. Базовый CSV‑pipeline (csv_to_greenplum)
Основная цель этого блока — понять, как устроен простой ETL: генерация данных через pandas, сохранение в CSV и загрузка в Greenplum.
### 1.1. Разбор готового pipeline
1. Найдите DAG `csv_to_greenplum` в `airflow/dags/csv_to_greenplum.py`.
2. Ответьте себе на вопросы (можно коротко в отдельном файле/блокноте):
- какие задачи (tasks) входят в DAG и что делает каждая из них;
- какие таблицы создаются в Greenplum;
- где физически лежат CSV‑файлы;
- какие параметры управляют размером датасета.
3. Поднимите стенд и запустите DAG:
- `make up` (Airflow инициализируется автоматически при первом старте)
- включите и запустите DAG `csv_to_greenplum` в Airflow UI.
4. Проверьте результат в Greenplum:
- `make gp-psql`
- `SELECT COUNT(*) FROM public.orders;`
- `SELECT * FROM public.orders LIMIT 5;`
### 1.2. Изменение параметров генерации
1. Найдите, где задаётся количество строк для генерации (`CSV_ROWS` в `.env` и параметр в DAG).
2. Поставьте другое значение и перезапустите DAG:
- оцените, как изменилось количество строк в `public.orders`;
- убедитесь, что пайплайн по‑прежнему работает без ошибок.
3. Попробуйте изменить схему данных (добавить колонку в CSV и таблицу в Greenplum):
- добавьте новую колонку в генерацию pandas;
- обновите DDL/SQL, чтобы колонка появилась в таблице `public.orders`;
- перезапустите DAG и убедитесь, что новая колонка заполняется.
### 1.3. Собственные проверки качества данных
1. Найдите DAG `csv_to_greenplum_dq` в `airflow/dags/csv_to_greenplum_dq.py`.
2. Посмотрите, какие проверки уже реализованы (наличие таблицы, схема, дубликаты).
3. Добавьте ещё одну простую проверку, например:
- проверка, что в таблице `public.orders` не больше N строк;
- проверка, что поле (например, `order_price`) не содержит отрицательных значений;
- проверка, что нет строк с `NULL` в ключевых колонках.
4. Запустите DAG `csv_to_greenplum_dq` и убедитесь, что:
- новая проверка проходит на «хороших» данных;
- при нарушении условия DAG падает с понятной ошибкой.
---
## 2. Greenplum и модель данных (введение)
В следующих заданиях мы будем опираться на демо‑БД bookings (Postgres) и слой STG в Greenplum.
На этом этапе достаточно бегло посмотреть на структуру и понять общую идею, детальная проработка пойдёт позже.
### 2.1. Знакомство с демо‑БД bookings
1. Прочитайте `bookings/README.md` — какие сервисы и команды относятся к демобазе.
2. Поднимите стенд и выполните:
- `make up`
- `make bookings-init`
3. Подключитесь к демобазе:
- `make bookings-psql`
- посмотрите таблицы в схеме `bookings` (например, `\dt bookings.*`).
4. Найдите таблицу `bookings.bookings` и посмотрите на её структуру:
- какие типы колонок используются;
- какие поля выглядят как ключи, даты, суммы.
### 2.2. Знакомство с STG в Greenplum
1. Прочитайте `sql/stg/bookings_ddl.sql` и мини‑README `docs/internal/bookings_stg_readme.md` (если интересно — `docs/internal/bookings_stg_design.md`).
2. Ответьте себе на вопросы:
- чем внешняя таблица `stg.bookings_ext` отличается от внутренней `stg.bookings`;
- зачем нужны тех.колонки `src_created_at_ts`, `load_dttm`, `batch_id`;
- чем слой STG отличается от итоговых витрин (DDS/DM) с точки зрения моделирования.
3. Выполните `make ddl-gp`, затем зайдите в Greenplum (`make gp-psql`) и проверьте наличие схемы и таблиц:
- `\dn` и `\dt stg.*`
- `SELECT * FROM stg.bookings LIMIT 5;` (после запуска соответствующего DAG).
### 2.3. Как генерируются учебные данные bookings
1. Откройте файл `bookings/generate_next_day.sql` и ответьте себе на вопросы:
- с какой даты начинается генерация данных (посмотрите на GUC `bookings.start_date` и переменную `v_start_cfg`);
- сколько дней генерируется при первой установке (переменная `bookings.init_days`);
- что происходит, если таблица `bookings.bookings` уже не пустая.
2. В демобазе (`make bookings-psql`) выполните:
- `SELECT min(book_date), max(book_date) FROM bookings.bookings;`
- затем запустите `make bookings-generate-day` и повторите запрос — как изменился максимальный день?
3. Откройте `sql/src/bookings_generate_day_if_missing.sql` и обратите внимание, что:
- логическая дата запуска DAG (`{{ ds }}`) не влияет на выбор дня генерации;
- скрипт всегда смотрит на `max(book_date)` и добавляет **следующий** день (или несколько стартовых дней, если база пуста).
4. Сделайте вывод: генератор всегда «шагает» по датам вперёд от максимальной даты, поэтому:
- при `make bookings-init` вы получаете `BOOKINGS_INIT_DAYS` дней начиная с `BOOKINGS_START_DATE`;
- при последующих вызовах (`make bookings-generate-day` или DAG) добавляется ровно один новый день.
---
## 3. DAG bookings_to_gp_stage (заготовка заданий)
Этот DAG показывает путь данных от демо‑БД bookings в Postgres до сырого слоя STG в Greenplum.
Сейчас он уже реализован как учебный пример, а в будущем вокруг него появятся отдельные задания по моделированию DWH.
### 3.1. Что есть сейчас
1. Откройте `airflow/dags/bookings_to_gp_stage.py`.
2. Найдите в коде ссылки на SQL‑файлы:
- `sql/src/bookings_generate_day_if_missing.sql`
- `sql/stg/bookings_load.sql`
- `sql/stg/bookings_dq.sql`
3. Соотнесите шаги DAG с документом `docs/internal/bookings_stg_readme.md`:
- генерация учебного дня в `bookings.bookings`;
- загрузка инкремента в `stg.bookings`;
- проверка количества строк между источником и STG.
4. Обратите внимание, как в DAG используется логическая дата запуска:
- `{{ ds_nodash }}` используется как `batch_id` — метка загрузки в таблице `stg.bookings` для конкретного запуска;
- сами даты данных (какие дни есть в `bookings.bookings`) определяются генератором по `max(book_date)`, а не по `ds`.
На этом этапе достаточно понять общую цепочку. Детальные задания по переработке модели данных и построению ODS/DDS/DM слоёв будут добавлены позже.
### 3.2. Идеи для будущих заданий (черновик)
> Ниже — набросок задач, к которым мы вернёмся, когда базовые темы по Airflow и CSV‑pipeline будут освоены.
Планируемые направления:
- Спроектировать модель данных для основных сущностей демобазы bookings (рейсы, билеты, перелёты) в слоях ODS/DDS/DM.
- Реализовать слой ODS поверх STG, аккуратно работая с временными атрибутами и ключами.
- Построить витрины (DM) для типичных аналитических вопросов: загрузка рейсов, выручка по направлениям, динамика бронирований.
- Добавить DAG’и, которые используют `stg.bookings` как источник и строят следующие слои DWH.
- Расширить проверки качества данных для потоков bookings → STG → витрины.
Когда будете готовы к этим темам, вернитесь к этому разделу — он станет основой для следующего «модуля» лабораторных заданий.
-98
View File
@@ -1,98 +0,0 @@
# План исправления: генератор demodb (bookings) остаётся пустым
## Контекст
В стенде используется демобаза bookings из репозитория `postgrespro/demodb`, закреплённая на коммите `d68de192850237719f09b47688d5f3fc94653ca6` (см. `DEMODB_COMMIT` в `Makefile`).
Инициализация источника для ETL выполняется командой `make bookings-init`:
- клонирует demodb в `bookings/demodb/`;
- пытается применить патчи из `bookings/patches/`;
- запускает `install.sql` в контейнере `bookings-db`;
- выставляет GUC-параметры (`gen.connstr`, `bookings.start_date/init_days/jobs`);
- запускает `/bookings/generate_next_day.sql` (должен сгенерировать минимум 1 день данных).
## Симптомы (как в TODO)
- после `make bookings-init` таблица `bookings.bookings` остаётся пустой;
- патчи `bookings/patches/engine_jobs1_sync.patch` и `bookings/patches/install_drop_if_exists.patch` падают при применении;
- из‑за этого DAG `bookings_to_gp_stage` валится на проверках (источник пустой).
## Предварительный диагноз (что уже видно)
1) `engine_jobs1_sync.patch` не является валидным unified diff (в hunk’ах нет номеров строк вида `@@ -N,M +N,M @@`), поэтому `patch` отвечает:
`patch: **** Only garbage was found in the patch input.`
2) `install_drop_if_exists.patch` устарел относительно закреплённого коммита demodb: в `install.sql` уже есть `DROP DATABASE IF EXISTS demo;`, поэтому hunk “не находится” и патч не накатывается.
3) Ошибки патча сейчас замаскированы в `Makefile` через `|| true`, поэтому `make bookings-init` может завершаться “успешно”, хотя критичные правки в demodb не применились.
## Цель фикса
- `make bookings-init` воспроизводимо создаёт и наполняет `demo.bookings.bookings` (>0 строк).
- Если патчи не применяются — процесс останавливается с понятным сообщением, что делать дальше.
- Патчи соответствуют закреплённому коммиту demodb и применяются идемпотентно.
## План диагностики (чтобы быстро подтвердить проблему)
1) Чистое воспроизведение:
- `make clean`
- `rm -rf bookings/demodb`
- `make bookings-init`
2) Проверка данных:
- `make bookings-psql`
- выполнить:
- `SELECT COUNT(*) FROM bookings.bookings;`
- `SELECT min(book_date), max(book_date) FROM bookings.bookings;`
3) Проверка патчей (без изменения файлов):
- `patch -d bookings/demodb -p1 --dry-run < bookings/patches/engine_jobs1_sync.patch`
- `patch -d bookings/demodb -p1 --dry-run < bookings/patches/install_drop_if_exists.patch`
Ожидаемо: сейчас dry-run показывает “garbage in patch” и/или “Hunk FAILED”.
## План решения
### Шаг 1. Пересобрать патчи под закреплённый демо‑коммит
Собираем патчи через `git diff`, чтобы получился корректный unified diff.
1) `bookings/patches/engine_jobs1_sync.patch`:
- Добавить/подтвердить 2 изменения в `engine.sql`:
- `busy()` игнорирует текущий backend: `AND pid <> pg_backend_pid()`.
- `continue()` при `jobs = 1` выполняет `process_queue(end_date)` синхронно и пишет заметный маркер в лог (`Job 1 (local): ok`), иначе — оставляет текущую логику через `dblink`.
2) `bookings/patches/install_drop_if_exists.patch`:
- Поменять строку (в актуальном `install.sql`):
- было: `DROP DATABASE IF EXISTS demo;`
- стало: `DROP DATABASE IF EXISTS demo WITH (FORCE);`
### Шаг 2. Сделать `make bookings-init` fail-fast на проблемах с патчами
В `Makefile`:
- убрать `|| true` у применения патчей;
- при ошибке патча — завершать `make` с ненулевым кодом и короткой подсказкой:
- “удалите `bookings/demodb` и повторите `make bookings-init`”,
- “если не помогло — проверьте, что `DEMODB_COMMIT` не менялся и патчи собраны под него”.
### Шаг 3. Добавить “защиту от тихого пустого результата”
После запуска `/bookings/generate_next_day.sql` (в `Makefile` или внутри SQL):
- выполнить проверку `COUNT(*)` по `bookings.bookings`;
- если 0 — завершаться ошибкой с подсказкой, куда смотреть (патчи/логи генератора).
Цель: чтобы проблема не уезжала дальше в DAG’и и DQ‑проверки, а ловилась сразу при init.
## Проверка (критерии готовности)
- `make clean && rm -rf bookings/demodb && make bookings-init` завершается без ошибок.
- `make bookings-psql` → `SELECT COUNT(*) FROM bookings.bookings;` возвращает `> 0`.
- `make bookings-generate-day` добавляет следующий день:
- `max(book_date)` сдвигается на +1 сутки.
- `./scripts/e2e_smoke.sh` проходит до проверки `stg.bookings` (или хотя бы DAG `bookings_to_gp_stage` перестаёт падать на “источник пустой”).
## Откат (если нужно быстро вернуть стенд в рабочее состояние)
- Временно отключить применение патчей в `Makefile` и явно предупреждать, что генерация может быть нестабильной (нежелательно для студентов).
- Или зафиксировать альтернативный `DEMODB_COMMIT`, под который уже готовы патчи (делать только вместе с обновлением документации и проверкой, что генерация стабильна).
-489
View File
@@ -1,489 +0,0 @@
# План улучшения Dockerfile и docker-compose.yml
## Обзор
Документ описывает план улучшения Dockerfile для Airflow и его интеграции с docker-compose.yml на основе анализа best practices.
## Согласованные изменения
✅ Переименовать `Dockerfile` → `Dockerfile.airflow`
✅ Обновить `build: .` → `build: { context: ., dockerfile: Dockerfile.airflow }`
✅ Добавить YAML anchors для устранения дублирования конфигурации
✅ Добавить healthcheck для airflow-webserver
✅ Исправить расположение requirements.txt в Dockerfile
✅ Добавить LABEL в Dockerfile
✅ Добавить `USER airflow` после установки зависимостей
✅ Добавить проверку `pip check`
✅ Переименовать контейнеры (`gp_airflow_web` → `gp_airflow_webserver`, `gp_airflow_sch` → `gp_airflow_scheduler`)
---
## Часть 1: Изменения в Dockerfile
### Текущее состояние (Dockerfile)
```dockerfile
FROM apache/airflow:2.9.2
COPY airflow/requirements.txt /requirements.txt
RUN pip install --no-cache-dir -r /requirements.txt
```
### Новое состояние (Dockerfile.airflow)
```dockerfile
# Apache Airflow с дополнительными зависимостями для Greenplum
FROM apache/airflow:2.9.2
LABEL maintainer="your-email@example.com"
LABEL description="Airflow with Greenplum and Pandas dependencies"
LABEL version="1.0"
# Копируем requirements в стандартное расположение
COPY airflow/requirements.txt /opt/airflow/requirements.txt
# Устанавливаем зависимости
RUN pip install --no-cache-dir -r /opt/airflow/requirements.txt \
&& pip check \
&& rm -rf /tmp/pip-*
# Переключаемся на пользователя airflow
USER airflow
WORKDIR /opt/airflow
```
### Обоснование изменений:
1. **LABEL** - стандартная практика для документирования образов
2. **`/opt/airflow/requirements.txt`** - стандартное расположение для Airflow
3. **`pip check`** - проверка совместимости установленных пакетов
4. **`USER airflow`** - безопасность и соответствие best practices
5. **`WORKDIR /opt/airflow`** - явное указание рабочей директории
---
## Часть 2: Изменения в docker-compose.yml
### Основные изменения:
1. **Добавить YAML anchors** для устранения дублирования
2. **Обновить build** для всех Airflow сервисов
3. **Добавить healthcheck** для airflow-webserver
4. **Добавить комментарии** для пояснения сокращений в именах контейнеров
### Структура YAML anchors:
```yaml
x-airflow-common-env: &airflow-env
TZ: ${TZ:-Europe/Moscow}
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
AIRFLOW__WEBSERVER__SECRET_KEY: ${AIRFLOW__WEBSERVER__SECRET_KEY}
AIRFLOW_CONN_GREENPLUM_CONN: postgresql://${GP_USER}:${GP_PASSWORD}@greenplum:${GP_PORT:-5432}/${GP_DB}
AIRFLOW_CONN_BOOKINGS_DB: postgresql://${BOOKINGS_DB_USER}:${BOOKINGS_DB_PASSWORD}@bookings-db:5432/demo
x-airflow-common-volumes: &airflow-volumes
- ./airflow/dags:/opt/airflow/dags
- ./sql:/sql:ro
- airflow_data:/opt/airflow/data
x-airflow-common-depends: &airflow-depends
pgmeta:
condition: service_healthy
greenplum:
condition: service_healthy
airflow-init:
condition: service_completed_successfully
```
### Изменения для airflow-webserver:
```yaml
airflow-webserver:
build:
context: .
dockerfile: Dockerfile.airflow
image: airflow-custom:latest
container_name: gp_airflow_webserver
env_file: .env
environment:
<<: *airflow-env
command: airflow webserver
ports:
- "8080:8080"
volumes:
<<: *airflow-volumes
# requirements.txt монтируется как volume для удобства разработки
# При изменении зависимостей не требуется пересборка образа
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 40s
depends_on:
<<: *airflow-depends
```
### Изменения для airflow-scheduler:
```yaml
airflow-scheduler:
build:
context: .
dockerfile: Dockerfile.airflow
image: airflow-custom:latest
container_name: gp_airflow_scheduler
env_file: .env
environment:
<<: *airflow-env
command: airflow scheduler
volumes:
<<: *airflow-volumes
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
depends_on:
<<: *airflow-depends
```
### Изменения для airflow-init:
```yaml
airflow-init:
build:
context: .
dockerfile: Dockerfile.airflow
image: airflow-custom:latest
# Имя контейнера закомментировано (одноразовый сервис)
user: "0"
env_file: .env
environment:
<<: *airflow-env
volumes:
<<: *airflow-volumes
command: >
bash -lc "
set -e;
mkdir -p /opt/airflow/data && chown -R airflow:root /opt/airflow/data;
for i in {1..30}; do
su -s /bin/bash airflow -c \"PATH='/home/airflow/.local/bin:$${PATH}' airflow db migrate\" && break || echo 'waiting for pgmeta' && sleep 3;
done;
su -s /bin/bash airflow -c \"PATH='/home/airflow/.local/bin:$${PATH}' airflow users create --username ${AIRFLOW_USER} --password ${AIRFLOW_PASSWORD} --firstname Admin --lastname User --role Admin --email admin@example.org\" || true
"
depends_on:
pgmeta:
condition: service_healthy
```
---
## Часть 3: Влияние переименования контейнеров
### Важно: Переименование контейнеров
При переименовании контейнеров (`gp_airflow_web` → `gp_airflow_webserver`, `gp_airflow_sch` → `gp_airflow_scheduler`) необходимо учитывать:
1. **Имена сервисов в docker-compose.yml остаются без изменений**
- `airflow-webserver`, `airflow-scheduler`, `airflow-init` - это имена сервисов
- Они используются в командах `docker-compose logs`, `docker-compose restart` и т.д.
- Эти имена НЕ меняются
2. **Имена контейнеров меняются**
- `gp_airflow_web` → `gp_airflow_webserver`
- `gp_airflow_sch` → `gp_airflow_scheduler`
- Они используются в командах `docker exec`, `docker inspect`, `docker logs`
3. **Проверка использования старых имён контейнеров**
```bash
# Поиск упоминаний старых имён в скриптах
grep -r "gp_airflow_web" .
grep -r "gp_airflow_sch" .
```
4. **Обновление Makefile (если используется)**
- Проверить команды, которые используют старые имена контейнеров
- Пример: `make logs` может использовать `docker-compose logs airflow-webserver` (это корректно)
5. **Обновление документации**
- Обновить все упоминания имён контейнеров в README.md, TESTING.md
- Обновить примеры команд в документации
---
## Часть 4: План тестирования изменений
### Этап 1: Подготовка окружения
1. **Остановить текущие контейнеры**
```bash
make down
```
2. **Удалить старые образы (опционально)**
```bash
docker rmi airflow-custom:latest
```
3. **Проверить наличие .env файла**
```bash
ls -la .env
# Если отсутствует, скопировать из .env.example
cp .env.example .env
```
### Этап 2: Сборка новых образов
1. **Собрать образы с новым Dockerfile**
```bash
docker-compose build
```
2. **Проверить успешность сборки**
```bash
docker images | grep airflow-custom
```
3. **Проверить наличие LABEL**
```bash
docker inspect airflow-custom:latest | grep -A 10 "Labels"
```
### Этап 3: Запуск сервисов
1. **Запустить весь стек**
```bash
make up
```
2. **Проверить статус контейнеров**
```bash
docker-compose ps
```
3. **Проверить логи инициализации**
```bash
docker-compose logs airflow-init
```
### Этап 4: Проверка healthcheck
1. **Проверить healthcheck для airflow-webserver**
```bash
docker inspect gp_airflow_webserver | grep -A 20 "Health"
```
2. **Дождаться healthy статуса**
```bash
watch -n 2 'docker inspect --format="{{.State.Health.Status}}" gp_airflow_webserver'
```
### Этап 5: Функциональное тестирование
1. **Проверить доступ к Airflow UI**
- Открыть http://localhost:8080
- Проверить авторизацию (использовать креды из .env)
- Проверить наличие DAG'ов в списке
2. **Проверить запуск DAG'ов**
- Выбрать любой DAG (например, `csv_to_greenplum`)
- Запустить вручную через UI
- Проверить успешность выполнения задач
3. **Проверить подключения к БД**
- Проверить Admin → Connections
- Убедиться, что `greenplum_conn` и `bookings_db` доступны
- Проверить Test Connection для каждого подключения
4. **Проверить логи scheduler**
```bash
docker-compose logs airflow-scheduler | tail -50
```
### Этап 6: Проверка использования старых имён контейнеров
1. **Поиск упоминаний старых имён в проекте**
```bash
grep -r "gp_airflow_web" . --exclude-dir=.git --exclude-dir=__pycache__
grep -r "gp_airflow_sch" . --exclude-dir=.git --exclude-dir=__pycache__
```
2. **Проверка Makefile**
```bash
grep -E "gp_airflow_web|gp_airflow_sch" Makefile
```
3. **Проверка документации**
```bash
grep -r "gp_airflow_web" README.md TESTING.md AGENTS.md docs/
grep -r "gp_airflow_sch" README.md TESTING.md AGENTS.md docs/
```
4. **Обновление найденных упоминаний**
- Заменить `gp_airflow_web` на `gp_airflow_webserver`
- Заменить `gp_airflow_sch` на `gp_airflow_scheduler`
- Обновить примеры команд в документации
### Этап 7: Тестирование требований
1. **Проверить установленные пакеты**
```bash
docker exec gp_airflow_webserver pip list | grep -E "psycopg2|pandas"
```
2. **Проверить совместимость пакетов**
```bash
docker exec gp_airflow_webserver pip check
```
3. **Проверить пользователя внутри контейнера**
```bash
docker exec gp_airflow_webserver whoami
# Ожидаемый результат: airflow
```
### Этап 8: Тестирование изменений requirements.txt
1. **Добавить тестовый пакет в requirements.txt**
```bash
echo "requests==2.31.0" >> airflow/requirements.txt
```
2. **Перезапустить контейнеры**
```bash
docker-compose restart airflow-webserver airflow-scheduler
```
3. **Проверить установку пакета**
```bash
docker exec gp_airflow_webserver pip list | grep requests
```
4. **Удалить тестовый пакет**
```bash
# Вернуть исходный requirements.txt
git checkout airflow/requirements.txt
```
### Этап 9: Регрессионное тестирование
1. **Запустить существующие тесты**
```bash
make test
```
2. **Проверить smoke-тесты DAG**
```bash
uv run pytest tests/test_dags_smoke.py -v
```
3. **Проверить тесты helpers**
```bash
uv run pytest tests/test_greenplum_helpers.py -v
```
### Этап 10: Проверка после перезапуска
1. **Полный перезапуск стека**
```bash
make down
make up
```
2. **Проверить сохранность данных**
- Проверить наличие DAG'ов в UI
- Проверить историю запусков DAG'ов
- Проверить подключения к БД
3. **Проверить логи на наличие ошибок**
```bash
docker-compose logs airflow-webserver | grep -i error
docker-compose logs airflow-scheduler | grep -i error
# Обратите внимание: имена сервисов не изменились, только имена контейнеров
```
---
## Часть 5: Критерии успеха
### Функциональные требования:
- ✅ Все контейнеры успешно запускаются
- ✅ Airflow UI доступен на http://localhost:8080
- ✅ Авторизация работает корректно
- ✅ Все DAG'ы отображаются в UI
- ✅ DAG'и успешно выполняются
- ✅ Подключения к Greenplum и bookings-db работают
- ✅ Healthcheck для airflow-webserver работает корректно
### Технические требования:
- ✅ Образ собирается без ошибок
- ✅ LABEL присутствуют в образе
- ✅ Пакеты устанавливаются от пользователя airflow
- ✅ `pip check` не возвращает ошибок
- ✅ requirements.txt монтируется как volume
- ✅ YAML anchors работают корректно
- ✅ Нет дублирования конфигурации
### Требования к совместимости:
- ✅ Существующие тесты проходят успешно
- ✅ Данные сохраняются после перезапуска
- ✅ История запусков DAG'ов сохраняется
- ✅ Подключения к БД работают как раньше
---
## Часть 6: Откат изменений
Если изменения вызывают проблемы, план отката:
1. **Остановить контейнеры**
```bash
make down
```
2. **Вернуть старые файлы**
```bash
git checkout Dockerfile docker-compose.yml
```
3. **Удалить новый образ**
```bash
docker rmi airflow-custom:latest
```
4. **Перезапустить стек**
```bash
make up
```
---
## Часть 7: Документация
После успешного внедрения изменений необходимо обновить:
1. **README.md** - добавить информацию о новом Dockerfile
2. **AGENTS.md** - обновить инструкции для агентов
3. **Комментарии в docker-compose.yml** - добавить пояснения к YAML anchors
---
## Резюме
План включает:
- 2 файла для изменения: `Dockerfile` → `Dockerfile.airflow`, `docker-compose.yml`
- 10 этапов тестирования
- 12 функциональных критериев успеха
- План отката на случай проблем
- Переименование контейнеров для улучшения читаемости
- Проверка использования старых имён контейнеров в проекте
### Важные замечания:
1. **Имена сервисов НЕ меняются** - `airflow-webserver`, `airflow-scheduler`, `airflow-init`
2. **Имена контейнеров меняются** - `gp_airflow_web` → `gp_airflow_webserver`, `gp_airflow_sch` → `gp_airflow_scheduler`
3. **Необходимо проверить** использование старых имён контейнеров в скриптах и документации
4. **Makefile команды** используют имена сервисов, поэтому они продолжат работать без изменений
Все изменения следуют best practices для Docker и Airflow, сохраняют обратную совместимость и не требуют изменений в DAG'ах.
-142
View File
@@ -1,142 +0,0 @@
# План работ: свой образ Greenplum с интегрированным PXF
## Контекст и проблема
Иногда (и у нас воспроизводится стабильно) контейнер `greenplum` падает при старте с ошибкой:
`chown: changing ownership of '/docker-entrypoint-initdb.d/10_pxf_bookings.sh': Read-only file system`
Причина: init-скрипт `pxf/init/10_pxf_bookings.sh` проброшен в контейнер как bind-mount `:ro`, а entrypoint базового образа пытается сделать `chown` файлов в `/docker-entrypoint-initdb.d/`.
## Цели
- Greenplum стабильно стартует после любого `restart/up` (без “иногда не стартует”).
- PXF готов к работе **после каждого запуска** контейнера.
- Не меняем права/владельца файлов в репозитории на хосте (никаких “файл стал root’ом / не редактируется”).
- Для студентов всё остаётся простым: `make build` (явная сборка) + `make up` (поднимает стенд; если образа нет — Docker Compose соберёт сам).
## Выбранный подход (высокоуровневый дизайн)
1) Делаем свой образ Greenplum на базе `woblerr/greenplum:6.27.1`.
2) Встраиваем в образ “seed” для PXF:
- JDBC-драйвер (JAR),
- конфиг сервера `bookings-db` (`jdbc-site.xml`),
- скрипт “ensure”, который идемпотентно гарантирует, что файлы лежат в `PXF_BASE` (на persistent volume).
3) Запускаем “ensure” **на каждом старте контейнера** через wrapper-entrypoint, а затем передаём управление оригинальному entrypoint базового образа.
4) `PXF_BASE` по умолчанию остаётся на volume (`/data/pxf`), чтобы настройки переживали рестарты.
## План работ (по шагам)
### Шаг 1. Разведка базового образа
- Проверить, где находится оригинальный entrypoint и как он запускается (путь, параметры, пользователь).
- Понять, как `GREENPLUM_PXF_ENABLE=true` влияет на старт (чтобы wrapper не ломал поведение).
Результат: фиксируем в README “как устроен старт” (1–2 абзаца).
### Шаг 2. Новый Dockerfile для Greenplum
- Добавить `Dockerfile.greenplum`:
- `FROM woblerr/greenplum:6.27.1`
- `COPY` seed-артефакты в образ (например, в `/opt/pxf-seed/...`)
- `COPY` wrapper-entrypoint в образ
- настроить права/владельца внутри образа так, чтобы старт был без ошибок
Результат: образ собирается локально через `make build` и/или автоматически через `make up`.
### Шаг 3. Wrapper-entrypoint (каждый старт)
- Добавить скрипт entrypoint-обёртки (например, `greenplum/entrypoint-wrapper.sh` или `pxf/entrypoint-wrapper.sh`):
- на старте вызывает `ensure`-скрипт;
- затем делает `exec` оригинального entrypoint базового образа с теми же аргументами.
Важно: wrapper не должен “перехватывать” логику инициализации кластера — только добавлять шаг подготовки PXF.
### Шаг 4. Переписать текущий init-скрипт в “ensure” (идемпотентный)
- Превратить `pxf/init/10_pxf_bookings.sh` в скрипт, который можно безопасно выполнять на каждом запуске:
- не опираться на `~/.bashrc`;
- `PXF_BASE` вычислять через env (`PXF_BASE`, `GREENPLUM_DATA_DIRECTORY`, fallback `/data/pxf`);
- seed-копирование делать “если файла нет”;
- добавить понятные логи (что сделано / что пропущено);
- `pxf cluster sync`:
- выполнять, только если команда доступна,
- не валить контейнер при ошибке (но писать предупреждение).
Дополнительно (опционально, но полезно для стенда):
- env-переключатель `PXF_SEED_OVERWRITE=1` — принудительно перезаписывать конфиг из образа в volume (для обновлений без удаления volume).
### Шаг 5. Обновить `docker-compose.yml`
- Для сервиса `greenplum` перейти на `build:` (и при желании оставить `image:` как тег).
- Убрать bind-mount’ы PXF (jar/config/init-скрипт), т.к. теперь всё в образе.
- Оставить `greenplum_data:/data` и `./sql:/sql:ro`.
- Исправить healthcheck Greenplum (сейчас конструкция вида `... || echo 1` делает healthcheck “вечно успешным”):
- healthcheck должен возвращать ненулевой код, если БД не готова;
- добавить `start_period`, чтобы не ловить ложные падения на холодном старте.
### Шаг 6. Обновить Makefile и README
- `Makefile`:
- убедиться, что `make build` собирает также Greenplum-образ (если введём build для сервиса);
- оставить `make up` как есть (Compose сам соберёт образ, если его нет).
- `README.md`:
- зачем свой образ (устойчивость, права на хосте, меньше mount’ов),
- как пересобрать образ,
- как обновить PXF-конфиг (через rebuild + `PXF_SEED_OVERWRITE=1` или через очистку volume).
## Проверка и критерии готовности
- `docker compose up -d greenplum` → контейнер остаётся `Up`, не падает.
- `docker compose restart greenplum` повторить 10–20 раз → без падений.
- Healthcheck Greenplum становится `healthy` (не “вечно healthy” и не “вечно starting”).
- Поднятие всего стека (`make up`) приводит к старту Airflow (scheduler/webserver), т.к. `depends_on: condition: service_healthy` начинает работать корректно.
## Риски и как их снизить
- **Стартап может замедлиться**, если `pxf cluster sync` делать каждый раз: поэтому скрипт должен быть быстрым, а sync — не фатальным при ошибках.
- **Обновление конфигов**: так как PXF_BASE на volume, изменения в образе сами не перетрут файлы — поэтому нужен `PXF_SEED_OVERWRITE=1` или понятная инструкция “как обновить”.
## Откат
- Вернуться к использованию `image: woblerr/greenplum:6.27.1` в `docker-compose.yml`.
- Вернуть mount’ы PXF, если нужно (но это вернёт риск с `:ro`).
---
## Статус (реализовано)
- Добавлен кастомный образ Greenplum: `Dockerfile.greenplum` (seed PXF + startup wrapper).
- Ensure‑скрипт перенесён в образ и стал идемпотентным: `pxf/init/10_pxf_bookings.sh`.
- Стартовый скрипт контейнера включает ensure и создаёт `EXTENSION pxf`: `pxf/init/start_greenplum_with_pxf.sh`.
- В `docker-compose.yml`:
- `greenplum` собирается через `build: Dockerfile.greenplum`;
- добавлен `hostname: gpdbsne`;
- убраны PXF bind-mount’ы (jar/config/init);
- healthcheck ждёт не только GPDB, но и готовность PXF.
- Обновлены инструкции: `README.md`, `.env.example`.
- Проблема с генератором demodb (пустая `bookings.bookings`) зафиксирована в `TODO.md`.
## Проверка (как воспроизвести)
Команды для ручной проверки:
- Пересобрать и перезапустить Greenplum:
- `make build`
- `docker compose up -d --force-recreate greenplum`
- 3–10 рестартов:
- `docker compose restart greenplum`
- дождаться `healthy` в `docker compose ps`
- Проверить PXF:
- `docker compose exec greenplum bash -lc "su - gpadmin -c '/usr/local/pxf/bin/pxf cluster status'"`
- Проверить DAG, который использует PXF:
- `docker exec -i gp_airflow_scheduler airflow dags test bookings_stg_ddl`
Текущий результат:
- `bookings_stg_ddl` проходит (PXF и `protocol pxf` доступны).
- `bookings_to_gp_stage` падает не из-за PXF, а из-за пустого источника
(`demo.bookings.bookings` = 0 строк). Это отдельная задача (см. `TODO.md`).
+5
View File
@@ -27,5 +27,10 @@
<value>bookings</value> <value>bookings</value>
</property> </property>
<property>
<name>jdbc.column.types</name>
<value>jsonb=TEXT,tstzrange=TEXT,_int4=TEXT</value>
</property>
</configuration> </configuration>
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
set -euo pipefail
# Скрипт автоматизированного прогона E2E-теста всей цепочки DWH через REST API Airflow.
# Отрабатывает 2 "учебных дня" для проверки инкрементальной загрузки.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
warn() { echo ":: ${1}" >&2; }
# Загружаем переменные из .env, если файл существует, иначе используем дефолты
if [ -f ".env" ]; then
export $(grep -v '^#' .env | xargs)
fi
AIRFLOW_USER=${AIRFLOW_USER:-admin}
AIRFLOW_PASSWORD=${AIRFLOW_PASSWORD:-admin}
API_URL="http://localhost:8080/api/v1/dags"
# Функция для триггера DAG и ожидания его завершения
trigger_and_wait() {
local dag_id=$1
warn "Запуск $dag_id через REST API..."
# 0. Unpause DAG
curl -s -X PATCH "$API_URL/$dag_id" \
--user "$AIRFLOW_USER:$AIRFLOW_PASSWORD" \
-H "Content-Type: application/json" \
-d '{"is_paused": false}' > /dev/null
# 1. Trigger
local response
response=$(curl -s -X POST "$API_URL/$dag_id/dagRuns" \
--user "$AIRFLOW_USER:$AIRFLOW_PASSWORD" \
-H "Content-Type: application/json" \
-d '{}')
local run_id
run_id=$(echo "$response" | grep -o '"dag_run_id": "[^"]*' | cut -d'"' -f4)
if [ -z "$run_id" ]; then
echo "Ошибка запуска $dag_id. Ответ API: $response" >&2
exit 1
fi
warn "Успешный триггер. Run ID: $run_id"
# 2. Wait
warn "Ожидание завершения $dag_id..."
local attempts=0
local max_attempts=120 # 10 минут (120 * 5 сек)
while true; do
local status
status=$(curl -s "$API_URL/$dag_id/dagRuns/$run_id" \
--user "$AIRFLOW_USER:$AIRFLOW_PASSWORD" | grep -o '"state": "[^"]*' | cut -d'"' -f4)
if [ "$status" = "success" ]; then
warn "DAG $dag_id завершен: SUCCESS"
break
elif [ "$status" = "failed" ]; then
echo "DAG $dag_id УПАЛ (FAILED)!" >&2
exit 1
elif [ "$status" = "queued" ] || [ "$status" = "running" ] || [ "$status" = "" ]; then
attempts=$((attempts + 1))
if [ "$attempts" -ge "$max_attempts" ]; then
echo "Таймаут ожидания $dag_id!" >&2
exit 1
fi
sleep 5
else
echo "Неизвестный статус: $status" >&2
exit 1
fi
done
}
warn "=== DDL: Создание схем и таблиц ==="
trigger_and_wait "bookings_stg_ddl"
trigger_and_wait "bookings_ods_ddl"
trigger_and_wait "bookings_dds_ddl"
trigger_and_wait "bookings_dm_ddl"
warn "=== DAY 1: Initial Load ==="
trigger_and_wait "bookings_to_gp_stage"
trigger_and_wait "bookings_to_gp_ods"
trigger_and_wait "bookings_to_gp_dds"
trigger_and_wait "bookings_to_gp_dm"
warn "=== DAY 2: Increment ==="
trigger_and_wait "bookings_to_gp_stage"
trigger_and_wait "bookings_to_gp_ods"
trigger_and_wait "bookings_to_gp_dds"
trigger_and_wait "bookings_to_gp_dm"
warn "=== Верификация данных ==="
# Выполняем проверки строк и бизнес-логики, чтобы убедиться, что все витрины DM-слоя заполнены
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c \"psql -d gp_dwh -c \\\"
SELECT 'STG bookings' as layer, COUNT(*) FROM stg.bookings UNION ALL
SELECT 'ODS bookings', COUNT(*) FROM ods.bookings UNION ALL
SELECT 'DDS fact', COUNT(*) FROM dds.fact_flight_sales UNION ALL
SELECT 'DM sales_report', COUNT(*) FROM dm.sales_report UNION ALL
SELECT 'DM route_performance', COUNT(*) FROM dm.route_performance UNION ALL
SELECT 'DM passenger_loyalty', COUNT(*) FROM dm.passenger_loyalty UNION ALL
SELECT 'DM airport_traffic', COUNT(*) FROM dm.airport_traffic UNION ALL
SELECT 'DM monthly_overview', COUNT(*) FROM dm.monthly_overview;
-- Дополнительная проверка бизнес-логики (load factor не должен быть NULL и должен быть в пределах разумного)
SELECT
'Check LF' as check,
COUNT(*) as total_rows,
SUM(CASE WHEN avg_load_factor IS NOT NULL AND avg_load_factor >= 0 THEN 1 ELSE 0 END) as valid_lf
FROM dm.monthly_overview;
\\\"\""
warn "E2E ETL тест успешно завершен!"
+2 -22
View File
@@ -38,30 +38,10 @@ wait_up airflow-scheduler
warn "Init demo DB bookings" warn "Init demo DB bookings"
make bookings-init make bookings-init
warn "Apply DDL to Greenplum"
make ddl-gp
warn "Run local pytest suite" warn "Run local pytest suite"
make test make test
warn "Airflow DAG test: csv_to_greenplum" warn "Start full E2E ETL test via REST API"
docker compose -f docker-compose.yml exec airflow-webserver airflow dags test csv_to_greenplum 2024-01-01 make e2e-etl
warn "Check orders count in Greenplum"
ORDERS_COUNT=$(docker compose -f docker-compose.yml exec greenplum bash -lc "su - gpadmin -c \"/usr/local/greenplum-db/bin/psql -t -A -d gp_dwh -c 'SELECT COUNT(*) FROM public.orders;'\"")
if [ "${ORDERS_COUNT:-0}" -le 0 ]; then
echo "orders table is empty after csv_to_greenplum (COUNT=${ORDERS_COUNT:-0})" >&2
exit 1
fi
warn "Airflow DAG test: bookings_to_gp_stage"
docker compose -f docker-compose.yml exec airflow-webserver airflow dags test bookings_to_gp_stage 2024-01-01
warn "Check stg.bookings count in Greenplum"
BOOKINGS_COUNT=$(docker compose -f docker-compose.yml exec greenplum bash -lc "su - gpadmin -c \"/usr/local/greenplum-db/bin/psql -t -A -d gp_dwh -c 'SELECT COUNT(*) FROM stg.bookings;'\"")
if [ "${BOOKINGS_COUNT:-0}" -le 0 ]; then
echo "stg.bookings is empty after bookings_to_gp_stage (COUNT=${BOOKINGS_COUNT:-0})" >&2
exit 1
fi
warn "Smoke test completed successfully" warn "Smoke test completed successfully"
-11
View File
@@ -1,11 +0,0 @@
-- DDL для базовой таблицы orders, которую использует CSV‑pipeline.
-- Выполняется идемпотентно: таблица создаётся, если ещё не существует.
CREATE TABLE IF NOT EXISTS public.orders (
order_id BIGINT,
order_ts TIMESTAMP NOT NULL,
customer_id BIGINT NOT NULL,
amount NUMERIC(12,2) NOT NULL
)
WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1)
DISTRIBUTED BY (order_id);
+43 -7
View File
@@ -1,15 +1,12 @@
-- Главный входной DDL-скрипт для Greenplum в учебном стенде. -- Главный входной DDL-скрипт для Greenplum в учебном стенде.
-- Выполняется из контейнера командой `make ddl-gp` и создаёт/обновляет -- Выполняется из контейнера командой `make ddl-gp` и создаёт/обновляет
-- все объекты, которые нужны базовым DAG (csv_to_greenplum, bookings_to_gp_stage); -- все объекты, которые нужны DAG'ам bookings ETL-пайплайна;
-- подключает файловые DDL через \i, чтобы сохранять единый входной скрипт. -- подключает файловые DDL через \i, чтобы сохранять единый входной скрипт.
-- --
-- Чтобы не ломать задания, новые объекты лучше добавлять в отдельные файлы -- Чтобы не ломать задания, новые объекты лучше добавлять в отдельные файлы
-- и подключать их отсюда, а существующие определения не удалять. -- и подключать их отсюда, а существующие определения не удалять.
-- --
-- Подробнее про STG/bookings: см. docs/internal/bookings_stg_readme.md. -- Подробнее про STG/bookings: см. docs/bookings_to_gp_stage.md.
-- Таблица для CSV‑пайплайна (csv_to_greenplum).
\i base/orders_ddl.sql
-- Внешняя таблица для чтения данных из демо-БД bookings через PXF (JDBC). -- Внешняя таблица для чтения данных из демо-БД bookings через PXF (JDBC).
-- Источник: таблица bookings.bookings в базе demo (Postgres, сервис bookings-db). -- Источник: таблица bookings.bookings в базе demo (Postgres, сервис bookings-db).
@@ -22,6 +19,45 @@ CREATE EXTERNAL TABLE public.ext_bookings_bookings (
LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db') LOCATION ('pxf://bookings.bookings?PROFILE=JDBC&SERVER=bookings-db')
FORMAT 'CUSTOM' (formatter='pxfwritable_import'); FORMAT 'CUSTOM' (formatter='pxfwritable_import');
-- DDL для слоя stg по таблице bookings вынесен в отдельный файл. -- DDL для слоя stg по таблицам bookings и tickets вынесены в отдельные файлы.
-- Здесь подключаем его через psql \i, чтобы сохранить единый входной скрипт. -- Здесь подключаем их через psql \i, чтобы сохранить единый входной скрипт.
\i stg/bookings_ddl.sql \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
-- 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
-- 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
-- DDL для DM-слоя (Data Mart).
\i dm/sales_report_ddl.sql
\i dm/route_performance_ddl.sql
\i dm/passenger_loyalty_ddl.sql
\i dm/airport_traffic_ddl.sql
\i dm/monthly_overview_ddl.sql
+23
View File
@@ -0,0 +1,23 @@
-- DDL для DDS-слоя по таблице dim_airplanes (SCD1-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
-- Тип таблицы: Heap (стандартная).
-- Обоснование: Необходим row-level UPDATE для реализации SCD1 UPSERT.
-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы.
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()
)
WITH (appendonly=false)
DISTRIBUTED BY (airplane_sk);
COMMENT ON TABLE dds.dim_airplanes IS 'Измерение моделей самолетов (DDS).';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dds.dim_airplanes
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+4
View File
@@ -0,0 +1,4 @@
-- TODO: реализуйте загрузку dds.dim_airplanes (см. ТЗ в docs/assignment/analyst_spec.md)
-- Паттерн: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+24
View File
@@ -0,0 +1,24 @@
-- DDL для DDS-слоя по таблице dim_airports (SCD1-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
-- Тип таблицы: Heap (стандартная).
-- Обоснование: Необходим row-level UPDATE для реализации SCD1 UPSERT.
-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы.
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()
)
WITH (appendonly=false)
DISTRIBUTED BY (airport_sk);
COMMENT ON TABLE dds.dim_airports IS 'Измерение аэропортов (DDS).';
+88
View File
@@ -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 $$;
+67
View File
@@ -0,0 +1,67 @@
-- Загрузка 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()).
-- Учебный комментарий: Генерация SK через MAX() + ROW_NUMBER()
-- Почему не SERIAL/IDENTITY? В MPP-базах данных (как Greenplum) sequence
-- работают через мастер-узел и могут стать узким местом при массовой вставке.
-- Паттерн MAX() + ROW_NUMBER() генерирует ключи распределённо на сегментах.
-- Этот подход работает безопасно только потому, что Airflow запускает
-- джобы загрузки для одной таблицы строго последовательно (concurrency=1).
-- При параллельной загрузке возникнет состояние гонки (race condition) и возможны дубли SK.
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;
+20
View File
@@ -0,0 +1,20 @@
-- DDL для DDS-слоя по таблице dim_calendar (статическое измерение дат).
CREATE SCHEMA IF NOT EXISTS dds;
-- Тип таблицы: Append-Only Row-oriented (zstd:1).
-- Обоснование: Статичные данные без обновлений. Обеспечивает эффективное сжатие.
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
)
WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1)
DISTRIBUTED BY (calendar_sk);
COMMENT ON TABLE dds.dim_calendar IS 'Измерение календаря (DDS).';
+87
View File
@@ -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 $$;
+29
View File
@@ -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;
+20
View File
@@ -0,0 +1,20 @@
-- DDL для DDS-слоя по таблице dim_passengers (SCD1-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
-- Тип таблицы: Heap (стандартная).
-- Обоснование: Необходим row-level UPDATE для реализации SCD1 UPSERT.
-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы.
CREATE TABLE IF NOT EXISTS dds.dim_passengers (
passenger_sk INTEGER NOT NULL,
passenger_id 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()
)
WITH (appendonly=false)
DISTRIBUTED BY (passenger_sk);
COMMENT ON TABLE dds.dim_passengers IS 'Измерение пассажиров (DDS).';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dds.dim_passengers
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+4
View File
@@ -0,0 +1,4 @@
-- TODO: реализуйте загрузку dds.dim_passengers (см. ТЗ в docs/assignment/analyst_spec.md)
-- Паттерн: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+40
View File
@@ -0,0 +1,40 @@
-- DDL для DDS-слоя по таблице dim_routes (SCD2-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
-- Тип таблицы: Heap (стандартная).
-- Обоснование: Необходим row-level UPDATE для реализации SCD2 (закрытие версий).
-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы.
--
-- Учебный комментарий (Kimball Star Schema):
-- Измерение должно быть «самодостаточным»: один JOIN к dim_routes —
-- и аналитик видит маршрут, города, модель самолёта и кол-во мест.
-- Денормализованные атрибуты (departure_city, arrival_city, airplane_model, total_seats)
-- НЕ участвуют в hashdiff. Версия SCD2 фиксирует изменения атрибутов маршрута
-- (аэропорт, самолёт, расписание). Если изменится название города —
-- обновим отдельным refresh-шагом, не создавая новую версию.
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,
departure_city TEXT NOT NULL,
arrival_city TEXT NOT NULL,
airplane_model TEXT NOT NULL,
total_seats INTEGER 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()
)
WITH (appendonly=false)
DISTRIBUTED BY (route_sk);
COMMENT ON TABLE dds.dim_routes IS 'Измерение маршрутов (DDS).';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dds.dim_routes (SCD2)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+4
View File
@@ -0,0 +1,4 @@
-- TODO: реализуйте загрузку dds.dim_routes (см. ТЗ в docs/assignment/analyst_spec.md)
-- Паттерн: SCD2 с hashdiff — самый нетривиальный паттерн в проекте.
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+18
View File
@@ -0,0 +1,18 @@
-- DDL для DDS-слоя по таблице dim_tariffs (SCD1-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
-- Тип таблицы: Append-Only Row-oriented (zstd:1).
-- Обоснование: Редко дополняемые данные без обновлений. Обеспечивает эффективное сжатие.
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()
)
WITH (appendonly=true, orientation=row, compresstype=zstd, compresslevel=1)
DISTRIBUTED BY (tariff_sk);
COMMENT ON TABLE dds.dim_tariffs IS 'Измерение тарифов (DDS).';
+98
View File
@@ -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 $$;
+40
View File
@@ -0,0 +1,40 @@
-- Загрузка 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 <> ''
),
-- Учебный комментарий: Генерация SK через MAX() + ROW_NUMBER()
-- Этот подход работает безопасно только потому, что Airflow запускает
-- джобы загрузки для одной таблицы строго последовательно (concurrency=1).
-- При параллельной загрузке возникнет состояние гонки (race condition) и возможны дубли SK.
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;
+34
View File
@@ -0,0 +1,34 @@
-- DDL для DDS-слоя по таблице fact_flight_sales.
CREATE SCHEMA IF NOT EXISTS dds;
-- Учебный комментарий: Почему у факта нет своего суррогатного ключа (fact_sk)?
-- В классическом DWH (Кимбалл) таблица фактов идентифицируется набором её
-- измерений или дегенеративных ключей (в нашем случае: ticket_no + flight_id).
-- Добавление отдельного ID только тратит место и не несёт аналитической ценности.
-- Тип таблицы: Heap (стандартная).
-- Обоснование: Необходим row-level UPDATE для обновления статусов (is_boarded).
-- Использование Append-Only при частых обновлениях приводит к раздуванию (bloat) таблицы.
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()
)
WITH (appendonly=false)
DISTRIBUTED BY (ticket_no);
COMMENT ON TABLE dds.fact_flight_sales IS 'Факт продаж билетов (DDS).';
+169
View File
@@ -0,0 +1,169 @@
-- 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_airport BIGINT;
v_null_student 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 будет NULL, пока вы не реализуете dim_passengers.
-- После реализации: TRUNCATE dds.fact_flight_sales → перезагрузка → все SK заполнены.
-- Полную версию DQ (с блокировкой) см. в ветке solution.
SELECT COUNT(*)
INTO v_null_passenger
FROM dds.fact_flight_sales
WHERE passenger_sk IS NULL;
IF v_null_passenger <> 0 THEN
RAISE NOTICE
'DQ INFO: студенческий SK (passenger) NULL: %. После реализации dim_passengers: TRUNCATE fact → перезагрузка.',
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;
-- departure_airport_sk и arrival_airport_sk заполняются через ods.routes (эталон).
-- NULL здесь — аномалия данных (пропущен маршрут в ODS), а не отсутствие студенческого кода.
-- Порог 1% — защита от единичных аномалий источника (аналогично calendar_sk).
SELECT COUNT(*)
INTO v_null_airport
FROM dds.fact_flight_sales
WHERE departure_airport_sk IS NULL
OR arrival_airport_sk IS NULL;
IF v_null_airport > 0 THEN
IF v_null_airport * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
RAISE EXCEPTION
'DQ FAILED: NULL airport_sk: % (>1%%)',
v_null_airport;
ELSE
RAISE NOTICE
'DQ WARNING: NULL airport_sk: % (<=1%%, допустимо)',
v_null_airport;
END IF;
END IF;
-- route_sk и airplane_sk будут NULL, пока вы не реализуете dim_routes.
-- Не блокируем pipeline.
-- Полную версию DQ (с блокировкой) см. в ветке solution.
SELECT COUNT(*)
INTO v_null_student
FROM dds.fact_flight_sales
WHERE route_sk IS NULL
OR airplane_sk IS NULL;
IF v_null_student > 0 THEN
RAISE NOTICE
'DQ INFO: студенческие SK (route/airplane) NULL: %. После реализации dim_routes: TRUNCATE fact → перезагрузка.',
v_null_student;
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 $$;
+129
View File
@@ -0,0 +1,129 @@
-- Загрузка 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.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.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.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
-- Учебный комментарий: Late-arriving dimensions (Опаздывающие измерения)
-- Мы используем LEFT JOIN, так как факт (рейс/билет) может прийти раньше,
-- чем справочник (пассажир/маршрут) обновится в DDS.
--
-- Airport lookup через ods.routes (эталонный справочник), а не через dds.dim_routes
-- (студенческое задание SCD2). Это архитектурное решение: эталонный пайплайн работает
-- независимо от студенческого кода. Аэропорты вылета/прилёта одинаковы во всех
-- версиях одного route_no — безопасно брать из ODS без point-in-time логики.
-- ROW_NUMBER по validity DESC: выбираем актуальную версию расписания маршрута.
LEFT JOIN (
SELECT route_no, departure_airport, arrival_airport
FROM (
SELECT route_no, departure_airport, arrival_airport,
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
FROM ods.routes
) ranked
WHERE rn = 1
) AS ods_rte ON ods_rte.route_no = flt.route_no
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 = ods_rte.departure_airport -- через ods.routes (эталон)
LEFT JOIN dds.dim_airports AS arr
ON arr.airport_bk = ods_rte.arrival_airport -- через ods.routes (эталон)
LEFT JOIN dds.dim_airplanes AS ap
ON ap.airplane_bk = rte.airplane_code -- через dim_routes (point-in-time, как прежде)
LEFT JOIN dds.dim_tariffs AS tar
ON tar.fare_conditions = seg.fare_conditions
LEFT JOIN dds.dim_passengers AS pax
ON pax.passenger_id = 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;
+45
View File
@@ -0,0 +1,45 @@
-- DDL для витрины dm.airport_traffic (Пассажиропоток аэропортов).
--
-- Учебные цели:
-- 1. Обработка dual-role dimensions: один аэропорт выступает и как точка вылета,
-- и как точка прилета. Витрина собирает статистику в едином разрезе (date, airport).
-- 2. Денормализация: включение кода аэропорта (BK) и города для удобства анализа.
-- 3. Предупреждение о семантике данных: риск "двойного счета" выручки.
-- 4. Стратегия HWM по датам: пересчет только затронутых дней.
CREATE TABLE IF NOT EXISTS dm.airport_traffic (
-- Зерно: дата и суррогатный ключ аэропорта
traffic_date DATE NOT NULL,
airport_sk INTEGER NOT NULL,
-- Денормализованные атрибуты (из dim_airports)
airport_bk 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 DEFAULT 0,
-- Служебные поля
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()
)
WITH (appendonly=false) -- Используем Heap для инкрементального UPSERT
DISTRIBUTED BY (airport_sk);
COMMENT ON TABLE dm.airport_traffic IS 'Витрина: ежедневный пассажиропоток аэропортов (UNION ALL, Heap)';
-- Комментарии к колонкам с предупреждением (учебная ценность)
COMMENT ON COLUMN dm.airport_traffic.departures_revenue IS 'Выручка от билетов, где аэропорт был точкой вылета. ВНИМАНИЕ: суммирование с arrivals_revenue приведет к двойному счету!';
COMMENT ON COLUMN dm.airport_traffic.arrivals_revenue IS 'Выручка от билетов, где аэропорт был точкой прилета. ВНИМАНИЕ: суммирование с departures_revenue приведет к двойному счету!';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dm.airport_traffic
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте загрузку витрины dm.airport_traffic (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+40
View File
@@ -0,0 +1,40 @@
-- DDL для витрины dm.monthly_overview (Помесячная сводка).
--
-- Учебные цели:
-- 1. Двухуровневая агрегация (до рейса, затем до месяца) для точного расчета
-- средних показателей (load factor), избегая парадокса Симпсона.
-- 2. Выбор ключа распределения (Distribution Key):
-- Мы используем airplane_sk, а НЕ (year, month). Распределение по датам в MPP —
-- это антипаттерн, приводящий к Data Skew (весь месяц на одном сегменте).
CREATE TABLE IF NOT EXISTS dm.monthly_overview (
-- Зерно: Месяц и самолет
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 NOT NULL,
-- Метрики
total_flights INTEGER NOT NULL DEFAULT 0,
total_tickets INTEGER NOT NULL DEFAULT 0,
total_boarded INTEGER NOT NULL DEFAULT 0,
total_revenue NUMERIC(15,2) NOT NULL DEFAULT 0,
avg_ticket_price NUMERIC(10,2),
avg_load_factor NUMERIC(5,4), -- Точный load factor (сначала по рейсу, потом среднее)
unique_routes INTEGER NOT NULL DEFAULT 0,
unique_passengers INTEGER NOT NULL DEFAULT 0,
-- Служебные
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()
)
WITH (appendonly=false) -- Heap для UPSERT
DISTRIBUTED BY (airplane_sk); -- Избегаем распределения по (year, month)
COMMENT ON TABLE dm.monthly_overview IS 'Витрина: помесячная аналитика с точным load_factor (Двухуровневая агрегация, Heap)';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dm.monthly_overview
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте загрузку витрины dm.monthly_overview (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+41
View File
@@ -0,0 +1,41 @@
-- DDL для витрины dm.passenger_loyalty (Лояльность пассажиров).
--
-- Учебные цели:
-- 1. Выбор зерна для SCD1-измерений:
-- В SCD2-витринах (напр. route_performance) зерно — бизнес-ключ (BK), т.к. версий много.
-- В SCD1 (dim_passengers) BK и SK связаны 1:1, поэтому зерном выступает суррогатный ключ (SK).
-- Это упрощает JOIN с фактами и ускоряет запросы в Greenplum.
-- 2. Использование Heap-таблицы для UPSERT:
-- В отличие от AO Column, Heap поддерживает эффективный UPDATE, что критично для
-- накопительных витрин с большим количеством строк.
-- 3. Служебные поля жизненного цикла:
-- Т.к. мы будем обновлять метрики пассажиров, нам нужны и created_at, и updated_at.
CREATE TABLE IF NOT EXISTS dm.passenger_loyalty (
-- Ключ: суррогатный ключ пассажира (SCD1 гарантирует 1:1 к бизнес-ключу)
passenger_sk INTEGER NOT NULL,
passenger_bk TEXT NOT NULL,
passenger_name TEXT NOT NULL,
-- Метрики лояльности
total_bookings INTEGER NOT NULL, -- кол-во бронирований (book_ref)
total_flights INTEGER NOT NULL, -- кол-во перелетов
total_boarded INTEGER NOT NULL, -- кол-во успешных посадок
total_spent NUMERIC(15,2) NOT NULL,
avg_ticket_price NUMERIC(10,2),
favorite_fare_conditions TEXT, -- самый частый класс обслуживания
unique_routes INTEGER NOT NULL, -- кол-во уникальных маршрутов
first_flight_date DATE,
last_flight_date DATE,
days_as_customer 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()
)
WITH (appendonly=false) -- Явно указываем Heap для поддержки эффективного UPDATE
DISTRIBUTED BY (passenger_sk);
COMMENT ON TABLE dm.passenger_loyalty IS 'Витрина: лояльность и активность пассажиров (Incremental UPSERT, Heap)';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dm.passenger_loyalty
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте загрузку витрины dm.passenger_loyalty (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+48
View File
@@ -0,0 +1,48 @@
-- DDL для витрины dm.route_performance (Эффективность маршрутов).
--
-- Учебные цели:
-- 1. Демонстрация AO Column Store (Append-Only Columnar Storage).
-- В Greenplum формат AO Column идеален для аналитики (сжатие, чтение только нужных колонок).
-- 2. Демонстрация стратегии Full Rebuild (TRUNCATE + INSERT).
-- Для небольших справочных витрин это проще и надёжнее, чем сложный инкремент.
-- 3. Выбор служебных полей:
-- Для Full Rebuild таблиц created_at/updated_at не имеют смысла, т.к. строки
-- каждый раз пересоздаются. Достаточно _load_ts.
CREATE TABLE IF NOT EXISTS dm.route_performance (
-- Ключ: бизнес-код маршрута (напр. 'SVO-LED')
route_bk TEXT NOT NULL,
-- SK текущей (актуальной) версии маршрута для связи с измерениями
route_sk INTEGER NOT NULL,
-- Денормализованные атрибуты (из актуальной версии dim_routes)
departure_airport_bk TEXT NOT NULL,
departure_city TEXT NOT NULL,
arrival_airport_bk TEXT NOT NULL,
arrival_city TEXT NOT NULL,
airplane_bk TEXT NOT NULL,
airplane_model TEXT NOT NULL,
total_seats INTEGER NOT NULL,
-- Метрики (агрегированы по всем версиям данного маршрута)
total_flights INTEGER NOT NULL,
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), -- средняя заполняемость кресел
first_flight_date DATE,
last_flight_date DATE,
-- Служебные поля.
-- created_at/updated_at здесь не нужны: при Full Rebuild все строки пересоздаются,
-- поэтому _load_ts достаточно для отслеживания момента загрузки.
_load_id TEXT NOT NULL,
_load_ts TIMESTAMP NOT NULL DEFAULT now()
)
WITH (appendonly=true, orientation=column, compresstype=zstd, compresslevel=1)
DISTRIBUTED BY (route_bk);
COMMENT ON TABLE dm.route_performance IS 'Витрина: эффективность авиамаршрутов (Full Rebuild, AO Column, zstd)';
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте проверки качества данных для dm.route_performance
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3
View File
@@ -0,0 +1,3 @@
-- TODO: реализуйте загрузку витрины dm.route_performance (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more