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
+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 …)` перезагружает словарь в случайный момент
внутри окна, чтобы разнести обращения разных серверов к источнику.
- Если хост источника локальный, запрос идёт без сети.