Files
clickstream-data-platform/docs/architecture/storage.md
T
ddadmin 75f28fe99f docs(ingestion): упрощены объяснения источников и приема данных
Зачем: помочь начинающему инженеру проследить событие и версию заказа
от генератора до хранилища, включая ошибки и повторные поступления.

Что: обновлены вводная генератора, справочники приема и модели заказа,
разделы хранилища и комментарии SQL и дага. Сохранены технические условия;
исполняемый код, схемы и поведение стенда не изменены.

Проверка: make lint, git diff --check, 142 локальные ссылки и справка CLI.
SQL без комментариев и Python AST без строк документации совпали
с исходными. Генерация данных и сброс состояния не выполнялись.

Refs #250
2026-09-13 16:32:45 -04:00

75 KiB
Raw Blame History

Хранилище: слои и конвенции

Документ описывает сторону ClickHouse: как называются объекты, какие служебные колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами, и раздел «Что проверено» — чему в этом тексте верить и на каком основании.

Что здесь описано и чего ещё нет. Собран этап 1: кластер из двух шардов, keeper, Kafka, каркас сервисов. Этап 2 собран: в sql/ddl/ лежит вся цепочка Kafka → STG → ODS — чтец топика hits, таблицы STG, типизированное событие с таблицей ошибок, поверхность актуального состояния и три матвью. Этап 3 добавил вход второго источника и довёл его до ODS: топик orders, свой чтец, свой сырой слой, версии заказов с таблицей ошибок и поверхность текущего состояния. Наполняет всю цепочку даг orders_ingest двумя шагами, а не матвью. Тот же этап принёс первый справочник — словарь товаров из общего с генератором CSV-файла. Живёт он вне цепочки слоёв, в зоне dic, и читает не файл, а подложку над ним (ADR 0012). Этап 4 начат публичным договором события dds.event_v над точным состоянием ODS, а следом получил две физические модели слоя — заказ и сессию: их собирает заменой партиций даг dds_transform. Сессия среди них особая: в потоке такой сущности нет, стенд режет её из событий сам. Дальше по тексту устройство описано так, как оно проектируется; построенное от заложенного отличает карта таблиц в конце.

Зона ответственности у документа одна — хранилище. Генератор описан отдельно: его замысел — в спеке генератора, формат события — в описании выгрузки. Хранилище строится по описанию выгрузки, как в бою строится по документации источника. Целевая картина всего стенда — спека «Боевой реализм стенда (v2)».

Имена объектов

Суффикс показывает назначение объекта:

Суффикс Что это
_rep локальная таблица: хранит данные своего шарда, движок семейства Replicated*
_dist таблица Distributed: читает данные шардов и распределяет запись между ними
_kafka таблица на движке Kafka: читает сообщения топика
_file таблица на движке File: читает файл
_mv материализованное представление: обрабатывает вставляемые данные
_v обычное представление: выполняет сохраненный запрос при чтении

Для запросов к моделям DDS и витринам DM используйте _v. Представления явно перечисляют колонки, поэтому добавление поля в физическую таблицу не меняет набор полей для читателя. У dds.event_v нет отдельной таблицы DDS: представление читает события ODS.

В документации имя без суффикса, например stg.hits_raw, обозначает сущность с несколькими объектами. Таблицы с таким именем нет: в запросе нужно указать суффикс. Исключение — словари ClickHouse: dic.products обращается к каталогу товаров, а dic.products_file читает его файл.

В STG имя берется из топика: hits_raw, orders_raw. В типизированных слоях оно называет сущность в единственном числе: event, order, session. Объекты одной сущности различаются суффиксом и стоят рядом при сортировке имен по алфавиту.

_rep и _dist нужны для загрузки, диагностики и обслуживания. Роль bi_reader может читать их в ODS, DDS и DM, но для обычного анализа принято использовать _v. Это соглашение о чтении, а не ограничение прав; причина широкого доступа описана в ADR 0007 и исследовании.

Раскладка по шардам

Шард хранит часть строк. При записи через _dist место строки выбирает ключ шардирования. При прямой записи в _rep она остается на той ноде, где выполнена вставка. Поэтому данные записывают через _dist, а операции с партициями выполняют на локальных _rep.

У событий ключ — cityHash64(ClientID), у заказов — cityHash64(order_id), у STG и таблиц ошибок — хеш сырой строки. Хеш распределяет числовые идентификаторы по шардам и направляет одинаковые сообщения на один шард. У сырой строки и разобранного события разные ключи: они могут оказаться на разных шардах. Счетчики STG и ODS сравнивают по всему кластеру через _dist, а не отдельно по каждой ноде.

При INSERT INTO цель_dist SELECT … FROM источник_dist важна настройка parallel_distributed_insert_select. При значении 2 и одном кластере вставка может выполняться локально на шардах, сохраняя раскладку источника вместо ключа цели. Если ключи различаются, укажите parallel_distributed_insert_select = 0: нода, принявшая запрос, соберет результат и распределит его по ключу цели. Так сделано в sql/ods/order_parse.sql и при сборке выручки. Неверную раскладку проверяют по hostName() и ожидаемому шарду строки; общий счет по _dist ее не показывает.

События, dds.session_dist и dds.identity_map_dist распределены по cityHash64(client_id) (в ODS поле называется ClientID). Данные одного посетителя лежат вместе — это ко-локация. Заказы DDS и их сверка сохраняют раскладку по cityHash64(order_id). Обычное соединение допускается по ключу ко-локации; для остальных соединений применяется GLOBAL. Само совпадение ключей еще не гарантирует локального выполнения запроса через представления — пример есть в описании daily_traffic.

Для дневной пересборки DDS и DM результат сначала записывают в промежуточную таблицу. Основная таблица в это время остается доступна для чтения с прежними данными. Например, заказы записываются через dds.order_stage_dist в dds.order_stage_rep, затем ее дневные разделы заменяют разделы основной dds.order_rep.

У промежуточной и основной таблиц должны совпадать структура, ключи партиции и сортировки, первичный ключ и политика хранения. Поэтому dds.order_stage_rep объявлена через AS dds.order_rep с совпадающими ключами. Требования сверены через Context7 с документацией ClickHouse для схемы на ClickHouse 26.3. DDL показан в sql/ddl/45-dds-tables.sql.

Промежуточная таблица должна также распределять строки по тому же ключу, здесь — cityHash64(order_id). REPLACE PARTITION копирует раздел между локальными таблицами каждого шарда и не перераспределяет строки. Поэтому уже в промежуточной таблице заказ должен находиться на нужном шарде. Перед заменой вставка с distributed_foreground_insert = 1 дожидается доставки данных на шарды.

В ods.order этот способ не применяется: туда добавляют версии заказов из слепков, а актуальные строки выбираются при чтении (ADR 0010).

Служебные колонки

STG сохраняет исходное сообщение в строке raw и добавляет сведения о доставке. JSON на этом шаге не разбирается: некорректное содержимое тоже попадает в таблицу.

Колонка STG Что хранит
kafka_topic Имя топика из виртуальной колонки Kafka _topic
kafka_partition Номер партиции из _partition
kafka_offset Позицию сообщения внутри партиции из _offset
kafka_timestamp Метку времени Kafka из _timestamp_ms, если она есть
consumer_host Имя ноды, прочитавшей Kafka
_load_ts Реальное время записи сообщения в STG
_load_id Только у заказов: run_id запуска Airflow, который принял порцию

Топик, партиция и смещение вместе позволяют найти сообщение Kafka. kafka_topic и consumer_host имеют тип LowCardinality(String), kafka_partition и kafka_offset — UInt64. kafka_timestamp имеет тип Nullable(DateTime64(3, 'UTC')): сохраняет миллисекунды и допускает отсутствие метки.

Виртуальные колонки предоставляет движок, а колонки STG хранят значения на диске. Их имена различаются, чтобы не скрыть виртуальную колонку собственной. Значения вычисляются в запросе приема. В частности, hostName() должен выполняться на ноде, читающей Kafka: DEFAULT hostName() только в локальной таблице назвал бы шард-получатель.

Таблицы STG используют ReplicatedMergeTree с ORDER BY (kafka_partition, kafka_offset). Повторная доставка остается отдельной строкой; STG нужен в том числе для разбора таких повторов.

_load_ts имеет тип DateTime64(3, 'UTC'). При переходе в ODS метка копируется из STG без пересчета. У событий она также выбирает последнюю доставку; у заказов версию задает snapshot_date. _load_id заказа переносится в годную строку ODS и в таблицу ошибок. У непрерывного потока событий нет запуска Airflow, поэтому _load_id в его STG и ODS отсутствует.

Время события UTCEventTime и дата EventDate приходят из счетчика сайта. Поля заказа created_at и updated_at приходят из базы магазина. Они относятся к модельному времени; _load_ts — к реальным часам стенда. Например, событие июня можно загрузить в сентябре: его EventDate останется июньской, а _load_ts будет сентябрьской.

События разбираются материализованными представлениями. Автоматической пакетной переобработки у них нет; сохраненный срез STG можно разобрать отдельным запросом до истечения срока хранения. При повторной вставке с тем же ключом и _load_ts ReplacingMergeTree оставляет более поздно вставленную строку. Заказы разбирает задача Airflow по _load_id. В DDS метки загрузки уже относятся к собственной сборке слоя — пример заказа.

Актуальные события

Повторная отправка события может дать несколько строк в ods.event_rep. Движок ReplicatedReplacingMergeTree(_load_ts) считает версиями строки с одинаковым полным ключом сортировки: (CounterID, EventDate, intHash32(ClientID), WatchID). При слиянии остается строка с максимальным _load_ts, то есть последняя доставка. Один WatchID обозначает событие, но физический ключ замены шире.

ods.event_v читает ods.event_dist FINAL: выбирает актуальную строку при выполнении запроса, не дожидаясь фоновых слияний. Поэтому события считают через _v; число строк в _dist зависит от уже прошедших слияний. dds.event_v использует этот результат, дает колонкам имена в snake_case и расшифровывает коды.

Выбор по полному ORDER BY и максимальной версии, включая чтение с FINAL, сверен через Context7 с документацией ClickHouse. В стенде закреплена версия 26.3.17.56; _v использует FINAL, чтобы результат не зависел от фоновых слияний.

Типы ODS повторяют описание выгрузки с одним денежным исключением: purchaseRevenue объявлен источником как Array(Float64), а из JSON разбирается сразу в Array(Decimal(18, 2)). Это сумма в рублях с копейками; DDS наследует тип. productPrice содержит округленные целые рубли и не подходит для восстановления точной суммы покупки. Прямой разбор purchaseRevenue проверен на ClickHouse 26.3; результаты сохранены в разделе «Что проверено».

Версии заказов

Слепки повторяют состояние заказов из семидневного окна источника. ods.order_rep хранит принятые версии в ReplicatedReplacingMergeTree(snapshot_date): ключ сортировки — order_id, партиция — toDate(created_at) в UTC. Запись через ods.order_dist распределяет их по cityHash64(order_id). У всех версий заказа одинаковы день создания и шард, поэтому они могут заменять друг друга.

ods.order_v читает _dist FINAL и оставляет строку с максимальной датой слепка. Дата snapshot_date показывает последнее принятое наблюдение, а updated_at — время изменения заказа в магазине. У неизменившегося заказа в следующем слепке дата наблюдения обновится, время изменения — нет.

Источник выпускает один слепок на дату, а поздний слепок показывает не менее новое состояние. Отсутствие заказа в следующем слепке не означает удаления. Физическая таблица не служит архивом: старые версии исчезают при фоновых слияниях.

Прием добавляет версии отдельными строками. snapshot_date не доказывает, что Kafka отдала весь слепок, поэтому партиция по этой дате не заменяется. Подробности — прием заказов, ADR 0010 и ADR 0014. Следующий слой описан в модели заказа DDS.

Часовые пояса

Пояс — линза, а не свойство значения. DateTime хранит одно число, секунды от начала эпохи; пояс решает лишь, какие часы по этому числу покажут время и в какие сутки оно попадёт.

Линза называется явно — в типе колонки либо в вызове функции. Третий источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там, где линза что-то решает — в объявлении хранимой колонки и в выражении, считающем дату, — его не остаётся. Прописать этот пояс своей рукой — <timezone> в конфигурации ноды или TZ контейнеру — было бы той же болезнью с другим умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах и в разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться, поменяв его. Правило стоит на источнике пояса, а не на функции: toDate по колонке, чей тип пояс несёт, законен и имени не требует — так и работают ключи партиций toDate(_load_ts) у таблицы STG и у таблицы ошибок. Имя пишется тогда, когда нужна другая линза, чем у колонки: toDate(UTCEventTime, 'Europe/Samara') — это «день по часам счётчика».

Какая линза, решает слой — по тому, кого он обслуживает. ODS хранит снимок выгрузки и говорит на языке выгрузки: у Метрики UTCEventTime абсолютна, значит DateTime('UTC'); служебные метки _load_ts и kafka_timestamp абсолютны тоже — DateTime64(3, 'UTC'). DDS и витрины обслуживают человека с дашбордом, поэтому время там лежит местным, в поясе счётчика (Europe/Samara, UTC+4), и пересчёт идёт один раз при наполнении слоя: автор отчёта пояса не пишет, он берёт готовую колонку. Date хранит только номер дня — чьи это сутки, в нём не записано. Поэтому время по EventDate считают, назвав пояс руками; промах виден сразу: часы суток уезжают за границы 0–23.

Объявить местным и ODS — DateTime('Europe/Samara') — соблазнительно: байты те же, меняется одна линза, и toDate(UTCEventTime) начинает совпадать с EventDate всегда. Отвергнуто потому, что колонка зовётся UTCEventTime и в настоящей выгрузке Метрики она в UTC, а стёртое расхождение — тот самый урок, ради которого мир сделан с поясом счётчика.

Линза выбирается дважды, и второй раз упустить легко. На чтении — показать метку или свести её к дате. На записи — превратить строку в число: суффикс Z на проводе зоны не даёт, маска разбора съедает его буквой, и parseDateTimeOrNull без третьего аргумента трактует показания часов по поясу сессии, а тот по умолчанию серверный. Тип колонки тут не помогает: разбор отдаёт готовое число, и колонка кладёт его как есть — пояс приёмника решал бы судьбу строки, а не числа. Механика и выбор функции — ADR 0005.

День берётся из EventDate. Дата в поясе счётчика уже посчитана генератором и лежит колонкой, так что суточные срезы группируются по ней и пояса не упоминают вовсе. Почему у ночных событий toDate(UTCEventTime) с ней расходится, объясняет ADR 0015.

Путь реплицированных таблиц в keeper

Шаблон — /clickhouse/tables/{shard}/{database}/{table}. База в пути обязательна: без неё одноимённые таблицы разных слоёв получат один и тот же узел в keeper и подерутся. Макрос {uuid} не используем, хотя он тоже развёл бы пути: он завязан на движок базы Atomic и делает путь нечитаемым, а на учебном стенде возможность открыть system.zookeeper и увидеть осмысленный путь — сама по себе половина урока про то, чем занят keeper.

Приём: поток и его свойства

События принимаются непрерывно. Заказы читает отдельным запросом даг orders_ingest — описание пакетного приема. У событий путь такой:

stg.hits_raw_kafka → stg.hits_raw_mv → stg.hits_raw_dist
                                      ├→ ods.event_mv → ods.event_dist
                                      └→ ods.event_errors_mv → ods.event_errors_dist

Таблицы Kafka на обеих нодах используют одну группу clickstream_hits. Ноды делят чтение партиций топика. Разные группы прочитали бы каждая свою полную копию потока. Имя группы одинаково в DDL на всем clickstream_cluster; конфигурация кластера — infra/clickhouse/config.d/cluster.xml.

Материализованные представления разбора срабатывают на вставку в stg.hits_raw_dist, до распределения строк по шардам. Оба видят вставляемую порцию на той ноде, которая читает Kafka. stg.hits_raw_mv создается после них: его подключение запускает чтение топика. Если начать чтение раньше, сообщения успеют попасть в STG без разбора в ODS.

Условие приема проверяет три вещи:

  • корень raw — объект JSON;
  • набор верхнеуровневых ключей совпадает с 47 полями формата;
  • пять опорных полей, WatchID, VisitID, ClientID, EventDate и UTCEventTime, разбираются в ожидаемые типы.

Остальные поля извлекаются по описанию выгрузки без отдельных проверок. Это граница приема: годная строка не означает, что проверен смысл всех 47 значений. Подробности — в ADR 0005.

ods.event_mv принимает строки, для которых условие истинно, ods.event_errors_mv — для которых оно ложно. Условие не должно возвращать NULL: такую строку не выбрали бы ни оно, ни его отрицание. Разбор использует функции без исключений на некорректном сообщении, поскольку ошибка материализованного представления прерывает вставку и останавливает чтение Kafka до устранения причины.

Доставка и повторы

Вставка в Distributed на этом пути фоновая: блок сначала записывается в локальную очередь на диске, затем отправляется на шарды. Kafka подтверждает обработку после выполнения материализованных представлений, когда данные еще могут находиться в очереди. Если после подтверждения потерять ноду вместе с очередью, сообщения не перечитаются. Возможен и повтор: данные уже записались на шард, но подтверждение Kafka не успело сохраниться. Поэтому прием не гарантирует доставку каждого сообщения хотя бы один раз.

В ETL-запросах Airflow distributed_foreground_insert = 1 дожидается записи на шарды. У фонового приема событий этой настройки нет: недоступность одного шарда тогда остановила бы весь прием. В принятом режиме доступная нода продолжает принимать и накапливает очередь для недоступного шарда. Ограничение сохранено для учебного стенда; подробности и наблюдения — в разделе «Что проверено».

В STG повтор сохраняется, в ODS его устраняет выбор актуальной версии. Хотя Replicated* умеет отбрасывать повторно вставленные одинаковые блоки, при новом чтении Kafka меняется _load_ts, а вместе с ним и хеш блока. Поэтому повторная отправка модельного дня увеличивает число строк STG.

Пустое значение сообщения и значение null не дают строки даже в STG: чтец продвигает позицию Kafka, но не передает строку на разбор. Генератор таких сообщений не создает. Поведение измерено на стенде и описано в разделе «Что проверено».

Что можно сверять

Для короткого неизменного среза доставки можно проверить баланс: число строк STG равно сумме годных строк и ошибок до удаления повторов. Постоянно сравнивать полные таблицы так нельзя: STG удаляет старые данные, ODS оставляет события и объединяет повторные доставки. Счетчики по слоям тогда расходятся при штатной работе.

make check-clickhouse сравнивает актуальные события в ods.event_v с паспортом мира только за восемь начальных дней. Эта проверка учитывает повторную отправку через FINAL и не зависит от срока жизни STG. Границы остальных проверок — в карте проверок.

Срок жизни сырых данных

TTL — правило автоматического удаления старых данных. В обеих таблицах STG срок задан как три суток от _load_ts, по реальному времени загрузки. Партиции тоже нарезаны по дню _load_ts. Это оставляет короткий период для поиска ошибок и повторного разбора, после которого данные освобождают место на диске.

Настройка ttl_only_drop_parts = 1 удаляет только целые части данных — файлы, которые ClickHouse создает при вставках и объединяет при слияниях. Часть удаляется, когда срок истек у всех ее строк. Внутри партиции дня загрузки возраст строк отличается не более чем на сутки. Поэтому к трем суткам может добавиться до суток ожидания самой свежей строки части, а затем ожидание фоновой обработки TTL. Точного времени удаления правило не обещает.

Модельная дата для TTL не подходит: события июня могут загружаться осенью, и срок от EventDate истек бы сразу. Партиция по модельному дню тоже смешала бы доставки разного возраста: очередная отправка дня могла бы продлевать хранение старых строк после слияния частей.

Для разбора сохраненных сообщений выбирайте небольшой срез по _load_ts. Так запрос ограничивает партиции и сохраняет некорректные сообщения, из которых модельную дату извлечь нельзя. EventDate появляется отдельной колонкой только в ODS. В задании ступени 6 такой срез разбирается рядом с действующим приемом.

Таблица ошибок

ods.event_errors_dist содержит сообщения, отвергнутые разбором событий: исходный raw, сведения о доставке и класс ошибки error_class. Класс имеет тип LowCardinality(String) и выбирается по первому нарушенному условию:

  1. not_an_object — корень JSON не объект;
  2. keyset_mismatch — набор ключей не совпал с форматом;
  3. key_field_unparsed — одно из пяти опорных полей не разобралось.

Порядок важен: строка вместо объекта нарушает сразу несколько условий, но получает один класс. Полные условия — в ADR 0005.

ods.order_errors_dist хранит такой же набор сведений и _load_id запуска приема. Первые два класса совпадают с событиями, третий называется field_invalid: у заказа проверяются все поля верхнего уровня. Точные правила — в описании приема заказов.

Таблицы ошибок используют ReplicatedMergeTree без замены версий: у некорректного сообщения может не быть разобранного ключа сущности. Шард выбирается по cityHash64(raw), ключ сортировки — (error_class, kafka_partition, kafka_offset), партиция — день _load_ts. Начать поиск можно с класса ошибки, затем перейти к сообщениям по координатам Kafka.

Срок хранения — один месяц от _load_ts, удаление целыми частями с ttl_only_drop_parts = 1, как в STG. Ошибка вместе с исходным текстом остается доступна дольше остальных сырых сообщений. Повторная обработка может добавить тот же брак еще раз; у заказов повтор виден по совпадению _load_id и координат Kafka.

Справочники

Справочные данные живут в базе dic — вне цепочки STG → ODS → DDS → DM. В хранилище их никто не производит: содержимое приходит из файла репозитория, а читают его несколько слоёв сразу. Отсюда правило принадлежности: читать зону вправе любой слой, писать — никто. Почему зона, а не прописка в слое модели, и почему словарь читает подложку, а не файл, — ADR 0012.

Первый и пока единственный справочник — каталог товаров: подложка dic.products_file над CSV репозитория и словарь dic.products поверх неё. Распределённых пар у зоны нет: обе ноды читают свой смонтированный файл и держат свою копию словаря в памяти. Словарь обновляется сам, окном LIFETIME, и потому после правки каталога ноды какое-то время отвечают по-разному. Разогнать его раньше срока — SYSTEM RELOAD DICTIONARY ON CLUSTER; это административная операция, роль etl права на неё не получает.

Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт Decimal(18, 2), productPrice события — целые рубли. Поэтому точную сумму заказа берут из purchaseRevenue, а не складывают округленные цены товаров (описание выгрузки).

Устройство обоих объектов — почему такой движок, такая форма пути, такой пользователь и такое окно — расписано в sql/ddl/05-dic-catalog.sql.

Раскладка SQL

Исполняемые дагами запросы лежат по правилу sql/<слой>/<объект>_<роль>.sql. Слой — слой цели запроса: забор заказов в STG живёт в sql/stg/orders_raw_load.sql, разбор — в sql/ods/order_parse.sql. Даг называет файлы, передаёт параметры и задаёт порядок выполнения, но не хранит текст преобразований. Два выражения разбора делят локальный макрос Jinja в том же файле; подключаемого SQL-фрагмента между ними нет.

SQL-файлы исполняет штатный SQLExecuteQueryOperator. При разборе Python-кода обработчик DAG читает список DDL-файлов, загружает содержимое каждого названного SQL-файла и кладёт его в сериализованный граф. При исполнении Airflow разворачивает в этом тексте Jinja; параметры ClickHouse передаются отдельно и не подставляются строкой. Каталог sql/ смонтирован во все службы Airflow только для чтения.

DDL

Файлы лежат в sql/ddl/ и применяются по порядку имён: источник и цель создаются до представления или матвью, которые их используют.

Файл Что в нём
00-databases.sql базы слоёв и зона справочников
05-dic-catalog.sql подложка над CSV-каталогом и словарь товаров поверх неё
10-stg-tables.sql чтецы топиков hits и orders, локальные и распределённые таблицы STG обоих источников
20-ods-tables.sql типизированное событие, версии заказа и обе таблицы ошибок
30-ods-views.sql актуальные события, текущие заказы и матвью разбора в ODS
40-stg-views.sql матвью приёма: чтец в STG
45-dds-tables.sql физические модели заказа, сессии и карты идентичностей, доноры партиций
50-dds-views.sql публичные договоры сущностей DDS
55-dm-tables.sql физические витрины выручки, сверки и трафика, доноры дневных партиций
60-dm-views.sql публичные договоры витрин DM

Справочники идут сразу за базами: зона dic не зависит ни от одного слоя, и правила порядка слоёв к ней не применяются.

Порядок остальных задают два правила. Первое: матвью принадлежит слою своей цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся матвью приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает читать топик, когда к нему привязывают первую матвью; создай её раньше разбора — и всё, что доедет в зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на обычной отладке.

Файлы применяет даг world_initialize с ноды 1 отдельной учётной записью lifecycle: один файл — одна последовательная задача SQLExecuteQueryOperator, а выражения внутри разделяет сам оператор. Всё ON CLUSTER, кроме чтеца заказов — он объявлен только на этой ноде. Перед DDL даг создаёт hits с двумя партициями и orders с одной (ADR 0008). Этот порядок не даёт hits автоматически создаться с одной партицией. Брокер автосоздание разрешает, а потребитель librdkafka внутри ClickHouse его не просит. Оба конца измерены; результаты приведены в разделе «Что проверено». Успех дага означает больше, чем применённый DDL: оба стартовых источника приняты, а позиция мира установлена последней задачей. Полный граф и состояния отказа описаны в спеке жизненного цикла.

Повторный make up поверх готового мира проходит зелёным без изменений: world_initialize ветвится раньше DDL. Поэтому изменённый объект обычным подъёмом не применяется. В разработке меняют или пересоздают только затронутый объект, партицию либо базу слоя. Если нужен целиком канонический мир, make rebuild-storage запускает world_recreate: тот удаляет прикладные базы stg, dm, dds, ods, dic, позиции групп потребителей и прикладные топики, затем вызывает world_initialize. Службы и их тома остаются на месте, а позиция возвращается к концу стартовых дней. Полный make clean для изменения DDL не нужен. Когда какая область очистки уместна перед проверкой, задаёт карта проверок.

Карта таблиц

Ниже — объекты, уже построенные этапами 2–4; всё перечисленное лежит в sql/ddl/.

База Объект Что это
stg stg.hits_raw_kafka чтец топика hits, формат RawBLOB
stg stg.hits_raw_rep / _dist сырая строка сообщения плюс метаданные доставки
stg stg.hits_raw_mv наполняет STG из чтеца
stg stg.orders_raw_kafka чтец топика orders, формат RawBLOB, только на ноде 1 и без матвью
stg stg.orders_raw_rep / _dist сырое сообщение слепка, метаданные доставки и _load_id
ods ods.event_rep / _dist типизированное широкое событие
ods ods.event_v актуальная версия события с полями источника
ods ods.event_errors_rep / _dist строки, не прошедшие строгий приём
ods ods.event_mv, ods.event_errors_mv разбор STG в событие и в ошибки
ods ods.order_rep / _dist типизированные версии заказа
ods ods.order_v текущая версия заказа на языке источника
ods ods.order_errors_rep / _dist строки слепка, не прошедшие строгий приём
dds dds.event_v событие на языке бизнес-модели без физической копии
dds dds.order_rep / _dist заказ в текущем состоянии, партиция — день заказа
dds dds.order_stage_rep / _dist двойник заказа: в нём день собирается перед заменой партиции
dds dds.order_v публичный договор заказа
dds dds.session_rep / _dist визит, нарезанный слоем; партиция — день сессии
dds dds.session_stage_rep / _dist двойник сессии: в нём день собирается перед заменой партиции
dds dds.session_v публичный договор сессии
dds dds.identity_map_rep / _dist известная пара куки с пользователем; таблицы полного пересчёта
dds dds.identity_map_v точный публичный договор карты идентичностей с FINAL
dm dm.revenue_daily_rep / _dist дневная выручка по категориям
dm dm.revenue_daily_stage_rep / _dist таблица-двойник, донор дневных партиций выручки
dm dm.revenue_daily_v публичный договор дневной выручки
dm dm.purchase_vs_orders_rep / _dist сверка покупок с заказами, партиция — день заказа
dm dm.purchase_vs_orders_stage_rep / _dist донор дневных партиций сверки
dm dm.purchase_vs_orders_v публичный договор сверки с NULL для отсутствующей стороны
dm dm.daily_traffic_rep / _dist версии дневных счетчиков трафика
dm dm.daily_traffic_v точные текущие счетчики трафика с FINAL
dm dm.dq_summary_rep / _dist текущее качество дня по атомарным проверкам
dm dm.dq_summary_stage_rep / _dist общий донор дневных партиций DQ-сводки
dm dm.dq_summary_v публичная сводка качества дней
dic dic.products_file чтец CSV-каталога, общего с генератором
dic dic.products словарь товаров поверх подложки

Матвью разбора у заказов нет: срез STG раскладывают по этим двум целям два INSERT SELECT одной задачи parse_batch из файла sql/ods/order_parse.sql.

В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции; карта пишет полный набор пар в версионную таблицу. В DM выручка и сверка заменяют партиции изменяемых дней, а трафик пишет полную историю новой версией.

Что проверено

Документ на каждом шагу опирается на поведение ClickHouse, а местами и Kafka. Поэтому утверждения о них разведены на три группы: насколько фразе можно верить, должно быть видно из текста, а не зависеть от того, хорошо ли автор помнит документацию. Сверка с документацией — через MCP Context7, 5 августа 2026 года; то же разведение для механики приёма — в ADR 0005.

Проверка ods.event_v 18 августа 2026 года. Документация ClickHouse через MCP Context7 подтвердила обычное представление с FINAL как способ скрыть схлопывание от потребителя. На закреплённом ClickHouse 26.3 в ODS добавили вторую физическую версию одного события: обычный счёт вырос на один, а ods.event_v остался равен ods.event_dist FINAL. Имена и типы всех колонок представления совпали с распределённой таблицей.

Проверка словаря товаров 19 августа 2026 года (#95). Context7 подтвердил DDL-словарь с файловым источником внутри user_files, хешированную раскладку для строкового ключа и отключение фонового обновления через LIFETIME(0). На закреплённом ClickHouse 26.3 цена известного sku после правки CSV и SYSTEM RELOAD DICTIONARY ON CLUSTER изменилась на обеих нодах; после отката и повторной команды вернулась обратно.

Проверка зоны справочников 20 августа 2026 года (#105). Подложка на движке File перечитывает файл на каждом запросе, словарь поверх неё обновляется сам внутри окна LIFETIME, а собранный с нуля стенд поднимает оба объекта и отдаёт словарь читателям. Замеры целиком — в ADR 0012, раздел «Что проверено»; там же разобрано, почему отказ в праве на движок называет не то право, которого не хватает.

Проверка ко-локации заказов 22 августа 2026 года (#137). Context7 и system.settings закреплённого ClickHouse 26.3 назвали одну и ту же тройку значений parallel_distributed_insert_select: 0 — выключено, 1 — SELECT на каждом шарде, 2 — SELECT и INSERT на каждом шарде; заводское — 2. На живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов лежали на обоих шардах, после явного нуля и повторного разбора того же _load_id — ни одного. Вершина версий уцелела: деловые поля ods.order_v сошлись с эталоном, посчитанным прямо из STG.

Проверка версий заказов 18 августа 2026 года (#94). MCP Context7 подтвердил, что JSONType возвращает имя типа значения JSON, — на нём стоит проверка типов в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3.

Три находки касаются не заказов, а самого ClickHouse, и знать их стоит любому, кто пишет здесь строгий разбор:

  • toDateOrNull календарь не проверяет. '2026-02-30' он молча превращает в 2026-03-02, '2026-13-01' — в 1970-01-01. Для строгого приёма он поэтому не годится: нужен parseDateTimeInJodaSyntaxOrNull(…, 'yyyy-MM-dd', 'UTC'), который на обеих строках даёт NULL.
  • Маска разбора формы не держит. parseDateTime64InJodaSyntaxOrNull по маске yyyy-MM-dd'T'HH:mm:ss.SSS'Z' берёт и 2026-6-3T14:21:07.123Z — без ведущих нулей. Форму приходится сверять отдельно, регулярным выражением; зато календарь маска проверяет честно (30 февраля, 13-й месяц, 25-й час дают NULL), как и лишние или недостающие знаки долей секунды и смещение +00:00 вместо Z.
  • У временных типов есть потолок, и он ниже, чем кажется. DateTime64(3) заканчивается на 2299-12-31, DateTime — на 2106-02-07; строки за потолком разбор отдаёт как NULL. Это ловит опыт, который берёт «заведомо далёкий» год: 2999-й не разбирается вовсе.

Про сам приём заказов снято следующее. Предикат формы провода разложил все 13 661 строку STG, приехавшую при исполнении #93, без исключений и без NULL: 13 653 годных и 8 брака, сумма сошлась с общим счётом по каждому _load_id, пересечение ветвей — ноль. Все восемь строк брака — версии одного заказа с отрицательным total; это дефект генератора, разобранный в спецификации приёма, «Что проверено». На управляемой порции из пяти строк (две версии одного заказа плюс по одной строке каждого класса брака) годные ушли в ods.order, брак — в ods.order_errors с ожидаемыми классами. Обе версии легли в одну партицию и на один шард; FINAL через распределённую таблицу вернул одну строку — ту, что несла позднейшую версию, — тогда как физический счёт показывал две. Повтор задачи с тем же _load_id строку в ods.order_v и её _load_ts не изменил, а таблица ошибок записала тот же брак второй раз, как и обещано спецификацией приёма.

Отдельно измерено, что сводка вставки не годится в счётчики строк: у запроса с WHERE read_rows считает прочитанное с диска, а не подошедшее, — на одном и том же срезе из пяти строк две вставки дали 4 и 5. Сколько чего легло, спрашивают у самих таблиц по _load_id.

Сверено с документацией. Собственная колонка с именем виртуальной делает виртуальную недоступной. При вставке в Distributed шард выбирается по ключу шардирования; фоновый режим — умолчание, а distributed_foreground_insert = 1 завершает вставку только после записи на все шарды. У семейства Replicated* есть дедупликация одинаковых блоков. Голый count() по ReplacingMergeTree зависит от того, сколько мержей прошло. ttl_only_drop_parts снимает кусок целиком и только когда истекли все строки в нём, а сам TTL исполняется на фоновых мержах. Kafka-движок начинает читать топик, когда к нему привязывают матвью, и одна группа потребителей на кластер спасает от дублей. Таблицы с одинаковым путём в keeper становятся репликами друг друга, а макрос {uuid} завязан на движок базы Atomic. ON CLUSTER ждёт все хосты и бросает по таймауту; CREATE ... IF NOT EXISTS на существующем объекте не бросает. parseDateTime и его родня принимают пояс необязательным последним аргументом, а без него берут пояс сессии — он же по умолчанию серверный. У колонки с объявленным поясом значения приводятся к нему; у колонки без объявленного session_timezone перекрывает серверную настройку на выводе, но тип, с которым считают функции, остаётся прежним (сверка 8 августа 2026 года).

Проверено на стенде. Опыты прогнаны на живом кластере: пять при исполнении #37 (четыре 5 августа 2026 года, пятый 6 августа), пять при исполнении #43 (7 августа) и четыре при #63 (8 августа). Все подтвердили то, что здесь написано.

  • Разбор строки берёт пояс у сессии, а не из строки. Под session_timezone = 'Europe/Samara' одна и та же строка 2026-06-01T01:24:09Z по маске %Y-%m-%dT%H:%i:%SZ дала 1780262649 без третьего аргумента и 1780277049 с аргументом 'UTC' — ровно четыре часа разницы. Тип результата без аргумента — Nullable(DateTime('Europe/Samara')). Отсюда имя пояса в разборе: суффикс Z маска съедает и выбрасывает.

  • toDate берёт пояс у типа своего аргумента. Из одного момента: по DateTime('UTC') — 2026-05-31, по DateTime('Europe/Samara') — 2026-06-01. Отсюда форма правила: имя пояса нужно там, где его не несёт тип.

  • У колонки без объявленного пояса глаз и GROUP BY расходятся. Замер снят до правки типов и на нынешнем стенде не повторяется — колонка уже с поясом. Событие WatchID = 113504893317, EventDate = 2026-06-05: без настроек колонка показана 2026-06-04 20:58:56, под session_timezone = 'Europe/Samara' — 2026-06-05 00:58:56, а toDate(UTCEventTime) в обоих случаях 2026-06-04. То есть вывод колонки идёт по поясу сессии, а функция — по поясу типа, и тип на сессию не смотрит. Родной клиент и HTTP ведут себя одинаково.

  • С объявленным поясом расхождение уходит. Стенд поднят с нуля уже по конвенции; событие WatchID = 384218330540, EventDate = 2026-06-01: колонка DateTime('UTC') показана 2026-05-31 23:37:00 и без настроек, и под session_timezone = 'Europe/Samara', по родному клиенту и по HTTP. toDate(UTCEventTime) даёт 2026-05-31, toDate(UTCEventTime, 'Europe/Samara') — 2026-06-01, EventDate — 2026-06-01: расхождение toDate(UTCEventTime) с EventDate осталось, это мир, а не пояс колонки.

  • Матвью с источником-Distributed срабатывает на вставку именно в эту распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над stg.hits_raw_dist, а пишет в неё матвью приёма — и события доезжают до ods.event; значит блок она видит. В документации ClickHouse случая нет вовсе, до 7 августа утверждение держалось на опыте владельца.

  • Упавшая матвью роняет вставку и останавливает потребление до починки. Проверено сносом цели — распределённой ods.event_dist — при живом чтеце: за двадцать секунд (сброс блока идёт за 7,5) в STG не приехало ничего, а офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение доехало само, без повторной отправки, отставание ушло в ноль, событие разобралось. Сносить надо именно распределённую таблицу: вставка в Distributed кладёт блок в спул и сразу возвращает управление, так что на сносе локальной ошибка всплыла бы фоном и утверждение показалось бы опровергнутым.

  • _load_ts в ods.event — это метка исходной строки STG, а не время разбора. Сверено по WatchID на двух тысячах событий модельного дня, залитого дважды: у каждого события метка совпала с меткой одной из двух его доставок, а после FINAL — с меткой поздней. Случаев «метки нет среди доставок» ноль, то есть now64() в матвью разбора нет.

  • Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново созданные матвью разбора при живом чтеце: сообщение, отправленное сразу после, легло в STG и не попало в ODS никуда — ни в событие, ни в ошибки; то же сообщение через минуту разобралось штатно. Воспроизведено дважды 7 августа 2026 года. Это тот же зазор, о котором предупреждает нумерация файлов DDL, только приходит он с другой стороны — не при первом создании, а при замене матвью на работающем стенде. Практический вывод один: правишь матвью — не верь ближайшей отправке, повтори её. Чем именно держится задержка, не измерено; наблюдение записано как наблюдение.

  • Форма ключа ods.event принимается такой, как её задумала спека: выражение intHash32(ClientID) стоит в ключе сортировки ReplacingMergeTree, а SAMPLE BY — по тому же выражению. Вопрос стоял открытым в разделе 11 мастер-спеки; ответ — DDL применяется и таблица работает.

  • 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) читаются, двигают офсет потребителя и не дают строки вовсе — ни в STG, ни в таблицу ошибок; приём при этом не останавливается. Проверено 5 августа 2026 года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, — в STG приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при чём: LineAsString, запасной по ADR 0005, на том же наборе даёт ровно те же две строки и тот же офсет.

Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за случая, которого стенд не производит, — размен не в ту сторону. Знать о нём стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в самой таблице STG, kafka_offset лежит там колонкой, и дырка в офсетах — это он.

Оговорка к третьему опыту, и она сама непроверенная: CREATE ... Distributed AS <локальная> копирует умолчания колонок, а значит, умолчание, оказавшееся заодно и на распределённой таблице, могло бы вычислиться до раскладки по шардам. То есть вывод опыта надёжен именно для умолчания, живущего только на локальной таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет: служебные колонки мы заполняем выражением при любом ответе.

Проверка замены партиций 26 августа 2026 года (#151). Context7 подтвердил договор REPLACE PARTITION: ON CLUSTER в синтаксисе есть, а источник и цель обязаны совпадать структурой, ключом партиции, ключом сортировки, первичным ключом и политикой хранения. На закреплённом ClickHouse 26.3 замена по локальным таблицам с ON CLUSTER прошла на обеих нодах, а сборка стартового мира в двойник разложила 1662 заказа строго по ключу: шард 1 получил только чётные cityHash64(order_id), шард 2 — только нечётные.

Проверка карты идентичностей 26 августа 2026 года (#153). Context7 подтвердил версию в ReplacingMergeTree и точное чтение через FINAL. Два полных прогона оставили 3180 физических строк и 1590 пар в _v; все пары подтверждены покупкой и заказом, а строк на чужом шарде нет.

Тем же днём измерены ещё три поведения ClickHouse.

  • Параметр в ALTER … PARTITION не подставляется. Запрос с {day:Date} в выражении партиции отвечает кодом 62, синтаксической ошибкой; литерал проходит. Отсюда единственное место в репозитории, где значение попадает в запрос подстановкой Jinja, а не параметром ClickHouse (sql/dds/order_replace.sql).
  • TRUNCATE — отдельное право, в ALTER оно не входит. Роль etl, у которой есть ALTER ON dds.*, получает на TRUNCATE код 497, а ALTER TABLE … DROP PARTITION ALL на той же таблице проходит. Двойник поэтому чистится снятием партиций: у операции нашлась форма, на которую право уже выдано, и расширять доступ не понадобилось.
  • JSONExtract читает Decimal прямо из строки JSON. Цена на проводе — строка с двумя знаками ("1289.99"), и разбор в Array(Tuple(sku String, qty UInt32, price Decimal(18, 2))) даёт число без промежуточного toDecimal64. Проверено на живом слепке; честность разбора сторожит инвариант бэкенда — сумма «цена × количество» совпала с items_total у всех 1662 заказов.
  • JSONExtract читает Decimal и из числа JSON. Массив purchaseRevenue, объявленный источником как Array(Float64), разбирается сразу в Array(Decimal(18, 2)). Синтаксис подтвержден через Context7 и на ClickHouse 26.3; все суммы после ODS совпали с сырыми данными до копейки.

Сказано по памяти, проверки нет. Группа пуста: оба утверждения, ждавшие матвью разбора, закрыты опытами при исполнении #43 и переехали выше.