chore(main): объединены локальные и удалённые изменения

- Зачем:
  - локальные уточнения документации нужно опубликовать без потери слитых изменений #96 и #109.
- Что:
  - актуальный `origin/main` объединён с двумя локальными коммитами документации.
  - сохранены уточнения применения настроек ClickHouse и границ проектирования заказов.
- Проверка:
  - `make config-test`.
  - `make lint`.
This commit is contained in:
ddadmin committed 2026-08-21 11:09:49 +03:00
commit 67313bf997
18 files changed
+198 -70

No files matched your search

+1
View File
@@ -13,6 +13,7 @@ GENERATOR_SPEED ?=
up: up:
$(COMPOSE) up --detach --build --wait --wait-timeout 600 $(COMPOSE) up --detach --build --wait --wait-timeout 600
COMPOSE_BIN="$(COMPOSE)" ./scripts/wait-for-world.sh COMPOSE_BIN="$(COMPOSE)" ./scripts/wait-for-world.sh
COMPOSE_BIN="$(COMPOSE)" ./scripts/ingest-starting-orders.sh
down: down:
$(COMPOSE) down --remove-orphans $(COMPOSE) down --remove-orphans
+13 -6
View File
@@ -126,13 +126,20 @@ Kafka по той же причине спрашивают снаружи. Её
Стенд поднимается не пустым: разовая служба `world-init` играет в топик `hits` Стенд поднимается не пустым: разовая служба `world-init` играет в топик `hits`
первые восемь дней модельного времени — понедельник по понедельник, 401 185 первые восемь дней модельного времени — понедельник по понедельник, 401 185
событий. Дальше их обычным путём разбирает хранилище, и к концу `make up` они событий. Следом второй запуск того же генератора отправляет в топик `orders`
лежат в `ods.event`. Так у всякой лабы есть данные, и всегда одни и те же. семь слепков — за дни 0…6. Дальше оба источника разбирает хранилище, и к концу
`make up` события лежат в `ods.event`, а заказы — в `ods.order_v`. Так у всякой
лабы есть данные, и всегда одни и те же.
Ждать приходится дольше, чем работает заливка: приём асинхронный, поэтому Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
вторым шагом `make up` зовёт `scripts/wait-for-world.sh` — тот опрашивает уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика
ClickHouse, пока мир не доедет. Повторный `make up` заливает мир заново; это выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так
не ошибка, а свойство: номера событий те же, и повтор схлопнет виден разный темп потокового трекера и ночной выгрузки магазина.
Ждать приходится дольше, чем работает заливка. `make up` сначала опрашивает
ClickHouse, пока события не доедут, затем разово запускает даг `orders_ingest`
и ждёт его конца. Повторный `make up` заливает мир заново; это не ошибка, а
свойство: номера событий и версии заказов те же, и повторы схлопнут
`ReplacingMergeTree`. `ReplacingMergeTree`.
Сам мир в git не хранится — он чистая функция зерна, и держать его в Сам мир в git не хранится — он чистая функция зерна, и держать его в
+24 -6
View File
@@ -36,11 +36,19 @@ x-clickhouse-common: &clickhouse-common
# пульт мира. Названы по одному разу — иначе однажды разойдутся, и заметит это # пульт мира. Названы по одному разу — иначе однажды разойдутся, и заметит это
# не проверка, а менти с пустым топиком. # не проверка, а менти с пустым топиком.
x-generator-image: &generator-image clickstream-generator:local x-generator-image: &generator-image clickstream-generator:local
x-orders-topic: &orders-topic orders
x-kafka-common: &kafka-common
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
x-kafka-target: &kafka-target x-kafka-target: &kafka-target
KAFKA_BOOTSTRAP_SERVERS: kafka:9092 <<: *kafka-common
KAFKA_TOPIC: hits KAFKA_TOPIC: hits
x-orders-kafka-target: &orders-kafka-target
<<: *kafka-common
KAFKA_TOPIC: *orders-topic
x-world-starting-days: &world-starting-days "8" x-world-starting-days: &world-starting-days "8"
x-airflow-common: &airflow-common x-airflow-common: &airflow-common
@@ -71,7 +79,7 @@ x-airflow-common: &airflow-common
GENERATOR_IMAGE: *generator-image GENERATOR_IMAGE: *generator-image
STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default
WORLD_STARTING_DAYS: *world-starting-days WORLD_STARTING_DAYS: *world-starting-days
KAFKA_ORDERS_TOPIC: orders KAFKA_ORDERS_TOPIC: *orders-topic
volumes: volumes:
- ./dags:/opt/airflow/dags:ro - ./dags:/opt/airflow/dags:ro
# Запросы дагов: даг называет файл, а текст читает Airflow при исполнении # Запросы дагов: даг называет файл, а текст читает Airflow при исполнении
@@ -307,6 +315,17 @@ services:
<<: *generator-common <<: *generator-common
command: ["batch", "--day", "0", "--days", *world-starting-days] command: ["batch", "--day", "0", "--days", *world-starting-days]
# Второй источник стартового мира идёт после событий тем же генератором.
# Сдвиг команды snapshot оставляет день 7 первому ходу мира: за восемь
# дней уезжают семь слепков, дней 0…6.
world-snapshots:
<<: *generator-common
depends_on:
world-init:
condition: service_completed_successfully
environment: *orders-kafka-target
command: ["snapshot", "--day", "0", "--days", *world-starting-days]
# Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`. # Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`.
# Под профилем — чтобы обычный подъём стенда её не трогал. # Под профилем — чтобы обычный подъём стенда её не трогал.
generator: generator:
@@ -355,10 +374,9 @@ services:
# упавшим для --wait. # упавшим для --wait.
clickhouse-init: clickhouse-init:
condition: service_completed_successfully condition: service_completed_successfully
# По той же причине. Заливка стартового мира идёт последней в цепи # По той же причине. Слепки идут за событиями и замыкают цепь разовых
# разовых служб, и зависимого ей взять больше негде: долгоживущие службы # служб; долгоживущие службы данных её не ждут, а `airflow-init` ждёт.
# данных не ждут, а `airflow-init` эту цепь и так замыкает. world-snapshots:
world-init:
condition: service_completed_successfully condition: service_completed_successfully
entrypoint: ["/bin/bash"] entrypoint: ["/bin/bash"]
command: ["/opt/airflow/init.sh"] command: ["/opt/airflow/init.sh"]
+2 -2
View File
@@ -101,8 +101,8 @@ def orders_ingest():
logging.info("срез разобран: _load_id %s", load_id) logging.info("срез разобран: _load_id %s", load_id)
pull_batch("stg/orders_raw_load.sql") >> parse_batch( pull_batch("stg/orders_raw_load.sql") >> parse_batch(
"ods/order_snapshot_load.sql", "ods/order_load.sql",
"ods/order_snapshot_errors_load.sql", "ods/order_errors_load.sql",
) )
+1 -1
View File
@@ -9,7 +9,7 @@
команде Airflow, но порция чтения больше не считается полным слепком и не команде Airflow, но порция чтения больше не считается полным слепком и не
публикуется заменой партиции `snapshot_date`. Прочитанные строки получают публикуется заменой партиции `snapshot_date`. Прочитанные строки получают
`_load_id` запуска, разбираются из одного среза STG в годные строки и ошибки, `_load_id` запуска, разбираются из одного среза STG в годные строки и ошибки,
а `ods.order_snapshot` хранит принятые версии заказа в а `ods.order` хранит принятые версии заказа в
`ReplacingMergeTree(updated_at)`. Ключ сущности — `order_id`, партиция строится `ReplacingMergeTree(updated_at)`. Ключ сущности — `order_id`, партиция строится
от неизменного `created_at`, все версии ключа направляются на один шард. от неизменного `created_at`, все версии ключа направляются на один шард.
+1 -1
View File
@@ -37,7 +37,7 @@
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным — `person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
[мост к склейке](identity.md). [мост к склейке](identity.md).
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два - **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order_snapshot` `INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order`
принимает версии заказа на `ReplacingMergeTree(updated_at)` — принимает версии заказа на `ReplacingMergeTree(updated_at)` —
[приём из Kafka в ODS](ingestion.md). [приём из Kafka в ODS](ingestion.md).
- **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого - **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого
+1 -1
View File
@@ -61,7 +61,7 @@
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя **Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
версия; различает их история версий: сырьё STG и физические версии версия; различает их история версий: сырьё STG и физические версии
`ods.order_snapshot` до фоновых слияний, а в витринах — выручка дня, которая `ods.order` до фоновых слияний, а в витринах — выручка дня, которая
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
+9 -9
View File
@@ -43,12 +43,12 @@
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
пишет годные строки в `ods.order_snapshot`, второй — брак в пишет годные строки в `ods.order`, второй — брак в `ods.order_errors`.
`ods.order_snapshot_errors`. Транзакции между запросами нет. При частичном сбое Транзакции между запросами нет. При частичном сбое Airflow повторяет весь
Airflow повторяет весь `task_id`; одинаковые исходные строки и служебные метки `task_id`; одинаковые исходные строки и служебные метки не вычисляются заново.
не вычисляются заново. Запросы лежат рядом с целями: Запросы лежат рядом с целями:
[`order_snapshot_load.sql`](../../../sql/ods/order_snapshot_load.sql) и [`order_load.sql`](../../../sql/ods/order_load.sql) и
[`order_snapshot_errors_load.sql`](../../../sql/ods/order_snapshot_errors_load.sql). [`order_errors_load.sql`](../../../sql/ods/order_errors_load.sql).
Условия запросов взаимодополняющие: один общий предикат определяет брак, а Условия запросов взаимодополняющие: один общий предикат определяет брак, а
годная ветвь использует его буквальное отрицание. Все функции предиката годная ветвь использует его буквальное отрицание. Все функции предиката
@@ -90,9 +90,9 @@ JSON-объектом с точным набором ключей: `order_id`, `
## Роль ODS ## Роль ODS
`ods.order_snapshot_rep` хранит физически принятые версии в `ods.order_rep` хранит физически принятые версии в
`ReplacingMergeTree(updated_at)`. Ключ сортировки — `order_id`, партиция — день `ReplacingMergeTree(updated_at)`. Ключ сортировки — `order_id`, партиция — день
неизменного `created_at`. `ods.order_snapshot_dist` шардирует по неизменного `created_at`. `ods.order_dist` шардирует по
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и `cityHash64(order_id)`: только так все версии заказа попадают на один шард и
`FINAL` даёт корректный результат через распределённую таблицу. `FINAL` даёт корректный результат через распределённую таблицу.
@@ -174,7 +174,7 @@ JSON-объектом с точным набором ключей: `order_id`, `
После исправления После исправления
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102) [#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
честный прогон дня снят на живом стенде 20 августа 2026 года: все 1694 строки честный прогон дня снят на живом стенде 20 августа 2026 года: все 1694 строки
слепка дня 7 приняты, `ods.order_snapshot_errors` осталась пустой. У заказа слепка дня 7 приняты, `ods.order_errors` осталась пустой. У заказа
`20260608-0007`, который прежде приходил с отрицательным итогом, теперь `20260608-0007`, который прежде приходил с отрицательным итогом, теперь
`5490.00 − 823.50 + 299.00 = 4965.50`. `5490.00 − 823.50 + 299.00 = 4965.50`.
+1 -1
View File
@@ -25,6 +25,6 @@
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
не выходит; генератор сторожит хеш, транспорт — счёт событий. не выходит; генератор сторожит хеш, транспорт — счёт событий.
- **Опись описывает мир, а не доставку.** Работа генератора кончается на - **Опись описывает мир, а не доставку.** Работа генератора кончается на
Kafka: дошли ли байты до `ods.order_snapshot` — вопрос стенда и его Kafka: дошли ли байты до `ods.order` — вопрос стенда и его
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка
приёма заказов — число заведётся вместе с ней. приёма заказов — число заведётся вместе с ней.
+17 -18
View File
@@ -60,7 +60,7 @@ CSV-файла. Живёт он вне цепочки слоёв, в зоне `d
приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем
топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях
имя приходит от предметной области и стоит в единственном числе: `event`, имя приходит от предметной области и стоит в единственном числе: `event`,
`order_snapshot`, `session`. Граница между «как привезли» и «что это такое» `order`, `session`. Граница между «как привезли» и «что это такое»
проходит по STG, и имена её показывают. проходит по STG, и имена её показывают.
## Раскладка по шардам ## Раскладка по шардам
@@ -91,7 +91,7 @@ CSV-файла. Живёт он вне цепочки слоёв, в зоне `d
по `cityHash64(order_id)`; ключи остальных частей будущей модели и витрин по `cityHash64(order_id)`; ключи остальных частей будущей модели и витрин
появятся вместе с ними. появятся вместе с ними.
Замена партиций не используется для `ods.order_snapshot`: версии прошлых дней Замена партиций не используется для `ods.order`: версии прошлых дней
доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR
0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка 0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка
понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее
@@ -141,7 +141,7 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает `ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
повтор доставки. У `ods.order_snapshot` версию задаёт `updated_at` источника; повтор доставки. У `ods.order` версию задаёт `updated_at` источника;
`_load_ts` только показывает, когда конкретная строка приехала. `_load_ts` только показывает, когда конкретная строка приехала.
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся. `created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
@@ -181,9 +181,9 @@ snake_case-имена и расшифровку кодов.
## Версии заказов ## Версии заказов
`ods.order_snapshot_rep` хранит принятые версии в `ods.order_rep` хранит принятые версии в
`ReplacingMergeTree(updated_at)`: ключ сортировки — `order_id`, партиция — `ReplacingMergeTree(updated_at)`: ключ сортировки — `order_id`, партиция —
`toDate(created_at)`. `ods.order_snapshot_dist` шардирует по `toDate(created_at)`. `ods.order_dist` шардирует по
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли `cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL` схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
выбрал одного победителя. выбрал одного победителя.
@@ -414,7 +414,7 @@ D0 и к реальному календарю не привязана; паке
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
доставки. доставки.
`ods.order_snapshot_errors` держит тот же диагностический минимум и `_load_id` `ods.order_errors` держит тот же диагностический минимум и `_load_id`
запуска. У заказов три класса по приоритету: `not_an_object`, запуска. У заказов три класса по приоритету: `not_an_object`,
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не `keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
разрастается до имени отдельного поля. Точная граница приёма — в разрастается до имени отдельного поля. Точная граница приёма — в
@@ -534,15 +534,15 @@ Airflow читает и собирает эти файлы штатным шаб
| `ods` | `ods.event_v` | актуальная версия события с полями источника | | `ods` | `ods.event_v` | актуальная версия события с полями источника |
| `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём | | `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём |
| `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки | | `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки |
| `ods` | `ods.order_snapshot_rep` / `_dist` | типизированные версии заказа | | `ods` | `ods.order_rep` / `_dist` | типизированные версии заказа |
| `ods` | `ods.order_v` | текущая версия заказа на языке источника | | `ods` | `ods.order_v` | текущая версия заказа на языке источника |
| `ods` | `ods.order_snapshot_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём | | `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором | | `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
| `dic` | `dic.products` | словарь товаров поверх подложки | | `dic` | `dic.products` | словарь товаров поверх подложки |
Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два
`INSERT SELECT` шага `parse_batch` из файлов `sql/ods/order_snapshot_load.sql` `INSERT SELECT` шага `parse_batch` из файлов `sql/ods/order_load.sql` и
и `sql/ods/order_snapshot_errors_load.sql`. `sql/ods/order_errors_load.sql`.
Слой DDS и слой DM появляются на следующих этапах; их состав задан разделом Слой DDS и слой DM появляются на следующих этапах; их состав задан разделом
7 мастер-спеки и переносится сюда по мере постройки. 7 мастер-спеки и переносится сюда по мере постройки.
@@ -606,14 +606,13 @@ DDL-словарь с файловым источником внутри `user_f
`_load_id`, пересечение ветвей — ноль. Все восемь строк брака — версии одного `_load_id`, пересечение ветвей — ноль. Все восемь строк брака — версии одного
заказа с отрицательным `total`; это дефект генератора, разобранный в заказа с отрицательным `total`; это дефект генератора, разобранный в
[спецификации приёма](orders/ingestion.md), «Что проверено». На управляемой [спецификации приёма](orders/ingestion.md), «Что проверено». На управляемой
порции из пяти строк (две версии порции из пяти строк (две версии одного заказа плюс по одной строке каждого
одного заказа плюс по одной строке каждого класса брака) годные ушли в класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с
`ods.order_snapshot`, брак — в `ods.order_snapshot_errors` с ожидаемыми ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL`
классами. Обе версии легли в одну партицию и на один шард; `FINAL` через через распределённую таблицу вернул одну строку — ту, у которой `updated_at`
распределённую таблицу вернул одну строку — ту, у которой `updated_at` позже, — позже, — тогда как физический счёт показывал две. Повтор задачи с тем же
тогда как физический счёт показывал две. Повтор задачи с тем же `_load_id` `_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок
строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок записала записала тот же брак второй раз, как и обещано спецификацией приёма.
тот же брак второй раз, как и обещано спецификацией приёма.
Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у
запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, — запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, —
+18 -9
View File
@@ -71,9 +71,9 @@
тогда окружение окупится, и условие возврата — именно это, а не «стало тогда окружение окупится, и условие возврата — именно это, а не «стало
неудобно». неудобно».
Цена самого `make up` — 2 м 50 с с нуля (`make clean` перед ним — ещё 23 с) и Цена самого `make up` — 8 м 18 с с нуля и 3 м 35 с на живом стенде. В цену
1 м 5 с на живом стенде. В цену целей она не входит, но записана здесь по той целей она не входит, но записана здесь по той же причине: подъём пересчитывает
же причине: с #42 подъём стенда заливает стартовый мир и стал заметно дороже. оба источника стартового мира и ждёт их приёма.
## Правило: смоук обязан оставаться быстрым ## Правило: смоук обязан оставаться быстрым
@@ -194,12 +194,12 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
| `make check-services` | `scripts/stand-services.sh` | | `make check-services` | `scripts/stand-services.sh` |
| `make check-clickhouse` | `scripts/check-clickhouse.sh` | | `make check-clickhouse` | `scripts/check-clickhouse.sh` |
Особняком — `scripts/wait-for-world.sh`: он не проверка, а вторая половина Особняком — два сценария продолжения `make up`, оба не проверки.
`make up`. `docker compose up --wait` дожидается служб, в том числе успешно `scripts/wait-for-world.sh` ждёт, пока события доедут из Kafka в ODS:
отработавшей заливки, но «заливка кончилась» значит «события в топике», а не `docker compose up --wait` видит конец отправки, но приём идёт асинхронно.
«события в хранилище»: приём асинхронный. Скрипт ждёт доезда ограниченным Затем `scripts/ingest-starting-orders.sh` разово запускает единственный даг
циклом опроса. Без него `make check-clickhouse` следом краснел бы по приёма заказов `orders_ingest` и ждёт его конца. Оба ждут ограниченным циклом,
устройству, а не по поломке. а не паузой наугад.
Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две
проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается. проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается.
@@ -221,6 +221,15 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
## Что проверено ## Что проверено
**Перезамер 20 августа 2026 года при исполнении #96.** `make up` после
`make clean` прошёл за 8 м 18 с, повтор на живом стенде — за 3 м 35 с. Служба
семи слепков заняла около 28 секунд между концом `world-init` и своим концом.
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и пульта
мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время
ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й
секунде, обработчик DAG стал здоров примерно на 458-й. Числа здесь называют
цену нынешнего стенда, а не выносят вердикт о регрессии.
**Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой **Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой
проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за
24 секунды. Это цена всей цели, а не одной новой строки. 24 секунды. Это цена всей цели, а не одной новой строки.
@@ -64,7 +64,7 @@ Debezium проводит ту же границу внутри одного с
`created_at` и `updated_at` — аудит строки источника. Поэтому `created_at` и `updated_at` — аудит строки источника. Поэтому
`toDate(created_at)` в `toDate(created_at)` в
[спецификации приёма заказов](../architecture/orders/ingestion.md) [спецификации приёма заказов](../architecture/orders/ingestion.md)
используется как стабильный технический ключ партиции `ods.order_snapshot`: используется как стабильный технический ключ партиции `ods.order`:
это день создания строки это день создания строки
источника, а не доказательство дня бизнес-события. источника, а не доказательство дня бизнес-события.
+4 -4
View File
@@ -219,7 +219,7 @@ Ecommerce (заполнены только у торговых событий):
Обоснование и проверка разбора — в Обоснование и проверка разбора — в
[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md). [исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md).
- `ods.order_snapshot` принимает версии заказа в - `ods.order` принимает версии заказа в
`ReplacingMergeTree(updated_at)`: `ORDER BY order_id`, партиция по дню `ReplacingMergeTree(updated_at)`: `ORDER BY order_id`, партиция по дню
неизменного `created_at`, шардирование по `cityHash64(order_id)`. неизменного `created_at`, шардирование по `cityHash64(order_id)`.
`snapshot_date` остаётся датой наблюдения источника, но не задаёт публикацию `snapshot_date` остаётся датой наблюдения источника, но не задаёт публикацию
@@ -410,7 +410,7 @@ README.
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах | | STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах |
| STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг | | STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг |
| ODS | `ods.event` (+`_errors`), `ods.event_v` | типизированные версии событий, брак и точное актуальное состояние | | ODS | `ods.event` (+`_errors`), `ods.event_v` | типизированные версии событий, брак и точное актуальное состояние |
| ODS | `ods.order_snapshot` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние | | ODS | `ods.order` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние |
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) | | DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) |
| DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую | | DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
| DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS | | DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS |
@@ -428,7 +428,7 @@ README.
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow — матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска. одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска.
Один следующий `task_id` двумя последовательными запросами пишет годные строки Один следующий `task_id` двумя последовательными запросами пишет годные строки
и ошибки из того же среза. `ods.order_snapshot` хранит версии по `updated_at`, и ошибки из того же среза. `ods.order` хранит версии по `updated_at`,
а не публикует партицию `snapshot_date`. Слой сырья остаётся точкой повтора и а не публикует партицию `snapshot_date`. Слой сырья остаётся точкой повтора и
разбора одной принятой порции. разбора одной принятой порции.
@@ -436,7 +436,7 @@ README.
из опорных точек раздела 12 переформулировано под них. из опорных точек раздела 12 переформулировано под них.
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия: Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
`ods.event` и `ods.order_snapshot` получают метку загрузки `_load_ts`, но у `ods.event` и `ods.order` получают метку загрузки `_load_ts`, но у
заказа бизнес-версию задаёт `updated_at`; `_load_id` проходит через STG и обе заказа бизнес-версию задаёт `updated_at`; `_load_id` проходит через STG и обе
цели ODS. Таблицы цели ODS. Таблицы
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды — `stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Последний шаг `make up`: запускает единственный путь приёма заказов и ждёт
# его конца. Слепки к этому моменту уже лежат в Kafka, а события доехали до ODS.
set -Eeuo pipefail
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly DAG_ID='orders_ingest'
# Потолок каждого ожидания — 300 с: больше чем всемеро от замеренных 40 с
# между запуском сценария и концом дага (#96). Это предел, а не бюджет времени.
readonly ATTEMPTS=100
readonly PAUSE_SECONDS=3
readonly REPORT_EVERY=5
read -r -a COMPOSE_CMD <<<"${COMPOSE_BIN:-docker compose}"
fail() {
printf 'ОШИБКА: %s\n' "$1" >&2
exit 1
}
compose() {
"${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" "$@"
}
config="$(compose config --format json)"
user="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_USER // empty' <<<"$config")"
password="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_PASSWORD // empty' <<<"$config")"
binding="$(compose port airflow-apiserver 8080)"
port="${binding##*:}"
[[ -n "$port" ]] || fail 'не найден отображённый порт API Airflow'
response="$(curl -sf --max-time 10 -X POST \
-H 'Content-Type: application/json' \
-d "$(jq -cn --arg username "$user" --arg password "$password" \
'{username: $username, password: $password}')" \
"http://127.0.0.1:${port}/auth/token" 2>/dev/null || true)"
token="$(jq -r '.access_token // empty' <<<"$response")"
[[ -n "$token" ]] || fail 'Airflow не принял учётные данные администратора'
# После `up --wait` обработчик DAG здоров, но файл мог ещё не попасть в список.
# Ограниченный опрос закрывает эту гонку без паузы наугад.
dag=''
for ((attempt = 1; attempt <= ATTEMPTS; attempt++)); do
dag="$(curl -sf --max-time 10 \
-H "Authorization: Bearer ${token}" \
"http://127.0.0.1:${port}/api/v2/dags/${DAG_ID}" \
2>/dev/null || true)"
jq -e --arg dag_id "$DAG_ID" '.dag_id == $dag_id' \
>/dev/null 2>&1 <<<"$dag" && break
sleep "$PAUSE_SECONDS"
done
jq -e --arg dag_id "$DAG_ID" '.dag_id == $dag_id' \
>/dev/null 2>&1 <<<"$dag" || \
fail "даг ${DAG_ID} не появился в Airflow за $((ATTEMPTS * PAUSE_SECONDS)) с"
# В API v2 Airflow 3.3 logical_date=null означает событийный ручной запуск.
# Это уместно для дага без расписания: календарь Airflow не подменяет дату
# слепка, которая уже записана в сообщениях.
response="$(curl -sf --max-time 10 -X POST \
-H "Authorization: Bearer ${token}" \
-H 'Content-Type: application/json' \
-d '{"logical_date":null}' \
"http://127.0.0.1:${port}/api/v2/dags/${DAG_ID}/dagRuns" \
2>/dev/null || true)"
run_id="$(jq -r '.dag_run_id // empty' <<<"$response")"
[[ -n "$run_id" ]] || fail "Airflow не запустил даг ${DAG_ID}"
encoded_run_id="$(jq -rn --arg value "$run_id" '$value | @uri')"
printf 'Ждём приём стартовых слепков дагом %s...\n' "$DAG_ID"
state=''
for ((attempt = 1; attempt <= ATTEMPTS; attempt++)); do
response="$(curl -sf --max-time 10 \
-H "Authorization: Bearer ${token}" \
"http://127.0.0.1:${port}/api/v2/dags/${DAG_ID}/dagRuns/${encoded_run_id}" \
2>/dev/null || true)"
state="$(jq -r '.state // empty' <<<"$response")"
case "$state" in
success)
printf 'ЗЕЛЁНО: стартовые слепки приняты в ODS.\n'
exit 0
;;
failed)
fail "даг ${DAG_ID} завершился с ошибкой; запуск ${run_id}"
;;
esac
if ((attempt % REPORT_EVERY == 0)); then
printf 'Приём ещё не завершён: состояние %s, ждём дальше (%d с)...\n' \
"${state:-неизвестно}" "$((attempt * PAUSE_SECONDS))"
fi
sleep "$PAUSE_SECONDS"
done
fail "даг ${DAG_ID} не завершился за $((ATTEMPTS * PAUSE_SECONDS)) с:
состояние ${state:-неизвестно}, запуск ${run_id}"
+8 -8
View File
@@ -175,7 +175,7 @@ ENGINE = Distributed('clickstream_cluster', 'ods', 'event_errors_rep', cityHash6
-- --
-- items остаётся сырым фрагментом JSON: приём проверяет только, что это -- items остаётся сырым фрагментом JSON: приём проверяет только, что это
-- массив. Что внутри позиций — забота DDS, а не границы провода. -- массив. Что внутри позиций — забота DDS, а не границы провода.
CREATE TABLE IF NOT EXISTS ods.order_snapshot_rep ON CLUSTER clickstream_cluster CREATE TABLE IF NOT EXISTS ods.order_rep ON CLUSTER clickstream_cluster
( (
order_id String, order_id String,
user_id UInt64, user_id UInt64,
@@ -199,9 +199,9 @@ ORDER BY order_id;
-- и выбор здесь не про перекос, а про корректность: только так все версии -- и выбор здесь не про перекос, а про корректность: только так все версии
-- одного заказа попадают на один шард, и FINAL через распределённую таблицу -- одного заказа попадают на один шард, и FINAL через распределённую таблицу
-- выбирает одного победителя, а не по победителю на шард. -- выбирает одного победителя, а не по победителю на шард.
CREATE TABLE IF NOT EXISTS ods.order_snapshot_dist ON CLUSTER clickstream_cluster CREATE TABLE IF NOT EXISTS ods.order_dist ON CLUSTER clickstream_cluster
AS ods.order_snapshot_rep AS ods.order_rep
ENGINE = Distributed('clickstream_cluster', 'ods', 'order_snapshot_rep', cityHash64(order_id)); ENGINE = Distributed('clickstream_cluster', 'ods', 'order_rep', cityHash64(order_id));
-- Локальная таблица брака слепка. -- Локальная таблица брака слепка.
-- --
@@ -215,7 +215,7 @@ ENGINE = Distributed('clickstream_cluster', 'ods', 'order_snapshot_rep', cityHas
-- field_invalid. Имя провалившегося поля в класс не входит — сырой текст лежит -- field_invalid. Имя провалившегося поля в класс не входит — сырой текст лежит
-- рядом, и единичный случай разбирается по нему, без постоянной детализации -- рядом, и единичный случай разбирается по нему, без постоянной детализации
-- предиката (docs/architecture/orders/ingestion.md). -- предиката (docs/architecture/orders/ingestion.md).
CREATE TABLE IF NOT EXISTS ods.order_snapshot_errors_rep ON CLUSTER clickstream_cluster CREATE TABLE IF NOT EXISTS ods.order_errors_rep ON CLUSTER clickstream_cluster
( (
raw String, raw String,
error_class LowCardinality(String), error_class LowCardinality(String),
@@ -233,6 +233,6 @@ ORDER BY (error_class, kafka_partition, kafka_offset)
TTL toDateTime(_load_ts) + INTERVAL 1 MONTH TTL toDateTime(_load_ts) + INTERVAL 1 MONTH
SETTINGS ttl_only_drop_parts = 1; SETTINGS ttl_only_drop_parts = 1;
CREATE TABLE IF NOT EXISTS ods.order_snapshot_errors_dist ON CLUSTER clickstream_cluster CREATE TABLE IF NOT EXISTS ods.order_errors_dist ON CLUSTER clickstream_cluster
AS ods.order_snapshot_errors_rep AS ods.order_errors_rep
ENGINE = Distributed('clickstream_cluster', 'ods', 'order_snapshot_errors_rep', cityHash64(raw)); ENGINE = Distributed('clickstream_cluster', 'ods', 'order_errors_rep', cityHash64(raw));
+1 -1
View File
@@ -22,7 +22,7 @@ FROM ods.event_dist FINAL;
CREATE VIEW IF NOT EXISTS ods.order_v ON CLUSTER clickstream_cluster CREATE VIEW IF NOT EXISTS ods.order_v ON CLUSTER clickstream_cluster
AS AS
SELECT * SELECT *
FROM ods.order_snapshot_dist FINAL; FROM ods.order_dist FINAL;
-- ODS: разбор сырья в событие и в таблицу ошибок. -- ODS: разбор сырья в событие и в таблицу ошибок.
-- --
@@ -4,7 +4,7 @@
-- первый совпавший: скаляр проваливает и проверку на объект, и сверку ключей — -- первый совпавший: скаляр проваливает и проверку на объект, и сверку ключей —
-- без объявленного порядка он попал бы то в один класс, то в другой. -- без объявленного порядка он попал бы то в один класс, то в другой.
INSERT INTO ods.order_snapshot_errors_dist INSERT INTO ods.order_errors_dist
{% include "ods/_order_wire_contract.sql" %} {% include "ods/_order_wire_contract.sql" %}
SELECT SELECT
raw, raw,
@@ -4,7 +4,7 @@
-- Разобранные значения берёт assumeNotNull: обнуляемый разбор стоит за -- Разобранные значения берёт assumeNotNull: обнуляемый разбор стоит за
-- предикатом, который NULL уже отсёк, и приведение здесь не может упасть. -- предикатом, который NULL уже отсёк, и приведение здесь не может упасть.
INSERT INTO ods.order_snapshot_dist INSERT INTO ods.order_dist
{% include "ods/_order_wire_contract.sql" %} {% include "ods/_order_wire_contract.sql" %}
SELECT SELECT
JSONExtractString(raw, 'order_id') AS order_id, JSONExtractString(raw, 'order_id') AS order_id,