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-ветке — все таски зелёные.
This commit is contained in:
2026-03-12 12:12:44 +03:00
parent 23cde47fe7
commit a51e2a6243
19 changed files with 850 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
-- Проверяем, что SCD2-логика студента сработала корректно после мутации.
-- Читаем route_no тестового маршрута из служебной таблицы _validate_scd2_target
-- (создана на шаге mutate), а не угадываем по побочным эффектам.
DO $$
DECLARE
v_route TEXT;
v_version_count BIGINT;
v_closed_count BIGINT;
v_open_count BIGINT;
v_old_hash TEXT;
v_new_hash TEXT;
v_gap_count BIGINT;
BEGIN
-- Читаем тестовый маршрут из служебной таблицы
SELECT route_no INTO v_route FROM _validate_scd2_target LIMIT 1;
IF v_route IS NULL THEN
RAISE EXCEPTION 'FAILED: служебная таблица _validate_scd2_target пуста. Шаг mutate не выполнился?';
END IF;
-- Если в dim_routes нет маршрута — load не запустился или упал
IF NOT EXISTS (SELECT 1 FROM dds.dim_routes WHERE route_bk = v_route) THEN
RAISE EXCEPTION E'FAILED: dds.dim_routes не содержит маршрут % после запуска load.\n'
'Проверьте sql/dds/dim_routes_load.sql.', v_route;
END IF;
-- Проверка a: Ровно 2 версии тестового маршрута (было 1, стало 2 после мутации)
SELECT COUNT(*) INTO v_version_count
FROM dds.dim_routes WHERE route_bk = v_route;
IF v_version_count < 2 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — найдена % версия (ожидается 2: старая закрытая + новая открытая).\n'
'SCD2 должен был создать новую версию после изменения departure_time.', v_route, v_version_count;
END IF;
IF v_version_count > 2 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — найдено % версий (ожидается 2).\n'
'Возможно, load создаёт лишние дубликаты. Проверьте условие NOT EXISTS при INSERT.', v_route, v_version_count;
END IF;
-- Проверка b: Старая версия закрыта (valid_to IS NOT NULL)
SELECT COUNT(*) INTO v_closed_count
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NOT NULL;
IF v_closed_count = 0 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — SCD2 не закрыл старую версию (valid_to IS NULL у всех версий).\n'
'Подсказка: hashdiff изменился (departure_time сдвинут на 1 час),\n'
'но ваш load-скрипт не обнаружил это изменение.\n'
'Проверьте:\n'
' 1. Формулу hashdiff — включает ли она departure_time?\n'
' 2. Логику сравнения hashdiff (UPDATE ... SET valid_to = CURRENT_DATE WHERE hashdiff <> новый_hashdiff)', v_route;
END IF;
-- Проверка c: Новая версия открыта (valid_to IS NULL)
SELECT COUNT(*) INTO v_open_count
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NULL;
IF v_open_count <> 1 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — ожидается ровно 1 открытая версия (valid_to IS NULL), найдено %.\n'
'Подсказка: SCD2 должен вставить новую строку с valid_to = NULL.', v_route, v_open_count;
END IF;
-- Проверка d: hashdiff старой ≠ hashdiff новой (мутация действительно отразилась в хеше)
SELECT hashdiff INTO v_old_hash
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NOT NULL
ORDER BY valid_from DESC LIMIT 1;
SELECT hashdiff INTO v_new_hash
FROM dds.dim_routes WHERE route_bk = v_route AND valid_to IS NULL;
IF v_old_hash = v_new_hash THEN
RAISE EXCEPTION E'FAILED: Маршрут % — hashdiff старой и новой версий совпадают.\n'
'Мутация сдвинула departure_time на 1 час, но hashdiff не изменился.\n'
'Проверьте, что departure_time входит в формулу hashdiff.', v_route;
END IF;
-- Проверка e: Нет «дыры» между valid_to старой и valid_from новой
SELECT COUNT(*) INTO v_gap_count
FROM dds.dim_routes AS old_v
JOIN dds.dim_routes AS new_v
ON old_v.route_bk = new_v.route_bk
WHERE old_v.route_bk = v_route
AND old_v.valid_to IS NOT NULL
AND new_v.valid_to IS NULL
AND old_v.valid_to <> new_v.valid_from;
IF v_gap_count > 0 THEN
RAISE EXCEPTION E'FAILED: Маршрут % — «дыра» между версиями:\n'
'valid_to старой ≠ valid_from новой.\n'
'Подсказка: полуоткрытый интервал [valid_from, valid_to).\n'
'valid_from новой версии должен = valid_to старой (обычно CURRENT_DATE).', v_route;
END IF;
RAISE NOTICE 'PASSED: SCD2 корректен для маршрута %. 2 версии, hashdiff различаются, «дыр» нет.', v_route;
END $$;