# Хранилище: слои и конвенции Документ описывает сторону ClickHouse: как называются объекты, какие служебные колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами, и раздел «Что проверено» — чему в этом тексте верить и на каком основании. **Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов, keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка `Kafka → STG → ODS` — чтец топика `hits`, таблицы STG, типизированное событие с таблицей ошибок, поверхность актуального состояния и три матвью. Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой чтец, свой сырой слой, версии заказов с таблицей ошибок и поверхность текущего состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью. Тот же этап принёс первый справочник — словарь товаров из общего с генератором CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат публичным договором события `dds.event_v` над точным состоянием ODS, а следом получил две физические модели слоя — заказ и сессию: их собирает заменой партиций даг `dds_transform`. Сессия среди них особая: в потоке такой сущности нет, стенд режет её из событий сам. Дальше по тексту устройство описано так, как оно проектируется; построенное от заложенного отличает карта таблиц в конце. Зона ответственности у документа одна — хранилище. Генератор описан отдельно: его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат события — в [описании выгрузки](../formats/clickstream-event.md). Хранилище строится по описанию выгрузки, как в бою строится по документации источника. Целевая картина всего стенда — [спека «Боевой реализм стенда (v2)»](../specs/2026-07-30-stand-v2-realism.md). ## Имена объектов Суффикс показывает назначение объекта: | Суффикс | Что это | |---|---| | `_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](../adr/0007-clickhouse-access.md) и [исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md). ## Раскладка по шардам Шард хранит часть строк. При записи через `_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`](../../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`](dm.md#dmdaily_traffic_v). Для дневной пересборки DDS и DM результат сначала записывают в промежуточную таблицу. Основная таблица в это время остается доступна для чтения с прежними данными. Например, заказы записываются через `dds.order_stage_dist` в `dds.order_stage_rep`, затем ее дневные разделы заменяют разделы основной `dds.order_rep`. У промежуточной и основной таблиц должны совпадать структура, ключи партиции и сортировки, первичный ключ и политика хранения. Поэтому `dds.order_stage_rep` объявлена через `AS dds.order_rep` с совпадающими ключами. Требования сверены через Context7 с [документацией ClickHouse](https://clickhouse.com/docs/sql-reference/statements/alter/partition#replace-partition) для схемы на ClickHouse 26.3. DDL показан в [`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql). Промежуточная таблица должна также распределять строки по тому же ключу, здесь — `cityHash64(order_id)`. `REPLACE PARTITION` копирует раздел между локальными таблицами каждого шарда и не перераспределяет строки. Поэтому уже в промежуточной таблице заказ должен находиться на нужном шарде. Перед заменой вставка с `distributed_foreground_insert = 1` дожидается доставки данных на шарды. В `ods.order` этот способ не применяется: туда добавляют версии заказов из слепков, а актуальные строки выбираются при чтении ([ADR 0010](../adr/0010-order-versions-in-ods.md)). ## Служебные колонки 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 метки загрузки уже относятся к собственной сборке слоя — [пример заказа](dds/order.md#служебные-колонки). ## Актуальные события Повторная отправка события может дать несколько строк в `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](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree). В стенде закреплена версия 26.3.17.56; `_v` использует `FINAL`, чтобы результат не зависел от фоновых слияний. Типы ODS повторяют [описание выгрузки](../formats/clickstream-event.md) с одним денежным исключением: `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 отдала весь слепок, поэтому партиция по этой дате не заменяется. Подробности — [прием заказов](orders/ingestion.md), [ADR 0010](../adr/0010-order-versions-in-ods.md) и [ADR 0014](../adr/0014-order-version-snapshot-date.md). Следующий слой описан в [модели заказа DDS](dds/order.md). ## Часовые пояса Пояс — линза, а не свойство значения. `DateTime` хранит одно число, секунды от начала эпохи; пояс решает лишь, какие часы по этому числу покажут время и в какие сутки оно попадёт. **Линза называется явно — в типе колонки либо в вызове функции.** Третий источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там, где линза что-то решает — в объявлении хранимой колонки и в выражении, считающем дату, — его не остаётся. Прописать этот пояс своей рукой — `` в конфигурации ноды или `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](../adr/0005-event-ingestion.md). **День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана генератором и лежит колонкой, так что суточные срезы группируются по ней и пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней расходится, объясняет [ADR 0015](../adr/0015-counter-timezone.md). ## Путь реплицированных таблиц в keeper Шаблон — `/clickhouse/tables/{shard}/{database}/{table}`. База в пути обязательна: без неё одноимённые таблицы разных слоёв получат один и тот же узел в keeper и подерутся. Макрос `{uuid}` не используем, хотя он тоже развёл бы пути: он завязан на движок базы `Atomic` и делает путь нечитаемым, а на учебном стенде возможность открыть `system.zookeeper` и увидеть осмысленный путь — сама по себе половина урока про то, чем занят keeper. ## Приём: поток и его свойства События принимаются непрерывно. Заказы читает отдельным запросом даг `orders_ingest` — [описание пакетного приема](orders/ingestion.md). У событий путь такой: ```text 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](../adr/0005-event-ingestion.md). `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` с [паспортом мира](../../data/world-inventory.json) только за восемь начальных дней. Эта проверка учитывает повторную отправку через `FINAL` и не зависит от срока жизни STG. Границы остальных проверок — в [карте проверок](testing.md). ## Срок жизни сырых данных TTL — правило автоматического удаления старых данных. В обеих таблицах STG срок задан как три суток от `_load_ts`, по реальному времени загрузки. Партиции тоже нарезаны по дню `_load_ts`. Это оставляет короткий период для поиска ошибок и повторного разбора, после которого данные освобождают место на диске. Настройка `ttl_only_drop_parts = 1` удаляет только целые части данных — файлы, которые ClickHouse создает при вставках и объединяет при слияниях. Часть удаляется, когда срок истек у всех ее строк. Внутри партиции дня загрузки возраст строк отличается не более чем на сутки. Поэтому к трем суткам может добавиться до суток ожидания самой свежей строки части, а затем ожидание фоновой обработки TTL. Точного времени удаления правило не обещает. Модельная дата для TTL не подходит: события июня могут загружаться осенью, и срок от `EventDate` истек бы сразу. Партиция по модельному дню тоже смешала бы доставки разного возраста: очередная отправка дня могла бы продлевать хранение старых строк после слияния частей. Для разбора сохраненных сообщений выбирайте небольшой срез по `_load_ts`. Так запрос ограничивает партиции и сохраняет некорректные сообщения, из которых модельную дату извлечь нельзя. `EventDate` появляется отдельной колонкой только в ODS. В [задании ступени 6](../labs/6-1-event-parsing.md) такой срез разбирается рядом с действующим приемом. ## Таблица ошибок `ods.event_errors_dist` содержит сообщения, отвергнутые разбором событий: исходный `raw`, сведения о доставке и класс ошибки `error_class`. Класс имеет тип `LowCardinality(String)` и выбирается по первому нарушенному условию: 1. `not_an_object` — корень JSON не объект; 2. `keyset_mismatch` — набор ключей не совпал с форматом; 3. `key_field_unparsed` — одно из пяти опорных полей не разобралось. Порядок важен: строка вместо объекта нарушает сразу несколько условий, но получает один класс. Полные условия — в [ADR 0005](../adr/0005-event-ingestion.md). `ods.order_errors_dist` хранит такой же набор сведений и `_load_id` запуска приема. Первые два класса совпадают с событиями, третий называется `field_invalid`: у заказа проверяются все поля верхнего уровня. Точные правила — в [описании приема заказов](orders/ingestion.md#граница-строгого-приёма). Таблицы ошибок используют `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](../adr/0012-dictionary-home.md). Первый и пока единственный справочник — каталог товаров: подложка `dic.products_file` над CSV репозитория и словарь `dic.products` поверх неё. Распределённых пар у зоны нет: обе ноды читают свой смонтированный файл и держат свою копию словаря в памяти. Словарь обновляется сам, окном `LIFETIME`, и потому после правки каталога ноды какое-то время отвечают по-разному. Разогнать его раньше срока — `SYSTEM RELOAD DICTIONARY ON CLUSTER`; это административная операция, роль `etl` права на неё не получает. Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт `Decimal(18, 2)`, `productPrice` события — целые рубли. Поэтому точную сумму заказа берут из `purchaseRevenue`, а не складывают округленные цены товаров ([описание выгрузки](../formats/clickstream-event.md)). Устройство обоих объектов — почему такой движок, такая форма пути, такой пользователь и такое окно — расписано в [`sql/ddl/05-dic-catalog.sql`](../../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](../adr/0008-order-ingestion.md)). Этот порядок не даёт `hits` автоматически создаться с одной партицией. Брокер автосоздание разрешает, а потребитель librdkafka внутри ClickHouse его не просит. Оба конца измерены; результаты приведены в разделе «Что проверено». Успех дага означает больше, чем применённый DDL: оба стартовых источника приняты, а позиция мира установлена последней задачей. Полный граф и состояния отказа описаны в [спеке жизненного цикла](../specs/2026-08-21-world-lifecycle.md). Повторный `make up` поверх готового мира проходит зелёным без изменений: `world_initialize` ветвится раньше DDL. Поэтому изменённый объект обычным подъёмом не применяется. В разработке меняют или пересоздают только затронутый объект, партицию либо базу слоя. Если нужен целиком канонический мир, `make rebuild-storage` запускает `world_recreate`: тот удаляет прикладные базы `stg`, `dm`, `dds`, `ods`, `dic`, позиции групп потребителей и прикладные топики, затем вызывает `world_initialize`. Службы и их тома остаются на месте, а позиция возвращается к концу стартовых дней. Полный `make clean` для изменения DDL не нужен. Когда какая область очистки уместна перед проверкой, задаёт [карта проверок](testing.md). ## Карта таблиц Ниже — объекты, уже построенные этапами 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](../adr/0005-event-ingestion.md). **Проверка `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](../adr/0012-dictionary-home.md), раздел «Что проверено»; там же разобрано, почему отказ в праве на движок называет не то право, которого не хватает. **Проверка ко-локации заказов 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`; это дефект генератора, разобранный в [спецификации приёма](orders/ingestion.md), «Что проверено». На управляемой порции из пяти строк (две версии одного заказа плюс по одной строке каждого класса брака) годные ушли в `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](../adr/0005-event-ingestion.md), на том же наборе даёт ровно те же две строки и тот же офсет. Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за случая, которого стенд не производит, — размен не в ту сторону. Знать о нём стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в самой таблице 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`](../../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 и переехали выше.