From abc94ba40650d89f9c13ef426fcc2d14293ec419 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Thu, 20 Aug 2026 11:37:26 +0300 Subject: [PATCH] =?UTF-8?q?feat(clickhouse):=20=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B8=20=D0=B2=D1=8B=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D0=B5=D0=BD=D1=8B=20=D0=B2=20=D0=B7=D0=BE=D0=BD?= =?UTF-8?q?=D1=83=20dic,=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8C=20?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82=20=D0=BF=D0=BE=D0=B4=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: место словаря в 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 --- CONTEXT.md | 8 + docs/adr/0006-object-naming.md | 8 +- docs/adr/0007-clickhouse-access.md | 36 ++-- docs/adr/0012-dictionary-home.md | 199 ++++++++++++++++++++++ docs/architecture/storage.md | 103 ++++++++--- docs/specs/2026-07-30-stand-v2-realism.md | 6 +- infra/clickhouse/users.d/access.xml | 35 +++- sql/ddl/00-databases.sql | 10 +- sql/ddl/05-dic-catalog.sql | 66 +++++++ sql/ddl/25-dds-dictionaries.sql | 16 -- 10 files changed, 428 insertions(+), 59 deletions(-) create mode 100644 docs/adr/0012-dictionary-home.md create mode 100644 sql/ddl/05-dic-catalog.sql delete mode 100644 sql/ddl/25-dds-dictionaries.sql diff --git a/CONTEXT.md b/CONTEXT.md index 6236f37..cfcd9cf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -94,6 +94,14 @@ _Избегать_: «покупатель» про того, кто купил страницы. Садится на ту страницу, где случилось: корзина — на карточку товара, покупка — на страницу подтверждения заказа. +**Справочник**: +Данные, входящие в хранилище сбоку, а не по цепочке слоёв: в хранилище их +никто не производит, а читают их несколько слоёв. Дом — база `dic`, общая для +всех справочников. Первый и пока единственный — каталог товаров. +_Не путать_: слово «словарь» в проекте значит ещё две вещи — этот глоссарий +понятий и объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник +доступен. + **Каталог товаров**: `data/catalog/products.csv` — общий справочник генератора и словаря ClickHouse. Форма файла решена, длина — нет: строки дописываются. diff --git a/docs/adr/0006-object-naming.md b/docs/adr/0006-object-naming.md index 707c99b..2c8d41d 100644 --- a/docs/adr/0006-object-naming.md +++ b/docs/adr/0006-object-naming.md @@ -6,7 +6,13 @@ Имя объекта в ClickHouse заканчивается тем, что это за объект: `_rep` — локальная таблица шарда, `_dist` — `Distributed` поверх неё, `_kafka` — чтец -топика, `_mv` — материализованное представление, `_v` — обычное представление. +топика, `_file` — чтец файла, `_mv` — материализованное представление, `_v` — +обычное представление. + +Суффикс `_file` добавлен 20 августа 2026 года вместе с подложкой под словарём +товаров ([ADR 0012](0012-dictionary-home.md)). Он ровно параллелен `_kafka`: +чтец внешнего источника, по копии на каждой ноде, без репликации и без +распределённой пары. Суффикс носит каждый физический объект, поэтому голого имени у таблицы не существует: запрос к `stg.hits_raw` даёт ошибку «нет такой таблицы». diff --git a/docs/adr/0007-clickhouse-access.md b/docs/adr/0007-clickhouse-access.md index c350832..549436e 100644 --- a/docs/adr/0007-clickhouse-access.md +++ b/docs/adr/0007-clickhouse-access.md @@ -4,19 +4,23 @@ ## Решение -У кластера четыре пользователя и три роли. +У кластера пять пользователей и четыре роли. - `default` — с паролем, только служебный: проверки здоровья нод и работа изнутри контейнеров. Приложения им не ходят. - `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходит Airflow и применяется DDL при подъёме стенда. -- `bi` с ролью `bi_reader` — чтение витрин DM и слоя DDS. Им ходит Superset. -- `analyst` с ролью `analyst_reader` — чтение всех слоёв. Им человек - подключается снаружи, из своего клиента. +- `bi` с ролью `bi_reader` — чтение витрин DM, слоя DDS и справочников `dic`. + Им ходит Superset. +- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им + человек подключается снаружи, из своего клиента. +- `dict` с ролью `dict_reader` — чтение одной базы `dic`, пароля нет. Им + словарь читает свою подложку, и больше он никем не используется + ([ADR 0012](0012-dictionary-home.md)). Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения -на запросы `ON CLUSTER` и на чтение системных таблиц, а без второго клиент -не открывает соединение вовсе. Состав прав каждой роли — работа тикета +на запросы `ON CLUSTER`, на чтение системных таблиц и на каждый движок +внешнего источника, а без второго клиент не открывает соединение вовсе. Состав прав каждой роли — работа тикета реализации. Пользователи, роли и права объявлены файлами настройки сервера, а не @@ -46,7 +50,16 @@ описания. Поэтому пользователей мало, имена у них говорящие, а границы проходят там, где их обычно проводят в компаниях: `bi` видит витрины и слой DDS под ними, потому что так чаще всего и бывает; заодно расхождение витрины -со слоем ниже остаётся проверяемым одним и тем же пользователем. +со слоем ниже остаётся проверяемым одним и тем же пользователем. Справочники +видны всем читателям и никому на запись: зона входит в хранилище сбоку, и её +содержимым владеет файл репозитория, а не слой. + +**Беспарольный `dict` — намеренное исключение.** Пароля у него нет не по +недосмотру: ходить этой учёткой некуда, кроме локального чтения одной базы, а +пароль пришлось бы вписать в текст DDL словаря — то есть положить секрет в git. +Право у него одно, `SELECT` на `dic`. Отдельная учётка под словарь взята из +промышленной практики, где источник словаря читают минимальной служебной +учётной записью, а не общим админом. **Секрет, а не учётные данные по репликам.** У каждой реплики в описании кластера своя учётка — вписанная явно или подразумеваемая, и тогда это @@ -92,10 +105,11 @@ Postgres и Grafana. Цена выбора известна: объявленн ## Следствия -До появления слоёв DDS и DM читать `bi` нечего, и это намеренно. Дашборды -рисуются поверх модели данных, а не поверх типизированных событий, поэтому -доступ Superset к ODS не планировался и правами не выдаётся. Пока витрин нет, -подключение Superset проверяется связью, а не запросом к таблице. +До появления слоёв DDS и DM читать `bi` почти нечего, и это намеренно. +Дашборды рисуются поверх модели данных, а не поверх типизированных событий, +поэтому доступ Superset к ODS не планировался и правами не выдаётся. +Единственное, что ему доступно сегодня, — справочники: с ними зона `dic` +пришла раньше своих потребителей. Владелец матвью приёма определяется тем, кто применил DDL. Весь DDL стенда идёт через `IF NOT EXISTS`, поэтому на существующих томах три матвью diff --git a/docs/adr/0012-dictionary-home.md b/docs/adr/0012-dictionary-home.md new file mode 100644 index 0000000..cad70ec --- /dev/null +++ b/docs/adr/0012-dictionary-home.md @@ -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 …)` перезагружает словарь в случайный момент + внутри окна, чтобы разнести обращения разных серверов к источнику. +- Если хост источника локальный, запрос идёт без сети. diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index 9c8aca5..05d676a 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -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. diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index f47a5db..3fc802a 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -413,7 +413,7 @@ README. | DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую | | DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS | | 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` | см. ниже | У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции @@ -695,6 +695,10 @@ v2, этап 0). - сцена «разные consumer groups → дубли»; - словарь регионов из CSV той же машинерией, что каталог товаров (оживляет `RegionCityID`); +- неатомарное обновление словаря на кластере: ноды перезагружают его в + случайный момент внутри окна `LIFETIME`, и после правки каталога до + тридцати секунд две ноды отвечают на один `dictGet` по-разному. Обвязка + готова ([ADR 0012](../adr/0012-dictionary-home.md)), урок остаётся на выбор; - лаба сессий: менти сначала собирает сессии сам, и только после — рассказ, что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично синтетическая постановка — осознанный приём; diff --git a/infra/clickhouse/users.d/access.xml b/infra/clickhouse/users.d/access.xml index a744933..6beae61 100644 --- a/infra/clickhouse/users.d/access.xml +++ b/infra/clickhouse/users.d/access.xml @@ -9,20 +9,28 @@ GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW ON default.* GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON stg.* GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON ods.* - GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, CREATE DICTIONARY, dictGet, DROP TABLE, DROP VIEW, CREATE DATABASE ON dds.* + GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON dds.* GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON dm.* + + GRANT SELECT, CREATE TABLE, CREATE DICTIONARY, dictGet, DROP TABLE, CREATE DATABASE ON dic.* GRANT SELECT ON system.* GRANT CLUSTER ON *.* GRANT REMOTE ON *.* - + GRANT KAFKA ON *.* + GRANT FILE ON *.* - GRANT SELECT, dictGet ON dds.* + GRANT SELECT ON dds.* GRANT SELECT ON dm.* + GRANT SELECT, dictGet ON dic.* GRANT SELECT ON system.settings @@ -30,11 +38,23 @@ GRANT SELECT ON stg.* GRANT SELECT ON ods.* - GRANT SELECT, dictGet ON dds.* + GRANT SELECT ON dds.* GRANT SELECT ON dm.* + GRANT SELECT, dictGet ON dic.* GRANT SELECT ON system.settings + + + + GRANT SELECT ON dic.* + + @@ -62,5 +82,12 @@ GRANT analyst_reader + + + default + + GRANT dict_reader + + diff --git a/sql/ddl/00-databases.sql b/sql/ddl/00-databases.sql index 4d35ddd..bf9f0f3 100644 --- a/sql/ddl/00-databases.sql +++ b/sql/ddl/00-databases.sql @@ -1,4 +1,4 @@ --- Базы слоёв хранилища. +-- Базы хранилища: слои цепочки и зона справочников. -- -- Файлы этой папки применяются по порядку имён с ноды 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; --- DDS начинается со словаря товаров; таблицы слоя появятся на следующем этапе. +-- Объекты DDS появятся на следующем этапе; база заводится заранее по той же +-- причине, что и ODS. 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; diff --git a/sql/ddl/05-dic-catalog.sql b/sql/ddl/05-dic-catalog.sql new file mode 100644 index 0000000..bddc05e --- /dev/null +++ b/sql/ddl/05-dic-catalog.sql @@ -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); diff --git a/sql/ddl/25-dds-dictionaries.sql b/sql/ddl/25-dds-dictionaries.sql deleted file mode 100644 index ce70161..0000000 --- a/sql/ddl/25-dds-dictionaries.sql +++ /dev/null @@ -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);