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

+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 и переехали выше.