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/*' — должно быть пусто.
This commit is contained in:
2026-03-10 23:02:41 +03:00
parent 5972bcc2d8
commit 6655326caa
29 changed files with 322 additions and 64 deletions
+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 (нужен запуск стенда)
@@ -0,0 +1,75 @@
# Бенчмарк генерации bookings: параллельность и тюнинг PostgreSQL
> Дата: 2026-03-09
> Железо: Intel Core i7-11800H @ 2.30GHz (8 ядер / 16 потоков), игровой ноутбук
> Среда: WSL2, Docker Desktop, PostgreSQL 16 в контейнере
> Данные: seed-дамп 60 дней (~500k bookings, ~180k boarding_passes, ~1.2M tickets)
## Контекст
Генератор demodb (`postgrespro/demodb`) использует очередь событий `gen.events` с обработкой через `process_queue()`. При `jobs>1` воркеры запускаются через `dblink_send_query` и конкурируют за очередь через `SELECT ... FOR UPDATE SKIP LOCKED`.
Задача: найти оптимальную конфигурацию для `make bookings-generate-day` (инкремент +1 день).
## Методика
- Между экспериментами: `make bookings-init BOOKINGS_JOBS=N` (восстановление из seed-дампа, ~18 сек).
- Замер: `time make bookings-generate-day BOOKINGS_JOBS=N`.
- Контроль: `max(book_date)` должен сдвинуться на +1 день.
- Тюнинг PG: `ALTER SYSTEM SET` + `docker compose restart bookings-db`.
Параметры тюнинга PostgreSQL:
```
shared_buffers = 512MB (дефолт: 128MB)
work_mem = 64MB (дефолт: 4MB)
maintenance_work_mem = 256MB (дефолт: 64MB)
effective_cache_size = 1GB (дефолт: 4GB)
wal_buffers = 16MB (дефолт: -1, авто)
checkpoint_completion_target = 0.9 (дефолт: 0.9)
random_page_cost = 1.1 (дефолт: 4.0)
```
## Результаты
| # | Тюнинг PG | sync_commit | Jobs | Время | vs baseline |
|---|-----------|-------------|------|--------|--------------|
| 1 | нет | on | 1 | 2m 48s | **baseline** |
| 2 | нет | on | 2 | 8m 36s | 3× хуже |
| 3 | да | on | 1 | 3m 01s | шум |
| 4 | да | on | 2 | 8m 42s | 3× хуже |
| 5 | да | off | 1 | 2m 50s | шум |
| 6 | да | off | 2 | 8m 39s | 3× хуже |
Повторный инкремент (прогретый кэш): 5m 03s (jobs=2), не замерялся (jobs=1).
## Выводы
### 1. jobs=1 оптимален для инкрементов
Для генерации +1 дня параллельность через dblink **контрпродуктивна**:
- Два воркера конкурируют за одну очередь `gen.events` через `FOR UPDATE SKIP LOCKED` — это row-level lock contention.
- Overhead: dblink-соединения, синхронизация через `gen.stat_jobs`, polling через `dblink_is_busy()`.
- На WSL2 дополнительно: виртуализированный I/O не масштабируется при параллельных записях.
При генерации с нуля (`make bookings-generate`, десятки тысяч событий) jobs>1 **может** давать прирост, но не тестировалось в этом бенчмарке.
### 2. Тюнинг PostgreSQL не влияет
Увеличение shared_buffers в 4 раза, work_mem в 16 раз и т.д. не дало измеримого эффекта. Узкое место — не буферы и не I/O, а сама логика генератора: последовательная обработка событий с COMMIT после каждого.
### 3. synchronous_commit=off не влияет
Генератор делает COMMIT после каждого события (~сотни раз за день). Ожидалось, что `synchronous_commit=off` ускорит запись WAL. Эффект не обнаружен — вероятно, WAL-буферы и так справляются при одном потоке.
### 4. VACUUM-ивенты — отдельная проблема
Генератор вставляет в очередь событие `VACUUM` каждую неделю модельного времени. Обработчик запускает `VACUUM ANALYZE` всей базы через `dblink_exec`. При 500k+ строках это занимает минуты. Решение: `DELETE FROM gen.events WHERE type = 'VACUUM'` перед `continue()` в инкрементальных скриптах.
## Итоговая конфигурация
```
BOOKINGS_JOBS=1 # синхронно, без dblink — ~3 мин на +1 день
BOOKINGS_INIT_DAYS=60 # seed-дамп покрывает ~34 дня бронирований + boarding_passes
```
Тюнинг PostgreSQL не требуется для текущих объёмов данных.
+29
View File
@@ -0,0 +1,29 @@
# Временное ТЗ по блоку bookings (для текущей разработки)
_Внутренний файл для наставника: поясняет, как устроен источник `bookings-db` и генерация данных. Студентам обычно не нужен._
- Контейнер `bookings-db` — отдельный сервис Postgres из `docker-compose.yml`, база по умолчанию `demo` (из upstream demodb), без переименований.
- Доступ снаружи не блокируем (порт `5434` по умолчанию), чтобы позже читать через PXF и подключаться из Greenplum.
- Инициализация: два способа:
- `make bookings-init` (рекомендуется): быстрое восстановление из seed-дампа (~18 сек).
- `make bookings-generate` (для разработчиков): полная генерация с нуля — клонирует demodb с закреплённым коммитом, накладывает патчи (`engine`: `jobs=1` синхронно + `busy()` игнорирует свой pid; `install.sql`: `DROP DATABASE IF EXISTS`, `connstr` без хардкода), ждёт `pg_isready`, ставит `gen.connstr` и GUC `bookings.start_date/init_days/jobs`, затем запускает `/bookings/generate_next_day.sql` через `psql -f`. Значения по умолчанию: стартовая дата 2017-01-01, `init_days=60`, `jobs=2`.
- Генерация следующего дня: `make bookings-generate-day` прогоняет тот же SQL (читает GUC, вызывает `generate/continue`, ждёт `busy()`, закрывает dblink). При `jobs=1` всё синхронно, без dblink.
- Исходники demodb: клонируем по требованию с фиксированным хешем, кладём в `bookings/demodb/``.gitignore`), патчи лежат в `bookings/patches/` и применяются автоматически при `make bookings-generate`.
- Документация: в README описаны команды (`bookings-init`, `bookings-generate`, проверка данных, генерация дня), параметры `.env`; настройка PXF/ETL — следующий этап.
## Текущее состояние
- `make bookings-init` — быстрое восстановление из seed-дампа (~18 сек), рекомендуется для студентов.
- `make bookings-generate` — полная генерация с нуля: автоматически применяет патчи (`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=60`, `BOOKINGS_JOBS=2`. При `jobs=1` генерация идёт синхронно без dblink, `busy()` не учитывает текущую сессию.
- `.env.example`/README обновлены под новые дефолты; каталог `bookings/demodb/` в `.gitignore`.
- Патчи лежат в `bookings/patches/` и накладываются при `bookings-clone-demodb`.
## Текущее состояние тестов/проблем
- Чистый прогон `make bookings-init` (восстановление из seed-дампа) проходит за ~18 секунд.
- Чистый прогон `make bookings-generate` (после `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
@@ -0,0 +1,201 @@
# 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/` (JDBCJAR и `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`.
## 8. Известная проблема: `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'';\"'"`
## 9. Связанные файлы
- `Dockerfile.greenplum`
- `docker-compose.yml` (сервис `greenplum`: `build`, `hostname`, env, healthcheck)
- `pxf/init/10_pxf_bookings.sh` (ensure‑логика)
- `pxf/init/start_greenplum_with_pxf.sh` (старт контейнера)
- `docs/stack.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` (а не в localauth).
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 уже запущен.
+595
View File
@@ -0,0 +1,595 @@
# План отладки эталонного пайплайна
> Цель: убедиться, что эталонный вертикальный срез (STG→ODS→DDS→DM) работает
> корректно при разных сценариях. Найти и исправить баги ДО того, как начнём
> готовить ветку main для студентов.
>
> Аудитория документа: AI-агент (Sonnet) или человек, выполняющий отладку.
>
> Зависимость: перед запуском этого плана нужно починить bookings-db
> (см. `docs/reference/bookings_db_issues.md`).
---
## Предусловия
```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: все 9 таблиц не пустые
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 таблиц не пустые
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 витрин не пустые
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;
```
**Ожидание:** ВСЕ таблицы > 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** (статика) — можно делать параллельно без стенда