feat(clickhouse): справочники вынесены в зону dic, словарь читает подложку

Зачем: место словаря в dds было унаследовано от первого объекта, а не решено.
Справочник читают несколько слоёв, а производит его в хранилище ни один —
прописка внутри одного из потребителей приписывала DDS владение, которого у
него нет.

Что: заведена база dic вне цепочки STG → ODS → DDS → DM, dds.products переехал
в dic.products. Под словарём появилась подложка dic.products_file на движке
File — намеренное усложнение ради урока: в бою источник словаря приезжает
процессом, а не лежит файлом у сервера. Источник объявлен формой query и
приводит цену из целых копеек каталога в Decimal(18, 2), как у денег бэкенда.
Обновление — окном LIFETIME(MIN 60 MAX 90) вместо ручной перезагрузки, ценой
заявленной неатомарности между нодами. У словаря свой беспарольный
пользователь dict с единственным правом на чтение dic. Суффикс _file добавлен
в конвенцию имён ADR 0006. Решение, отвергнутые варианты и условия пересмотра
— ADR 0012.

Проверка: make clean && make up на собранном заново стенде — зелено, make smoke
20 проверок и 0 ошибок, make lint чисто. Словарь LOADED со 180 строками, цена
Decimal(18, 2) и точна на копейках: 188990 → 1889.90. Пользователь bi читает
словарь dictGet-ом со второй ноды, analyst — подложку соединением, база dds
пуста. Минимальное право на движок замерено тремя пользователями: достаточно
GRANT FILE ON *.*, хотя отказ называет TABLE ENGINE ON File.

Closes #105

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:37:26 +03:00
co-authored by Claude Opus 5
parent 862dde9fa4
commit abc94ba406
10 changed files with 428 additions and 59 deletions
+79 -24
View File
@@ -12,8 +12,9 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/`
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой
чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью.
Тот же этап принёс первый объект DDS — словарь товаров из общего с генератором
CSV-файла.
Тот же этап принёс первый справочник — словарь товаров из общего с генератором
CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а
подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)).
Дальше по тексту устройство описано так, как оно проектируется; построенное от
заложенного отличает карта таблиц в конце.
@@ -33,6 +34,7 @@ CSV-файла.
| `_rep` | локальная таблица шарда, движок семейства `Replicated*` |
| `_dist` | `Distributed` поверх одноимённой локальной |
| `_kafka` | таблица на движке `Kafka` |
| `_file` | таблица на движке `File` — чтец файла |
| `_mv` | материализованное представление |
| `_v` | обычное представление |
@@ -40,7 +42,9 @@ CSV-файла.
запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым
именем в документах и разговоре понимается сущность, у которой этих объектов
несколько. Единственное исключение — словари: у них воплощение одно, шардировать
нечего, и суффикс ничего не различал бы.
нечего, и суффикс ничего не различал бы. В зоне `dic` обе формы стоят рядом и
правило видно целиком: `dic.products_file` — чтец файла, `dic.products` — сам
словарь.
Распространённая конвенция, где голое имя означает локальную таблицу, а
распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо
@@ -416,22 +420,52 @@ kafka_offset)`: смотрят такую таблицу от класса, а
разрастается до имени отдельного поля. Точная граница приёма — в
[спецификации заказов](orders/ingestion.md).
## Словарь товаров
## Справочники
`dds.products` читает `data/catalog/products.csv` напрямую. Compose монтирует
каталог только для чтения в `user_files` обеих нод, а DDL создаёт словарь
`ON CLUSTER`: имя и форма одни, но каждая нода держит свою копию в памяти.
Справочные данные живут в базе `dic` — вне цепочки STG → ODS → DDS → DM. В
хранилище их никто не производит: содержимое приходит из файла репозитория, а
читают его несколько слоёв сразу. Поэтому зона своя, читать её вправе любой
слой, писать — никто. Почему так, а не пропиской в слое модели, —
[ADR 0012](../adr/0012-dictionary-home.md).
Источник `FILE` с форматом `CSVWithNames` читает заголовок файла. Строковый ключ
`sku` требует `COMPLEX_KEY_HASHED`; числовой `FLAT` здесь неприменим. Цена
остаётся целым числом копеек типа `Int64`, как в контракте события.
### Словарь товаров
`LIFETIME(0)` отключает фоновое обновление. Каталог меняется только явной
правкой репозитория, а независимый опрос двух нод позволил бы им временно
отвечать разными версиями. Изменение применяют к обеим нодам штатной командой:
`SYSTEM RELOAD DICTIONARY ON CLUSTER clickstream_cluster dds.products`.
Это административная операция: её выполняют под `default`; роль `etl` права
перезагрузки словарей не получает.
Под словарём лежит подложка `dic.products_file` на движке `File`. Она ничего не
хранит и перечитывает `data/catalog/products.csv` на каждом запросе. Compose
монтирует каталог только для чтения в `user_files` обеих нод, DDL создаёт оба
объекта `ON CLUSTER`: имя и форма одни, но каждая нода читает свой файл и держит
свою копию словаря в памяти.
Путь у движка `File` считается **от `user_files`**, а не от корня данных.
Форма `./user_files/catalog/products.csv`, которой требовал прежний файловый
источник словаря, даёт `FILE_DOESNT_EXIST` — легко принять за пропавший монтаж.
Сам `dic.products` берёт подложку источником `CLICKHOUSE`, причём формой
`query`, а не `table`: словарь нормализует данные на входе, а не зеркалит
подложку. Пользователя надо называть явно — без него словарь идёт как `default`
с пустым паролем и падает с `AUTHENTICATION_FAILED`. Ходит он беспарольным
`dict`, у которого одно право: чтение `dic`. Хост локальный, поэтому запрос
идёт без сети.
Строковый ключ `sku` требует `COMPLEX_KEY_HASHED`; числовой `FLAT` здесь
неприменим.
**Цена: три единицы, и их не надо путать.** В файле каталога лежат целые
копейки — так их пишет генератор, и часть цен несёт копейки намеренно. Словарь
приводит их к `Decimal(18, 2)`, как у денег бэкенда: единица становится видна в
самом числе, `129000` против `1290.00`. А в контракте события `productPrice`
целые **рубли**, округление формата. Разрыв между ценой каталога и ценой в
событии заложен специально: на нём держится урок про `Float64` и расхождение
представлений денег.
`LIFETIME(MIN 60 MAX 90)` включает фоновое обновление: правка каталога доезжает
до словаря сама, без команды. Момент внутри окна случаен — так разводят
обращения разных серверов к источнику, чтобы они не шли разом. Цена у этого
заявленная: ноды обновляются вразнобой, и до тридцати секунд одна отвечает по
новому каталогу, а вторая по старому. Разогнать словари вручную можно штатной
командой — `SYSTEM RELOAD DICTIONARY ON CLUSTER clickstream_cluster
dic.products`; это административная операция, её выполняют под `default`, роль
`etl` права перезагрузки словарей не получает.
## Раскладка SQL
@@ -455,16 +489,19 @@ Airflow читает и собирает эти файлы штатным шаб
| Файл | Что в нём |
|---|---|
| `00-databases.sql` | базы слоёв |
| `00-databases.sql` | базы слоёв и зона справочников |
| `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё |
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников |
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
| `25-dds-dictionaries.sql` | словарь товаров из общего CSV-каталога |
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
Порядок задают два правила. Первое: матвью принадлежит слою своей цели, а не
источника, — разбор из STG в ODS лежит среди файлов ODS, потому что наполняет
ODS. Второе: матвью приёма создаётся последней из всех, и потому нарушает
Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и
правила порядка слоёв к ней не применяются.
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что
наполняет ODS. Второе: матвью приёма создаётся последней из всех, и потому нарушает
нумерацию слоёв. Kafka-движок начинает читать топик ровно тогда, когда к нему
привязывают первую матвью; создай её раньше разбора — и всё, что доедет в
зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На
@@ -507,7 +544,7 @@ ODS. Второе: матвью приёма создаётся последне
Ниже — то, что закладывают этапы 2 и 3; всё перечисленное лежит в `sql/ddl/`.
| Слой | Объект | Что это |
| База | Объект | Что это |
|---|---|---|
| STG | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
| STG | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки |
@@ -521,13 +558,14 @@ ODS. Второе: матвью приёма создаётся последне
| ODS | `ods.order_snapshot_rep` / `_dist` | типизированные версии заказа |
| ODS | `ods.order_v` | текущая версия заказа на языке источника |
| ODS | `ods.order_snapshot_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
| DDS | `dds.products` | словарь товаров из общего с генератором CSV-каталога |
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
| `dic` | `dic.products` | словарь товаров поверх подложки |
Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два
`INSERT SELECT` шага `parse_batch` из файлов `sql/ods/order_snapshot_load.sql`
и `sql/ods/order_snapshot_errors_load.sql`.
Таблицы DDS и слой DM появляются на следующих этапах; их состав задан разделом
Слой DDS и слой DM появляются на следующих этапах; их состав задан разделом
7 мастер-спеки и переносится сюда по мере постройки.
## Что проверено
@@ -553,6 +591,23 @@ DDL-словарь с файловым источником внутри `user_f
`SYSTEM RELOAD DICTIONARY ON CLUSTER`
изменилась на обеих нодах; после отката и повторной команды вернулась обратно.
**Проверка зоны справочников 20 августа 2026 года (#105).** Подложка на движке
`File` работает `ON CLUSTER` и перечитывает файл на каждом запросе: строка,
дописанная снаружи, меняет счёт без команд. Словарь поверх неё обновляется сам
внутри окна `LIFETIME` — замер поймал изменение через 116 секунд, без
`SYSTEM RELOAD DICTIONARY`. Относительный путь у движка считается от
`user_files`, а не от корня данных. Источник `CLICKHOUSE` без явного `user`
идёт как `default` с пустым паролем и падает с `AUTHENTICATION_FAILED`;
беспарольный пользователь из файла настройки принимается молча. Приведение
`toDecimal64(price, 2) / 100` даёт `Decimal(18, 2)` и точно на ценах с
копейками: `188990 → 1889.90`. Собранный с нуля стенд поднял оба объекта, `bi`
читает словарь `dictGet`-ом со второй ноды, `analyst` — подложку соединением.
Отдельно про право на движок: отказ называет `TABLE ENGINE ON File`, но права
с таким именем не хватает. Замер тремя пользователями показал, что достаточно
`GRANT FILE ON *.*` — привилегии на источник, как у Kafka, — а `TABLE ENGINE ON
File` не нужен вовсе. Текст ошибки здесь уводит в сторону.
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3.