docs(labs): добавлено задание по проверкам заказов

- Зачем:
  - менти нужен наблюдаемый опыт, в котором проверки защищают изменение конвейера заказов.
- Что:
  - добавлено ТЗ ступени 5 с договорами DQ, управляемыми ошибками и восстановлением.
  - в оглавление лабораторных работ добавлена ссылка на новое задание.
- Проверка:
  - проверены локальные ссылки, пробная пачка jq и отсутствие ошибок пробелов в diff.
This commit is contained in:
ddadmin committed 2026-09-06 15:12:11 -04:00
1 parent d7e8a6f1a1
commit d686de516b
2 files changed
+349 -2

No files matched your search

+347
View File
@@ -0,0 +1,347 @@
# Сначала сторож, потом ломай: заказы
**Чему учит:** ставить проверку качества до рискованного изменения и
доказывать управляемой ошибкой, что она защищает нужный договор.
## Сначала прочитать
- [прием заказов в 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` сохранил деловые данные и зеленые проверки;
- [ ] строки обоих опытов удалены только по своим маркерам.
+2 -2
View File
@@ -12,11 +12,11 @@
| 2 | [Свой график в Superset](2-1-superset-chart.md) | превратить свой результат из хранилища в отчет в BI | | 2 | [Свой график в Superset](2-1-superset-chart.md) | превратить свой результат из хранилища в отчет в BI |
| 3 | [Нарежь сессии сам](3-1-sessions-by-hand.md) | сверить число сессий в своей нарезке с эталоном и найти события, из-за которых оно расходится | | 3 | [Нарежь сессии сам](3-1-sessions-by-hand.md) | сверить число сессий в своей нарезке с эталоном и найти события, из-за которых оно расходится |
| 4 | [Перепиши нарезку стенда](4-1-session-pipeline.md) | изменить расчетную сущность внутри готового конвейера и проверить ее внешним эталоном | | 4 | [Перепиши нарезку стенда](4-1-session-pipeline.md) | изменить расчетную сущность внутри готового конвейера и проверить ее внешним эталоном |
| 5 | Сначала сторож, потом ломай: заказы | написать проверки качества до рискованного изменения, а не после первой поломки | | 5 | [Сначала сторож, потом ломай: заказы](5-1-order-checks.md) | написать проверки качества до рискованного изменения, а не после первой поломки |
| 6 | Разбери сырые события и сверь результат со стендом | изучить разбор потока отдельно от живого приема и сверить его со стендовым результатом | | 6 | Разбери сырые события и сверь результат со стендом | изучить разбор потока отдельно от живого приема и сверить его со стендовым результатом |
| 7 | Мониторинг: куда смотреть | выбрать, куда и на что смотреть в живой системе | | 7 | Мониторинг: куда смотреть | выбрать, куда и на что смотреть в живой системе |
ТЗ для ступеней 5-7 появятся по мере продвижения программы. ТЗ для ступеней 6-7 появятся по мере продвижения программы.
## Ступень 1: пять витрин слоя DM ## Ступень 1: пять витрин слоя DM