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