docs(storage): связность восстановлена, форма дат на проводе задана

- Зачем:
  - холодное ревью связности нашло девять мест, где вставленный текст спорит с
    соседним; отдельно вскрылось, что представление дат в JSON не зафиксировано
    нигде, а #43 обязан его знать раньше, чем #41 напишет сериализатор.
- Что:
  - гарантия приёма переписана: после снятия синхронной вставки «хотя бы один
    раз» стало неправдой — есть и окно потери, и окно дубля.
  - критерий выбора пяти опорных колонок приведён к списку, который он
    порождает; `CounterID` оговорён отдельно.
  - «переобработки у ODS нет вовсе» смягчено до пакетной: ручная вставка из
    сырья в пределах окна возможна.
  - в спеку генератора добавлена форма дат на проводе — ISO-8601, с доводом от
    читаемости слоя сырья.
  - убраны осиротевшая фраза про порядок сервисов, дубль порядка классов брака,
    устаревшая датировка сверки и ещё три следа вставок.
- Проверка:
  - make config-test

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 21:53:03 +03:00
co-authored by Claude Opus 5
parent f1254ce79d
commit 923ebad80e
4 changed files with 65 additions and 31 deletions
+10 -6
View File
@@ -41,8 +41,11 @@ JSON.
цены и пользы: единственный производитель топика — собственный генератор, цены и пользы: единственный производитель топика — собственный генератор,
сериализующий из контракта по объявленным типам, поэтому неверный тип может сериализующий из контракта по объявленным типам, поэтому неверный тип может
прийти только из руки, а сорок семь проверок на NULL превратили бы матвью в прийти только из руки, а сорок семь проверок на NULL превратили бы матвью в
простыню. Пять выбраны по последствию: на них стоят ключ сортировки, партиция и простыню. Пять выбраны по последствию: это идентификаторы события, визита и
дедупликация, и их порча отравляет всё ниже по течению. посетителя, дата партиции и метка времени, по которой события упорядочиваются
внутри сессии, — порча любой отравляет всё ниже по течению. `CounterID`
формально тоже входит в ключ сортировки, но на стенде он константа, и NULL там
взяться неоткуда.
Присутствие иначе и не проверить. `Nullable` Присутствие иначе и не проверить. `Nullable`
в ClickHouse не оборачивает составные типы: `Nullable(Array)` запрещён, а в ClickHouse не оборачивает составные типы: `Nullable(Array)` запрещён, а
@@ -133,7 +136,9 @@ contract-тест из #43 сюда не дотягивается — он ср
## Что проверено ## Что проверено
По документации ClickHouse через MCP Context7, 3 августа 2026 года. По документации ClickHouse через MCP Context7: основная сверка — 3 августа
2026 года, перепроверка после правок — 5 августа. Датировка важна: 5 августа
утверждение про `Nullable(Tuple)` развернулось на противоположное.
При режиме `stream` движок отдаёт `_raw_message` и `_error` только для При режиме `stream` движок отдаёт `_raw_message` и `_error` только для
сообщений, которые не разобрались, и оставляет их пустыми для разобранных. сообщений, которые не разобрались, и оставляет их пустыми для разобранных.
@@ -159,9 +164,8 @@ contract-тест из #43 сюда не дотягивается — он ср
распределённую таблицу — блок она видит до разрезания по шардам. Проверено распределённую таблицу — блок она видит до разрезания по шардам. Проверено
владельцем на рабочих проектах; на стенде подтверждается заодно с приёмкой #37. владельцем на рабочих проектах; на стенде подтверждается заодно с приёмкой #37.
На живом стенде проверяется при исполнении #37. Первые два пункта внесены в На живом стенде проверяется при исполнении #37. Первые два внесены в раздел 11
раздел 11 спеки как несущие; остальные — однострочные `SELECT`, их довольно спеки; остальные — однострочные `SELECT`, их довольно прогнать заодно:
прогнать заодно:
- `RawBLOB` в Kafka-движке даёт ровно одну строку на сообщение. Проверять это - `RawBLOB` в Kafka-движке даёт ровно одну строку на сообщение. Проверять это
нужно первым и до написания DDL: формулировка «читает вход в одно значение» нужно первым и до написания DDL: формулировка «читает вход в одно значение»
+29 -22
View File
@@ -2,7 +2,8 @@
Документ описывает сторону ClickHouse: как называются объекты, какие служебные Документ описывает сторону ClickHouse: как называются объекты, какие служебные
колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из
каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами. каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами, и
раздел «Что проверено» — чему в этом тексте верить и на каком основании.
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов, **Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
keeper, Kafka, каркас сервисов. Объекты хранилища и механизм применения DDL keeper, Kafka, каркас сервисов. Объекты хранилища и механизм применения DDL
@@ -79,11 +80,12 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
`cityHash64(ClientID)`, а `dds.order` и производные от заказа — по `cityHash64(ClientID)`, а `dds.order` и производные от заказа — по
`cityHash64(order_id)`. Ключи витрин появятся вместе с самими витринами. `cityHash64(order_id)`. Ключи витрин появятся вместе с самими витринами.
Известное ограничение правила «пишем только в `_dist`»: пакетные слои собираются Открытый вопрос на будущее — не сама замена партиций: операции с ними по
заменой дневных партиций, а `DROP/REPLACE PARTITION` работает только по локальным локальным таблицам правило разрешает прямо. Вопрос в шаге до неё. Партиция-донор
таблицам. Чем и как раскладывать партицию-донор по шардам до замены, здесь не должна быть уже разложена по шардам по тому же ключу, а разложить её можно
решено — вопрос встаёт вместе со сборкой DDS, и решать его нужно тогда, а не только вставкой через распределённую таблицу — значит у каждой пакетной сущности
задним числом. появится вторая пара объектов, и имени для неё конвенция пока не даёт. Решать
это вместе со сборкой DDS, а не задним числом.
## Служебные колонки ## Служебные колонки
@@ -104,8 +106,8 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
(раздел 11 спеки) — записать её в необнуляемый тип значит либо уронить приём на (раздел 11 спеки) — записать её в необнуляемый тип значит либо уронить приём на
первом сообщении, либо получить тихие нули за 1970 год. первом сообщении, либо получить тихие нули за 1970 год.
Заполняются обе группы колонок выражением в `SELECT` матвью приёма, а не Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
`DEFAULT` в таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()`
сработал бы на шарде-получателе и назвал бы не ту ноду, которая читала топик, — сработал бы на шарде-получателе и назвал бы не ту ноду, которая читала топик, —
то есть колонка молча отвечала бы на другой вопрос. то есть колонка молча отвечала бы на другой вопрос.
@@ -124,8 +126,10 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у
этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же
самое, отличается только метка, поэтому какая из двух строк переживёт мерж, самое, отличается только метка, поэтому какая из двух строк переживёт мерж,
безразлично. Переобработки как стадии у ODS нет вовсе: слой наполняет матвью, а безразлично. Пакетной переобработки у ODS нет: слой наполняет матвью, а не
не пакетное задание, и пакетная работа с партициями начинается выше. задание Airflow, и работа с партициями начинается выше. Переделать разобранное
руками можно — вставкой из сырья с фильтром по `_load_ts`, в пределах
трёхсуточного окна; ничья по версии разрешается в пользу вставленного позже.
Имя согласовано с каноном служебных полей соседнего учебного стенда на Имя согласовано с каноном служебных полей соседнего учебного стенда на
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
@@ -185,8 +189,13 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
компромисс полезнее показать, чем спрятать за галочкой. компромисс полезнее показать, чем спрятать за галочкой.
**Гарантия — «хотя бы один раз», не транзакция.** Падение после записи на шард, **Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
но до коммита офсетов даёт повтор при перечитывании. В ODS повтор схлопнет выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня `ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения. честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения.
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к
@@ -267,11 +276,8 @@ D0 и к реальному календарю не привязана; паке
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
качества не на что опереться. Сами классы перечислены в качества не на что опереться. Сами классы, их порядок и довод, почему порядок
[ADR 0005](../adr/0005-event-ingestion.md) и проверяются по порядку, потому что обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md).
пересекаются: сообщение, не являющееся объектом JSON, проваливает заодно и сверку
набора ключей — `JSONExtractKeys` от скаляра даёт пустой массив. Побеждает первый
совпавший класс, тем же приёмом, что `mismatch_class` в витрине сверки.
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по
чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition, чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition,
@@ -304,7 +310,11 @@ ODS. Второе: матвью приёма создаётся последне
Применение — двумя одноразовыми сервисами при `make up`, по образцу уже Применение — двумя одноразовыми сервисами при `make up`, по образцу уже
работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топик работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топик
`hits` с двумя партициями, затем `clickhouse-init` дожидается его завершения и `hits` с двумя партициями, затем `clickhouse-init` дожидается его завершения и
применяет файлы с ноды 1, `ON CLUSTER`. применяет файлы с ноды 1, `ON CLUSTER`. Этот порядок страхует от автосоздания
топика брокером с одной партицией: у потребителя librdkafka разрешение на
автосоздание по умолчанию выключено, так что случиться это не обязано, но урок
«обе ноды читают топик» умирает тихо, и полагаться на умолчание клиента здесь
не стоит.
Образцы копируются не целиком, и в двух местах. `clickhouse-init` обязан ждать Образцы копируются не целиком, и в двух местах. `clickhouse-init` обязан ждать
готовности **обеих** нод: `ON CLUSTER` ждёт исполнения на всех хостах и по готовности **обеих** нод: `ON CLUSTER` ждёт исполнения на всех хостах и по
@@ -312,10 +322,7 @@ ODS. Второе: матвью приёма создаётся последне
только вторую. И второе: оба образца переживают `make up --wait` лишь потому, что только вторую. И второе: оба образца переживают `make up --wait` лишь потому, что
от них зависят долгоживущие сервисы; у пары `kafka-init` / `clickhouse-init` от них зависят долгоживущие сервисы; у пары `kafka-init` / `clickhouse-init`
таких зависимых нет, и как поведёт себя `--wait` с одноразовым сервисом без них — таких зависимых нет, и как поведёт себя `--wait` с одноразовым сервисом без них —
проверяется при исполнении #37. Порядок страхует от автосоздания топика проверяется при исполнении #37.
брокером с одной партицией: у потребителя librdkafka разрешение на автосоздание
по умолчанию выключено, так что случиться это не обязано, но урок «обе ноды
читают топик» умирает тихо, и полагаться на умолчание клиента здесь не стоит.
Повторный `make up` поверх живого тома проходит зелёным: весь DDL идёт через Повторный `make up` поверх живого тома проходит зелёным: весь DDL идёт через
`CREATE ... IF NOT EXISTS`. Оборотная сторона — изменённый объект тем же `CREATE ... IF NOT EXISTS`. Оборотная сторона — изменённый объект тем же
+15 -3
View File
@@ -315,8 +315,11 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`, - **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей `EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
сообщения сверяется с контрактным; строка с NULL среди опорных колонок сообщения сверяется с контрактным; строка с NULL среди опорных колонок
или с разошедшимся набором ключей уходит в `*_errors`. Опорными выбраны те, на или с разошедшимся набором ключей уходит в `*_errors`. Опорными выбраны те,
которых стоят ключ сортировки, партиция и дедупликация; остальные сорок две чья порча отравляет всё ниже по течению: идентификаторы события, визита и
посетителя, дата партиции и метка времени, по которой события упорядочиваются
внутри сессии. `CounterID` формально тоже в ключе сортировки, но на стенде он
константа, и NULL там взяться неоткуда. Остальные сорок две
достаются обычными типами — сорок семь проверок на NULL превратили бы матвью в достаются обычными типами — сорок семь проверок на NULL превратили бы матвью в
простыню, а присутствие и так целиком закрыто сверкой ключей. Сверка ключей — простыню, а присутствие и так целиком закрыто сверкой ключей. Сверка ключей —
не добавка: у массивов NULL не бывает, и пропавшее поле-массив иначе не добавка: у массивов NULL не бывает, и пропавшее поле-массив иначе
@@ -379,7 +382,7 @@ README.
| Слой | Объект | Что это | | Слой | Объект | Что это |
|---|---|---| |---|---|---|
| Kafka | `hits`, `orders` | два топика, по 2 партиции | | Kafka | `hits`, `orders` | два топика, по 2 партиции |
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV; для orders — развилка этапа 3, см. раздел 12 | сырые строки, Kafka Engine на обеих нодах | | STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV; для orders — развилка этапа 3, не решена (ниже) | сырые строки, Kafka Engine на обеих нодах |
| ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree | | ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree |
| ODS | `ods.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа | | ODS | `ods.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа |
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) | | DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) |
@@ -393,6 +396,15 @@ README.
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья — суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья —
см. [доку хранилища](../architecture/storage.md). см. [доку хранилища](../architecture/storage.md).
Как принимаются заказы — развилка этапа 3, и она не решена. Событиям выбран
приём сырья байтами с разбором функциями ([ADR 0005](../adr/0005-event-ingestion.md));
заказам этот же способ идёт только вместе с ответом на вопрос, нужен ли им слой
сырья вообще — у них слепок, а не поток. Нужен — и типизированный чтец даст двух
чтецов на один топик, а такую схему ADR 0005 отверг; не нужен — и слои
перестают быть единообразными. Разбирать грилингом, когда дойдём до заказов;
как учебное сравнение двух способов приёма это записано и в опорных точках
раздела 12.
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия: Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
`ods.event` и `ods.order_snapshot` получают метку загрузки `_load_ts`, и у `ods.event` и `ods.order_snapshot` получают метку загрузки `_load_ts`, и у
`ods.event` она же служит колонкой версии ReplacingMergeTree; а таблицы `ods.event` она же служит колонкой версии ReplacingMergeTree; а таблицы
+11
View File
@@ -261,6 +261,17 @@
([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких ([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких
событий в одно сообщение сломала бы разбор целиком. Сторожится тестом событий в одно сообщение сломала бы разбор целиком. Сторожится тестом
приёмника. приёмника.
- **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`,
`UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: весь
смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте и
разбирает событие глазами, а число эпохи этот урок убивает. Разбору это
ничего не стоит: `JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')`
принимает ISO без плясок. Колонка `ecommerce` — строка, внутри которой лежит
экранированный JSON, как отдаёт Метрика.
Форму пинит хранилище (#43) как первый потребитель, сериализатор (#41)
её соблюдает. Порядок тикетов обратный порядку зависимости, поэтому здесь она
и записана — иначе каждый выберет своё, и разойдётся это уже после приёмки.
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 514 раз, - **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 514 раз,
numpy-массивы и datetime сериализует нативно (заметка исследования #31). numpy-массивы и datetime сериализует нативно (заметка исследования #31).
Смена библиотеки меняет канонические байты, поэтому проходит как Смена библиотеки меняет канонические байты, поэтому проходит как