Files
clickstream-data-platform/docs/adr/0005-event-ingestion.md
T
ddadmin 319db308bf 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
2026-08-04 00:14:13 +03:00

13 KiB
Raw Blame History

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-членами — нужна для свёртки сорока семи вызовов в один, если разбор окажется дорогим.