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:
@@ -41,8 +41,11 @@ JSON.
|
||||
цены и пользы: единственный производитель топика — собственный генератор,
|
||||
сериализующий из контракта по объявленным типам, поэтому неверный тип может
|
||||
прийти только из руки, а сорок семь проверок на NULL превратили бы матвью в
|
||||
простыню. Пять выбраны по последствию: на них стоят ключ сортировки, партиция и
|
||||
дедупликация, и их порча отравляет всё ниже по течению.
|
||||
простыню. Пять выбраны по последствию: это идентификаторы события, визита и
|
||||
посетителя, дата партиции и метка времени, по которой события упорядочиваются
|
||||
внутри сессии, — порча любой отравляет всё ниже по течению. `CounterID`
|
||||
формально тоже входит в ключ сортировки, но на стенде он константа, и NULL там
|
||||
взяться неоткуда.
|
||||
|
||||
Присутствие иначе и не проверить. `Nullable`
|
||||
в 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` только для
|
||||
сообщений, которые не разобрались, и оставляет их пустыми для разобранных.
|
||||
@@ -159,9 +164,8 @@ contract-тест из #43 сюда не дотягивается — он ср
|
||||
распределённую таблицу — блок она видит до разрезания по шардам. Проверено
|
||||
владельцем на рабочих проектах; на стенде подтверждается заодно с приёмкой #37.
|
||||
|
||||
На живом стенде проверяется при исполнении #37. Первые два пункта внесены в
|
||||
раздел 11 спеки как несущие; остальные — однострочные `SELECT`, их довольно
|
||||
прогнать заодно:
|
||||
На живом стенде проверяется при исполнении #37. Первые два внесены в раздел 11
|
||||
спеки; остальные — однострочные `SELECT`, их довольно прогнать заодно:
|
||||
|
||||
- `RawBLOB` в Kafka-движке даёт ровно одну строку на сообщение. Проверять это
|
||||
нужно первым и до написания DDL: формулировка «читает вход в одно значение»
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Документ описывает сторону ClickHouse: как называются объекты, какие служебные
|
||||
колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из
|
||||
каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами.
|
||||
каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами, и
|
||||
раздел «Что проверено» — чему в этом тексте верить и на каком основании.
|
||||
|
||||
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
|
||||
keeper, Kafka, каркас сервисов. Объекты хранилища и механизм применения DDL
|
||||
@@ -79,11 +80,12 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
|
||||
`cityHash64(ClientID)`, а `dds.order` и производные от заказа — по
|
||||
`cityHash64(order_id)`. Ключи витрин появятся вместе с самими витринами.
|
||||
|
||||
Известное ограничение правила «пишем только в `_dist`»: пакетные слои собираются
|
||||
заменой дневных партиций, а `DROP/REPLACE PARTITION` работает только по локальным
|
||||
таблицам. Чем и как раскладывать партицию-донор по шардам до замены, здесь не
|
||||
решено — вопрос встаёт вместе со сборкой DDS, и решать его нужно тогда, а не
|
||||
задним числом.
|
||||
Открытый вопрос на будущее — не сама замена партиций: операции с ними по
|
||||
локальным таблицам правило разрешает прямо. Вопрос в шаге до неё. Партиция-донор
|
||||
должна быть уже разложена по шардам по тому же ключу, а разложить её можно
|
||||
только вставкой через распределённую таблицу — значит у каждой пакетной сущности
|
||||
появится вторая пара объектов, и имени для неё конвенция пока не даёт. Решать
|
||||
это вместе со сборкой DDS, а не задним числом.
|
||||
|
||||
## Служебные колонки
|
||||
|
||||
@@ -104,8 +106,8 @@ keeper, Kafka, каркас сервисов. Объекты хранилища
|
||||
(раздел 11 спеки) — записать её в необнуляемый тип значит либо уронить приём на
|
||||
первом сообщении, либо получить тихие нули за 1970 год.
|
||||
|
||||
Заполняются обе группы колонок выражением в `SELECT` матвью приёма, а не
|
||||
`DEFAULT` в таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()`
|
||||
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
|
||||
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()`
|
||||
сработал бы на шарде-получателе и назвал бы не ту ноду, которая читала топик, —
|
||||
то есть колонка молча отвечала бы на другой вопрос.
|
||||
|
||||
@@ -124,8 +126,10 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
|
||||
разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у
|
||||
этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же
|
||||
самое, отличается только метка, поэтому какая из двух строк переживёт мерж,
|
||||
безразлично. Переобработки как стадии у ODS нет вовсе: слой наполняет матвью, а
|
||||
не пакетное задание, и пакетная работа с партициями начинается выше.
|
||||
безразлично. Пакетной переобработки у ODS нет: слой наполняет матвью, а не
|
||||
задание Airflow, и работа с партициями начинается выше. Переделать разобранное
|
||||
руками можно — вставкой из сырья с фильтром по `_load_ts`, в пределах
|
||||
трёхсуточного окна; ничья по версии разрешается в пользу вставленного позже.
|
||||
|
||||
Имя согласовано с каноном служебных полей соседнего учебного стенда на
|
||||
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
|
||||
@@ -185,8 +189,13 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
|
||||
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
|
||||
компромисс полезнее показать, чем спрятать за галочкой.
|
||||
|
||||
**Гарантия — «хотя бы один раз», не транзакция.** Падение после записи на шард,
|
||||
но до коммита офсетов даёт повтор при перечитывании. В ODS повтор схлопнет
|
||||
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
|
||||
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
|
||||
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при
|
||||
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы
|
||||
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
|
||||
|
||||
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
|
||||
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня
|
||||
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения.
|
||||
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к
|
||||
@@ -267,11 +276,8 @@ D0 и к реальному календарю не привязана; паке
|
||||
|
||||
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
|
||||
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
|
||||
качества не на что опереться. Сами классы перечислены в
|
||||
[ADR 0005](../adr/0005-event-ingestion.md) и проверяются по порядку, потому что
|
||||
пересекаются: сообщение, не являющееся объектом JSON, проваливает заодно и сверку
|
||||
набора ключей — `JSONExtractKeys` от скаляра даёт пустой массив. Побеждает первый
|
||||
совпавший класс, тем же приёмом, что `mismatch_class` в витрине сверки.
|
||||
качества не на что опереться. Сами классы, их порядок и довод, почему порядок
|
||||
обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md).
|
||||
|
||||
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по
|
||||
чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition,
|
||||
@@ -304,7 +310,11 @@ ODS. Второе: матвью приёма создаётся последне
|
||||
Применение — двумя одноразовыми сервисами при `make up`, по образцу уже
|
||||
работающих `airflow-init` и `superset-init`. Сначала `kafka-init` создаёт топик
|
||||
`hits` с двумя партициями, затем `clickhouse-init` дожидается его завершения и
|
||||
применяет файлы с ноды 1, `ON CLUSTER`.
|
||||
применяет файлы с ноды 1, `ON CLUSTER`. Этот порядок страхует от автосоздания
|
||||
топика брокером с одной партицией: у потребителя librdkafka разрешение на
|
||||
автосоздание по умолчанию выключено, так что случиться это не обязано, но урок
|
||||
«обе ноды читают топик» умирает тихо, и полагаться на умолчание клиента здесь
|
||||
не стоит.
|
||||
|
||||
Образцы копируются не целиком, и в двух местах. `clickhouse-init` обязан ждать
|
||||
готовности **обеих** нод: `ON CLUSTER` ждёт исполнения на всех хостах и по
|
||||
@@ -312,10 +322,7 @@ ODS. Второе: матвью приёма создаётся последне
|
||||
только вторую. И второе: оба образца переживают `make up --wait` лишь потому, что
|
||||
от них зависят долгоживущие сервисы; у пары `kafka-init` / `clickhouse-init`
|
||||
таких зависимых нет, и как поведёт себя `--wait` с одноразовым сервисом без них —
|
||||
проверяется при исполнении #37. Порядок страхует от автосоздания топика
|
||||
брокером с одной партицией: у потребителя librdkafka разрешение на автосоздание
|
||||
по умолчанию выключено, так что случиться это не обязано, но урок «обе ноды
|
||||
читают топик» умирает тихо, и полагаться на умолчание клиента здесь не стоит.
|
||||
проверяется при исполнении #37.
|
||||
|
||||
Повторный `make up` поверх живого тома проходит зелёным: весь DDL идёт через
|
||||
`CREATE ... IF NOT EXISTS`. Оборотная сторона — изменённый объект тем же
|
||||
|
||||
@@ -315,8 +315,11 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
|
||||
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
|
||||
сообщения сверяется с контрактным; строка с NULL среди опорных колонок
|
||||
или с разошедшимся набором ключей уходит в `*_errors`. Опорными выбраны те, на
|
||||
которых стоят ключ сортировки, партиция и дедупликация; остальные сорок две
|
||||
или с разошедшимся набором ключей уходит в `*_errors`. Опорными выбраны те,
|
||||
чья порча отравляет всё ниже по течению: идентификаторы события, визита и
|
||||
посетителя, дата партиции и метка времени, по которой события упорядочиваются
|
||||
внутри сессии. `CounterID` формально тоже в ключе сортировки, но на стенде он
|
||||
константа, и NULL там взяться неоткуда. Остальные сорок две
|
||||
достаются обычными типами — сорок семь проверок на NULL превратили бы матвью в
|
||||
простыню, а присутствие и так целиком закрыто сверкой ключей. Сверка ключей —
|
||||
не добавка: у массивов NULL не бывает, и пропавшее поле-массив иначе
|
||||
@@ -379,7 +382,7 @@ README.
|
||||
| Слой | Объект | Что это |
|
||||
|---|---|---|
|
||||
| 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.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа |
|
||||
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) |
|
||||
@@ -393,6 +396,15 @@ README.
|
||||
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья —
|
||||
см. [доку хранилища](../architecture/storage.md).
|
||||
|
||||
Как принимаются заказы — развилка этапа 3, и она не решена. Событиям выбран
|
||||
приём сырья байтами с разбором функциями ([ADR 0005](../adr/0005-event-ingestion.md));
|
||||
заказам этот же способ идёт только вместе с ответом на вопрос, нужен ли им слой
|
||||
сырья вообще — у них слепок, а не поток. Нужен — и типизированный чтец даст двух
|
||||
чтецов на один топик, а такую схему ADR 0005 отверг; не нужен — и слои
|
||||
перестают быть единообразными. Разбирать грилингом, когда дойдём до заказов;
|
||||
как учебное сравнение двух способов приёма это записано и в опорных точках
|
||||
раздела 12.
|
||||
|
||||
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
||||
`ods.event` и `ods.order_snapshot` получают метку загрузки `_load_ts`, и у
|
||||
`ods.event` она же служит колонкой версии ReplacingMergeTree; а таблицы
|
||||
|
||||
@@ -261,6 +261,17 @@
|
||||
([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 в 5–14 раз,
|
||||
numpy-массивы и datetime сериализует нативно (заметка исследования #31).
|
||||
Смена библиотеки меняет канонические байты, поэтому проходит как
|
||||
|
||||
Reference in New Issue
Block a user