Files
clickstream-data-platform/docs/adr/0012-dictionary-home.md
T
ddadminandClaude Opus 5 d77acfdf6c fix(docs): расхождение нод при обновлении словаря названо верно
Зачем: горячее ревью поймало ошибку в трёх местах сразу. Было записано, что
после правки каталога ноды расходятся до тридцати секунд — число взято из
ширины окна LIFETIME(MIN 60 MAX 90). Ширина тут ни при чём: каждая нода ждёт
правки от нуля до верхней границы окна, фазы у них независимы, поэтому
расходиться они могут почти на все полторы минуты.

Что: исправлены ADR 0012, опорная точка мастер-спеки и комментарий DDL. Заодно
уточнено, что LIFETIME(0) неатомарность не предотвращал, а сжимал до разброса
исполнения SYSTEM RELOAD DICTIONARY ON CLUSTER, и что витрина выручки пока
спроектирована, а не построена.

Проверка: рассуждением, замером не подтверждалось — прежнее число тоже было
выведено, а не измерено. Замер в ADR (116 секунд на окне MIN 60 MAX 120)
относится к другому утверждению: что словарь обновляется сам.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:47:23 +03:00

203 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`. Исключение для словарей остаётся и
читается яснее прежнего: голое имя означает словарь.
- **Обновление словаря на кластере не атомарно, и это заявленное свойство.**
Случайный момент внутри окна разводит опросы разных серверов, чтобы они не
ходили к источнику разом. Побочный эффект — ноды перезагружают словарь в
разное время. Ждать правки каталога каждой из них приходится от нуля до
верхней границы окна, а фазы у них независимы, поэтому расходиться они могут
почти на все полторы минуты: одна перезагрузилась сразу после правки, вторая
ещё нет. Прежнее решение `LIFETIME(0)` окно не закрывало, а сжимало до
разброса исполнения `SYSTEM RELOAD DICTIONARY ON CLUSTER`; теперь оно
раскрыто намеренно. Урок записан опорной точкой в мастер-спеке, тащить его в курс —
отдельное решение.
- **Номенклатура товаров — медленно изменяющийся справочник**, и в предметной
области это так. Стенд упрощает: файл в репозитории под версией git.
Историзации в хранилище нет, и обещать её этот документ не должен.
- Витрина выручки спроектирована представлением, поэтому категория будет
подставляться в момент запроса, и правка каталога изменит отчёт задним
числом. Материализуй мы категорию при приёме — не изменила бы. Самой витрины
ещё нет, она приходит этапом 4.
## Что проверено
Замерено на живом стенде 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 …)` перезагружает словарь в случайный момент
внутри окна, чтобы разнести обращения разных серверов к источнику.
- Если хост источника локальный, запрос идёт без сети.