docs(glossary): четыре термина переименованы, world.md и stand.md вычитаны
- Зачем:
- справочники читает человек со стороны, а «пульт мира», «опись мира»,
«работник» и «сырьё» звучали шуткой или калькой времён разработки
(тикет #214, выбор владельца).
- Что:
- CONTEXT.md: статьи «Паспорт мира», «Даги модельного времени»,
«Шаговый даг», «Сырые данные», старые слова в строках «Избегать»;
пометки в ADR 0005, 0008, 0009 без переписывания решений.
- замены по README, справочникам, путеводителю, лабам, спекам,
комментариям дагов и SQL, тегу дага и панели Grafana; имена в коде
не тронуты.
- world.md и stand.md: 35 находок вычитки внесены, регистр выровнен.
- Проверка:
- make config-test; make lint в корне и в generator/; make test в
generator/ (437 passed); скрипт ссылок из #212 пуст; grep старых
слов вне ADR, research и CONTEXT.md пуст.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QBCtU38qSHg5DvgtnKXcW
This commit is contained in:
1 parent
86ad8afaf2
commit
59d8c83b17
54 files changed
+794
-776
No files matched your search
@@ -77,9 +77,9 @@ ODS; точный тип и проверка описаны в разделе
|
||||
- **`Sign`** — колонка формата без механики: генератор исправлений не шлёт,
|
||||
и в модели события колонке ответить нечего. Честный рассказ о ней живёт в
|
||||
DDL ODS и лекции.
|
||||
- **`ecommerce`** — сырой JSON: сырьё, а не атрибут. Разобранные массивы
|
||||
`purchase_*` и `product_*` в контракте есть; лаба про разбор JSON внутри
|
||||
колонки читает ODS.
|
||||
- **`ecommerce`** — сырой JSON из STG. Это содержимое источника; отдельным
|
||||
атрибутом модели оно не становится. Разобранные массивы `purchase_*` и
|
||||
`product_*` в контракте есть; лаба про разбор JSON внутри колонки читает ODS.
|
||||
- **`_load_ts`** — у представления нет собственной загрузки, а чужие
|
||||
координаты слой не переносит (правило слоя — [модель
|
||||
заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по
|
||||
|
||||
@@ -75,12 +75,12 @@ ODS: это единственный устойчивый ключ сущнос
|
||||
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
|
||||
спрашиваются у их слоя по ключу сущности**.
|
||||
|
||||
Происшествие качества данных расследуется цепочкой, где каждый шаг
|
||||
спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой
|
||||
запуск собрал день; по `order_id` ODS показывает физические версии и брак —
|
||||
там свой `_load_id` приёма связывает годные строки, ошибки и сырьё; дальше
|
||||
сырьё STG, пока живо. Окно расследования задано сроками хранения: сырьё —
|
||||
трое суток, брак — месяц ([дока хранилища](../storage.md)).
|
||||
Происшествие качества данных расследуется цепочкой, где каждый шаг спрашивает
|
||||
свой слой на его языке: по `_load_id` строки DDS видно, какой запуск собрал
|
||||
день; по `order_id` ODS показывает физические версии и брак — там свой
|
||||
`_load_id` приёма связывает годные строки, ошибки и данные STG; дальше читают
|
||||
STG, пока данные живы. Окно расследования задано сроками хранения: сырые данные
|
||||
— трое суток, брак — месяц ([дока хранилища](../storage.md)).
|
||||
|
||||
## Читатели
|
||||
|
||||
|
||||
@@ -176,9 +176,10 @@ Distributed-таблица вернула бы каждый непарный з
|
||||
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк
|
||||
`dds.session_v`;
|
||||
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
|
||||
`orders` в описи мира, объект — число заказов класса в
|
||||
`orders` в паспорте мира, объект — число заказов класса в
|
||||
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
|
||||
`duplicate_event` и `amount_delta` только для покрытых описью закрытых дней;
|
||||
`duplicate_event` и `amount_delta` только для покрытых паспортом закрытых
|
||||
дней;
|
||||
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
|
||||
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
|
||||
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
|
||||
@@ -194,14 +195,14 @@ Distributed-таблица вернула бы каждый непарный з
|
||||
сопоставления деловых ключей.
|
||||
|
||||
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
|
||||
подтверждает, что до витрины доехал записанный в описи мир, но не проверяет
|
||||
подтверждает, что до витрины доехал записанный в паспорте мир, но не проверяет
|
||||
сам генератор: у настоящего магазина такого оракула нет.
|
||||
|
||||
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow
|
||||
только сводку и малый срез диагностики. Техническая сверка с описью читает
|
||||
небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их
|
||||
успеха даг заменяет проверенные дневные партиции. Затем именованные задачи
|
||||
читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
|
||||
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
|
||||
сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой
|
||||
файл JSON в Python. Все пять сборок пишут в общий донор. После их успеха даг
|
||||
заменяет проверенные дневные партиции. Затем именованные задачи читают
|
||||
опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
|
||||
сохраниться до красного завершения, а ошибка сборки не затирает прежний
|
||||
результат. Красная задача печатает не больше двадцати нарушенных ключей за
|
||||
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
|
||||
|
||||
+12
-12
@@ -37,13 +37,13 @@ world_next_day ─────> источники без ETL
|
||||
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
|
||||
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
|
||||
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
|
||||
зеленеть, не узнав, разложился ли день, работник не должен
|
||||
([ADR 0003](../adr/0003-dag-run-state.md)).
|
||||
Такой отказ чинится перезапуском `etl_pipeline` рукой — перезапуску ничего
|
||||
не нужно помнить, см. «Объём прогона».
|
||||
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: пульт управляет
|
||||
генератором и только им. После разгона менти запускает конвейер сам —
|
||||
и видит, как слои догоняют уехавший вперёд мир.
|
||||
зеленеть, не узнав, разложился ли день, шаговый даг не должен ([ADR
|
||||
0003](../adr/0003-dag-run-state.md)). Такой отказ чинится перезапуском
|
||||
`etl_pipeline` рукой — перезапуску ничего не нужно помнить, см. «Объём
|
||||
прогона».
|
||||
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: даги модельного
|
||||
времени управляют генератором и только им. После разгона менти запускает
|
||||
конвейер сам — и видит, как слои догоняют уехавший вперёд мир.
|
||||
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
|
||||
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
|
||||
ручного запуска начинаются со следующих дней.
|
||||
@@ -145,9 +145,9 @@ ODS и ещё не собранный в DDS, затем при необходи
|
||||
|
||||
## Отклонённые варианты
|
||||
|
||||
- **Задачи трансформаций прямо в работниках пульта.** Один зелёный прогон —
|
||||
весь день, но пульт — про ось времени: работников два, шаги дублировались
|
||||
бы, а этапу 5 пришлось бы выдирать их обратно.
|
||||
- **Задачи трансформаций прямо в шаговых дагах.** Один зелёный прогон —
|
||||
весь день, но даги времени отвечают за ось времени: шаговых дагов два, шаги
|
||||
дублировались бы, а этапу 5 пришлось бы выдирать их обратно.
|
||||
- **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с
|
||||
Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом
|
||||
режиме слои копили бы отставание молча.
|
||||
@@ -163,8 +163,8 @@ ODS и ещё не собранный в DDS, затем при необходи
|
||||
|
||||
- [ADR 0003](../adr/0003-dag-run-state.md) — зелёный конец графа не
|
||||
переживает отказ выше: отсюда ожидания в триггерах.
|
||||
- [ADR 0009](../adr/0009-world-control.md) — пульт мира: его работник
|
||||
живого дня и дёргает конвейер.
|
||||
- [ADR 0009](../adr/0009-world-control.md) — даги модельного времени: их шаговый
|
||||
даг живого дня и дёргает конвейер.
|
||||
- [ADR 0013](../adr/0013-world-lifecycle.md) — жизненный цикл мира: почему
|
||||
прикладным процессом владеет Airflow и почему `world_initialize` кончается
|
||||
собранными слоями.
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
Новый бросок в середине функции сдвигает все последующие значения этого
|
||||
подпотока. Код продолжает работать, но тем же свойствам достается другая
|
||||
случайность. Соседние подпотоки остаются прежними, а измененная часть
|
||||
канонического мира проявляется в описи и побайтовом сравнении.
|
||||
канонического мира проявляется в паспорте и побайтовом сравнении.
|
||||
|
||||
Какое обещание воспроизводимости это защищает, записано в
|
||||
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
|
||||
|
||||
@@ -19,23 +19,23 @@
|
||||
## Почему слои у панелей разные
|
||||
|
||||
Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него
|
||||
длиннее: сырьё привозит забор, а типизированную строку кладёт разбор. Встань
|
||||
разбор — свежесть ODS это покажет, свежесть сырья нет.
|
||||
длиннее: в STG данные привозит забор, а типизированную строку кладёт разбор.
|
||||
Встань разбор — свежесть ODS это покажет, а свежесть STG — нет.
|
||||
|
||||
Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление
|
||||
прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос
|
||||
здесь про доставку. Местом диагностики доставленных версий физическая таблица
|
||||
названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`).
|
||||
|
||||
Поток и брак считаются по слою сырья. Сырьё записывает каждую доставку и ничего
|
||||
не схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события,
|
||||
и все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
|
||||
Поток и брак считаются по сырому слою. Он хранит каждую доставку и ничего не
|
||||
схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события, и
|
||||
все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
|
||||
сильнее, чем дольше живёт стенд.
|
||||
|
||||
Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не
|
||||
отдельная куча, а часть сырья, которая не прошла разбор.
|
||||
отдельная куча, а часть сырых данных, которая не прошла разбор.
|
||||
|
||||
Окно у панели брака — окно дашборда, и это не косметика. Сырьё живёт трое
|
||||
Окно у панели брака — окно дашборда, и это не косметика. Сырые данные живут трое
|
||||
суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток
|
||||
приехавшего и однажды перевалила бы за сотню процентов.
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
укладываются в минуты реального времени, и соседние дни мира помещаются в одно
|
||||
окно панели сами.
|
||||
|
||||
## Приземлившееся считают по слою сырья
|
||||
## Приземлившееся считают в сыром слое
|
||||
|
||||
Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам
|
||||
экспортёра. Таблицы движка Kafka `stg.hits_raw_kafka` и `stg.orders_raw_kafka`
|
||||
@@ -66,7 +66,7 @@
|
||||
Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse.
|
||||
Единицы общие: сообщения за интервал панели. Со стороны Kafka это разность
|
||||
офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со
|
||||
стороны ClickHouse — `count()` строк сырья по тому же интервалу.
|
||||
стороны ClickHouse — `count()` строк STG по тому же интервалу.
|
||||
|
||||
Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase`
|
||||
растягивает приращение на всё окно и на минутном интервале промахивается на
|
||||
@@ -87,11 +87,11 @@ Kafka» приходит из Prometheus, ряд «приземлилось в C
|
||||
Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в
|
||||
один столбец не значит ничего.
|
||||
|
||||
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден
|
||||
сразу. Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему
|
||||
другой — не «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него
|
||||
отвечает своя панель, и модельный день она берёт из самого сообщения: в сырьё
|
||||
он приезжает полем `snapshot_date`.
|
||||
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден сразу.
|
||||
Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему другой — не
|
||||
«сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него отвечает своя
|
||||
панель, и модельный день она берёт из самого сообщения: в STG он приезжает полем
|
||||
`snapshot_date`.
|
||||
|
||||
## Что проверено
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
|
||||
пересечения выходят арифметикой, приоритет классов работает по-настоящему —
|
||||
[классы расхождений и опоздание](fate.md).
|
||||
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
|
||||
каждого слепка и счётчики наблюдаемых классов —
|
||||
[что хранит опись](inventory.md).
|
||||
- **Паспорт хранит только то, чего движение мира не меняет**: хеш байтов
|
||||
каждого слепка и счётчики наблюдаемых классов — [что хранит
|
||||
паспорт](inventory.md).
|
||||
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
|
||||
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
|
||||
[мост к склейке](identity.md).
|
||||
|
||||
@@ -8,18 +8,18 @@
|
||||
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
|
||||
решённая при его рождении и уложенная в окно K целиком.
|
||||
|
||||
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты,
|
||||
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и
|
||||
дубль. Довод за разделение — различимость по описи: правка заказной механики
|
||||
не двигает хеши событий, правка событийной не двигает байты слепка, и по
|
||||
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
|
||||
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
||||
компонент называет часть мира; новые стороны занимают те же позиции.
|
||||
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
|
||||
событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо
|
||||
отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка
|
||||
событийной механики молча меняла бы байты слепка; *компонент на класс* —
|
||||
пять имён под ручки калибровки, которые крутятся разом.
|
||||
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, дельта,
|
||||
опоздание. Событийная сторона — порча событийного потока: потеря и дубль. Довод
|
||||
за разделение — различимость по паспорту: правка заказной механики не двигает
|
||||
хеши событий, правка событийной не двигает байты слепка, и по покрасневшим хешам
|
||||
видно, какую сторону трогали. Прежние имена `DISCREPANCIES` и `LATECOMERS`
|
||||
решения не переживают — они названы по классам витрины, а компонент называет
|
||||
часть мира; новые стороны занимают те же позиции. Заказная сторона названа при
|
||||
реализации — подпоток `ORDERS` на позиции 2; событийная — `BEACONS` на позиции
|
||||
3: маячок счетчика либо не долетает, либо отправляется повторно. Отклонено:
|
||||
*один компонент на всю судьбу* — правка событийной механики молча меняла бы
|
||||
байты слепка; *компонент на класс* — пять имён под ручки калибровки, которые
|
||||
крутятся разом.
|
||||
|
||||
**Правило формы, без которого разделение не работает: броски заказной стороны
|
||||
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
||||
@@ -62,15 +62,14 @@
|
||||
процентов» не заводим.
|
||||
|
||||
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
||||
версия; различает их история версий: сырьё STG и физические версии
|
||||
`ods.order` до фоновых слияний, а в витринах — выручка дня, которая
|
||||
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
|
||||
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
|
||||
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
|
||||
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда
|
||||
деться, кроме как остаться брошенным; *мгновенная отмена при рождении* —
|
||||
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1,
|
||||
он в резерве.
|
||||
версия; различает их история версий: строки STG и физические версии `ods.order`
|
||||
до фоновых слияний, а в витринах — выручка дня, которая сначала выросла, потом
|
||||
убыла. Полное различение не обещано: слепок — состояние на границе суток, и
|
||||
оплата с отменой в один день в сырых данных неразличимы; то же у сильно
|
||||
опоздавших, приехавших уже терминальными. Точную форму этого урока решает этап
|
||||
4. Отклонено: *отмена только после оплаты* — неоплаченному некуда деться, кроме
|
||||
как остаться брошенным; *мгновенная отмена при рождении* — «дыхание» окна на
|
||||
отменах исчезает; *отмен нет вовсе* — страховочный срез 1, он в резерве.
|
||||
|
||||
## Классы расхождений и опоздание
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
|
||||
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
|
||||
отдельные прогоны одного мира дают то же соответствие; принятый мир
|
||||
воспроизводит эффект склейки без случайных ложных объединений — конкретные
|
||||
числа пар контрактом описи не являются. Межкогортные столкновения принимаются
|
||||
по той же дисциплине, что у случайных `ClientID`: принимается конкретный
|
||||
канонический мир по внешнему результату.
|
||||
воспроизводит эффект склейки без случайных ложных объединений. Паспорт не
|
||||
закрепляет конкретное число пар. Межкогортные столкновения принимаются по той же
|
||||
дисциплине, что у случайных `ClientID`: принимается конкретный канонический мир
|
||||
по внешнему результату.
|
||||
|
||||
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
|
||||
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
|
||||
|
||||
@@ -35,10 +35,11 @@ PARTITION` могла бы заменить партицию дня неполн
|
||||
|
||||
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
|
||||
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
|
||||
дёргает его тот, кто положил слепок в топик, — работник пульта мира, — и ждёт
|
||||
конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
|
||||
запускает его шаговый даг модельного времени, который положил слепок в топик и
|
||||
ждёт конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
|
||||
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта.
|
||||
Запрос забора лежит в [`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
|
||||
Запрос забора лежит в
|
||||
[`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
|
||||
`SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр
|
||||
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
|
||||
|
||||
@@ -157,7 +158,7 @@ dbt snapshots — стратегия `check` вместо `timestamp`.
|
||||
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
|
||||
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
|
||||
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
|
||||
Позиция мира при этом не двигается, и тот же день уедет заново — в сырье он
|
||||
Позиция мира при этом не двигается, и тот же день уедет заново — в STG он
|
||||
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
|
||||
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»).
|
||||
|
||||
@@ -191,10 +192,10 @@ dbt snapshots — стратегия `check` вместо `timestamp`.
|
||||
## Что проверено
|
||||
|
||||
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94.
|
||||
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение сырья на годные
|
||||
строки и брак полное и непересекающееся; две версии одного заказа легли в одну
|
||||
партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от фонового
|
||||
слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
|
||||
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение строк STG на
|
||||
годные строки и брак полное и непересекающееся; две версии одного заказа легли в
|
||||
одну партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от
|
||||
фонового слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
|
||||
`_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки
|
||||
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
|
||||
опирается, — [документ хранилища](../storage.md), «Что проверено».
|
||||
@@ -217,9 +218,9 @@ dbt snapshots — стратегия `check` вместо `timestamp`.
|
||||
|
||||
Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
|
||||
исполнении #165. После двух слепков физическая таблица содержала 3356 версий
|
||||
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним
|
||||
слепком заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из
|
||||
сырья. В обеих сверках расхождений нет.
|
||||
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним слепком
|
||||
заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из сырых
|
||||
данных. В обеих сверках расхождений нет.
|
||||
|
||||
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
|
||||
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Что хранит опись
|
||||
# Что хранит паспорт мира
|
||||
|
||||
Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что
|
||||
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72)
|
||||
и [«Места заказов в стартовом
|
||||
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
||||
Словарь описи — растяжка-хеш против опоры-счётчика — задан «Чем меряется
|
||||
генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
|
||||
термином «Опись мира» в [CONTEXT.md](../../../CONTEXT.md).
|
||||
Термины паспорта — «растяжка-хеш» и «опора-счётчик» — заданы разделом «Чем
|
||||
меряется генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
|
||||
статьёй «Паспорт мира» в [CONTEXT.md](../../../CONTEXT.md).
|
||||
|
||||
> Опись хранит только то, чего движение мира не меняет.
|
||||
> Хеш кладём всегда, счётчик — только когда назван его читатель.
|
||||
> Паспорт хранит только то, чего движение мира не меняет. Хеш кладём всегда,
|
||||
> счётчик — только когда назван его читатель.
|
||||
|
||||
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
|
||||
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
|
||||
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же
|
||||
байты») опись начинает сторожить.
|
||||
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же байты»)
|
||||
паспорт начинает сторожить.
|
||||
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
|
||||
итоговой судьбе заказов дня. В строке закрытого дня поле `orders` хранит
|
||||
число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных
|
||||
@@ -24,7 +24,7 @@
|
||||
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
|
||||
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
|
||||
не выходит; генератор сторожит хеш, транспорт — счёт событий.
|
||||
- **Опись описывает мир, а не доставку.** Работа генератора кончается на
|
||||
Kafka: дошли ли байты до `ods.order` — вопрос стенда и его
|
||||
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка
|
||||
приёма заказов — число заведётся вместе с ней.
|
||||
- **Паспорт описывает мир, а не доставку.** Работа генератора кончается на
|
||||
Kafka: дошли ли байты до `ods.order` — вопрос стенда и его проверок. Поэтому
|
||||
счёта строк у слепка в паспорте нет; понадобится проверка приёма заказов —
|
||||
число заведётся вместе с ней.
|
||||
@@ -112,5 +112,5 @@
|
||||
такого значения означает ошибку арифметики источника.
|
||||
|
||||
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
|
||||
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
|
||||
его даром, а хешу слепка в описи нужен именно названный порядок.
|
||||
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт его
|
||||
даром, а хешу слепка в паспорте нужен именно названный порядок.
|
||||
@@ -25,11 +25,11 @@
|
||||
|
||||
Цена принята с открытыми глазами: у части пар стартового мира поздний
|
||||
назначенный заказ падает на день 7, и до первого хода мира этих пар в
|
||||
`dds.identity_map` нет. Опись пар не считает
|
||||
([что хранит опись](inventory.md)), поэтому красной проверки из этого не
|
||||
выходит. Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на
|
||||
свежем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем
|
||||
ровно там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* —
|
||||
число верно ровно до первого хода мира; *отменить сдвиг отправки* —
|
||||
`awaiting_order` пропал бы навсегда; *подогнать план под горизонт* — мир
|
||||
перестал бы быть чистой функцией зерна.
|
||||
`dds.identity_map` нет. Паспорт пар не считает ([что хранит
|
||||
паспорт](inventory.md)), поэтому красной проверки из этого не выходит.
|
||||
Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на свежем
|
||||
стенде, в CLI заводится рычаг, стартовый мир становится особым случаем ровно
|
||||
там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* — число
|
||||
верно ровно до первого хода мира; *отменить сдвиг отправки* — `awaiting_order`
|
||||
пропал бы навсегда; *подогнать план под горизонт* — мир перестал бы быть чистой
|
||||
функцией зерна.
|
||||
+36
-35
@@ -17,27 +17,28 @@
|
||||
`airflow` и `superset`;
|
||||
- `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor` —
|
||||
Airflow 3.3 с LocalExecutor, без triggerer;
|
||||
- `superset` — интерфейс и подготовленное подключение ClickHouse;
|
||||
- `superset` — интерфейс с заранее настроенным подключением к ClickHouse;
|
||||
- `prometheus` и `grafana` — сбор и просмотр метрик ClickHouse и Kafka.
|
||||
|
||||
## Пользователи и роли ClickHouse
|
||||
|
||||
В ClickHouse семь пользователей и шесть ролей:
|
||||
|
||||
- `etl` подключает рабочие даги Airflow; роль `etl_writer` читает и пишет слои
|
||||
хранилища;
|
||||
- `lifecycle` применяет канонический DDL и пересоздаёт прикладные базы; роль
|
||||
`lifecycle_owner` не даёт эти административные права рабочим дагам;
|
||||
- `bi` подключает Superset; роль `bi_reader` читает ODS, DDS, DM и справочники
|
||||
`dic`, а запросы Superset обращаются к публичным `_v`;
|
||||
- `analyst` предназначен для подключения человека и читает все слои и
|
||||
справочники;
|
||||
- `grafana` с ролью `monitoring_reader` подключает Grafana и читает все слои,
|
||||
- `etl` — учётная запись рабочих дагов Airflow; роль `etl_writer` читает и
|
||||
пишет слои хранилища;
|
||||
- `lifecycle` — учётная запись дагов жизненного цикла; роль `lifecycle_owner`
|
||||
разрешает им применять канонический DDL и пересоздавать прикладные базы. У
|
||||
рабочих дагов этих административных прав нет;
|
||||
- `bi` — учётная запись Superset; роль `bi_reader` читает ODS, DDS, DM и
|
||||
справочники `dic`, а запросы Superset обращаются к публичным `_v`;
|
||||
- `analyst` — учётная запись аналитика с правом чтения всех слоёв и
|
||||
справочников;
|
||||
- `grafana` — учётная запись Grafana; роль `monitoring_reader` читает все слои,
|
||||
системные таблицы и данные со всех реплик кластера;
|
||||
- `dict` читает только базу `dic` и пароля не имеет: им словарь товаров ходит
|
||||
за своей подложкой;
|
||||
- `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри
|
||||
контейнеров, но не приложения.
|
||||
- `dict` — беспарольная учётная запись словаря товаров с правом чтения базы
|
||||
`dic`; словарь читает через неё свою подложку;
|
||||
- `default` — служебная учётная запись для проверок состояния и скриптов внутри
|
||||
контейнеров; приложения с ней не подключаются.
|
||||
|
||||
Почему пользователи объявлены файлом, а ноды доверяют общему секрету, — в
|
||||
[ADR 0007](../adr/0007-clickhouse-access.md).
|
||||
@@ -48,9 +49,8 @@ Superset получает пароль `bi`, а Grafana — пароль `grafan
|
||||
|
||||
## Применение настроек
|
||||
|
||||
Изменения значений ClickHouse в `.env` и файлов настройки серверов в
|
||||
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/` применяются
|
||||
пересозданием нод:
|
||||
Чтобы применить новые значения ClickHouse из `.env` или изменения файлов в
|
||||
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/`, пересоздайте ноды:
|
||||
|
||||
```bash
|
||||
docker compose up --force-recreate --wait clickhouse-01 clickhouse-02
|
||||
@@ -62,9 +62,8 @@ docker compose up --force-recreate --wait clickhouse-01 clickhouse-02
|
||||
|
||||
## Секреты и порты
|
||||
|
||||
Пароли ClickHouse и интерфейсов, пароли Postgres, ключи Airflow и Superset,
|
||||
отсутствие проверки доступа у Kafka и Prometheus — намеренно простые и явно
|
||||
ненастоящие настройки локального учебного стенда. Это не пример настройки
|
||||
В локальном учебном стенде намеренно используются простые ненастоящие пароли и
|
||||
ключи. Kafka и Prometheus работают без проверки доступа. Это не пример настройки
|
||||
защиты: не копируйте значения из `.env.example` в рабочую среду. Все
|
||||
опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не
|
||||
опубликован.
|
||||
@@ -84,20 +83,22 @@ ClickHouse:
|
||||
[ReplicatedMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replication),
|
||||
[ON CLUSTER](https://clickhouse.com/docs/sql-reference/distributed-ddl) и
|
||||
[Distributed](https://clickhouse.com/docs/engines/table-engines/special/distributed).
|
||||
Описание кластера задаётся через `remote_servers`, макросы — через `macros`,
|
||||
подключение к keeper — через `zookeeper`; путь `ReplicatedMergeTree` содержит
|
||||
`{shard}` и `{replica}`, а `Distributed` получает имя кластера, базу, локальную
|
||||
таблицу и ключ шардирования. Макросы выбраны, чтобы один DDL через `ON CLUSTER`
|
||||
создавал отдельный путь каждого шарда без вписанных вручную значений. Для
|
||||
образа зафиксирован точный текущий
|
||||
[LTS-выпуск 26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts);
|
||||
Кластер описан в `remote_servers`, макросы — в `macros`, а подключение к
|
||||
keeper — в `zookeeper`. Путь `ReplicatedMergeTree` содержит `{shard}` и
|
||||
`{replica}`.
|
||||
Для `Distributed` задаются имя кластера, база, локальная таблица и ключ
|
||||
шардирования. Макросы выбраны, чтобы один DDL через `ON CLUSTER` создавал
|
||||
отдельный путь для каждого шарда без вписанных вручную значений. В образе
|
||||
закреплена точная версия текущего LTS-выпуска —
|
||||
[26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts);
|
||||
серверы и keeper используют один образ.
|
||||
|
||||
Настройки Kafka 4.3.1 сверены с
|
||||
[примером односерверного KRaft](https://github.com/apache/kafka/blob/4.3.1/docker/examples/docker-compose-files/single-node/plaintext/docker-compose.yml).
|
||||
|
||||
Секция метрик взята из конфигурации закреплённого образа ClickHouse и проверена
|
||||
на серверах и keeper. Подготовка источников Grafana сверена с
|
||||
Раздел метрик взят из конфигурации закреплённого образа ClickHouse и проверен
|
||||
на серверах и keeper. Автоматическая настройка источников данных Grafana
|
||||
сверена с
|
||||
[официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/)
|
||||
и [документацией плагина ClickHouse](https://grafana.com/docs/plugins/grafana-clickhouse-datasource/latest/configure/).
|
||||
Prometheus собирает встроенные метрики двух серверов и keeper, а через
|
||||
@@ -109,18 +110,18 @@ Prometheus собирает встроенные метрики двух сер
|
||||
пока нет.
|
||||
|
||||
Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor,
|
||||
публичный `airflow.sdk`, API здоровья и SimpleAuthManager сверены с
|
||||
публичный `airflow.sdk`, API проверки состояния и SimpleAuthManager сверены с
|
||||
[архитектурой Airflow 3.3](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/overview.html),
|
||||
[публичным интерфейсом](https://airflow.apache.org/docs/apache-airflow/3.3.0/public-airflow-interface.html)
|
||||
и [описанием здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html).
|
||||
Для ClickHouse установлен официальный провайдер Airflow: SQL-файлы исполняет
|
||||
общий `SQLExecuteQueryOperator`, программные запросы идут через
|
||||
`ClickHouseHook`. Провайдер работает поверх закреплённого
|
||||
`clickhouse-connect`. Официальный провайдер Kafka сам использует
|
||||
`confluent-kafka`; отдельное подключение и его обёртки здесь не нужны, поэтому
|
||||
клиент Kafka добавлен в образ напрямую.
|
||||
`clickhouse-connect`. Официальный провайдер Kafka уже использует
|
||||
`confluent-kafka`. В стенде клиент добавлен в образ напрямую, без отдельного
|
||||
подключения Airflow и обёрток провайдера.
|
||||
|
||||
Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, форма
|
||||
`clickhousedb://` и драйвер Postgres сверены с
|
||||
Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, схема адреса
|
||||
подключения `clickhousedb://` и драйвер Postgres сверены с
|
||||
[документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/)
|
||||
и [настройкой базы метаданных](https://superset.apache.org/admin-docs/6.1.0/configuration/configuring-superset/).
|
||||
+159
-161
@@ -7,20 +7,19 @@
|
||||
|
||||
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
|
||||
keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка
|
||||
`Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное
|
||||
событие с таблицей ошибок, поверхность актуального состояния и три матвью.
|
||||
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой
|
||||
чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего
|
||||
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью.
|
||||
Тот же этап принёс первый справочник — словарь товаров из общего с генератором
|
||||
CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а
|
||||
подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат
|
||||
публичным договором события `dds.event_v` над точным состоянием ODS, а следом
|
||||
получил две физические модели слоя — заказ и сессию: их собирает заменой
|
||||
партиций даг `dds_transform`. Сессия среди них особая: в потоке такой сущности
|
||||
нет, стенд режет её из событий сам.
|
||||
Дальше по тексту устройство описано так, как оно проектируется; построенное от
|
||||
заложенного отличает карта таблиц в конце.
|
||||
`Kafka → STG → ODS` — чтец топика `hits`, таблицы STG, типизированное событие с
|
||||
таблицей ошибок, поверхность актуального состояния и три матвью. Этап 3 добавил
|
||||
вход второго источника и довёл его до ODS: топик `orders`, свой чтец, свой сырой
|
||||
слой, версии заказов с таблицей ошибок и поверхность текущего состояния.
|
||||
Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью. Тот же этап
|
||||
принёс первый справочник — словарь товаров из общего с генератором CSV-файла.
|
||||
Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а подложку над ним
|
||||
([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат публичным договором
|
||||
события `dds.event_v` над точным состоянием ODS, а следом получил две физические
|
||||
модели слоя — заказ и сессию: их собирает заменой партиций даг `dds_transform`.
|
||||
Сессия среди них особая: в потоке такой сущности нет, стенд режет её из событий
|
||||
сам. Дальше по тексту устройство описано так, как оно проектируется; построенное
|
||||
от заложенного отличает карта таблиц в конце.
|
||||
|
||||
Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
|
||||
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
|
||||
@@ -97,7 +96,7 @@ ClickHouse и отклонённые варианты записаны в
|
||||
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
|
||||
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
|
||||
выбран против перекоса: структурированный числовой идентификатор распределяется
|
||||
по остатку от деления неравномерно. У сырья выбора нет — строку иначе не
|
||||
по остатку от деления неравномерно. У сырого слоя выбора нет — строку иначе не
|
||||
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
|
||||
шард.
|
||||
|
||||
@@ -157,10 +156,10 @@ FROM источник_dist`. Заводское значение `parallel_distr
|
||||
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и
|
||||
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
|
||||
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
|
||||
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а слой сырья
|
||||
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а сырой слой
|
||||
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
|
||||
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы
|
||||
либо падение приёма на первом сообщении, либо тихие нули за 1970 год.
|
||||
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы либо
|
||||
падение приёма на первом сообщении, либо тихие нули за 1970 год.
|
||||
|
||||
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
|
||||
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
|
||||
@@ -173,10 +172,10 @@ FROM источник_dist`. Заводское значение `parallel_distr
|
||||
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
|
||||
0010](../adr/0010-order-versions-in-ods.md)).
|
||||
|
||||
Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
||||
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и
|
||||
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради
|
||||
которого он заведён: повтор доставки в сырье обязан быть виден.
|
||||
Движок таблицы STG — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
||||
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у
|
||||
таблицы STG и нет. Замену версий сюда ставить нельзя — она отменила бы свойство
|
||||
слоя, ради которого он заведён: повтор доставки в STG обязан быть виден.
|
||||
|
||||
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
|
||||
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
|
||||
@@ -192,7 +191,7 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
|
||||
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
|
||||
|
||||
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
|
||||
Airflow. Переделать разобранное руками можно вставкой из сырья с фильтром по
|
||||
Airflow. Переделать разобранное руками можно вставкой из STG с фильтром по
|
||||
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
|
||||
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
|
||||
`_load_id`; дневные партиции ODS он не заменяет.
|
||||
@@ -223,10 +222,10 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
|
||||
|
||||
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
|
||||
исключение: источник объявляет `Array(Float64)`, а материализованное
|
||||
представление ODS разбирает числа сырого JSON сразу в
|
||||
`Array(Decimal(18, 2))`. DDS наследует этот тип без нового преобразования.
|
||||
Прямой разбор подтвержден документацией ClickHouse через Context7 и запросом
|
||||
к ClickHouse 26.3; сравнение с сырьем подтвердило: копейки всех покупок целы.
|
||||
представление ODS разбирает числа сырого JSON сразу в `Array(Decimal(18, 2))`.
|
||||
DDS наследует этот тип без нового преобразования. Прямой разбор подтвержден
|
||||
документацией ClickHouse через Context7 и запросом к ClickHouse 26.3; сравнение
|
||||
с сырыми данными подтвердило: копейки всех покупок целы.
|
||||
|
||||
## Версии заказов
|
||||
|
||||
@@ -257,16 +256,16 @@ DDS. `snapshot_date` в нём одновременно дата наблюде
|
||||
|
||||
**Линза называется явно — в типе колонки либо в вызове функции.** Третий
|
||||
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там,
|
||||
где линза что-то решает — в объявлении хранимой колонки и в выражении,
|
||||
считающем дату, — его не остаётся. Прописать этот пояс своей рукой —
|
||||
`<timezone>` в конфигурации ноды или `TZ` контейнеру — было бы той же болезнью
|
||||
с другим умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах
|
||||
и в разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
|
||||
поменяв его. Правило стоит на источнике пояса, а не на функции:
|
||||
`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и
|
||||
работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя
|
||||
пишется тогда, когда нужна другая линза, чем у колонки:
|
||||
`toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика».
|
||||
где линза что-то решает — в объявлении хранимой колонки и в выражении, считающем
|
||||
дату, — его не остаётся. Прописать этот пояс своей рукой — `<timezone>` в
|
||||
конфигурации ноды или `TZ` контейнеру — было бы той же болезнью с другим
|
||||
умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах и в
|
||||
разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
|
||||
поменяв его. Правило стоит на источнике пояса, а не на функции: `toDate` по
|
||||
колонке, чей тип пояс несёт, законен и имени не требует — так и работают ключи
|
||||
партиций `toDate(_load_ts)` у таблицы STG и у таблицы ошибок. Имя пишется тогда,
|
||||
когда нужна другая линза, чем у колонки: `toDate(UTCEventTime, 'Europe/Samara')`
|
||||
— это «день по часам счётчика».
|
||||
|
||||
**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок
|
||||
выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна,
|
||||
@@ -314,8 +313,8 @@ UTC+4), и пересчёт идёт один раз при наполнении
|
||||
0008](../adr/0008-order-ingestion.md) и [спецификация приёма
|
||||
заказов](orders/ingestion.md).
|
||||
|
||||
Цепочка одна: чтец топика → матвью → сырьё STG → матвью разбора → событие и
|
||||
таблица ошибок ODS.
|
||||
Цепочка одна: чтец топика → матвью → STG → матвью разбора → событие и таблица
|
||||
ошибок ODS.
|
||||
|
||||
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
|
||||
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
|
||||
@@ -344,15 +343,15 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
|
||||
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
|
||||
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
|
||||
как при фоновом первая нода продолжает принимать и копит спул для соседа.
|
||||
Платится при этом не одно ожидание на блок, а три распределённые вставки — сырьё,
|
||||
Платится при этом не одно ожидание на блок, а три распределённые вставки — STG,
|
||||
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
|
||||
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
|
||||
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
|
||||
компромисс полезнее показать, чем спрятать за галочкой.
|
||||
|
||||
Ещё одно место, где сырьё хранит не всё приехавшее: запись с пустым значением и
|
||||
запись-надгробие проходят молча, не оставляя строки. Свойство измерено и принято
|
||||
осознанно — подробности в разделе «Что проверено».
|
||||
Ещё одно место, где сырой слой хранит не всё приехавшее: запись с пустым
|
||||
значением и запись-надгробие проходят молча, не оставляя строки. Свойство
|
||||
измерено и принято осознанно — подробности в разделе «Что проверено».
|
||||
|
||||
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
|
||||
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
|
||||
@@ -361,14 +360,14 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
|
||||
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
|
||||
|
||||
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
|
||||
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня
|
||||
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения.
|
||||
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к
|
||||
сырому слою не относится.
|
||||
`ReplacingMergeTree`, а таблицы STG вовсе не устраняют дубли: перезаливка
|
||||
модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения
|
||||
срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности
|
||||
конвейера к сырому слою не относится.
|
||||
|
||||
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
|
||||
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
|
||||
Удвоение сырья проходит мимо неё только потому, что при повторном чтении
|
||||
Удвоение строк STG проходит мимо неё только потому, что при повторном чтении
|
||||
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
|
||||
отсутствии механизма.
|
||||
|
||||
@@ -384,53 +383,54 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
|
||||
роняет вставку и останавливает потребление до починки
|
||||
([ADR 0005](../adr/0005-event-ingestion.md)).
|
||||
|
||||
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырья
|
||||
срок жизни трое суток, а ODS хранит всё, поэтому равенство «сырьё = события +
|
||||
ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня
|
||||
задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не
|
||||
смотреть на оповещения
|
||||
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым
|
||||
опытом при исполнении #43, на управляемой пачке: отправили N сообщений —
|
||||
получили N строк сырья и N в сумме событий и ошибок. Постоянной целью такой
|
||||
опыт не становится, и почему — в [карте
|
||||
проверок](testing.md), раздел «Интеграционная проверка постоянной целью не
|
||||
становится». Счёт актуальных событий идёт через `ods.event_v`, которое скрывает
|
||||
`FINAL`: голый `count()` по `ReplacingMergeTree` зависит от того, сколько
|
||||
мержей успело пройти, и спека это прямо запрещает (раздел 6).
|
||||
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырого
|
||||
слоя срок жизни трое суток, а ODS хранит всё, поэтому равенство «строки STG =
|
||||
события + ошибки» разъедется само: строки STG истекут раньше, а перезаливка
|
||||
модельного дня задвоит их, тогда как в ODS тот же повтор схлопнется. Правило,
|
||||
красное в норме, учит не смотреть на оповещения ([ADR
|
||||
0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при
|
||||
исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк
|
||||
STG и N в сумме событий и ошибок. Постоянной целью такой опыт не становится, и
|
||||
почему — в [карте проверок](testing.md), раздел «Интеграционная проверка
|
||||
постоянной целью не становится». Счёт актуальных событий идёт через
|
||||
`ods.event_v`, которое скрывает `FINAL`: голый `count()` по `ReplacingMergeTree`
|
||||
зависит от того, сколько мержей успело пройти, и спека это прямо запрещает
|
||||
(раздел 6).
|
||||
|
||||
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
|
||||
против [описи мира](../../data/world-inventory.json), лежащей в git
|
||||
(`make check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в
|
||||
этом вся разница: опись описывает восемь дней стартового мира, счёт обрамлён
|
||||
их датами, повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не
|
||||
помеха — ODS хранит всё. Обе стороны равенства зафиксированы: одна кодом
|
||||
генератора, другая файлом в git. У межслойной сверки такой опоры нет ни с
|
||||
одной стороны.
|
||||
против [паспорта мира](../../data/world-inventory.json), лежащего в git (`make
|
||||
check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в этом вся
|
||||
разница: паспорт описывает восемь дней стартового мира, счёт обрамлён их датами,
|
||||
повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не помеха — ODS
|
||||
хранит всё. Обе стороны равенства зафиксированы: одна кодом генератора, другая
|
||||
файлом в git. У межслойной сверки такой опоры нет ни с одной стороны.
|
||||
|
||||
## Срок жизни сырья
|
||||
## Срок жизни сырых данных
|
||||
|
||||
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно
|
||||
отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты,
|
||||
после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же
|
||||
колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). В DDL
|
||||
значение проставлено явно, чтобы поведение не зависело от умолчания версии.
|
||||
Сырые данные в STG живут трое суток реального времени и уходят сами. Трое — это
|
||||
окно отладки: столько сырые данные хранятся на ноутбуке, чтобы менти успел
|
||||
разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню
|
||||
загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками
|
||||
(`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не
|
||||
зависело от умолчания версии.
|
||||
|
||||
Нарезать сырьё по модельному дню события было бы соблазнительно — он единица
|
||||
переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
|
||||
снимается целиком, только когда в нём истекли все строки, а в партиции модельного
|
||||
дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста,
|
||||
самая свежая строка удержит весь кусок, и данные переживут срок неограниченно.
|
||||
Нарезать сырые данные по модельному дню события было бы соблазнительно — он
|
||||
единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
|
||||
снимается целиком, только когда в нём истекли все строки, а в партиции
|
||||
модельного дня лежит приехавшее в разное реальное время: мерж склеит куски
|
||||
разного возраста, самая свежая строка удержит весь кусок, и данные переживут
|
||||
срок неограниченно.
|
||||
|
||||
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
|
||||
возраста — но не более чем на сутки, потому что партицию закрывает календарный
|
||||
день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно
|
||||
трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что
|
||||
«уходит само» здесь обещано, а «уходит вовремя» — нет.
|
||||
день. Отсюда и оценка: сырые данные живут трое суток плюс хвост до суток, а не
|
||||
ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику,
|
||||
так что «уходит само» здесь обещано, а «уходит вовремя» — нет.
|
||||
|
||||
Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а
|
||||
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё
|
||||
режется своими координатами: и разбор полётов, и переобработка фильтруют по
|
||||
`_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
|
||||
Модельного дня среди колонок STG нет вовсе. Он свойство содержимого, а
|
||||
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырые
|
||||
данные режутся по своим координатам: и разбор полётов, и переобработка фильтруют
|
||||
по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
|
||||
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
|
||||
|
||||
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
|
||||
@@ -440,21 +440,21 @@ D0 и к реальному календарю не привязана; паке
|
||||
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
|
||||
эталонные дни прямо на входе.
|
||||
|
||||
В бою слой сырья иногда собирают на движке `Null` — тогда он не хранится вовсе.
|
||||
Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не
|
||||
ноль.
|
||||
В бою сырой слой иногда собирают на движке `Null` — тогда он не хранится вовсе.
|
||||
Такой вариант отвергнут: на стенде сырые данные нужны для отладки, поэтому окно,
|
||||
а не ноль.
|
||||
|
||||
## Таблица ошибок
|
||||
|
||||
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
|
||||
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у
|
||||
брака нет разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у
|
||||
строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и
|
||||
сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним,
|
||||
разбираться к моменту разбирательства будет уже нечем. Снятие — целыми кусками
|
||||
(`ttl_only_drop_parts`), как у сырья и по той же причине: строки в партиции дня
|
||||
загрузки разного возраста не более чем на сутки, и куску незачем переживать
|
||||
срок из-за самой свежей строки.
|
||||
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что
|
||||
у брака нет разобранных полей: шардируется `cityHash64` сырой строки —
|
||||
`ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню
|
||||
загрузки, как и сырые данные; живёт месяц. Дольше сырых данных — намеренно: если
|
||||
брак истекает вместе с ними, разбираться к моменту разбирательства будет уже
|
||||
нечем. Снятие — целыми кусками (`ttl_only_drop_parts`), как у STG и по той же
|
||||
причине: строки в партиции дня загрузки разного возраста не более чем на сутки,
|
||||
и куску незачем переживать срок из-за самой свежей строки.
|
||||
|
||||
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
|
||||
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
|
||||
@@ -500,13 +500,12 @@ kafka_offset)`: смотрят такую таблицу от класса, а
|
||||
|
||||
## Раскладка SQL
|
||||
|
||||
Исполняемые дагами запросы лежат по правилу
|
||||
`sql/<слой>/<объект>_<роль>.sql`. Слой — слой цели запроса: забор заказов в
|
||||
сырьё живёт в `sql/stg/orders_raw_load.sql`, разбор — в
|
||||
`sql/ods/order_parse.sql`. Даг называет файлы, передаёт параметры и задаёт
|
||||
порядок выполнения, но не хранит текст преобразований. Два выражения разбора
|
||||
делят локальный макрос Jinja в том же файле; подключаемого SQL-фрагмента между
|
||||
ними нет.
|
||||
Исполняемые дагами запросы лежат по правилу `sql/<слой>/<объект>_<роль>.sql`.
|
||||
Слой — слой цели запроса: забор заказов в STG живёт в
|
||||
`sql/stg/orders_raw_load.sql`, разбор — в `sql/ods/order_parse.sql`. Даг
|
||||
называет файлы, передаёт параметры и задаёт порядок выполнения, но не хранит
|
||||
текст преобразований. Два выражения разбора делят локальный макрос Jinja в том
|
||||
же файле; подключаемого SQL-фрагмента между ними нет.
|
||||
|
||||
SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода
|
||||
обработчик DAG читает список DDL-файлов, загружает содержимое каждого
|
||||
@@ -524,10 +523,10 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
||||
|---|---|
|
||||
| `00-databases.sql` | базы слоёв и зона справочников |
|
||||
| `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё |
|
||||
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников |
|
||||
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы STG обоих источников |
|
||||
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
|
||||
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
|
||||
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
|
||||
| `40-stg-views.sql` | матвью приёма: чтец в STG |
|
||||
| `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций |
|
||||
| `50-dds-views.sql` | публичные договоры сущностей DDS |
|
||||
| `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций |
|
||||
@@ -537,14 +536,14 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
||||
правила порядка слоёв к ней не применяются.
|
||||
|
||||
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
|
||||
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому
|
||||
что наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся
|
||||
матвью приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает
|
||||
читать топик, когда к нему привязывают первую матвью; создай её раньше
|
||||
разбора — и всё, что доедет в зазоре, ляжет в сырьё и не попадёт в ODS
|
||||
никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден,
|
||||
поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse
|
||||
снесены, а данные Kafka целы, — то есть на обычной отладке.
|
||||
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что
|
||||
наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся матвью
|
||||
приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает читать
|
||||
топик, когда к нему привязывают первую матвью; создай её раньше разбора — и всё,
|
||||
что доедет в зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в
|
||||
ошибки. На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет.
|
||||
Проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на
|
||||
обычной отладке.
|
||||
|
||||
Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
|
||||
`lifecycle`: один файл — одна последовательная задача
|
||||
@@ -579,13 +578,13 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
||||
|---|---|---|
|
||||
| `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
|
||||
| `stg` | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки |
|
||||
| `stg` | `stg.hits_raw_mv` | наполняет сырьё из чтеца |
|
||||
| `stg` | `stg.hits_raw_mv` | наполняет STG из чтеца |
|
||||
| `stg` | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью |
|
||||
| `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` |
|
||||
| `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие |
|
||||
| `ods` | `ods.event_v` | актуальная версия события с полями источника |
|
||||
| `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём |
|
||||
| `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки |
|
||||
| `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор STG в событие и в ошибки |
|
||||
| `ods` | `ods.order_rep` / `_dist` | типизированные версии заказа |
|
||||
| `ods` | `ods.order_v` | текущая версия заказа на языке источника |
|
||||
| `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
|
||||
@@ -612,9 +611,8 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
||||
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
|
||||
| `dic` | `dic.products` | словарь товаров поверх подложки |
|
||||
|
||||
Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два
|
||||
`INSERT SELECT` одной задачи `parse_batch` из файла
|
||||
`sql/ods/order_parse.sql`.
|
||||
Матвью разбора у заказов нет: срез STG раскладывают по этим двум целям два
|
||||
`INSERT SELECT` одной задачи `parse_batch` из файла `sql/ods/order_parse.sql`.
|
||||
|
||||
В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие
|
||||
остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции;
|
||||
@@ -655,11 +653,11 @@ DDL-словарь с файловым источником внутри `user_f
|
||||
**Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и
|
||||
`system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку
|
||||
значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT`
|
||||
на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`.
|
||||
На живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов
|
||||
лежали на обоих шардах, после явного нуля и повторного разбора того же
|
||||
`_load_id` — ни одного. Вершина версий уцелела: деловые поля `ods.order_v`
|
||||
сошлись с эталоном, посчитанным прямо из сырья.
|
||||
на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`. На
|
||||
живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов лежали на
|
||||
обоих шардах, после явного нуля и повторного разбора того же `_load_id` — ни
|
||||
одного. Вершина версий уцелела: деловые поля `ods.order_v` сошлись с эталоном,
|
||||
посчитанным прямо из STG.
|
||||
|
||||
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
|
||||
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
|
||||
@@ -683,19 +681,19 @@ DDL-словарь с файловым источником внутри `user_f
|
||||
потолком разбор отдаёт как `NULL`. Это ловит опыт, который берёт «заведомо
|
||||
далёкий» год: 2999-й не разбирается вовсе.
|
||||
|
||||
Про сам приём заказов снято следующее. Предикат формы провода разложил все
|
||||
13 661 строку сырья, приехавшую при исполнении #93, без исключений и без
|
||||
`NULL`: 13 653 годных и 8 брака, сумма сошлась с общим счётом по каждому
|
||||
`_load_id`, пересечение ветвей — ноль. Все восемь строк брака — версии одного
|
||||
заказа с отрицательным `total`; это дефект генератора, разобранный в
|
||||
[спецификации приёма](orders/ingestion.md), «Что проверено». На управляемой
|
||||
порции из пяти строк (две версии одного заказа плюс по одной строке каждого
|
||||
класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с
|
||||
ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL`
|
||||
через распределённую таблицу вернул одну строку — ту, что несла позднейшую
|
||||
версию, — тогда как физический счёт показывал две. Повтор задачи с тем же
|
||||
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок
|
||||
записала тот же брак второй раз, как и обещано спецификацией приёма.
|
||||
Про сам приём заказов снято следующее. Предикат формы провода разложил все 13
|
||||
661 строку STG, приехавшую при исполнении #93, без исключений и без `NULL`: 13
|
||||
653 годных и 8 брака, сумма сошлась с общим счётом по каждому `_load_id`,
|
||||
пересечение ветвей — ноль. Все восемь строк брака — версии одного заказа с
|
||||
отрицательным `total`; это дефект генератора, разобранный в [спецификации
|
||||
приёма](orders/ingestion.md), «Что проверено». На управляемой порции из пяти
|
||||
строк (две версии одного заказа плюс по одной строке каждого класса брака)
|
||||
годные ушли в `ods.order`, брак — в `ods.order_errors` с ожидаемыми классами.
|
||||
Обе версии легли в одну партицию и на один шард; `FINAL` через распределённую
|
||||
таблицу вернул одну строку — ту, что несла позднейшую версию, — тогда как
|
||||
физический счёт показывал две. Повтор задачи с тем же `_load_id` строку в
|
||||
`ods.order_v` и её `_load_ts` не изменил, а таблица ошибок записала тот же брак
|
||||
второй раз, как и обещано спецификацией приёма.
|
||||
|
||||
Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у
|
||||
запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, —
|
||||
@@ -758,27 +756,26 @@ DDL-словарь с файловым источником внутри `user_f
|
||||
вовсе, до 7 августа утверждение держалось на опыте владельца.
|
||||
- Упавшая матвью роняет вставку и останавливает потребление до починки.
|
||||
Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за
|
||||
двадцать секунд (сброс блока идёт за 7,5) в сырьё не приехало ничего, а
|
||||
офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение
|
||||
доехало само, без повторной отправки, отставание ушло в ноль, событие
|
||||
разобралось. Сносить надо именно распределённую таблицу: вставка в
|
||||
`Distributed` кладёт блок в спул и сразу возвращает управление, так что на
|
||||
сносе локальной ошибка всплыла бы фоном и утверждение показалось бы
|
||||
опровергнутым.
|
||||
- `_load_ts` в `ods.event` — это метка исходной строки сырья, а не время
|
||||
двадцать секунд (сброс блока идёт за 7,5) в STG не приехало ничего, а офсет
|
||||
группы застыл с отставанием в одно сообщение. Цель вернули — сообщение доехало
|
||||
само, без повторной отправки, отставание ушло в ноль, событие разобралось.
|
||||
Сносить надо именно распределённую таблицу: вставка в `Distributed` кладёт
|
||||
блок в спул и сразу возвращает управление, так что на сносе локальной ошибка
|
||||
всплыла бы фоном и утверждение показалось бы опровергнутым.
|
||||
- `_load_ts` в `ods.event` — это метка исходной строки STG, а не время
|
||||
разбора. Сверено по `WatchID` на двух тысячах событий модельного дня,
|
||||
залитого дважды: у каждого события метка совпала с меткой одной из двух его
|
||||
доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди
|
||||
доставок» ноль, то есть `now64()` в матвью разбора нет.
|
||||
- Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново
|
||||
созданные матвью разбора при живом чтеце: сообщение, отправленное сразу
|
||||
после, легло в сырьё и не попало в ODS никуда — ни в событие, ни в ошибки;
|
||||
то же сообщение через минуту разобралось штатно. Воспроизведено дважды
|
||||
7 августа 2026 года. Это тот же зазор, о котором предупреждает нумерация
|
||||
файлов DDL, только приходит он с другой стороны — не при первом создании, а
|
||||
при замене матвью на работающем стенде. Практический вывод один: правишь
|
||||
матвью — не верь ближайшей отправке, повтори её. Чем именно держится
|
||||
задержка, не измерено; наблюдение записано как наблюдение.
|
||||
созданные матвью разбора при живом чтеце: сообщение, отправленное сразу после,
|
||||
легло в STG и не попало в ODS никуда — ни в событие, ни в ошибки; то же
|
||||
сообщение через минуту разобралось штатно. Воспроизведено дважды 7 августа
|
||||
2026 года. Это тот же зазор, о котором предупреждает нумерация файлов DDL,
|
||||
только приходит он с другой стороны — не при первом создании, а при замене
|
||||
матвью на работающем стенде. Практический вывод один: правишь матвью — не верь
|
||||
ближайшей отправке, повтори её. Чем именно держится задержка, не измерено;
|
||||
наблюдение записано как наблюдение.
|
||||
- Форма ключа `ods.event` принимается такой, как её задумала спека: выражение
|
||||
`intHash32(ClientID)` стоит в ключе сортировки `ReplacingMergeTree`, а
|
||||
`SAMPLE BY` — по тому же выражению. Вопрос стоял открытым в разделе 11
|
||||
@@ -811,18 +808,19 @@ DDL-словарь с файловым источником внутри `user_f
|
||||
|
||||
Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись
|
||||
Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`)
|
||||
читаются, двигают офсет потребителя и не дают строки вовсе — ни в сырьё, ни в
|
||||
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа
|
||||
2026 года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, —
|
||||
в сырьё приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при
|
||||
чём: `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на
|
||||
том же наборе даёт ровно те же две строки и тот же офсет.
|
||||
читаются, двигают офсет потребителя и не дают строки вовсе — ни в STG, ни в
|
||||
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа 2026
|
||||
года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, — в STG
|
||||
приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при чём:
|
||||
`LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на том
|
||||
же наборе даёт ровно те же две строки и тот же офсет.
|
||||
|
||||
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
|
||||
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
|
||||
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
|
||||
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
|
||||
самом сырье, `kafka_offset` лежит там колонкой, и дырка в офсетах — это он.
|
||||
самой таблице STG, `kafka_offset` лежит там колонкой, и дырка в офсетах — это
|
||||
он.
|
||||
|
||||
Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS
|
||||
<локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
|
||||
@@ -865,7 +863,7 @@ Kafka с пустым значением (ноль байт) и запись-н
|
||||
- **`JSONExtract` читает `Decimal` и из числа JSON.** Массив
|
||||
`purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается
|
||||
сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на
|
||||
ClickHouse 26.3; все суммы после ODS совпали с сырьем до копейки.
|
||||
ClickHouse 26.3; все суммы после ODS совпали с сырыми данными до копейки.
|
||||
|
||||
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
|
||||
матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
|
||||
@@ -23,9 +23,9 @@
|
||||
заметит потерянного подключения Superset: он про ClickHouse и только.
|
||||
|
||||
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
|
||||
против описи — вопрос к ClickHouse: строки в `ods.event` считает сам
|
||||
сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене
|
||||
они подошли бы смоуку.
|
||||
против паспорта — вопрос к ClickHouse: строки в `ods.event` считает сам сервер и
|
||||
отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене они
|
||||
подошли бы смоуку.
|
||||
|
||||
## Карта целей
|
||||
|
||||
@@ -44,11 +44,11 @@
|
||||
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
||||
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
|
||||
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с |
|
||||
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против паспорта | нужен | 24 с |
|
||||
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
|
||||
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
||||
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
|
||||
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [паспорт мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
|
||||
|
||||
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
|
||||
разными дверями охватывают разное: корневая не видит ни одного файла
|
||||
@@ -87,7 +87,7 @@ Airflow. В цену целей она не входит. На готовом м
|
||||
|
||||
`make rebuild-storage` тоже не проверка. Команда через Airflow удаляет
|
||||
прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL,
|
||||
заливает стартовый мир и возвращает его пульт к концу стартовых дней. Службы и
|
||||
заливает стартовый мир и возвращает его позицию к концу стартовых дней. Службы и
|
||||
их тома она сохраняет, поэтому возврат к каноническим данным не требует
|
||||
холодного запуска всего стенда.
|
||||
|
||||
@@ -131,10 +131,9 @@ ClickHouse отвечает сразу.
|
||||
дорогая. Правки дагов и Superset — корневой `make lint`.
|
||||
|
||||
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
|
||||
для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
|
||||
узнать, тот ли получается мир, его надо получить. Место выбрано по той же
|
||||
оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не
|
||||
нужен, — и
|
||||
для сверки с паспортом мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
|
||||
узнать, тот ли получается мир, его надо получить. Место выбрано по той же оси
|
||||
«кого спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и
|
||||
краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
|
||||
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
|
||||
поломкой хранилища, а не забытой пересборкой.
|
||||
@@ -172,10 +171,10 @@ ClickHouse отвечает сразу.
|
||||
жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он
|
||||
становится темой урока.
|
||||
|
||||
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырья срок
|
||||
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырых данных срок
|
||||
жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в
|
||||
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и
|
||||
их. Хуже явного мусора: явный видно.
|
||||
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и их.
|
||||
Хуже явного мусора: явный видно.
|
||||
|
||||
Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.**
|
||||
Дешевле всего это выходит партицией — опыт играет модельный день за границей
|
||||
@@ -184,10 +183,10 @@ ClickHouse отвечает сразу.
|
||||
же механизм, на котором стоит переобработка дня X.
|
||||
|
||||
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
|
||||
что стенд произвёл сам: подневный счёт событий против описи мира ловит и
|
||||
что стенд произвёл сам: подневный счёт событий против паспорта мира ловит и
|
||||
сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге,
|
||||
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки
|
||||
Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
||||
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки Kafka →
|
||||
STG → ODS, и живёт он в `make check-clickhouse`.
|
||||
|
||||
Правило, которое такая проверка обязана выдержать и которое стоит держать в
|
||||
голове для следующей: **на растущих данных утверждать можно только про
|
||||
@@ -281,9 +280,9 @@ Kafka по той же причине спрашивают снаружи. Её
|
||||
|
||||
`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse:
|
||||
описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`,
|
||||
`Distributed`, очередь распределённых DDL и очистку временных таблиц. Девятая
|
||||
из десяти проверок спрашивает словарь товаров на обеих нодах. Последняя —
|
||||
подневный счёт событий против описи, о нем сказано выше.
|
||||
`Distributed`, очередь распределённых DDL и очистку временных таблиц. Девятая из
|
||||
десяти проверок спрашивает словарь товаров на обеих нодах. Последняя — подневный
|
||||
счёт событий против паспорта, о нем сказано выше.
|
||||
|
||||
## Что проверено
|
||||
|
||||
@@ -352,11 +351,11 @@ wc -c /tmp/stage7-events.jsonl /tmp/stage7-snapshot-{1,2,3,4,5,6,7}.jsonl
|
||||
time make test
|
||||
```
|
||||
|
||||
Опись пересобралась за 20,515 с. Файловый приемник записал 460 731 812 байт:
|
||||
458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0
|
||||
за 176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов
|
||||
стало 437 против 407, и потяжелел сам мир — та же пересборка восьми дней
|
||||
стоила 13 с, теперь 20,5. Эта правка добавила один тест — проигрыш дня.
|
||||
Паспорт пересобрался за 20,515 с. Файловый приемник записал 460 731 812 байт:
|
||||
458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0 за
|
||||
176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов стало
|
||||
437 против 407, и потяжелел сам мир — та же пересборка восьми дней стоила 13 с,
|
||||
теперь 20,5. Эта правка добавила один тест — проигрыш дня.
|
||||
|
||||
**Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
|
||||
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
|
||||
@@ -464,11 +463,11 @@ shell-версия узкой пересборки за 2 м 25 с пересо
|
||||
`make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за
|
||||
3 м 35 с. Отправка семи слепков заняла около 28 секунд.
|
||||
|
||||
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и пульта
|
||||
мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время
|
||||
ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й
|
||||
секунде, обработчик DAG стал здоров примерно на 458-й. Числа здесь называют
|
||||
цену нынешнего стенда, а не выносят вердикт о регрессии.
|
||||
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и дагов
|
||||
модельного времени. Весь рост приписывать слепкам нельзя: на холодном прогоне
|
||||
основное время ушло ещё до сценариев ожидания: `airflow-init` завершился
|
||||
примерно на 320-й секунде, обработчик DAG стал здоров примерно на 458-й. Числа
|
||||
здесь называют цену нынешнего стенда, а не выносят вердикт о регрессии.
|
||||
|
||||
**Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой
|
||||
проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за
|
||||
@@ -484,25 +483,25 @@ shell-версия узкой пересборки за 2 м 25 с пересо
|
||||
от файла, и ближний выигрывает.
|
||||
|
||||
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
|
||||
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка:
|
||||
18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka.
|
||||
Повторный `make up` на живом стенде — 1 м 5 с: мир заливается заново, и это
|
||||
не оплошность — `WatchID` те же, повтор схлопнет `ReplacingMergeTree`.
|
||||
Смоук — 9 с, `check-clickhouse` — 8 с с новой девятой проверкой. `make test`
|
||||
вырос до 71 с: 58 с прежних тестов плюс 13 с на пересборку восьми дней для
|
||||
сверки с описью. Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
|
||||
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка: 18,7
|
||||
с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka. Повторный
|
||||
`make up` на живом стенде — 1 м 5 с: мир заливается заново, и это не оплошность
|
||||
— `WatchID` те же, повтор схлопнет `ReplacingMergeTree`. Смоук — 9 с,
|
||||
`check-clickhouse` — 8 с с новой девятой проверкой. `make test` вырос до 71 с:
|
||||
58 с прежних тестов плюс 13 с на пересборку восьми дней для сверки с паспортом.
|
||||
Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
|
||||
#41 и #43.
|
||||
|
||||
Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и
|
||||
проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в
|
||||
`ods.event_rep` — проверка покраснела, показала недостающий день и назвала
|
||||
адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца
|
||||
топика». Затем положили в сырьё заведомо негодную строку — брак появился, и
|
||||
диагноз сменился на второй, с признаком для различения: «сойдётся недостача с
|
||||
числом брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки
|
||||
опыта убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт
|
||||
вернулся к 401 185, и это заодно показало дедупликацию: повтор дня не удвоил
|
||||
счёт под `FINAL`.
|
||||
адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца топика».
|
||||
Затем положили в STG заведомо негодную строку — брак появился, и диагноз
|
||||
сменился на второй, с признаком для различения: «сойдётся недостача с числом
|
||||
брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки опыта
|
||||
убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт вернулся к
|
||||
401 185, и это заодно показало дедупликацию: повтор дня не удвоил счёт под
|
||||
`FINAL`.
|
||||
|
||||
Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и
|
||||
совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону:
|
||||
|
||||
+75
-73
@@ -1,12 +1,13 @@
|
||||
# Мир и его пульт
|
||||
# Мир и модельное время
|
||||
|
||||
Модельное время стенда со стороны оператора: как рождается мир, что двигает
|
||||
его ось, кто играет дни и где хранится позиция.
|
||||
Доводы об устройстве пульта — в [ADR 0009](../adr/0009-world-control.md), о
|
||||
безопасном повторном подъеме и жизненном цикле мира — в
|
||||
[ADR 0013](../adr/0013-world-lifecycle.md). Почему последний день стартового
|
||||
мира неполон и что хранит опись, разобрано в [справочнике стартового
|
||||
мира](orders/start-world.md) и [справочнике описи](orders/inventory.md).
|
||||
Оператор управляет модельным временем стенда. Здесь разобрано, как рождается
|
||||
мир, что двигает его ось, какие даги играют дни и где хранится позиция. Доводы
|
||||
об устройстве дагов модельного времени — в [ADR
|
||||
0009](../adr/0009-world-control.md), о безопасном повторном подъёме и жизненном
|
||||
цикле мира — в [ADR 0013](../adr/0013-world-lifecycle.md). Почему последний день
|
||||
стартового мира неполон и что хранит паспорт, разобрано в [справочнике
|
||||
стартового мира](orders/start-world.md) и [справочнике
|
||||
паспорта](orders/inventory.md).
|
||||
|
||||
## Стартовый мир
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
генератора и играет в `hits` первые восемь дней модельного времени — понедельник
|
||||
по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в
|
||||
`orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только
|
||||
тогда ставит позицию мира в `8`. Так у всякой лабы есть данные, и всегда одни и
|
||||
тогда ставит позицию мира в `8`. Так у каждой лабы есть данные, и всегда одни и
|
||||
те же.
|
||||
|
||||
Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
|
||||
@@ -28,21 +29,21 @@
|
||||
образ и не заливает данные повторно. Изменённую схему применяют адресно либо
|
||||
явной командой `make rebuild-storage`.
|
||||
|
||||
## Опись мира
|
||||
## Паспорт мира
|
||||
|
||||
Сам мир в git не хранится — он чистая функция зерна, и держать его в
|
||||
репозитории значило бы держать там кэш. Вместо него лежит **опись мира**,
|
||||
Сам мир в git не хранится: он вычисляется из зерна, поэтому готовая копия была
|
||||
бы лишь кэшем. Вместо него лежит **паспорт мира**,
|
||||
[`data/world-inventory.json`](../../data/world-inventory.json): зерно, версия
|
||||
генератора, хеш каталога товаров и по строке на каждый день — дата, число
|
||||
событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики
|
||||
классов расхождений по заказам. Опись отвечает на единственный вопрос: тот ли
|
||||
это мир, что был вчера.
|
||||
событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики классов
|
||||
расхождений по заказам. Паспорт отвечает на единственный вопрос: тот ли это мир,
|
||||
что был вчера.
|
||||
|
||||
Спрашивают её двое. `make test` в `generator/` сверяет опись с тем, что
|
||||
собирается из кода сегодня: правка генератора меняет мир, и опись надо
|
||||
пересобрать — `make inventory` там же. `make check-clickhouse` в корне сверяет
|
||||
с описью то, что доехало до `ods.event`, подневно. Хеш дня можно пересчитать и
|
||||
руками — это обычный `sha256sum` файла, который пишет файловый приёмник:
|
||||
Спрашивают его двое. `make test` в `generator/` сверяет паспорт с тем, что
|
||||
собирается из кода сегодня: правка генератора меняет мир, и паспорт надо
|
||||
пересобрать — `make inventory` там же. `make check-clickhouse` в корне по дням
|
||||
сверяет паспорт с данными, поступившими в `ods.event`. Хеш дня можно пересчитать
|
||||
и руками — это обычный `sha256sum` файла, который пишет файловый приёмник:
|
||||
|
||||
```bash
|
||||
uv run --project generator python -m clickstream_generator batch \
|
||||
@@ -52,18 +53,19 @@ sha256sum tmp/day0.jsonl
|
||||
|
||||
## Как запустить генератор
|
||||
|
||||
События производит генератор из [`generator/`](../../generator/). Модельный день —
|
||||
функция зерна и номера дня, поэтому один и тот же день всегда даёт те же
|
||||
события: обрыв лечится повтором. Позицию на оси генератор не помнит — какой
|
||||
день играть, решает зовущий.
|
||||
События производит генератор из [`generator/`](../../generator/). Модельный день
|
||||
— функция зерна и номера дня, поэтому один и тот же день всегда даёт те же
|
||||
события: обрыв лечится повтором. Позицию на оси генератор не помнит — номер дня
|
||||
передаёт вызывающая команда.
|
||||
|
||||
Режима два, и различаются они темпом. **Пакетный** гонит день подряд, без пауз:
|
||||
так заливается мир и так переигрывается день после обрыва. **Живой** держит темп
|
||||
модельного времени — по умолчанию ×60: модельные сутки за 24 реальные минуты,
|
||||
суточная волна разворачивается на глазах, отставание видно в логе.
|
||||
Режима два, и различаются они темпом. В **пакетном** режиме события дня
|
||||
отправляются подряд, без пауз: так заливается мир и так переигрывается день
|
||||
после обрыва. **Живой** держит темп модельного времени — по умолчанию ×60:
|
||||
модельные сутки за 24 реальные минуты, суточная волна разворачивается на глазах,
|
||||
отставание видно в журнале.
|
||||
|
||||
На стенде генератор ходит своим образом — разовой службой, которую поднимают
|
||||
и убирают на один прогон. На каждый режим по цели:
|
||||
На стенде генератор запускается из отдельного образа как разовая служба. После
|
||||
прогона её убирают. Для каждого режима есть своя цель:
|
||||
|
||||
```bash
|
||||
# день D0 целиком в топик hits
|
||||
@@ -80,24 +82,24 @@ make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000
|
||||
| `GENERATOR_LIMIT` | потолок событий на прогон; только `generate-batch` | нет: день целиком |
|
||||
| `GENERATOR_SPEED` | ускорение модельного времени; только `generate-live` | ×60, задано в генераторе |
|
||||
|
||||
Куда отправлять, обеим целям задаёт сам стенд: внутри сети Compose это брокер
|
||||
Адрес назначения для обеих целей задаёт стенд: внутри сети Compose это брокер
|
||||
`kafka:9092` и топик `hits`.
|
||||
|
||||
Образ цели не пересобирают: нет образа — Compose соберёт его сам, есть —
|
||||
возьмёт как есть. Пересобрать намеренно, после правки `generator/Dockerfile`
|
||||
или зависимостей, — отдельной командой:
|
||||
При обычном запуске образ генератора не пересобирается. Если образа нет, Compose
|
||||
соберёт его; если есть, использует готовый. После правки `generator/Dockerfile`
|
||||
или зависимостей образ пересобирают отдельной командой:
|
||||
|
||||
```bash
|
||||
docker compose --profile generator build generator
|
||||
```
|
||||
|
||||
Тот же образ несёт заливка стартового мира. Перед такой заливкой
|
||||
`world_initialize` сам собирает его из текущего дерева; на готовом мире
|
||||
повторный `make up` до сборки не доходит.
|
||||
Для заливки стартового мира нужен тот же образ. Перед такой заливкой
|
||||
`world_initialize` собирает его из текущего дерева; на готовом мире повторный
|
||||
`make up` до сборки не доходит.
|
||||
|
||||
Разведено это нарочно: собрать образ и запустить контейнер — разные действия, и
|
||||
цель запуска, молча пересобирающая образ, стирает между ними границу. Что образ
|
||||
устарел, видно по собственному прогону — это обратная связь, а не ловушка.
|
||||
Сборка образа и запуск контейнера разделены намеренно. Если цель запуска сама
|
||||
пересобирает образ, различие между этими действиями исчезает. Следующий запуск
|
||||
покажет, что образ устарел.
|
||||
|
||||
Посмотреть на события, не поднимая стенд, помогает файловый приёмник: одно
|
||||
событие — одна строка. Флаг `--limit` берёт начало дня вместо целого дня —
|
||||
@@ -108,21 +110,21 @@ uv run --project generator python -m clickstream_generator batch \
|
||||
--day 0 --limit 100 --file tmp/day0.jsonl
|
||||
```
|
||||
|
||||
Несколько дней подряд играет один запуск: `--days 8`. Всё, что принимается
|
||||
флагами, принимается и переменными окружения — так генератор позовёт даг
|
||||
этапа 5; полный список у `--help`.
|
||||
За один запуск можно проиграть несколько дней подряд: `--days 8`. Всё, что
|
||||
принимается флагами, принимается и переменными окружения — так генератор позовёт
|
||||
даг этапа 5; полный список у `--help`.
|
||||
|
||||
**Значения по умолчанию есть не у всех параметров, и это сделано специально.**
|
||||
Зерно, число дней и темп живого дня описывают модель мира — их генератор знает
|
||||
сам. День на оси, приемник и имя топика описывают стенд, на котором генератор
|
||||
запустили: если их не задать, он откажется работать и вернет ненулевой код еще
|
||||
до первого события. Поэтому значение дня по умолчанию живет в `Makefile`: день
|
||||
задает тот, кто запускает, а не тот, кого запускают.
|
||||
сам. День на оси, приёмник и имя топика относятся к конкретному запуску
|
||||
генератора на стенде: если их не задать, он откажется работать и вернёт
|
||||
ненулевой код ещё до первого события. Поэтому значение дня по умолчанию живёт в
|
||||
`Makefile`: день задаёт тот, кто запускает, а не тот, кого запускают.
|
||||
|
||||
## Пульт мира
|
||||
## Даги модельного времени
|
||||
|
||||
Считать дни самому необязательно. В Airflow живёт **пульт мира**: два дага
|
||||
создают его начальное состояние, а три двигают ось дальше.
|
||||
Считать дни самому необязательно. В Airflow живут **даги модельного времени**:
|
||||
два дага создают начальное состояние мира, а три двигают ось дальше.
|
||||
|
||||
| Даг | Что делает |
|
||||
| --- | --- |
|
||||
@@ -130,33 +132,33 @@ uv run --project generator python -m clickstream_generator batch \
|
||||
| `world_recreate` | удаляет только прикладной мир и запускает его создание заново |
|
||||
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
|
||||
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
|
||||
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём |
|
||||
| `world_live` | выключатель: пока снят с паузы, запускает `world_live_day` день за днём |
|
||||
|
||||
Работник не только играет день: следом он отправляет слепок заказов за
|
||||
сыгранное и дёргает отдельный даг, `orders_ingest`, — тот забирает приехавший
|
||||
слепок из топика `orders` в сырьё, — и дожидается его конца. Так две половины
|
||||
стенда идут в ногу: упал приём — краснеет и работник, а позиция остаётся на
|
||||
месте ([ADR 0008](../adr/0008-order-ingestion.md)).
|
||||
Сыграв день, шаговый даг отправляет слепок заказов и запускает даг
|
||||
`orders_ingest`. Тот забирает слепок из топика `orders` в `stg`; шаговый даг
|
||||
ждёт его завершения. Так две половины стенда идут в ногу: упал приём — падает и
|
||||
шаговый даг, а позиция остаётся на месте ([ADR
|
||||
0008](../adr/0008-order-ingestion.md)).
|
||||
|
||||
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
|
||||
дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон
|
||||
позицию не двигает, и следующий запуск играет тот же день заново. Отсутствующая
|
||||
переменная означает, что мир ещё не создавали; `-1` — очистка не закончена,
|
||||
`0` — очистка закончена, а начальная генерация ещё не завершена. Работники
|
||||
начинают играть только с позиции `8` и больше.
|
||||
дня. Шаговый даг записывает позицию только после успешного завершения дня:
|
||||
оборванный прогон позицию не двигает, и следующий запуск играет тот же день
|
||||
заново. Отсутствующая переменная означает, что мир ещё не создавали; `-1` —
|
||||
очистка не закончена, `0` — очистка закончена, а начальная генерация ещё не
|
||||
завершена. Шаговые даги начинают играть только с позиции `8` и больше.
|
||||
|
||||
В форме запуска можно выбрать только число дней. Первый день работник берет из
|
||||
`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options».
|
||||
Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую
|
||||
позицию работник пишет в журнал шага `remember_played`.
|
||||
В форме запуска можно выбрать только число дней. Первый день шаговый даг берёт
|
||||
из `world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced
|
||||
Options». Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и
|
||||
новую позицию шаговый даг пишет в журнал шага `remember_played`.
|
||||
|
||||
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
|
||||
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
|
||||
целиком — [ADR 0009](../adr/0009-world-control.md).
|
||||
встанет на границе модельных суток, доиграв начатый день. Форма дагов времени и
|
||||
доводы целиком — [ADR 0009](../adr/0009-world-control.md).
|
||||
|
||||
**У этого решения есть цена: планировщику Airflow отдан сокет Docker** —
|
||||
иначе ему не поднять контейнер генератора. Доступ к сокету равен правам root
|
||||
на машине; для локального учебного стенда это приемлемо, но знать об этом
|
||||
нужно. Доступ дает не само монтирование сокета, а членство в группе `docker`:
|
||||
у нее на каждой машине свой GID, он записан в `.env`, а узнать его можно
|
||||
командой `getent group docker`.
|
||||
**У этого решения есть цена: планировщик Airflow подключён к сокету Docker.**
|
||||
Без него планировщик не сможет запустить контейнер генератора. Доступ к сокету
|
||||
равен правам root на машине; для локального учебного стенда это приемлемо, но
|
||||
знать об этом нужно. Доступ даёт не само монтирование сокета, а членство в
|
||||
группе `docker`: у неё на каждой машине свой GID. Он записан в `.env`, а узнать
|
||||
его можно командой `getent group docker`.
|
||||
Reference in new issue
Block a user