docs(storage): приняты решения по приёму событий и именам
- Зачем: - тикет #37 молча опирался на конвенции хранилища, которых в проекте не было; без них #43 и следующие этапы разъехались бы в именах, служебных колонках и механике приёма. - Что: - ADR 0005: топик читается байтами в STG, разбор идёт функциями в матвью ODS; строгий приём — сверка набора ключей плюс Nullable на пяти опорных колонках. - ADR 0006: суффикс вида в именах объектов (_rep, _dist, _kafka, _mv, _v). - docs/architecture/storage.md: конвенции имён и служебных колонок, путь в keeper, раскладка по шардам, срок жизни сырья, свойства приёма, раскладка файлов DDL и карта таблиц. - спеки приведены в соответствие: механизм строгого приёма, имена объектов, контракт транспорта «одно событие — одно сообщение Kafka», три проверки при исполнении. - Проверка: - make config-test
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
# ADR 0005. Приём событий: сырьё в STG байтами, разбор функциями в ODS
|
||||
|
||||
Дата: 3 августа 2026 года. Статус: принято.
|
||||
|
||||
## Решение
|
||||
|
||||
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: сообщение ложится в
|
||||
`stg.hits_raw_rep` строкой, как пришло, рядом с метаданными доставки. Ни
|
||||
типизации, ни проверки на этом шаге нет — слой сырья ничего не интерпретирует.
|
||||
|
||||
Типизированный слой наполняют две матвью, привязанные к `stg.hits_raw_dist`.
|
||||
Поля достаются `JSONExtract`. В таблицу ошибок уходят три класса брака:
|
||||
сообщение, не являющееся объектом JSON; объект, чей набор ключей разошёлся с
|
||||
контрактным; объект, у которого не разобрался ключевой идентификатор или метка
|
||||
времени. Остальное — в событие. Первый класс проверяется именно на объект, а не
|
||||
на валидность: `isValidJSON('123')` возвращает единицу, скаляр — тоже законный
|
||||
JSON.
|
||||
|
||||
Присутствие полей целиком держит сверка набора ключей — одно сравнение
|
||||
отсортированного `JSONExtractKeys` с контрактным списком. Обязательны все сорок
|
||||
семь полей: генератор шлёт их все в каждом событии, а «пусто» по контракту —
|
||||
пустое значение, а не отсутствие ключа. Этим же закрыт критерий #43 про опечатку
|
||||
в имени.
|
||||
|
||||
Тип проверяется не у всех колонок, а у пяти: `WatchID`, `VisitID`, `ClientID`,
|
||||
`EventDate`, `UTCEventTime` разбираются в `Nullable` и дают NULL, если значение
|
||||
не той природы. Остальные сорок две достаются обычными типами. Соотношение
|
||||
цены и пользы: единственный производитель топика — собственный генератор,
|
||||
сериализующий из контракта по объявленным типам, поэтому неверный тип может
|
||||
прийти только из руки, а сорок семь проверок на NULL превратили бы матвью в
|
||||
простыню. Пять выбраны по последствию: на них стоят ключ сортировки, партиция и
|
||||
дедупликация, и их порча отравляет всё ниже по течению.
|
||||
|
||||
Присутствие иначе и не проверить. `Nullable`
|
||||
в ClickHouse не оборачивает составные типы: `Nullable(Array)` запрещён, а
|
||||
`Array(Nullable(T))` при пропавшем ключе даёт пустой массив, неотличимый от
|
||||
пустого по смыслу. Таких колонок в контракте двенадцать из сорока семи.
|
||||
|
||||
Типизированная Kafka-таблица не используется. Режим `kafka_handle_error_mode =
|
||||
'stream'` не используется тоже: при чтении байтами на входе нечему ломаться, и
|
||||
ошибке разбора взяться неоткуда.
|
||||
|
||||
Этим решение снимает ограничение, записанное в постановке #37: «ошибки разбора
|
||||
должны рождаться на шаге Kafka-движка». Оно ставилось как условие достижимости
|
||||
критерия #43 про громкую ошибку на опечатку в имени поля — критерий достижим и
|
||||
без него, средствами матвью.
|
||||
|
||||
Требование строгого приёма из раздела 6 спеки остаётся в силе, меняется его
|
||||
механизм: настройка формата `input_format_skip_unknown_fields` при чтении
|
||||
байтами беспредметна, раскладки по полям на этом шаге нет вовсе.
|
||||
|
||||
## Почему
|
||||
|
||||
Типизированный чтец не отдаёт сырьё. При `kafka_handle_error_mode = 'stream'`
|
||||
виртуальные колонки `_raw_message` и `_error` заполняются только при ошибке
|
||||
разбора и пусты у разобранных сообщений. Один типизированный чтец оставил бы в
|
||||
STG сырьё исключительно от брака: слой сырых строк хранил бы всё, кроме того,
|
||||
что доехало.
|
||||
|
||||
Слои идут цепочкой. Kafka → STG → ODS → DDS → DM, каждый читает предыдущий.
|
||||
Отвергнутая схема с двумя чтецами одного топика — сырым для STG и типизированным
|
||||
для ODS — в бою обычна и дала бы строгий приём даром, средствами самого движка.
|
||||
Но ODS перестал бы быть надстройкой над STG и стал бы вторым входом с шины, а на
|
||||
стенде, где слои и есть предмет изучения, это дороже сэкономленного. Побочная
|
||||
выгода скромнее, чем кажется на первый взгляд: слой сырья при двух чтецах
|
||||
существовал бы точно так же, но SQL разбора не существовал бы нигде, и для
|
||||
переобработки дня X его пришлось бы писать заново. В цепочке он уже есть в
|
||||
матвью, и ручная вставка получается его копией.
|
||||
|
||||
Слой сырья ничего не проверяет, и формат чтеца выбран под это. Соседний вариант,
|
||||
`JSONAsString`, требует, чтобы каждое сообщение было корректным объектом JSON, и
|
||||
на некорректном падает: потребление встаёт, хотя правило репозитория гласит, что
|
||||
грязные записи не должны валить пайплайн. Лечится это двумя способами — включить
|
||||
на чтеце режим обработки ошибок или не проверять на входе вовсе. Второе честнее:
|
||||
частичная проверка в слое, чья работа — не проверять, спорит сама с собой, а
|
||||
настоящий разбор всё равно идёт ниже. При `RawBLOB` ломаться нечему, доезжают
|
||||
любые байты, и оба класса брака разбираются в одном месте.
|
||||
|
||||
Архив нужен буквальный и читаемый глазами. `RawBLOB` — это про способ чтения, а
|
||||
не про тип колонки: на диске лежит обычный `String` с текстом события, и менти
|
||||
открывает его в обычном клиенте и читает. В этом и смысл слоя — увидеть, что
|
||||
реально пришло. Мусор при этом лежит в той же колонке, что и целые события, а не
|
||||
в отдельной. Отвергнутый боевой вариант — собрать слой сырья на движке `Null`,
|
||||
тогда данные не хранятся вовсе, а матвью работает чистым триггером. Отлаживать
|
||||
так неудобно, поэтому здесь окно в трое суток, а не ноль.
|
||||
|
||||
Что при этом потеряно и чем закрыто. Настройка `input_format_skip_unknown_fields
|
||||
= 0` ловила два случая: ожидаемое поле пропало и появилось лишнее, неизвестное.
|
||||
Оба берёт на себя сверка набора ключей: опечатка в имени — это одновременно
|
||||
пропавшее ожидаемое и появившееся лишнее, и сравнение множеств видит и то, и
|
||||
другое. Она же единственная защита у колонок-массивов и она же ловит молчаливое
|
||||
расширение контракта, когда в сообщении появляется поле, о котором хранилище не
|
||||
знает. `Nullable`-разбор к этой работе отношения не имеет — он про тип, а не про
|
||||
присутствие, и потому сужен до пяти колонок.
|
||||
|
||||
Цена решения. Контракт получает ещё два места: типы сорока семи колонок в
|
||||
выражениях матвью и список тех же имён для сверки ключей. Раздел 1.4 спеки
|
||||
предупреждает, что, повторяясь примерно в семи местах, они расходятся молча, а
|
||||
contract-тест из #43 сюда не дотягивается — он сравнивает `system.columns`
|
||||
целевой таблицы со схемой генератора и о выражениях матвью ничего не знает.
|
||||
Смягчение работает не везде: опечатка в имени скалярного поля уводит строки в
|
||||
таблицу ошибок пачкой и видна сразу, а опечатка в имени массива даёт пустой
|
||||
массив тихо — сверка ключей проверяет ключи сообщения, а не выражения матвью.
|
||||
Эти двенадцать колонок сторожит smoke: известное событие с товарами обязано
|
||||
доезжать с непустыми массивами.
|
||||
|
||||
Второе — разбор функциями дороже разбора форматом. На объёмах стенда это
|
||||
несущественно; если станет заметно, сорок семь вызовов сворачиваются в один
|
||||
`JSONExtract` в именованный кортеж, и строка разбирается однократно.
|
||||
|
||||
## Что проверено
|
||||
|
||||
По документации ClickHouse через MCP Context7, 3 августа 2026 года.
|
||||
|
||||
При режиме `stream` движок отдаёт `_raw_message` и `_error` только для
|
||||
сообщений, которые не разобрались, и оставляет их пустыми для разобранных.
|
||||
Отсюда весь довод о том, что типизированный чтец не может наполнить слой сырья.
|
||||
|
||||
Составные типы `Nullable` не оборачивает: `Nullable(Array)`, `Nullable(Map)` и
|
||||
`Nullable(Tuple)` не поддерживаются, а `Nullable` внутри них — да. Отсюда
|
||||
слепота `Nullable`-разбора на двенадцати колонках и необходимость сверки ключей.
|
||||
`Nullable` внутри кортежа разрешён, поэтому запасной вариант со свёрткой в
|
||||
именованный кортеж жив.
|
||||
|
||||
Оттуда же: у Kafka-движка есть третий режим обработки ошибок —
|
||||
`dead_letter_queue` с записью в системную таблицу. Он отвергнут независимо от
|
||||
остального: спека требует свои `*_errors`, системная таблица их не заменяет.
|
||||
|
||||
Форматы `RawBLOB` и `LineAsString` в ClickHouse есть, и Kafka-движок
|
||||
поддерживает все форматы.
|
||||
|
||||
Матвью с источником-`Distributed` срабатывает на вставку именно в эту
|
||||
распределённую таблицу — блок она видит до разрезания по шардам. Проверено
|
||||
владельцем на рабочих проектах; на стенде подтверждается заодно с приёмкой #37.
|
||||
|
||||
На живом стенде проверяется при исполнении #37, и то же внесено в раздел 11
|
||||
спеки:
|
||||
|
||||
- `RawBLOB` в Kafka-движке даёт ровно одну строку на сообщение;
|
||||
- форма именованного кортежа в `JSONExtract` с `Nullable`-членами — нужна для
|
||||
свёртки сорока семи вызовов в один, если разбор окажется дорогим.
|
||||
Reference in New Issue
Block a user