feat(stg): DDL-бутстрап, топик hits и приём сырья обеими нодами

Зачем: стенду нужен воспроизводимый холодный старт, при котором схема
хранилища и топик появляются сами, а сырьё из Kafka доезжает в STG обеими
нодами кластера — без ручных шагов между `make clean` и рабочим приёмом.

Что:
- `sql/ddl/` — три файла, применяются по порядку имён: базы `stg` и `ods`,
  Kafka-чтец `hits_raw_kafka` формата RawBLOB, реплицируемая `hits_raw_rep`
  с окном TTL в трое суток, распределённая `hits_raw_dist` и матвью
  `hits_raw_mv`, переносящая сырьё вместе с метаданными доставки.
- `compose.yaml` — службы `kafka-init` (топик `hits` на две партиции, с
  ремонтом уже созданного однопартиционного) и `clickhouse-init` (применяет
  `/ddl/*.sql`); `hostname:` у обеих нод, чтобы `hostName()` отдавал имя узла,
  а не идентификатор контейнера; `airflow-init` зависит от `clickhouse-init` —
  без зависимого успешный одноразовый сервис считается упавшим для `--wait`.
- Доки: конвенции и раздел «Что проверено» в справочнике хранилища, указатели
  и границы обещаний в ADR 0005, снятые пункты в разделе 11 спеки.

Проверка: `make lint`, `make typecheck`, `make config-test`, `make smoke`
(25 проверок), `make smoke-guards` — зелёные. Приёмочный прогон с чистого
тома подтвердил все пять критериев #37: холодный старт и идемпотентный
повтор, две партиции у `hits`, метаданные доставки у доехавшего сообщения,
обе партиции на обеих потребляющих нодах в одном прогоне, некорректный JSON
лежит сырым и приём не встаёт.

Известная граница: RawBLOB молча теряет запись с пустым значением и
запись-надгробие; принято как свойство, замер и довод — в справочнике
хранилища.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 08:04:25 +03:00
co-authored by Claude Opus 5
parent a0144e0fff
commit daf13384a8
7 changed files with 344 additions and 63 deletions
+22 -15
View File
@@ -4,14 +4,18 @@
## Решение
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: сообщение ложится в
`stg.hits_raw_dist` строкой, как пришло, рядом с метаданными доставки. Ни
типизации, ни проверки на этом шаге нет — слой сырья ничего не интерпретирует.
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение
ложится в `stg.hits_raw_dist` строкой, как пришло, рядом с метаданными доставки.
Ни типизации, ни проверки на этом шаге нет — слой сырья ничего не интерпретирует.
У формата есть следствие для DDL: он читает вход в одно значение и рассчитан на
таблицу с единственной колонкой, поэтому у чтеца она ровно одна — `raw`, а
метаданные доставки берутся только из виртуальных колонок и добавить к чтецу
что-либо своё нельзя.
Слово «непустое» приписано позже самого решения: у обещания нашлась измеренная
граница, и она датирована ниже, в разделе «Что проверено». Решения она не
меняет — от того, рождает ли пустая запись строку, выбор формата не зависит.
Типизированный слой наполняют две матвью, привязанные к `stg.hits_raw_dist`.
Поля достаются `JSONExtract`. В таблицу ошибок уходят три класса брака:
сообщение, не являющееся объектом JSON; объект, чей набор ключей разошёлся с
@@ -99,8 +103,9 @@ STG сырьё исключительно от брака: слой сырых
грязные записи не должны валить пайплайн. Лечится это двумя способами — включить
на чтеце режим обработки ошибок или не проверять на входе вовсе. Второе честнее:
частичная проверка в слое, чья работа — не проверять, спорит сама с собой, а
настоящий разбор всё равно идёт ниже. При `RawBLOB` ломаться нечему, доезжают
любые байты, и оба класса брака разбираются в одном месте.
настоящий разбор всё равно идёт ниже. При `RawBLOB` ломаться нечему, доезжает
любое непустое сообщение, каким бы мусором оно ни было (про «непустое» — там же,
в «Что проверено»), и оба класса брака разбираются в одном месте.
Архив нужен буквальный и читаемый глазами. `RawBLOB` — это про способ чтения, а
не про тип колонки: на диске лежит обычный `String` с текстом события, и менти
@@ -162,18 +167,20 @@ contract-тест из #43 сюда не дотягивается — он ср
Матвью с источником-`Distributed` срабатывает на вставку именно в эту
распределённую таблицу — блок она видит до разрезания по шардам. Проверено
владельцем на рабочих проектах; на стенде подтверждается заодно с приёмкой #37.
владельцем на рабочих проектах; на стенде проверяется вместе с матвью разбора,
то есть при исполнении #43.
На живом стенде проверяется при исполнении #37. Первые два внесены в раздел 11
спеки; остальные — однострочные `SELECT`, их довольно прогнать заодно:
Пункт про `RawBLOB`, стоявший в списке ниже первым, закрыт при исполнении #37,
на стенде 5 августа 2026 года: формат даёт ровно одну строку на каждое непустое
сообщение, пачка продюсера границы сообщений не стирает, запасной `LineAsString`
не понадобился.
Тогда же нашлась и граница обещания — запись с пустым значением и
запись-надгробие не дают строки вовсе; из-за неё в «Решении» и в «Почему»
приписано слово «непустое». Замер целиком — в [доке
хранилища](../architecture/storage.md), раздел «Что проверено». Остальные три
по-прежнему ждут живого стенда; это однострочные `SELECT`, их довольно прогнать
заодно:
- `RawBLOB` в Kafka-движке даёт ровно одну строку на сообщение. Проверять это
нужно первым и до написания DDL: формулировка «читает вход в одно значение»
про файл понятна, а про пачку сообщений из топика — нет, и если сообщения
склеятся, переделывать придётся решение целиком, а не DDL. Опыт стоит трёх
сообщений и одного `count()`. Запасной вариант — `LineAsString`: он режет по
переводу строки, а события у нас однострочные; цена запасного — сообщение с
переводом строки внутри даст две строки вместо одной;
- форма именованного кортежа в `JSONExtract` с `Nullable`-членами — нужна для
свёртки сорока семи вызовов в один, если разбор окажется дорогим;
- `isValidJSON('123')` возвращает единицу, а `JSONExtractKeys` от скаляра —
+98 -41
View File
@@ -6,10 +6,10 @@
раздел «Что проверено» — чему в этом тексте верить и на каком основании.
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
keeper, Kafka, каркас сервисов. Объекты хранилища и механизм применения DDL
закладывает этап 2 — на момент написания их в репозитории нет. Дальше по тексту
устройство описано так, как оно проектируется; построенное от заложенного
отличает карта таблиц в конце.
keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/` уже лежат базы слоёв
и объекты STG — чтец топика `hits`, таблицы сырья и матвью приёма. Объектов ODS
в репозитории пока нет. Дальше по тексту устройство описано так, как оно
проектируется; построенное от заложенного отличает карта таблиц в конце.
Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
@@ -100,16 +100,19 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
Типы у них такие: `kafka_topic` и `consumer_host``LowCardinality(String)`,
значений мало и они повторяются; `kafka_partition` и `kafka_offset``UInt64`.
С `kafka_timestamp` сложнее: виртуальная колонка `_timestamp` заполнена не
всегда, а разрядность у секундной и миллисекундной версий разная. Поэтому колонка
объявляется `Nullable(DateTime)`, а точная форма проверяется на стенде
(раздел 11 спеки) — записать её в необнуляемый тип значит либо уронить приём на
первом сообщении, либо получить тихие нули за 1970 год.
С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок
даёт две: `_timestamp``Nullable(DateTime)`, то есть секунды, и
`_timestamp_ms` `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
`Nullable(DateTime64(3))` и заполняется из `_timestamp_ms`: у брокера метка
миллисекундная, соседняя `_load_ts` тоже `DateTime64(3)`, а слой сырья хранит
приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от разрядности:
брокер метку заполняет не всегда, а необнуляемый тип значил бы либо падение
приёма на первом сообщении, либо тихие нули за 1970 год.
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()`
сработал бы на шарде-получателе и назвал бы не ту ноду, которая читала топик, —
то есть колонка молча отвечала бы на другой вопрос.
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
на шарде-получателе, то есть назвал бы не ту ноду, которая читала топик, —
колонка молча отвечала бы на другой вопрос.
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
не проверяет, поэтому там оказываются и целые события, и мусор. Разбирается всё
@@ -189,6 +192,10 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
компромисс полезнее показать, чем спрятать за галочкой.
Ещё одно место, где сырьё хранит не всё приехавшее: запись с пустым значением и
запись-надгробие проходят молча, не оставляя строки. Свойство измерено и принято
осознанно — подробности в разделе «Что проверено».
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при
@@ -311,18 +318,23 @@ ODS. Второе: матвью приёма создаётся последне
работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топик
`hits` с двумя партициями, затем `clickhouse-init` дожидается его завершения и
применяет файлы с ноды 1, `ON CLUSTER`. Этот порядок страхует от автосоздания
топика брокером с одной партицией: у потребителя librdkafka разрешение на
автосоздание по умолчанию выключено, так что случиться это не обязано, но урок
«обе ноды читают топик» умирает тихо, и полагаться на умолчание клиента здесь
не стоит.
топика с одной партицией. Переключателей тут два, и путать их не надо: брокер
автосоздание разрешает, а потребитель librdkafka внутри ClickHouse его не
просит — оба конца измерены, см. «Что проверено». То есть стенд держится на
умолчании клиента, а урок «обе ноды читают топик» умирает тихо, поэтому топик и
создаётся явно, до применения DDL.
Образцы копируются не целиком, и в двух местах. `clickhouse-init` обязан ждать
готовности **обеих** нод: `ON CLUSTER` ждёт исполнения на всех хостах и по
таймауту бросает, а `airflow-init` ждёт только первую ноду, `superset-init`
только вторую. И второе: оба образца переживают `make up --wait` лишь потому, что
от них зависят долгоживущие сервисы; у пары `kafka-init` / `clickhouse-init`
таких зависимых нет, и как поведёт себя `--wait` с одноразовым сервисом без них —
проверяется при исполнении #37.
от них зависят долгоживущие сервисы. Что делает `--wait` с одноразовым сервисом
без зависимых, проверено при исполнении #37 на Docker Compose 2.40.3: считает
его упавшим и возвращает единицу, хотя контейнер вышел с нулём. Поэтому
зависимые есть и у новой пары: `clickhouse-init` ждёт `kafka-init`, а
`airflow-init``clickhouse-init`, и цепочка упирается в долгоживущий Airflow.
Побочная выгода важнее обхода `--wait`: к моменту старта Airflow DDL заведомо
применён.
Повторный `make up` поверх живого тома проходит зелёным: весь DDL идёт через
`CREATE ... IF NOT EXISTS`. Оборотная сторона — изменённый объект тем же
@@ -333,7 +345,8 @@ ODS. Второе: матвью приёма создаётся последне
## Карта таблиц
Ниже — то, что закладывает этап 2. В репозитории этих объектов пока нет.
Ниже — то, что закладывает этап 2. DDL слоя STG уже лежит в `sql/ddl/`;
объектов ODS в репозитории пока нет.
| Слой | Объект | Что это |
|---|---|---|
@@ -349,12 +362,12 @@ ODS. Второе: матвью приёма создаётся последне
## Что проверено
Документ описывает устройство, которого в репозитории ещё нет, и на каждом шагу
опирается на поведение ClickHouse. Поэтому утверждения о движке разведены на три
группы: насколько фразе можно верить, должно быть видно из текста, а не зависеть
от того, хорошо ли автор помнит документацию. Сверка — через MCP Context7,
5 августа 2026 года; то же разведение для механики приёма — в
[ADR 0005](../adr/0005-event-ingestion.md).
Документ на каждом шагу опирается на поведение ClickHouse, а местами и Kafka.
Поэтому утверждения о них разведены на три группы: насколько фразе можно верить,
должно быть видно из текста, а не зависеть от того, хорошо ли автор помнит
документацию. Сверка с
документацией — через MCP Context7, 5 августа 2026 года; то же разведение для
механики приёма — в [ADR 0005](../adr/0005-event-ingestion.md).
**Сверено с документацией.** Собственная колонка с именем виртуальной делает
виртуальную недоступной. При вставке в `Distributed` шард выбирается по ключу
@@ -369,19 +382,63 @@ ODS. Второе: матвью приёма создаётся последне
завязан на движок базы `Atomic`. `ON CLUSTER` ждёт все хосты и бросает по
таймауту; `CREATE ... IF NOT EXISTS` на существующем объекте не бросает.
**Записано как проверка на стенде** — раздел 11 спеки и ADR 0005. Срабатывание
матвью с источником-`Distributed` на вставку именно в неё: этого случая в
документации нет вовсе, утверждение держится на опыте владельца. Одна строка на
сообщение у `RawBLOB`. Обнуляемость и разрядность виртуальной колонки
`_timestamp`.
**Проверено на стенде.** Опыты прогнаны на живом кластере при исполнении #37:
четыре — 5 августа 2026 года, пятый — 6 августа. Все подтвердили то, что здесь
написано.
**Сказано по памяти, проверки пока нет.** Что `DROP/REPLACE PARTITION` не
работает по `Distributed` — прямого запрета в документации нет, все примеры даны
для семейства MergeTree. Что `DEFAULT hostName()` вычислился бы на
шарде-получателе, а не на вставляющей ноде, и что имя читавшей ноды после записи
в `Distributed` уже невосстановимо. Что у потребителя librdkafka автосоздание
топиков по умолчанию выключено. Что упавшая матвью роняет вставку и
останавливает потребление до починки — на этой фразе держится правило «грязные
записи не валят пайплайн», и стоит она пока на одном рассуждении. Проверяются
все пятеро дёшево и заодно с приёмкой #37; до тех пор это предположения, а не
знание.
- `RawBLOB` даёт ровно одну строку на каждое непустое сообщение. Три сообщения
с ключами, поставленные в очередь до одного сброса продюсера, стали тремя
строками с тремя разными офсетами: пачка продюсера границы сообщений не
стирает. Запасной формат `LineAsString` не понадобился. Про границу «непустое»
— сразу ниже.
- Меток времени у Kafka-движка две, и разрядность у них разная: `_timestamp`
`Nullable(DateTime)`, `_timestamp_ms``Nullable(DateTime64(3))`. Отсюда
форма `kafka_timestamp` в разделе о служебных колонках.
- `DEFAULT hostName()`, объявленный только на локальной таблице, вычисляется на
шарде-получателе. Строки, вставленные с ноды 1 и уехавшие на ноду 2, несут в
этой колонке ноду 2, а в соседней, заполненной явным `hostName()` во
вставляющем `SELECT`, — ноду 1. Довод за то, чтобы служебные колонки
заполняла матвью выражением, держится. Отсюда же и невосстановимость: после
записи в `Distributed` имя читавшей ноды взять больше неоткуда — своей
колонкой оно не сохранено, а умолчание назовёт получателя.
- `DROP PARTITION` и `REPLACE PARTITION` по `Distributed` не работают: обе
операции отвечают кодом 48, «Table engine Distributed doesn't support
partitioning», и оба шарда остаются нетронутыми.
- Автосоздания топиков ClickHouse не просит, и переключателей здесь два. На
брокере автосоздание разрешено: `auto.create.topics.enable=true`, и это
умолчание образа, а не наша настройка. На клиенте — выключено: чтец с матвью,
наведённые на несуществующий топик, ждали его с ошибкой «Broker: Unknown topic
or partition», и топик не появился. Значит, от тихого топика с одной партицией
стенд бережёт клиентское умолчание, а не брокер.
Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись
Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`)
читаются, двигают офсет потребителя и не дают строки вовсе — ни в сырьё, ни в
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа
2026 года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, —
в сырьё приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при
чём: `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на
том же наборе даёт ровно те же две строки и тот же офсет.
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
самом сырье, `kafka_offset` лежит там колонкой, и дырка в офсетах — это он.
Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS
<локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
и на распределённой таблице, могло бы вычислиться до раскладки по шардам. То
есть вывод опыта надёжен именно для умолчания, живущего только на локальной
таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет:
служебные колонки мы заполняем выражением при любом ответе.
**Сказано по памяти, проверки пока нет.** Осталось два утверждения, и оба ждут
одного и того же — матвью разбора, а она приходит с #43.
- Матвью с источником-`Distributed` срабатывает на вставку именно в эту
распределённую таблицу, до раскладки по шардам. В документации случая нет
вовсе, утверждение держится на опыте владельца.
- Упавшая матвью роняет вставку и останавливает потребление до починки. На этой
фразе стоит правило «грязные записи не валят пайплайн», а сама она стоит пока
на одном рассуждении.
+8 -7
View File
@@ -579,18 +579,19 @@ smoke-проверки, а не «дашборд зелёный». Это мин
## 11. Проверить при исполнении
Список убывает по мере постройки: проверенное уходит отсюда, а ответ с датой
остаётся там, где на него опираются. Формат чтеца и форма виртуальной метки
времени закрыты при исполнении #37 — см. [доку
хранилища](../architecture/storage.md), раздел «Что проверено».
- Поведение соединения двух Distributed-таблиц и `distributed_product_mode`
эмпирически на стенде (хвост #14).
- Kafka Engine на двух нодах в одной consumer group: ребаланс партиций между
прогонами, отсутствие дублей при штатной работе.
прогонами, отсутствие дублей при штатной работе. Закрыто пока наполовину: что
обе ноды читают топик и обе партиции доезжают, показал #37; что дублей нет и
как раскладка меняется между прогонами — нет.
- Точная форма `ORDER BY` ODS-таблиц (выражение `intHash32` в ключе
ReplacingMergeTree).
- `RawBLOB` в Kafka-движке даёт ровно одну строку на сообщение (ADR 0005).
Проверять первым, до написания DDL: если сообщения склеятся, переделывать
придётся решение, а не запрос. Запасной формат — `LineAsString`.
- Тип виртуальной колонки `_timestamp` у Kafka-движка: обнуляемость и
разрядность (секунды против миллисекунд) — от этого зависит объявление
`kafka_timestamp` в таблице сырья.
- Матвью с источником-`Distributed` срабатывает на вставку именно в эту
распределённую таблицу, до раскладки по шардам: на этом стоит цепочка
STG → ODS (ADR 0005). Проверено владельцем на рабочих проектах, в документации