Зачем: помочь начинающему инженеру проследить событие и версию заказа от генератора до хранилища, включая ошибки и повторные поступления. Что: обновлены вводная генератора, справочники приема и модели заказа, разделы хранилища и комментарии SQL и дага. Сохранены технические условия; исполняемый код, схемы и поведение стенда не изменены. Проверка: make lint, git diff --check, 142 локальные ссылки и справка CLI. SQL без комментариев и Python AST без строк документации совпали с исходными. Генерация данных и сброс состояния не выполнялись. Refs #250
828 lines
75 KiB
Markdown
828 lines
75 KiB
Markdown
# Хранилище: слои и конвенции
|
||
|
||
Документ описывает сторону 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` хранит одно число, секунды от
|
||
начала эпохи; пояс решает лишь, какие часы по этому числу покажут время и в
|
||
какие сутки оно попадёт.
|
||
|
||
**Линза называется явно — в типе колонки либо в вызове функции.** Третий
|
||
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там,
|
||
где линза что-то решает — в объявлении хранимой колонки и в выражении, считающем
|
||
дату, — его не остаётся. Прописать этот пояс своей рукой — `<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](../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 и переехали выше.
|