Files
clickstream-data-platform/docs/labs/5-1-order-checks.md
T
ddadmin d686de516b docs(labs): добавлено задание по проверкам заказов
- Зачем:
  - менти нужен наблюдаемый опыт, в котором проверки защищают изменение конвейера заказов.
- Что:
  - добавлено ТЗ ступени 5 с договорами DQ, управляемыми ошибками и восстановлением.
  - в оглавление лабораторных работ добавлена ссылка на новое задание.
- Проверка:
  - проверены локальные ссылки, пробная пачка jq и отсутствие ошибок пробелов в diff.
2026-09-06 15:12:11 -04:00

348 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Сначала сторож, потом ломай: заказы
**Чему учит:** ставить проверку качества до рискованного изменения и
доказывать управляемой ошибкой, что она защищает нужный договор.
## Сначала прочитать
- [прием заказов в ODS](../architecture/orders/ingestion.md);
- [модель заказа DDS](../architecture/dds/order.md);
- [дневной конвейер](../architecture/etl.md#объём-прогона);
- [раскладку по шардам](../architecture/storage.md#раскладка-по-шардам);
- [правила проверок](../architecture/testing.md#проверка-которая-не-умеет-краснеть-бесполезна).
Посмотрите также на устройство одной проверки в [`sql/dq/`](../../sql/dq/)
и на ее включение в [`dq_check`](../../dags/dq_check.py). Готового ответа для
проверок из этого задания в репозитории нет.
## Граница задания
Сначала добавьте два сторожа:
- `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](../../README.md#подключиться-из-dbeaver) под
пользователем `etl`. Прибавьте одну копейку к `items_total` этого заказа в
локальной таблице `dds.order_rep` на всем кластере:
```sql
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` на всем кластере:
```sql
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` проведите отдельный опыт, а после своей реализации повторите
его с новым маркером.
Сделайте четыре сообщения с общим уникальным маркером:
1. годная строка с маркером в `order_id`;
2. JSON-строка вместо объекта - класс `not_an_object`;
3. объект с лишним ключом - класс `keyset_mismatch`;
4. объект с точным набором ключей, но строкой в `user_id` - класс
`field_invalid`.
`not_an_object` означает, что корень JSON не объект. `keyset_mismatch` означает,
что у объекта отличается точный набор верхнеуровневых ключей. `field_invalid`
означает, что ключи на месте, но значение не прошло проверку типа, формата или
вместимости.
Соберите четыре строки в `/tmp/<маркер>.jsonl`. Следующая заготовка дает одну
годную строку и по одному примеру трех классов; уникальный маркер не смешивает
опыт с данными мира:
```bash
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"
```
Отправьте файл в обычный топик заказов штатным производителем контейнера:
```bash
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`.
После записи результатов удалите только строки маркера из локальных таблиц:
```sql
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-записи и отдают раскладку ключу цели.
Точный набор полей, типы и граница строгого приема описаны в
[документе приема](../architecture/orders/ingestion.md#граница-строгого-приёма),
а целевые колонки - в [`20-ods-tables.sql`](../../sql/ddl/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`. Удалите только его партицию:
```sql
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` должны быть зелены обе новые и пять прежних проверок:
```sql
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` сохранил деловые данные и зеленые проверки;
- [ ] строки обоих опытов удалены только по своим маркерам.