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:
ddadminandClaude Fable 5.1 committed 2026-09-02 16:46:50 +03:00
1 parent 86ad8afaf2
commit 59d8c83b17
54 files changed
+794 -776

No files matched your search

+3 -3
View File
@@ -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 по
+6 -6
View File
@@ -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)).
## Читатели
+9 -8
View File
@@ -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
View File
@@ -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` кончается
собранными слоями.
+1 -1
View File
@@ -42,7 +42,7 @@
Новый бросок в середине функции сдвигает все последующие значения этого
подпотока. Код продолжает работать, но тем же свойствам достается другая
случайность. Соседние подпотоки остаются прежними, а измененная часть
канонического мира проявляется в описи и побайтовом сравнении.
канонического мира проявляется в паспорте и побайтовом сравнении.
Какое обещание воспроизводимости это защищает, записано в
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
+14 -14
View File
@@ -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`.
## Что проверено
+3 -3
View File
@@ -30,9 +30,9 @@
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
пересечения выходят арифметикой, приоритет классов работает по-настоящему —
[классы расхождений и опоздание](fate.md).
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
каждого слепка и счётчики наблюдаемых классов —
[что хранит опись](inventory.md).
- **Паспорт хранит только то, чего движение мира не меняет**: хеш байтов
каждого слепка и счётчики наблюдаемых классов — [что хранит
паспорт](inventory.md).
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
[мост к склейке](identity.md).
+20 -21
View File
@@ -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, он в резерве.
## Классы расхождений и опоздание
+4 -4
View File
@@ -24,10 +24,10 @@
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
отдельные прогоны одного мира дают то же соответствие; принятый мир
воспроизводит эффект склейки без случайных ложных объединений — конкретные
числа пар контрактом описи не являются. Межкогортные столкновения принимаются
по той же дисциплине, что у случайных `ClientID`: принимается конкретный
канонический мир по внешнему результату.
воспроизводит эффект склейки без случайных ложных объединений. Паспорт не
закрепляет конкретное число пар. Межкогортные столкновения принимаются по той же
дисциплине, что у случайных `ClientID`: принимается конкретный канонический мир
по внешнему результату.
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
+12 -11
View File
@@ -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: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
+12 -12
View File
@@ -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` — вопрос стенда и его проверок. Поэтому
счёта строк у слепка в паспорте нет; понадобится проверка приёма заказов —
число заведётся вместе с ней.
+2 -2
View File
@@ -112,5 +112,5 @@
такого значения означает ошибку арифметики источника.
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
его даром, а хешу слепка в описи нужен именно названный порядок.
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт его
даром, а хешу слепка в паспорте нужен именно названный порядок.
+8 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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 и переехали выше.
+42 -43
View File
@@ -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
View File
@@ -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`.