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
+8
View File
@@ -94,6 +94,14 @@ _Избегать_: «покупатель» про того, кто купил
страницы. Садится на ту страницу, где случилось: корзина — на карточку страницы. Садится на ту страницу, где случилось: корзина — на карточку
товара, покупка — на страницу подтверждения заказа. товара, покупка — на страницу подтверждения заказа.
**Справочник**:
Данные, входящие в хранилище сбоку, а не по цепочке слоёв: в хранилище их
никто не производит, а читают их несколько слоёв. Дом — база `dic`, общая для
всех справочников. Первый и пока единственный — каталог товаров.
_Не путать_: слово «словарь» в проекте значит ещё две вещи — этот глоссарий
понятий и объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник
доступен.
**Каталог товаров**: **Каталог товаров**:
`data/catalog/products.csv` — общий справочник генератора и словаря `data/catalog/products.csv` — общий справочник генератора и словаря
ClickHouse. Форма файла решена, длина — нет: строки дописываются. ClickHouse. Форма файла решена, длина — нет: строки дописываются.
+7 -1
View File
@@ -6,7 +6,13 @@
Имя объекта в ClickHouse заканчивается тем, что это за объект: `_rep` Имя объекта в ClickHouse заканчивается тем, что это за объект: `_rep`
локальная таблица шарда, `_dist``Distributed` поверх неё, `_kafka` — чтец локальная таблица шарда, `_dist``Distributed` поверх неё, `_kafka` — чтец
топика, `_mv` — материализованное представление, `_v` обычное представление. топика, `_file` — чтец файла, `_mv` — материализованное представление, `_v`
обычное представление.
Суффикс `_file` добавлен 20 августа 2026 года вместе с подложкой под словарём
товаров ([ADR 0012](0012-dictionary-home.md)). Он ровно параллелен `_kafka`:
чтец внешнего источника, по копии на каждой ноде, без репликации и без
распределённой пары.
Суффикс носит каждый физический объект, поэтому голого имени у таблицы не Суффикс носит каждый физический объект, поэтому голого имени у таблицы не
существует: запрос к `stg.hits_raw` даёт ошибку «нет такой таблицы». существует: запрос к `stg.hits_raw` даёт ошибку «нет такой таблицы».
+25 -11
View File
@@ -4,19 +4,23 @@
## Решение ## Решение
У кластера четыре пользователя и три роли. У кластера пять пользователей и четыре роли.
- `default` — с паролем, только служебный: проверки здоровья нод и работа - `default` — с паролем, только служебный: проверки здоровья нод и работа
изнутри контейнеров. Приложения им не ходят. изнутри контейнеров. Приложения им не ходят.
- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходит - `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходит
Airflow и применяется DDL при подъёме стенда. Airflow и применяется DDL при подъёме стенда.
- `bi` с ролью `bi_reader` — чтение витрин DM и слоя DDS. Им ходит Superset. - `bi` с ролью `bi_reader` — чтение витрин DM, слоя DDS и справочников `dic`.
- `analyst` с ролью `analyst_reader` — чтение всех слоёв. Им человек Им ходит Superset.
подключается снаружи, из своего клиента. - `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
человек подключается снаружи, из своего клиента.
- `dict` с ролью `dict_reader` — чтение одной базы `dic`, пароля нет. Им
словарь читает свою подложку, и больше он никем не используется
([ADR 0012](0012-dictionary-home.md)).
Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения
на запросы `ON CLUSTER` и на чтение системных таблиц, а без второго клиент на запросы `ON CLUSTER`, на чтение системных таблиц и на каждый движок
не открывает соединение вовсе. Состав прав каждой роли — работа тикета внешнего источника, а без второго клиент не открывает соединение вовсе. Состав прав каждой роли — работа тикета
реализации. реализации.
Пользователи, роли и права объявлены файлами настройки сервера, а не Пользователи, роли и права объявлены файлами настройки сервера, а не
@@ -46,7 +50,16 @@
описания. Поэтому пользователей мало, имена у них говорящие, а границы описания. Поэтому пользователей мало, имена у них говорящие, а границы
проходят там, где их обычно проводят в компаниях: `bi` видит витрины и слой проходят там, где их обычно проводят в компаниях: `bi` видит витрины и слой
DDS под ними, потому что так чаще всего и бывает; заодно расхождение витрины DDS под ними, потому что так чаще всего и бывает; заодно расхождение витрины
со слоем ниже остаётся проверяемым одним и тем же пользователем. со слоем ниже остаётся проверяемым одним и тем же пользователем. Справочники
видны всем читателям и никому на запись: зона входит в хранилище сбоку, и её
содержимым владеет файл репозитория, а не слой.
**Беспарольный `dict` — намеренное исключение.** Пароля у него нет не по
недосмотру: ходить этой учёткой некуда, кроме локального чтения одной базы, а
пароль пришлось бы вписать в текст DDL словаря — то есть положить секрет в git.
Право у него одно, `SELECT` на `dic`. Отдельная учётка под словарь взята из
промышленной практики, где источник словаря читают минимальной служебной
учётной записью, а не общим админом.
**Секрет, а не учётные данные по репликам.** У каждой реплики в описании **Секрет, а не учётные данные по репликам.** У каждой реплики в описании
кластера своя учётка — вписанная явно или подразумеваемая, и тогда это кластера своя учётка — вписанная явно или подразумеваемая, и тогда это
@@ -92,10 +105,11 @@ Postgres и Grafana. Цена выбора известна: объявленн
## Следствия ## Следствия
До появления слоёв DDS и DM читать `bi` нечего, и это намеренно. Дашборды До появления слоёв DDS и DM читать `bi` почти нечего, и это намеренно.
рисуются поверх модели данных, а не поверх типизированных событий, поэтому Дашборды рисуются поверх модели данных, а не поверх типизированных событий,
доступ Superset к ODS не планировался и правами не выдаётся. Пока витрин нет, поэтому доступ Superset к ODS не планировался и правами не выдаётся.
подключение Superset проверяется связью, а не запросом к таблице. Единственное, что ему доступно сегодня, — справочники: с ними зона `dic`
пришла раньше своих потребителей.
Владелец матвью приёма определяется тем, кто применил DDL. Весь DDL стенда Владелец матвью приёма определяется тем, кто применил DDL. Весь DDL стенда
идёт через `IF NOT EXISTS`, поэтому на существующих томах три матвью идёт через `IF NOT EXISTS`, поэтому на существующих томах три матвью
+199
View File
@@ -0,0 +1,199 @@
# ADR 0012. Справочники: зона вне цепочки слоёв и подложка под словарём
Дата: 20 августа 2026 года. Статус: принято.
## Решение
**Дом.** Справочные данные живут в базе `dic`, вне цепочки STG → ODS → DDS → DM.
Каталог товаров переезжает из `dds.products` в `dic.products`. Правило одно и
оно не про слой: справочник — самостоятельная зона; читать её вправе любой
слой, писать — никто, кроме владельца источника. Будущий словарь регионов
попадает туда же, независимо от того, какой слой его читает.
**Устройство.** Словарь читает не файл, а таблицу хранилища. Под `dic.products`
лежит подложка `dic.products_file` на движке `File` — она ничего не хранит и
перечитывает CSV на каждом запросе. Словарь берёт её источником `CLICKHOUSE` от
собственного пользователя с одним правом на чтение и обновляется сам, окном
`LIFETIME(MIN 60 MAX 90)`.
Источник объявлен формой `query`, а не `table`: словарь приводит цену каталога
из целых копеек к `Decimal(18, 2)` — то есть нормализует на входе, а не
зеркалит подложку.
Форма чтения принадлежит потребителю, а не месту хранения: подстановка атрибута
по ключу идёт через `dictGet`, а соединение и фильтрация по атрибуту — по
подложке напрямую.
## Почему зона, а не прописка в слое
**Справочник читают несколько слоёв, а производит его ни один.** Каталог нужен
модели заказов в DDS и витрине выручки в DM; будущие регионы — представлению
событий в DDS; `analyst` читает его напрямую. Писателя нет вовсе: содержимое
приходит из файла репозитория. Прописка внутри `dds` приписывала слою модели
владение, которого у него нет: DDS этот объект не производит, не обновляет и не
проверяет.
**На направление данных ссылаться нельзя, и это важно не спутать.** Чтение из
DM объекта, лежащего в DDS, — течение вниз, ровно то, которое разрешено.
Промах был во владении, а не в направлении. Но зона вне цепочки закрывает и
будущий случай: как только справочник понадобится выше по течению — например,
проверка `sku` позиции при разборе заказа в ODS, — чтение `dds.*` из `ods.*`
стало бы настоящей инверсией. У зоны вне цепочки направления относительно слоёв
нет, и такой запрос законен по построению.
**Отраслевой образец говорит то же самое другим средством.** В хранилищах, где
база одна, а слои выражены префиксами имён, справочники несут собственный
префикс наравне с префиксами стадий — то есть образуют свой слой, а не
приписаны к слою модели. Причём префикс накрывает всю цепочку справочника: и
таблицу, и представление над ней, и сам объект словаря. Средство разное,
утверждение одно. У нас слои выражены базами — значит справочникам полагается
база.
**Имя `dic`, а не `ref`.** `ref` точнее называет содержимое, `dic`
узнаваемее: это то слово, которое менти встретит в промышленном хранилище. Три
буквы становятся в ряд к `stg`, `ods`, `dds`, `dm`. Опасение, что `dic` называет
механизм и содержимое его перерастёт, снято проверкой: словарь и так читается
как таблица, а зона держит и обычные таблицы тоже.
## Почему подложка, а не файл прямо в словаре
Это намеренное усложнение, и платит за него учебная ценность. Урок называется
одной фразой: **словарь читает таблицу хранилища, а не файл рядом с сервером, —
потому что в бою справочник приезжает процессом.** Файловый источник у словаря
работает и на стенде работал, но в бою он редкость: там источник приезжает ETL
или словарь смотрит прямо в чужую базу. Стенд учит форме, которую менти
встретит.
Подложка добавляет к уроку три вещи, которых у файлового источника нет:
предложение `SOURCE` с запросом и учётной записью; обновление окном вместо
ручной команды; и справочник, доступный соединению — а не только `dictGet`.
**Цена приводится к `Decimal(18, 2)` в самом источнике.** В файле лежат целые
копейки, и `129000` о своей единице не говорит ничего — а каталог единственное
место на стенде, где число правит рукой человек. Довод не в единообразии:
стенд намеренно держит три представления денег, и расхождение между ними
заявлено уроком. Довод в том, что урок про округление живёт ровно на стыке
каталога и события, где `productPrice` — уже целые рубли. С `Decimal` менти
сравнивает `1290.00` против `1290` и видит потерю; с `Int64` он сначала делит
на сто в уме, в тот самый момент, когда должен смотреть на округление.
Приведение вынуждает форму `query` у источника — подложка сырая и
конвертировать не умеет, а `Decimal` прямо на ней прочитал бы `129000.00`.
**Историзацию и представление «последняя загрузка» из образца не берём.** В
промышленном примере таблица копит загрузки с меткой времени, а представление
берёт последнюю: источник приезжал извне, и версий у него не было нигде. У нас
версии есть — **история каталога это git**. Дублировать её в ClickHouse значит
поставить конструкцию, у которой на стенде нет причины, а расшифровывать её
менти всё равно придётся.
**Материализованной таблицы с шагом наполнения тоже не берём.** Шаг наполнения
— вторая половина промышленного устройства, и она стоит движущейся части:
кто-то обязан его запускать. Расписание для файла в git врало бы: каталог не
меняется сам. Подложка на файловом движке даёт `SOURCE` без этой цены.
## Отвергнутые варианты размещения
- **Оставить в `dds`** — разделяемый ресурс внутри одного из потребителей; учит
тому, что в бою устроено иначе. Совпадение с будущими регионами держится, но
по другой причине, чем у товаров, — значит это не правило.
- **Рядом с главным потребителем** — потребителей несколько и они в разных
слоях: товары читает витрина, регионы — представление DDS. Правило требует
выбрать главного там, где главного нет.
- **Рядом с владельцем содержимого или предметной областью** — владелец у обоих
справочников один и тот же, репозиторий стенда; правило ничего не различает.
Предметные базы вдобавок разошлись бы со слоёвыми.
- **Без единого правила, по происхождению каждого справочника** — оставляет
менти список случаев вместо урока.
- **Выразить принадлежность именем объекта, а не базой** (снять исключение
[ADR 0006](0006-object-naming.md), дать словарю суффикс вида) — чинит
читаемость имени, но не трогает ни владение, ни зону. При базе `dic`
избыточно: адрес говорит это раньше имени.
- **Не выражать принадлежность в хранилище вовсе** — отказ от предмета решения.
Вдобавок `SHOW DICTIONARIES` без указания базы возвращает пусто, так что поиск
объекта переложился бы на `system.dictionaries`.
- **Отказаться от объекта-словаря, читать CSV табличной функцией** — убирает
разговор о политике обновления, ради которого раздел 3 мастер-спеки словарь и
держит.
- **Отложить решение до второго справочника** — выглядит осторожным, но им не
является. Регионы стоят в спеке опорной точкой для будущих лекций, и CSV под
них не существует. DM же придёт этапом 4 и впишет старый адрес в витрины.
Повод пересмотреть не наступает, а цена только растёт.
## Условия пересмотра
- Справочник перестаёт быть файлом репозитория и начинает приезжать извне —
возвращается вопрос устройства: историзованная таблица, «последняя загрузка»,
словарь поверх. Адрес это переживает.
- Усложнение перестаёт окупаться: если менти проходит мимо подложки не заметив
её, режется обратно до файлового источника у словаря.
## Следствия
- [ADR 0007](0007-clickhouse-access.md) правится тем же коммитом: у зоны свой
блок прав и свой пользователь.
- [ADR 0006](0006-object-naming.md) получает шестой суффикс вида — `_file`,
чтец файла, ровно параллельный `_kafka`. Исключение для словарей остаётся и
читается яснее прежнего: голое имя означает словарь.
- **Обновление словаря на кластере не атомарно, и это заявленное свойство.**
Случайный момент внутри окна разводит опросы разных серверов, чтобы они не
ходили к источнику разом. Побочный эффект — ноды перезагружают словарь в
разное время, и на нашей паре расхождение доходит до 30 секунд: правка
каталога полминуты видна одной ноде и не видна другой. Прежнее решение
`LIFETIME(0)` эту неатомарность предотвращало; теперь она предъявлена
намеренно. Урок записан опорной точкой в мастер-спеке, тащить его в курс —
отдельное решение.
- **Номенклатура товаров — медленно изменяющийся справочник**, и в предметной
области это так. Стенд упрощает: файл в репозитории под версией git.
Историзации в хранилище нет, и обещать её этот документ не должен.
- Витрина выручки — представление, поэтому категория подставляется в момент
запроса, и правка каталога меняет отчёт задним числом. Материализуй мы
категорию при приёме — не изменила бы.
## Что проверено
Замерено на живом стенде 20 августа 2026 года, ClickHouse 26.3.17.56.
- Движок `File` с явным путём внутрь `user_files` работает и раскатывается
`ON CLUSTER`: обе ноды читают свой смонтированный файл, по 180 строк.
- Относительный путь у этого движка считается **от `user_files`**, а не от
корня данных: форма `./user_files/catalog/products.csv`, рабочая для
файлового источника словаря, даёт `Code: 107 ... FILE_DOESNT_EXIST`.
- Подложка ничего не хранит: строка, дописанная в файл снаружи, меняет
`count()` без единой команды перезагрузки.
- Словарь подхватывает правку сам. `element_count` изменился без
`SYSTEM RELOAD DICTIONARY` через 116 секунд после предыдущей загрузки —
внутри окна `MIN 60 MAX 120`, на котором шёл замер.
- Без указания пользователя источник `CLICKHOUSE` ходит как `default` с пустым
паролем и падает: `Code: 516 ... AUTHENTICATION_FAILED`. Пользователя надо
называть явно.
- Приведение цены: `toDecimal64(price, 2) / 100` даёт ровно `Decimal(18, 2)`,
`129000 → 1290.00`, `128990 → 1289.90`. Вариант через `divideDecimal`
возвращает `Decimal(76, 2)` — шире, чем нужно, и не взят.
- Беспарольный пользователь, объявленный файлом настройки, принимается молча:
словарь грузится, `dictGet` отвечает, в журнале после перечитывания конфига
нет ни одного предупреждения про `no_password`.
- `SHOW DICTIONARIES` без указания базы возвращает пусто; словарь в базе стоит
среди таблиц слоя и отличается от них только колонкой движка в
`system.tables`.
- До переноса `dds.products` упоминался в репозитории четырежды, в дагах — ноль.
- Движок `File` требует у `etl` права на источник. Отказ называет
`TABLE ENGINE ON File`, но права с таким именем не хватает: замер тремя
пользователями показал, что работает `GRANT FILE ON *.*`, а `TABLE ENGINE ON
File` не нужен вовсе.
- Стенд, собранный с нуля этим решением, поднимается зелёным: оба объекта зоны
на месте, словарь `LOADED` со 180 строками, база `dds` пуста, `bi` читает
словарь со второй ноды, `analyst` — подложку соединением. `make smoke`
20 проверок, 0 ошибок.
Сверено по документации ClickHouse через MCP Context7 20 августа 2026 года.
- Движок таблиц `Dictionary` существует затем, чтобы выставить словарь явной
таблицей — когда нужен доступ к сырым данным или соединение.
- Для небольших измерений документация рекомендует словарь вместо `JOIN`:
соединение выполняется до фильтрации `WHERE` и между запросами не кэшируется.
- Обратный случай назван там же: если фильтровать по подставленному значению на
многих строках, лучше обычная колонка с индексом — `dictGet` считается
построчно и индексом не поддержан. Отсюда обе формы чтения в решении.
- Диапазон `LIFETIME(MIN … MAX …)` перезагружает словарь в случайный момент
внутри окна, чтобы разнести обращения разных серверов к источнику.
- Если хост источника локальный, запрос идёт без сети.
+79 -24
View File
@@ -12,8 +12,9 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/`
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой
чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью. состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью.
Тот же этап принёс первый объект DDS — словарь товаров из общего с генератором Тот же этап принёс первый справочник — словарь товаров из общего с генератором
CSV-файла. CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а
подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)).
Дальше по тексту устройство описано так, как оно проектируется; построенное от Дальше по тексту устройство описано так, как оно проектируется; построенное от
заложенного отличает карта таблиц в конце. заложенного отличает карта таблиц в конце.
@@ -33,6 +34,7 @@ CSV-файла.
| `_rep` | локальная таблица шарда, движок семейства `Replicated*` | | `_rep` | локальная таблица шарда, движок семейства `Replicated*` |
| `_dist` | `Distributed` поверх одноимённой локальной | | `_dist` | `Distributed` поверх одноимённой локальной |
| `_kafka` | таблица на движке `Kafka` | | `_kafka` | таблица на движке `Kafka` |
| `_file` | таблица на движке `File` — чтец файла |
| `_mv` | материализованное представление | | `_mv` | материализованное представление |
| `_v` | обычное представление | | `_v` | обычное представление |
@@ -40,7 +42,9 @@ CSV-файла.
запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым
именем в документах и разговоре понимается сущность, у которой этих объектов именем в документах и разговоре понимается сущность, у которой этих объектов
несколько. Единственное исключение — словари: у них воплощение одно, шардировать несколько. Единственное исключение — словари: у них воплощение одно, шардировать
нечего, и суффикс ничего не различал бы. нечего, и суффикс ничего не различал бы. В зоне `dic` обе формы стоят рядом и
правило видно целиком: `dic.products_file` — чтец файла, `dic.products` — сам
словарь.
Распространённая конвенция, где голое имя означает локальную таблицу, а Распространённая конвенция, где голое имя означает локальную таблицу, а
распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо
@@ -416,22 +420,52 @@ kafka_offset)`: смотрят такую таблицу от класса, а
разрастается до имени отдельного поля. Точная граница приёма — в разрастается до имени отдельного поля. Точная граница приёма — в
[спецификации заказов](orders/ingestion.md). [спецификации заказов](orders/ingestion.md).
## Словарь товаров ## Справочники
`dds.products` читает `data/catalog/products.csv` напрямую. Compose монтирует Справочные данные живут в базе `dic` — вне цепочки STG → ODS → DDS → DM. В
каталог только для чтения в `user_files` обеих нод, а DDL создаёт словарь хранилище их никто не производит: содержимое приходит из файла репозитория, а
`ON CLUSTER`: имя и форма одни, но каждая нода держит свою копию в памяти. читают его несколько слоёв сразу. Поэтому зона своя, читать её вправе любой
слой, писать — никто. Почему так, а не пропиской в слое модели, —
[ADR 0012](../adr/0012-dictionary-home.md).
Источник `FILE` с форматом `CSVWithNames` читает заголовок файла. Строковый ключ ### Словарь товаров
`sku` требует `COMPLEX_KEY_HASHED`; числовой `FLAT` здесь неприменим. Цена
остаётся целым числом копеек типа `Int64`, как в контракте события.
`LIFETIME(0)` отключает фоновое обновление. Каталог меняется только явной Под словарём лежит подложка `dic.products_file` на движке `File`. Она ничего не
правкой репозитория, а независимый опрос двух нод позволил бы им временно хранит и перечитывает `data/catalog/products.csv` на каждом запросе. Compose
отвечать разными версиями. Изменение применяют к обеим нодам штатной командой: монтирует каталог только для чтения в `user_files` обеих нод, DDL создаёт оба
`SYSTEM RELOAD DICTIONARY ON CLUSTER clickstream_cluster dds.products`. объекта `ON CLUSTER`: имя и форма одни, но каждая нода читает свой файл и держит
Это административная операция: её выполняют под `default`; роль `etl` права свою копию словаря в памяти.
перезагрузки словарей не получает.
Путь у движка `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 ## Раскладка SQL
@@ -455,16 +489,19 @@ Airflow читает и собирает эти файлы штатным шаб
| Файл | Что в нём | | Файл | Что в нём |
|---|---| |---|---|
| `00-databases.sql` | базы слоёв | | `00-databases.sql` | базы слоёв и зона справочников |
| `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё |
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников | | `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников |
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок | | `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
| `25-dds-dictionaries.sql` | словарь товаров из общего CSV-каталога |
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS | | `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
| `40-stg-views.sql` | матвью приёма: чтец в сырьё | | `40-stg-views.sql` | матвью приёма: чтец в сырьё |
Порядок задают два правила. Первое: матвью принадлежит слою своей цели, а не Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и
источника, — разбор из STG в ODS лежит среди файлов ODS, потому что наполняет правила порядка слоёв к ней не применяются.
ODS. Второе: матвью приёма создаётся последней из всех, и потому нарушает
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что
наполняет ODS. Второе: матвью приёма создаётся последней из всех, и потому нарушает
нумерацию слоёв. Kafka-движок начинает читать топик ровно тогда, когда к нему нумерацию слоёв. Kafka-движок начинает читать топик ровно тогда, когда к нему
привязывают первую матвью; создай её раньше разбора — и всё, что доедет в привязывают первую матвью; создай её раньше разбора — и всё, что доедет в
зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На
@@ -507,7 +544,7 @@ ODS. Второе: матвью приёма создаётся последне
Ниже — то, что закладывают этапы 2 и 3; всё перечисленное лежит в `sql/ddl/`. Ниже — то, что закладывают этапы 2 и 3; всё перечисленное лежит в `sql/ddl/`.
| Слой | Объект | Что это | | База | Объект | Что это |
|---|---|---| |---|---|---|
| STG | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` | | STG | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
| STG | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки | | STG | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки |
@@ -521,13 +558,14 @@ ODS. Второе: матвью приёма создаётся последне
| ODS | `ods.order_snapshot_rep` / `_dist` | типизированные версии заказа | | ODS | `ods.order_snapshot_rep` / `_dist` | типизированные версии заказа |
| ODS | `ods.order_v` | текущая версия заказа на языке источника | | ODS | `ods.order_v` | текущая версия заказа на языке источника |
| ODS | `ods.order_snapshot_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём | | 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` `INSERT SELECT` шага `parse_batch` из файлов `sql/ods/order_snapshot_load.sql`
и `sql/ods/order_snapshot_errors_load.sql`. и `sql/ods/order_snapshot_errors_load.sql`.
Таблицы DDS и слой DM появляются на следующих этапах; их состав задан разделом Слой DDS и слой DM появляются на следующих этапах; их состав задан разделом
7 мастер-спеки и переносится сюда по мере постройки. 7 мастер-спеки и переносится сюда по мере постройки.
## Что проверено ## Что проверено
@@ -553,6 +591,23 @@ DDL-словарь с файловым источником внутри `user_f
`SYSTEM RELOAD DICTIONARY ON CLUSTER` `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 подтвердил, **Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3. в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3.
+5 -1
View File
@@ -413,7 +413,7 @@ README.
| DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую | | DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
| DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS | | DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS |
| DDS | `dds.identity_map` | карта кука↔пользователь | | DDS | `dds.identity_map` | карта кука↔пользователь |
| DDS | словарь `products` | каталог из CSV | | `dic` | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) |
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже | | DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
@@ -695,6 +695,10 @@ v2, этап 0).
- сцена «разные consumer groups → дубли»; - сцена «разные consumer groups → дубли»;
- словарь регионов из CSV той же машинерией, что каталог товаров (оживляет - словарь регионов из CSV той же машинерией, что каталог товаров (оживляет
`RegionCityID`); `RegionCityID`);
- неатомарное обновление словаря на кластере: ноды перезагружают его в
случайный момент внутри окна `LIFETIME`, и после правки каталога до
тридцати секунд две ноды отвечают на один `dictGet` по-разному. Обвязка
готова ([ADR 0012](../adr/0012-dictionary-home.md)), урок остаётся на выбор;
- лаба сессий: менти сначала собирает сессии сам, и только после — рассказ, - лаба сессий: менти сначала собирает сессии сам, и только после — рассказ,
что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично
синтетическая постановка — осознанный приём; синтетическая постановка — осознанный приём;
+31 -4
View File
@@ -9,20 +9,28 @@
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW ON default.*</query> <query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW ON default.*</query>
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON stg.*</query> <query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON stg.*</query>
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON ods.*</query> <query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON ods.*</query>
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, CREATE DICTIONARY, dictGet, DROP TABLE, DROP VIEW, CREATE DATABASE ON dds.*</query> <query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON dds.*</query>
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON dm.*</query> <query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON dm.*</query>
<!-- Зона справочников: etl применяет её DDL и читает словари из преобразований. -->
<query>GRANT SELECT, CREATE TABLE, CREATE DICTIONARY, dictGet, DROP TABLE, CREATE DATABASE ON dic.*</query>
<query>GRANT SELECT ON system.*</query> <query>GRANT SELECT ON system.*</query>
<query>GRANT CLUSTER ON *.*</query> <query>GRANT CLUSTER ON *.*</query>
<query>GRANT REMOTE ON *.*</query> <query>GRANT REMOTE ON *.*</query>
<!-- В 26.3 движок Kafka требует право на внешний источник. --> <!--
В 26.3 движки внешних источников требуют права на источник.
Отказ при этом называет не источник, а `TABLE ENGINE ON File`
— и права с таким именем не хватит: замерено, нужен FILE.
-->
<query>GRANT KAFKA ON *.*</query> <query>GRANT KAFKA ON *.*</query>
<query>GRANT FILE ON *.*</query>
</grants> </grants>
</etl_writer> </etl_writer>
<!-- Оба читателя: clickhouse-connect читает system.settings при подключении. --> <!-- Оба читателя: clickhouse-connect читает system.settings при подключении. -->
<bi_reader> <bi_reader>
<grants> <grants>
<query>GRANT SELECT, dictGet ON dds.*</query> <query>GRANT SELECT ON dds.*</query>
<query>GRANT SELECT ON dm.*</query> <query>GRANT SELECT ON dm.*</query>
<query>GRANT SELECT, dictGet ON dic.*</query>
<query>GRANT SELECT ON system.settings</query> <query>GRANT SELECT ON system.settings</query>
</grants> </grants>
</bi_reader> </bi_reader>
@@ -30,11 +38,23 @@
<grants> <grants>
<query>GRANT SELECT ON stg.*</query> <query>GRANT SELECT ON stg.*</query>
<query>GRANT SELECT ON ods.*</query> <query>GRANT SELECT ON ods.*</query>
<query>GRANT SELECT, dictGet ON dds.*</query> <query>GRANT SELECT ON dds.*</query>
<query>GRANT SELECT ON dm.*</query> <query>GRANT SELECT ON dm.*</query>
<query>GRANT SELECT, dictGet ON dic.*</query>
<query>GRANT SELECT ON system.settings</query> <query>GRANT SELECT ON system.settings</query>
</grants> </grants>
</analyst_reader> </analyst_reader>
<!--
Словарь читает подложку не сам по себе, а от имени пользователя:
без явного user он идёт как default с пустым паролем и падает.
Пароля нет намеренно — ходить этой учёткой некуда, кроме локального
чтения одной базы, а пароль в тексте DDL означал бы секрет в git.
-->
<dict_reader>
<grants>
<query>GRANT SELECT ON dic.*</query>
</grants>
</dict_reader>
</roles> </roles>
<users> <users>
@@ -62,5 +82,12 @@
<query>GRANT analyst_reader</query> <query>GRANT analyst_reader</query>
</grants> </grants>
</analyst> </analyst>
<dict>
<no_password/>
<quota>default</quota>
<grants>
<query>GRANT dict_reader</query>
</grants>
</dict>
</users> </users>
</clickhouse> </clickhouse>
+8 -2
View File
@@ -1,4 +1,4 @@
-- Базы слоёв хранилища. -- Базы хранилища: слои цепочки и зона справочников.
-- --
-- Файлы этой папки применяются по порядку имён с ноды 1 и всегда ON CLUSTER: -- Файлы этой папки применяются по порядку имён с ноды 1 и всегда ON CLUSTER:
-- объекты обязаны появиться на обеих нодах, иначе распределённая таблица -- объекты обязаны появиться на обеих нодах, иначе распределённая таблица
@@ -14,5 +14,11 @@ CREATE DATABASE IF NOT EXISTS stg ON CLUSTER clickstream_cluster;
-- порядок файлов от этого не зависит. -- порядок файлов от этого не зависит.
CREATE DATABASE IF NOT EXISTS ods ON CLUSTER clickstream_cluster; CREATE DATABASE IF NOT EXISTS ods ON CLUSTER clickstream_cluster;
-- DDS начинается со словаря товаров; таблицы слоя появятся на следующем этапе. -- Объекты DDS появятся на следующем этапе; база заводится заранее по той же
-- причине, что и ODS.
CREATE DATABASE IF NOT EXISTS dds ON CLUSTER clickstream_cluster; CREATE DATABASE IF NOT EXISTS dds ON CLUSTER clickstream_cluster;
-- Справочники стоят вне цепочки STG → ODS → DDS → DM: в хранилище их никто не
-- производит, а читают их несколько слоёв ([ADR 0012]). Поэтому зона своя, и
-- порядок слоёв к ней не применяется — её файл идёт сразу за этим.
CREATE DATABASE IF NOT EXISTS dic ON CLUSTER clickstream_cluster;
+66
View File
@@ -0,0 +1,66 @@
-- Каталог товаров: подложка на файловом движке и словарь поверх неё.
--
-- Словарь читает не файл, а таблицу хранилища — намеренное усложнение
-- ([ADR 0012](../../docs/adr/0012-dictionary-home.md)). В бою справочник
-- приезжает процессом, и предложение SOURCE с запросом и учётной записью —
-- та форма, которую менти встретит; файловый источник работает, но редок.
--
-- Зона dic лежит вне цепочки STG → ODS → DDS → DM: справочник в хранилище
-- никто не производит, а читают его несколько слоёв.
-- Подложка ничего не хранит: движок File перечитывает CSV на каждом запросе,
-- поэтому правка каталога доезжает до словаря сама. Compose монтирует один и
-- тот же файл в user_files обеих нод только для чтения.
--
-- Путь считается ОТ user_files, а не от корня данных: форма
-- './user_files/catalog/products.csv' даёт FILE_DOESNT_EXIST. У файлового
-- источника словаря база пути была другой — отсюда разница с прежним DDL.
-- Типы здесь повторяют файл, а не модель: цена лежит целыми копейками, как её
-- пишет генератор. Приведение к деньгам делает словарь.
CREATE TABLE IF NOT EXISTS dic.products_file ON CLUSTER clickstream_cluster
(
sku String,
name String,
category String,
brand String,
price Int64,
demand String
)
ENGINE = File(CSVWithNames, './catalog/products.csv');
-- Пользователь dict объявлен файлом настройки и умеет одно — читать dic.
-- Назвать его обязательно: без user словарь идёт как default с пустым паролем
-- и падает с AUTHENTICATION_FAILED. Хост локальный, поэтому запрос к подложке
-- идёт без сети.
--
-- Окно обновления вместо LIFETIME(0): словарь перезагружается сам в случайный
-- момент внутри окна. Случайность разводит обращения разных серверов к
-- источнику, и цена у неё заявленная — ноды обновляются вразнобой, до 30
-- секунд одна видит правку каталога, а вторая ещё нет.
--
-- Ключ строковый, поэтому COMPLEX_KEY_HASHED: числовой FLAT здесь неприменим.
--
-- Цена приводится к деньгам прямо в источнике — оттого форма query, а не
-- table: словарь нормализует на входе, а не зеркалит подложку. В файле лежат
-- целые копейки, наружу словарь отдаёт Decimal(18, 2), как заказы бэкенда.
-- Единица в числе становится видна: 129000 против 1290.00.
--
-- Стык с событием на этом и стоит: в контракте события productPrice — целые
-- РУБЛИ, округление формата. Разрыв между ценой каталога и ценой в событии
-- намеренный, на нём держится урок про Float64.
CREATE DICTIONARY IF NOT EXISTS dic.products ON CLUSTER clickstream_cluster
(
sku String,
name String,
category String,
brand String,
price Decimal(18, 2),
demand String
)
PRIMARY KEY sku
SOURCE(CLICKHOUSE(
host 'localhost' port 9000 user 'dict'
query 'SELECT sku, name, category, brand, toDecimal64(price, 2) / 100 AS price, demand FROM dic.products_file'
))
LAYOUT(COMPLEX_KEY_HASHED())
LIFETIME(MIN 60 MAX 90);
-16
View File
@@ -1,16 +0,0 @@
-- Общий с генератором каталог товаров. ClickHouse разрешает файловому
-- источнику читать только из user_files; Compose монтирует сюда один и тот же
-- файл на обе ноды. Цена хранится в копейках, как и в контракте события.
CREATE DICTIONARY IF NOT EXISTS dds.products ON CLUSTER clickstream_cluster
(
sku String,
name String,
category String,
brand String,
price Int64,
demand String
)
PRIMARY KEY sku
SOURCE(FILE(PATH './user_files/catalog/products.csv' FORMAT 'CSVWithNames'))
LAYOUT(COMPLEX_KEY_HASHED())
LIFETIME(0);