- Зачем: - менти нужен наблюдаемый опыт, в котором проверки защищают изменение конвейера заказов. - Что: - добавлено ТЗ ступени 5 с договорами DQ, управляемыми ошибками и восстановлением. - в оглавление лабораторных работ добавлена ссылка на новое задание. - Проверка: - проверены локальные ссылки, пробная пачка jq и отсутствие ошибок пробелов в diff.
23 KiB
Сначала сторож, потом ломай: заказы
Чему учит: ставить проверку качества до рискованного изменения и доказывать управляемой ошибкой, что она защищает нужный договор.
Сначала прочитать
Посмотрите также на устройство одной проверки в sql/dq/
и на ее включение в dq_check. Готового ответа для
проверок из этого задания в репозитории нет.
Граница задания
Сначала добавьте два сторожа:
sql/dq/order_items_total.sqlпроверяет сумму позиций заказа;sql/dq/orders_vs_ods.sqlсверяет DDS с его источником;dags/dq_check.pyзапускает обе проверки, публикует их строки вdm.dq_summaryи красит именованные задачи.
Только после красного и зеленого опыта с каждым сторожем перепишите готовые преобразования:
sql/ods/order_parse.sql;sql/dds/order_scope.sql;sql/dds/order_rebuild.sql;sql/dds/order_replace.sql.
Даги приема и трансформации, DDL, таблицы-доноры, Kafka и службы остаются
несущей частью упражнения. Их интерфейсы уже заданы. Перед опытами остановите
автоматическое движение мира: поставьте world_live на паузу и дождитесь
завершения активных прогонов. До конца очередного опыта не запускайте
world_next_day, world_live_day и world_initialize руками. Это условие
стабильного модельного времени: одной паузы world_live для него мало. Даг
world_recreate тоже оставьте нетронутым: он пересоздает весь прикладной мир.
Два договора качества
Обе проверки следуют договору dq_check: запрос возвращает по одной строке
на день с колонками data_date, reference_rows, actual_rows,
failed_rows, diagnostics. Даг записывает результат под отдельным
check_name и падает, если failed_rows больше нуля. Диагностика называет
день, order_id или сравниваемые дневные значения. Ограничьте ее тем же
малым числом примеров, что и существующие запросы.
Сумма позиций
Имя проверки - order_items_total. Для каждого заказа из dds.order_v
посчитайте сумму item_price * item_quantity и сравните ее с items_total.
reference_rows считает ключи со стороны items_total, actual_rows - ключи
со стороны вычисленной суммы, а failed_rows - несовпавшие значения. В
исправном наборе первые два счетчика равны по построению; сам инвариант стережет
failed_rows. В диагностике достаточно order_id, ожидаемой суммы и суммы
позиций.
Эта проверка стережет молчаливый типизированный разбор массива items на
границе ODS -> DDS. Она не проверяет скидку, доставку или равенство total:
это другие договоры заказа.
DDS против ODS
Имя проверки - orders_vs_ods. Источник модели DDS здесь - текущее состояние
ods.order_v. Внутреннее состояние генератора в этот договор не входит.
Одним запросом соберите обе стороны на зерне дня заказа и сравните:
- число заказов;
- максимальный
updated_at.
День ODS вычисляется в Europe/Samara, как order_date в DDS. Постройте
область из объединения дней обеих сторон: полностью пропавший день тоже должен
остаться в ответе. Перед сравнением приведите оба времени к одному часовому
поясу. reference_rows - число заказов ODS, actual_rows - число заказов DDS,
а failed_rows равен единице для дня, где расходится хотя бы одно из двух
значений. Обе стороны распределены по order_id, поэтому дневные агрегаты
соедините через GLOBAL FULL OUTER JOIN с join_use_nulls = 1, как в
существующих сверках.
Если разбор ODS и последующая сборка DDS одинаково потеряют строку, эта сверка
согласится с обоими. От общей потери при разборе защищает отдельный баланс по
_load_id: его вы проверите до запуска нижележащих преобразований.
Докажите, что сторожа работают
Сначала запустите dq_check на нетронутых данных. Обе новые задачи должны
стать зелеными, а в dm.dq_summary_v у обоих имен не должно быть нарушенных
дней.
Выберите оплаченный заказ из последнего слепка с непустым массивом позиций и
сохраните его order_id и order_date. Подключитесь к первой ноде из DBeaver
по инструкции из README под
пользователем etl. Прибавьте одну копейку к items_total этого заказа в
локальной таблице dds.order_rep на всем кластере:
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
UPDATE items_total = items_total + toDecimal64(0.01, 2)
WHERE order_id = '<order_id>'
SETTINGS mutations_sync = 2;
Затем запустите только dq_check.
order_items_total должен стать красным и назвать выбранный заказ.
purchase_vs_orders_vs_dds тоже покраснеет: его сохраненная витрина еще несет
прежний items_total. Исправьте опыт запуском etl_pipeline. Заказы
пересобирают всё текущее окно, и строка вернется из ODS. Убедитесь, что новый
сторож снова зеленый.
Теперь удалите тот же заказ из dds.order_rep на всем кластере:
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
DELETE WHERE order_id = '<order_id>'
SETTINGS mutations_sync = 2;
До восстановления запустите только dq_check: orders_vs_ods,
purchase_vs_orders_vs_dds и revenue_daily_vs_dds должны покраснеть на дне
оплаченного заказа. Затем запустите etl_pipeline и получите зеленый
результат. Если новая проверка не увидела свою поломку, чините ее до перехода
к разбору.
Проверьте обе ветви разбора
Чистый мир не производит брак заказов. Поэтому до вычитания
order_parse.sql проведите отдельный опыт, а после своей реализации повторите
его с новым маркером.
Сделайте четыре сообщения с общим уникальным маркером:
- годная строка с маркером в
order_id; - JSON-строка вместо объекта - класс
not_an_object; - объект с лишним ключом - класс
keyset_mismatch; - объект с точным набором ключей, но строкой в
user_id- классfield_invalid.
not_an_object означает, что корень JSON не объект. keyset_mismatch означает,
что у объекта отличается точный набор верхнеуровневых ключей. field_invalid
означает, что ключи на месте, но значение не прошло проверку типа, формата или
вместимости.
Соберите четыре строки в /tmp/<маркер>.jsonl. Следующая заготовка дает одну
годную строку и по одному примеру трех классов; уникальный маркер не смешивает
опыт с данными мира:
LAB_MARKER="lab5-$(date +%s)"
LAB_BATCH="/tmp/${LAB_MARKER}.jsonl"
LAB_DAY="$(
docker compose exec -T clickhouse-01 sh -lc \
'clickhouse-client --user etl --password "$CLICKHOUSE_ETL_PASSWORD" \
--query "SELECT toString(max(snapshot_date)) FROM ods.order_dist"'
)"
jq -cn --arg marker "$LAB_MARKER" --arg day "$LAB_DAY" '
def good($id): {
order_id: $id, user_id: 1, status: "paid",
created_at: ($day + "T00:00:00.000Z"),
updated_at: ($day + "T01:00:00.000Z"),
items_total: "10.00", discount: "0.00",
delivery: "0.00", total: "10.00",
items: [{sku: "lab5", qty: 1, price: "10.00"}],
snapshot_date: $day
};
good($marker),
($marker + "-not-an-object"),
(good($marker + "-keys") + {lab_marker: true}),
(good($marker + "-field") | .user_id = "broken")
' > "$LAB_BATCH"
Отправьте файл в обычный топик заказов штатным производителем контейнера:
docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server kafka:9092 --topic orders < "$LAB_BATCH"
Запустите orders_ingest и найдите _load_id по маркеру в
stg.orders_raw_dist. У всех четырех строк должен быть один идентификатор
прогона. До любого запуска etl_pipeline проверьте этот срез:
- в STG четыре строки;
- в физической
ods.order_distодна годная строка; - в
ods.order_errors_distтри строки, по одной каждого класса; STG = годные + брак;- текущая строка
ods.order_vсовпадает по деловым полям с результатомargMaxпоsnapshot_dateиз годного сырья среза.
Счет физической таблицы нужен именно по _load_id: представление
ods.order_v оставляет текущую версию сущности и не отвечает, сколько строк
разобрал запуск. Запрос сверки с argMax напишите сами. Эталонные деловые
поля в нем строятся только из raw; среди них отдельно сравните различающиеся
created_at и updated_at.
Проверьте раскладку единственной годной строки через hostName() на
ods.order_dist. Фактическая нода должна совпасть с остатком
cityHash64(order_id) % 2: 0 - clickhouse-01, 1 - clickhouse-02.
После записи результатов удалите только строки маркера из локальных таблиц:
ALTER TABLE stg.orders_raw_rep ON CLUSTER clickstream_cluster
DELETE WHERE position(raw, '<маркер>') > 0
SETTINGS mutations_sync = 2;
ALTER TABLE ods.order_rep ON CLUSTER clickstream_cluster
DELETE WHERE startsWith(order_id, '<маркер>')
SETTINGS mutations_sync = 2;
ALTER TABLE ods.order_errors_rep ON CLUSTER clickstream_cluster
DELETE WHERE position(raw, '<маркер>') > 0
SETTINGS mutations_sync = 2;
В ClickHouse 26.3 мутации выполняются в фоне. С mutations_sync = 2 команда
ждет их завершения на всех репликах. Затем запросом к трем Distributed-таблицам
докажите, что строк маркера не осталось, и удалите временный файл. Сообщения
в Kafka уже подтверждены группой clickstream_orders и повторно не читаются.
В журнале они останутся до удаления по политике хранения топика.
Очистка должна закончиться до любого запуска etl_pipeline. Если конвейер все
же успел собрать временный заказ в DDS, после очистки запустите его еще раз:
обычная пересборка текущего окна уберет строку маркера.
Перепишите разбор ODS
Удалите готовый SQL из sql/ods/order_parse.sql и соберите переход заново.
Его интерфейс остается прежним:
- вход - строки
stg.orders_raw_distодного{load_id:String}; - первая вставка пишет годные строки в
ods.order_dist; - вторая пишет остальные строки в
ods.order_errors_dist; - классы брака и их порядок -
not_an_object,keyset_mismatch,field_invalid; - обе ветви используют один предикат, который на произвольном
rawдает только 0 или 1 и не бросает исключение; - метки доставки и
_load_idпереходят из STG без пересчета; - обе вставки дожидаются Distributed-записи и отдают раскладку ключу цели.
Точный набор полей, типы и граница строгого приема описаны в
документе приема,
а целевые колонки - в 20-ods-tables.sql.
Внутренность items на этой границе остается сырым JSON: ее разбирает DDS.
Повторите опыт четырех строк с новым маркером. Сначала получите тот же баланс,
три класса, текущую версию по snapshot_date, разные времена и правильный
шард. Только затем очистите срез и переходите в DDS.
Перепишите модель DDS
Удалите готовый SQL из трех файлов sql/dds/order_*.sql и сохраните договор с
dds_transform:
| Файл | Вход | Результат |
|---|---|---|
order_scope.sql |
без параметров | одна строка first_day, last_day |
order_rebuild.sql |
{first_day:Date}, {last_day:Date}, {load_id:String} |
выбранные дни в dds.order_stage_dist |
order_replace.sql |
день в {{ params.day }} |
партиция дня в dds.order_rep |
Объем состоит из текущего окна источника и хвоста от первого дня, который есть
в ODS, но отсутствует в DDS. Текущее окно дают строки последнего
snapshot_date: их минимальный и максимальный день заказа. Если источник
пуст, order_scope.sql не возвращает строку. Если дыр нет, first_day равен
началу окна. Если дыра старше, он расширяет непрерывный отрезок влево до нее.
Пересборка сначала очищает все партиции dds.order_stage_rep на кластере,
потом одним запросом пишет весь отрезок через dds.order_stage_dist. Она
читает текущее состояние ods.order_v, считает order_date и переводит
времена в Europe/Samara, разбирает items в три параллельных массива и
ставит координаты собственного запуска. Distributed-вставка должна
завершиться до замены и разложить строки по ключу цели.
Замена работает с локальными dds.order_rep и dds.order_stage_rep на всем
кластере. REPLACE PARTITION переносит готовую дневную партицию из донора и
оставляет донор на месте. Структура, ключи и политика хранения у цели и донора
уже совпадают, потому что DDL объявляет донор через AS dds.order_rep.
Восстановите пропущенный день
Найдите самый ранний день DDS, который уже лежит левее текущего окна ODS. Если
такого дня еще нет, один раз продвиньте мир через world_next_day, дождитесь
orders_ingest, затем запустите etl_pipeline. После этого снова зафиксируйте
модельное время до конца опыта.
Сохраните для выбранного закрытого дня число заказов и контрольную сумму
деловых полей без _load_id и _load_ts. Удалите только его партицию:
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
DROP PARTITION 'YYYY-MM-DD';
Сразу запустите dq_check. orders_vs_ods и
purchase_vs_orders_vs_dds должны покраснеть до того, как пересборка скроет
дыру. revenue_daily_vs_dds тоже станет красным, если в партиции были
оплаченные заказы. classes_vs_inventory останется зеленым: до запуска
dm_transform эта проверка читает прежнюю витрину сверки.
Запустите etl_pipeline. В группе dds_transform.order граница
first_day должна начаться с удаленного дня, а задачи replace - покрыть
непрерывный отрезок от него до конца текущего окна. После успешного прогона
число заказов и контрольная сумма выбранного дня должны совпасть с
сохраненными.
Как проверить себя
В dm.dq_summary_v должны быть зелены обе новые и пять прежних проверок:
SELECT check_name, sum(failed_rows) AS failed_rows, groupUniqArray(status)
FROM dm.dq_summary_v
GROUP BY check_name
ORDER BY check_name;
Проверка суммы должна охватывать каждый день dds.order_v. Сверка слоев
должна охватывать объединение дней ODS и DDS, включая дни только одной стороны.
Напишите отдельный запрос к dds.order_dist, который получает фактическую
ноду через hostName(), вычисляет ожидаемую по cityHash64(order_id) % 2 и
считает несовпадения по каждой ноде. На обеих нодах результат должен быть
нулевым. Счетные DQ читают Distributed-таблицы и ошибку раскладки не увидят.
Запустите etl_pipeline еще раз при неизменном модельном времени. Группа
заказов снова пересоберет текущее изменяемое окно. Контрольные суммы деловых
полей должны остаться прежними, а все семь проверок - зелеными.
Приемка
- две новые проверки встроены в
dq_check, были красными на своих управляемых ошибках и вернулись в зеленое состояние после восстановления; - до и после переписывания ODS срез дал баланс
STG = годные + брак, три класса ошибок, верную текущую строку и верный шард; - переписаны только
order_parse.sqlи триsql/dds/order_*.sql, а несущая инфраструктура приема и трансформации сохранена; - удаленная партиция закрытого дня восстановлена вместе с непрерывным хвостом, ее число строк и контрольная сумма совпали;
order_items_total,orders_vs_odsи пять прежних DQ зелены;- проверки
hostName()не нашли заказов ODS и DDS на чужом шарде; - повторный
etl_pipelineсохранил деловые данные и зеленые проверки; - строки обоих опытов удалены только по своим маркерам.