From abc94ba40650d89f9c13ef426fcc2d14293ec419 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Thu, 20 Aug 2026 11:37:26 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(clickhouse):=20=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B8=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D1=8B=20=D0=B2=20=D0=B7=D0=BE?= =?UTF-8?q?=D0=BD=D1=83=20dic,=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80?= =?UTF-8?q?=D1=8C=20=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BB=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); -- 2.54.0 From b3e66034fc82f54d1f49dcdabf0449c738c354f2 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Thu, 20 Aug 2026 11:42:26 +0300 Subject: [PATCH 2/4] =?UTF-8?q?docs(storage):=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D1=81=D1=82=D0=B2=D0=BE=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=BE=D0=B2=20=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D0=BE=D0=BC=20=D0=BC=D0=B5=D1=81=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: каждое утверждение о словаре было записано трижды — в доке хранилища, в комментариях DDL и в ADR 0012. Правка поведения стоила бы трёх согласованных правок, а расхождение между копиями обнаружилось бы не сразу. Дока хранилища — карта, а не пересказ реализации. Что: раздел «Справочники» сведён к своему уровню — правило принадлежности зоны, топология на кластере, единицы денег и указатели. Механика (движок, форма пути, пользователь, окно обновления) осталась только в sql/ddl/05-dic-catalog.sql, рядом с кодом, который она объясняет. Блок «Что проверено» отправлен к замерам ADR 0012 вместо их пересказа — так же, как он уже поступает с ADR 0005. Проверка: минус 30 строк; ссылок на убранный подзаголовок в репозитории нет, три относительные ссылки нового текста разрешаются в существующие файлы. Co-Authored-By: Claude Opus 5 --- docs/architecture/storage.md | 78 +++++++++++------------------------- 1 file changed, 24 insertions(+), 54 deletions(-) diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index 05d676a..f932cbd 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -424,48 +424,27 @@ kafka_offset)`: смотрят такую таблицу от класса, а Справочные данные живут в базе `dic` — вне цепочки STG → ODS → DDS → DM. В хранилище их никто не производит: содержимое приходит из файла репозитория, а -читают его несколько слоёв сразу. Поэтому зона своя, читать её вправе любой -слой, писать — никто. Почему так, а не пропиской в слое модели, — -[ADR 0012](../adr/0012-dictionary-home.md). +читают его несколько слоёв сразу. Отсюда правило принадлежности: читать зону +вправе любой слой, писать — никто. Почему зона, а не прописка в слое модели, и +почему словарь читает подложку, а не файл, — [ADR +0012](../adr/0012-dictionary-home.md). -### Словарь товаров +Первый и пока единственный справочник — каталог товаров: подложка +`dic.products_file` над CSV репозитория и словарь `dic.products` поверх неё. +Реплик у зоны нет: обе ноды читают свой смонтированный файл и держат свою копию +словаря в памяти. Словарь обновляется сам, окном `LIFETIME`, и потому после +правки каталога ноды какое-то время отвечают по-разному. Разогнать его раньше +срока — `SYSTEM RELOAD DICTIONARY ON CLUSTER`; это административная операция, +роль `etl` права на неё не получает. -Под словарём лежит подложка `dic.products_file` на движке `File`. Она ничего не -хранит и перечитывает `data/catalog/products.csv` на каждом запросе. Compose -монтирует каталог только для чтения в `user_files` обеих нод, DDL создаёт оба -объекта `ON CLUSTER`: имя и форма одни, но каждая нода читает свой файл и держит -свою копию словаря в памяти. +Деньги каталога стоит держать в голове отдельно от остальных: в CSV лежат целые +копейки, словарь отдаёт `Decimal(18, 2)`, а `productPrice` события — уже целые +рубли. Разрыв намеренный, на нём стоит урок про `Float64` ([описание +выгрузки](../formats/clickstream-event.md)). -Путь у движка `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/ddl/05-dic-catalog.sql`](../../sql/ddl/05-dic-catalog.sql). ## Раскладка SQL @@ -592,21 +571,12 @@ DDL-словарь с файловым источником внутри `user_f изменилась на обеих нодах; после отката и повторной команды вернулась обратно. **Проверка зоны справочников 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` не нужен вовсе. Текст ошибки здесь уводит в сторону. +`File` перечитывает файл на каждом запросе, словарь поверх неё обновляется сам +внутри окна `LIFETIME`, а собранный с нуля стенд поднимает оба объекта и отдаёт +словарь читателям. Замеры целиком — в +[ADR 0012](../adr/0012-dictionary-home.md), раздел «Что проверено»; там же +разобрано, почему отказ в праве на движок называет не то право, которого не +хватает. **Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил, что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов -- 2.54.0 From d77acfdf6c2161ea419a7bd584076f24ccb86f3a Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Thu, 20 Aug 2026 11:47:23 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(docs):=20=D1=80=D0=B0=D1=81=D1=85=D0=BE?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8F?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=BE=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: горячее ревью поймало ошибку в трёх местах сразу. Было записано, что после правки каталога ноды расходятся до тридцати секунд — число взято из ширины окна 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 --- docs/adr/0012-dictionary-home.md | 21 ++++++++++++--------- docs/specs/2026-07-30-stand-v2-realism.md | 7 ++++--- sql/ddl/05-dic-catalog.sql | 6 ++++-- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/docs/adr/0012-dictionary-home.md b/docs/adr/0012-dictionary-home.md index cad70ec..d2b4de2 100644 --- a/docs/adr/0012-dictionary-home.md +++ b/docs/adr/0012-dictionary-home.md @@ -26,7 +26,7 @@ ## Почему зона, а не прописка в слое -**Справочник читают несколько слоёв, а производит его ни один.** Каталог нужен +**Справочник читают несколько слоёв, а не производит ни один.** Каталог нужен модели заказов в DDS и витрине выручки в DM; будущие регионы — представлению событий в DDS; `analyst` читает его напрямую. Писателя нет вовсе: содержимое приходит из файла репозитория. Прописка внутри `dds` приписывала слою модели @@ -82,7 +82,7 @@ DM объекта, лежащего в DDS, — течение вниз, ров **Историзацию и представление «последняя загрузка» из образца не берём.** В промышленном примере таблица копит загрузки с меткой времени, а представление берёт последнюю: источник приезжал извне, и версий у него не было нигде. У нас -версии есть — **история каталога это git**. Дублировать её в ClickHouse значит +версии есть — **история каталога это и есть git**. Дублировать её в ClickHouse значит поставить конструкцию, у которой на стенде нет причины, а расшифровывать её менти всё равно придётся. @@ -137,17 +137,20 @@ DM объекта, лежащего в DDS, — течение вниз, ров - **Обновление словаря на кластере не атомарно, и это заявленное свойство.** Случайный момент внутри окна разводит опросы разных серверов, чтобы они не ходили к источнику разом. Побочный эффект — ноды перезагружают словарь в - разное время, и на нашей паре расхождение доходит до 30 секунд: правка - каталога полминуты видна одной ноде и не видна другой. Прежнее решение - `LIFETIME(0)` эту неатомарность предотвращало; теперь она предъявлена - намеренно. Урок записан опорной точкой в мастер-спеке, тащить его в курс — + разное время. Ждать правки каталога каждой из них приходится от нуля до + верхней границы окна, а фазы у них независимы, поэтому расходиться они могут + почти на все полторы минуты: одна перезагрузилась сразу после правки, вторая + ещё нет. Прежнее решение `LIFETIME(0)` окно не закрывало, а сжимало до + разброса исполнения `SYSTEM RELOAD DICTIONARY ON CLUSTER`; теперь оно + раскрыто намеренно. Урок записан опорной точкой в мастер-спеке, тащить его в курс — отдельное решение. - **Номенклатура товаров — медленно изменяющийся справочник**, и в предметной области это так. Стенд упрощает: файл в репозитории под версией git. Историзации в хранилище нет, и обещать её этот документ не должен. -- Витрина выручки — представление, поэтому категория подставляется в момент - запроса, и правка каталога меняет отчёт задним числом. Материализуй мы - категорию при приёме — не изменила бы. +- Витрина выручки спроектирована представлением, поэтому категория будет + подставляться в момент запроса, и правка каталога изменит отчёт задним + числом. Материализуй мы категорию при приёме — не изменила бы. Самой витрины + ещё нет, она приходит этапом 4. ## Что проверено diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index 3fc802a..54270b2 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -696,9 +696,10 @@ v2, этап 0). - словарь регионов из CSV той же машинерией, что каталог товаров (оживляет `RegionCityID`); - неатомарное обновление словаря на кластере: ноды перезагружают его в - случайный момент внутри окна `LIFETIME`, и после правки каталога до - тридцати секунд две ноды отвечают на один `dictGet` по-разному. Обвязка - готова ([ADR 0012](../adr/0012-dictionary-home.md)), урок остаётся на выбор; + случайный момент внутри окна `LIFETIME`, фазы у них независимы, и после + правки каталога две ноды отвечают на один `dictGet` по-разному — почти всё + окно целиком. Обвязка готова ([ADR + 0012](../adr/0012-dictionary-home.md)), урок остаётся на выбор; - лаба сессий: менти сначала собирает сессии сам, и только после — рассказ, что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично синтетическая постановка — осознанный приём; diff --git a/sql/ddl/05-dic-catalog.sql b/sql/ddl/05-dic-catalog.sql index bddc05e..5f4fdfe 100644 --- a/sql/ddl/05-dic-catalog.sql +++ b/sql/ddl/05-dic-catalog.sql @@ -35,8 +35,10 @@ ENGINE = File(CSVWithNames, './catalog/products.csv'); -- -- Окно обновления вместо LIFETIME(0): словарь перезагружается сам в случайный -- момент внутри окна. Случайность разводит обращения разных серверов к --- источнику, и цена у неё заявленная — ноды обновляются вразнобой, до 30 --- секунд одна видит правку каталога, а вторая ещё нет. +-- источнику, и цена у неё заявленная — ноды обновляются вразнобой. Ждать +-- правки каталога каждой из них приходится от нуля до верхней границы окна, +-- фазы у них независимы, поэтому расходиться они могут почти на все +-- полторы минуты: одна перезагрузилась сразу после правки, вторая ещё нет. -- -- Ключ строковый, поэтому COMPLEX_KEY_HASHED: числовой FLAT здесь неприменим. -- -- 2.54.0 From 5cc0ede75a653a9ede6eb467ea9c5afc9544a055 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Thu, 20 Aug 2026 12:10:03 +0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(clickhouse):=20=D0=BF=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0=D1=80=D1=8F,=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=BC=D0=B5=D1=82=D0=B5=D0=BD=D1=8B=20=D1=85=D0=B2?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: ревью в три линии нашло настоящий дефект. Девятая проверка кластера звала dictGet('dds.products', …) — объекта после переноса не существует, и make check-clickhouse падал. Промах вырос из недосчёта: упоминания dds.products я искал с фильтром по расширениям, .sh туда не попал, и в ADR уехало «четыре упоминания» вместо семи. Что: проверка переведена на dic.products и сверяет цену, умножив её обратно на сто, — так утверждается ещё и точность приведения к Decimal. Раздел 3 мастер-спеки и список пользователей README догнали перенос: оба описывали отменённое устройство. В ADR 0012 добавлено условие пересмотра для дома — для него его не было, хотя ради дома тикет и заводился; недосчёт записан в «Что проверено» как урок. Разнобой обозначений сведён: карта таблиц в доке хранилища перешла на имена баз строчными, таблица мастер-спеки — на заголовок «Где», зону всюду зовут зоной, а не слоем. Термин «Справочник» переписан без метафоры и уложен в формат глоссария. Вычтено лишнее: комментарии DDL сократились вдвое, из ADR 0006 и 0007 убраны самооправдание и дублирующие абзацы, отраслевой образец получил честную оговорку о непроверяемости. Проверка: make config-test, make lint, make smoke и make check-clickhouse — все зелёные, десять проверок кластера из десяти. Co-Authored-By: Claude Opus 5 --- CONTEXT.md | 13 +++--- README.md | 10 +++-- docs/adr/0006-object-naming.md | 6 +-- docs/adr/0007-clickhouse-access.md | 12 +++-- docs/adr/0012-dictionary-home.md | 53 +++++++++++++++-------- docs/architecture/storage.md | 40 ++++++++--------- docs/specs/2026-07-30-stand-v2-realism.md | 20 +++++---- infra/clickhouse/users.d/access.xml | 7 +-- scripts/check-clickhouse.sh | 9 ++-- sql/ddl/00-databases.sql | 4 +- sql/ddl/05-dic-catalog.sql | 50 +++++++-------------- 11 files changed, 110 insertions(+), 114 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index cfcd9cf..fd73d35 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -95,15 +95,14 @@ _Избегать_: «покупатель» про того, кто купил товара, покупка — на страницу подтверждения заказа. **Справочник**: -Данные, входящие в хранилище сбоку, а не по цепочке слоёв: в хранилище их -никто не производит, а читают их несколько слоёв. Дом — база `dic`, общая для -всех справочников. Первый и пока единственный — каталог товаров. -_Не путать_: слово «словарь» в проекте значит ещё две вещи — этот глоссарий -понятий и объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник -доступен. +Данные, которые хранилище не производит, а получает готовыми со стороны: +содержимым владеет источник вне хранилища, слои только читают. Живут в базе +`dic`. Первый и пока единственный — каталог товаров. +_Избегать_: словарь — этим словом в проекте зовут и глоссарий понятий, и +объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник доступен. **Каталог товаров**: -`data/catalog/products.csv` — общий справочник генератора и словаря +`data/catalog/products.csv` — общий файл генератора и словаря ClickHouse. Форма файла решена, длина — нет: строки дописываются. **Уровень спроса**: diff --git a/README.md b/README.md index 3a9a1b4..68f5f1d 100644 --- a/README.md +++ b/README.md @@ -278,12 +278,16 @@ uv run --project generator python -m clickstream_generator batch \ - Prometheus — `http://127.0.0.1:29090`; - Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`. -В ClickHouse четыре пользователя: +В ClickHouse пять пользователей: - `etl` применяет DDL и подключает Airflow; роль `etl_writer` читает и пишет слои хранилища; -- `bi` подключает Superset; роль `bi_reader` читает будущие слои DDS и DM; -- `analyst` предназначен для подключения человека и читает все слои; +- `bi` подключает Superset; роль `bi_reader` читает справочники `dic` и будущие + слои DDS и DM; +- `analyst` предназначен для подключения человека и читает все слои и + справочники; +- `dict` читает только базу `dic` и пароля не имеет: им словарь товаров ходит + за своей подложкой; - `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри контейнеров, но не приложения. diff --git a/docs/adr/0006-object-naming.md b/docs/adr/0006-object-naming.md index 2c8d41d..24e1bea 100644 --- a/docs/adr/0006-object-naming.md +++ b/docs/adr/0006-object-naming.md @@ -9,10 +9,8 @@ топика, `_file` — чтец файла, `_mv` — материализованное представление, `_v` — обычное представление. -Суффикс `_file` добавлен 20 августа 2026 года вместе с подложкой под словарём -товаров ([ADR 0012](0012-dictionary-home.md)). Он ровно параллелен `_kafka`: -чтец внешнего источника, по копии на каждой ноде, без репликации и без -распределённой пары. +Суффикс `_file` пришёл с подложкой под словарём товаров +([ADR 0012](0012-dictionary-home.md)). Суффикс носит каждый физический объект, поэтому голого имени у таблицы не существует: запрос к `stg.hits_raw` даёт ошибку «нет такой таблицы». diff --git a/docs/adr/0007-clickhouse-access.md b/docs/adr/0007-clickhouse-access.md index 549436e..f38c67f 100644 --- a/docs/adr/0007-clickhouse-access.md +++ b/docs/adr/0007-clickhouse-access.md @@ -20,7 +20,8 @@ Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения на запросы `ON CLUSTER`, на чтение системных таблиц и на каждый движок -внешнего источника, а без второго клиент не открывает соединение вовсе. Состав прав каждой роли — работа тикета +внешнего источника. Без права на системные таблицы клиент вовсе не открывает +соединение. Состав прав каждой роли — работа тикета реализации. Пользователи, роли и права объявлены файлами настройки сервера, а не @@ -54,12 +55,9 @@ DDS под ними, потому что так чаще всего и быва видны всем читателям и никому на запись: зона входит в хранилище сбоку, и её содержимым владеет файл репозитория, а не слой. -**Беспарольный `dict` — намеренное исключение.** Пароля у него нет не по -недосмотру: ходить этой учёткой некуда, кроме локального чтения одной базы, а -пароль пришлось бы вписать в текст DDL словаря — то есть положить секрет в git. -Право у него одно, `SELECT` на `dic`. Отдельная учётка под словарь взята из -промышленной практики, где источник словаря читают минимальной служебной -учётной записью, а не общим админом. +**Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то +есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно — +`SELECT` на `dic`, и хост источника локальный. **Секрет, а не учётные данные по репликам.** У каждой реплики в описании кластера своя учётка — вписанная явно или подразумеваемая, и тогда это diff --git a/docs/adr/0012-dictionary-home.md b/docs/adr/0012-dictionary-home.md index d2b4de2..0a1ad17 100644 --- a/docs/adr/0012-dictionary-home.md +++ b/docs/adr/0012-dictionary-home.md @@ -33,18 +33,19 @@ владение, которого у него нет: DDS этот объект не производит, не обновляет и не проверяет. -**На направление данных ссылаться нельзя, и это важно не спутать.** Чтение из -DM объекта, лежащего в DDS, — течение вниз, ровно то, которое разрешено. -Промах был во владении, а не в направлении. Но зона вне цепочки закрывает и -будущий случай: как только справочник понадобится выше по течению — например, -проверка `sku` позиции при разборе заказа в ODS, — чтение `dds.*` из `ods.*` -стало бы настоящей инверсией. У зоны вне цепочки направления относительно слоёв -нет, и такой запрос законен по построению. +**Промах был во владении, а не в направлении.** Чтение из DM объекта, лежащего +в DDS, — течение вниз, ровно то, которое разрешено; на инверсию слоёв это +решение не опирается. Зато зона закрывает будущий случай: как только справочник +понадобится выше по течению — скажем, проверка `sku` при разборе заказа в ODS — +чтение `dds.*` из `ods.*` стало бы настоящей инверсией. У зоны вне цепочки +направления относительно слоёв нет, и такой запрос законен по построению. -**Отраслевой образец говорит то же самое другим средством.** В хранилищах, где -база одна, а слои выражены префиксами имён, справочники несут собственный -префикс наравне с префиксами стадий — то есть образуют свой слой, а не -приписаны к слою модели. Причём префикс накрывает всю цепочку справочника: и +**Отраслевой образец говорит то же самое другим средством.** Владелец принёс +устройство промышленного хранилища телекома — источник закрытый, проверить по +ссылке нельзя, поэтому вес у довода такой же, как у полевого опыта, а не как у +документации. Там база одна, слои выражены префиксами имён, и справочники несут +собственный префикс наравне с префиксами стадий — то есть образуют свою зону, а +не приписаны к слою модели. Причём префикс накрывает всю цепочку справочника: и таблицу, и представление над ней, и сам объект словаря. Средство разное, утверждение одно. У нас слои выражены базами — значит справочникам полагается база. @@ -82,8 +83,8 @@ DM объекта, лежащего в DDS, — течение вниз, ров **Историзацию и представление «последняя загрузка» из образца не берём.** В промышленном примере таблица копит загрузки с меткой времени, а представление берёт последнюю: источник приезжал извне, и версий у него не было нигде. У нас -версии есть — **история каталога это и есть git**. Дублировать её в ClickHouse значит -поставить конструкцию, у которой на стенде нет причины, а расшифровывать её +версии есть — **история каталога это и есть git**. Дублировать её в ClickHouse +значит поставить конструкцию, у которой на стенде нет причины, а расшифровывать её менти всё равно придётся. **Материализованной таблицы с шагом наполнения тоже не берём.** Шаг наполнения @@ -121,6 +122,12 @@ DM объекта, лежащего в DDS, — течение вниз, ров ## Условия пересмотра +- **Дом.** Появляется справочные данные, которые хранилище производит само — + скажем, таблица соответствий, собранная из DDS. Посылка решения «в хранилище + их никто не производит» на них не распространяется, и зону придётся либо + сузить до пришедших извне, либо переопределить. Дешевле всего это до этапа 4: + как только витрины начнут звать словарь по имени, каждый разворот станет + правкой по всем ссылкам. - Справочник перестаёт быть файлом репозитория и начинает приезжать извне — возвращается вопрос устройства: историзованная таблица, «последняя загрузка», словарь поверх. Адрес это переживает. @@ -142,8 +149,8 @@ DM объекта, лежащего в DDS, — течение вниз, ров почти на все полторы минуты: одна перезагрузилась сразу после правки, вторая ещё нет. Прежнее решение `LIFETIME(0)` окно не закрывало, а сжимало до разброса исполнения `SYSTEM RELOAD DICTIONARY ON CLUSTER`; теперь оно - раскрыто намеренно. Урок записан опорной точкой в мастер-спеке, тащить его в курс — - отдельное решение. + раскрыто намеренно. Урок записан опорной точкой в мастер-спеке, тащить его + в курс — отдельное решение. - **Номенклатура товаров — медленно изменяющийся справочник**, и в предметной области это так. Стенд упрощает: файл в репозитории под версией git. Историзации в хранилище нет, и обещать её этот документ не должен. @@ -178,15 +185,25 @@ DM объекта, лежащего в DDS, — течение вниз, ров - `SHOW DICTIONARIES` без указания базы возвращает пусто; словарь в базе стоит среди таблиц слоя и отличается от них только колонкой движка в `system.tables`. -- До переноса `dds.products` упоминался в репозитории четырежды, в дагах — ноль. +- До переноса `dds.products` стоял в семи строках трёх файлов: дока хранилища, + DDL словаря и проверка `scripts/check-clickhouse.sh`. Первый замер насчитал + четыре и проверку не увидел — искали с фильтром по расширениям, куда `.sh` + не попал. Из недосчёта вырос настоящий дефект: девятая проверка кластера + осталась звать несуществующий объект и упала. Поймало ревью, не автор. + Урок тот же, что в [ADR 0006](0006-object-naming.md): «прогнал по + репозиторию» — такое же утверждение, как утверждение о поведении системы, и + проверять его надо так же. - Движок `File` требует у `etl` права на источник. Отказ называет `TABLE ENGINE ON File`, но права с таким именем не хватает: замер тремя пользователями показал, что работает `GRANT FILE ON *.*`, а `TABLE ENGINE ON File` не нужен вовсе. - Стенд, собранный с нуля этим решением, поднимается зелёным: оба объекта зоны на месте, словарь `LOADED` со 180 строками, база `dds` пуста, `bi` читает - словарь со второй ноды, `analyst` — подложку соединением. `make smoke` — - 20 проверок, 0 ошибок. + словарь со второй ноды, `analyst` — подложку соединением. Словарь спрашивает + `make check-clickhouse` — 10 проверок из 10; девятая берёт известный `sku` на + обеих нодах и сверяет цену с колонкой файла, умножив её обратно на сто, так + что приведение к `Decimal` проверено вместе с ответом. `make smoke` словарь + не трогает вовсе. Сверено по документации ClickHouse через MCP Context7 20 августа 2026 года. diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index f932cbd..2dad3a2 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -431,15 +431,15 @@ kafka_offset)`: смотрят такую таблицу от класса, а Первый и пока единственный справочник — каталог товаров: подложка `dic.products_file` над CSV репозитория и словарь `dic.products` поверх неё. -Реплик у зоны нет: обе ноды читают свой смонтированный файл и держат свою копию -словаря в памяти. Словарь обновляется сам, окном `LIFETIME`, и потому после -правки каталога ноды какое-то время отвечают по-разному. Разогнать его раньше -срока — `SYSTEM RELOAD DICTIONARY ON CLUSTER`; это административная операция, -роль `etl` права на неё не получает. +Распределённых пар у зоны нет: обе ноды читают свой смонтированный файл и +держат свою копию словаря в памяти. Словарь обновляется сам, окном `LIFETIME`, +и потому после правки каталога ноды какое-то время отвечают по-разному. +Разогнать его раньше срока — `SYSTEM RELOAD DICTIONARY ON CLUSTER`; это +административная операция, роль `etl` права на неё не получает. -Деньги каталога стоит держать в голове отдельно от остальных: в CSV лежат целые -копейки, словарь отдаёт `Decimal(18, 2)`, а `productPrice` события — уже целые -рубли. Разрыв намеренный, на нём стоит урок про `Float64` ([описание +Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт +`Decimal(18, 2)`, `productPrice` события — целые рубли. Разрыв намеренный, на +нём стоит урок про `Float64` ([описание выгрузки](../formats/clickstream-event.md)). Устройство обоих объектов — почему такой движок, такая форма пути, такой @@ -525,18 +525,18 @@ Airflow читает и собирает эти файлы штатным шаб | База | Объект | Что это | |---|---|---| -| STG | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` | -| STG | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки | -| STG | `stg.hits_raw_mv` | наполняет сырьё из чтеца | -| STG | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью | -| STG | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` | -| ODS | `ods.event_rep` / `_dist` | типизированное широкое событие | -| ODS | `ods.event_v` | актуальная версия события с полями источника | -| ODS | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём | -| ODS | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки | -| ODS | `ods.order_snapshot_rep` / `_dist` | типизированные версии заказа | -| ODS | `ods.order_v` | текущая версия заказа на языке источника | -| ODS | `ods.order_snapshot_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём | +| `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` | +| `stg` | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки | +| `stg` | `stg.hits_raw_mv` | наполняет сырьё из чтеца | +| `stg` | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью | +| `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` | +| `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие | +| `ods` | `ods.event_v` | актуальная версия события с полями источника | +| `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём | +| `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки | +| `ods` | `ods.order_snapshot_rep` / `_dist` | типизированные версии заказа | +| `ods` | `ods.order_v` | текущая версия заказа на языке источника | +| `ods` | `ods.order_snapshot_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём | | `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором | | `dic` | `dic.products` | словарь товаров поверх подложки | diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index 54270b2..77e715d 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -240,10 +240,12 @@ Ecommerce (заполнены только у торговых событий): ## 3. Каталог товаров CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`, -`brand`, `price`, `demand`) — **словарь ClickHouse** из файла. Тот же файл -использует генератор — расхождений нет по построению. Даёт `dictGet` в -витринах и разговор о политике обновления словаря. На кластере файл -монтируется в обе ноды, словарь создаётся ON CLUSTER. Последняя колонка — +`brand`, `price`, `demand`) — **словарь ClickHouse**. Тот же файл использует +генератор — расхождений нет по построению. Даёт `dictGet` в витринах и +разговор о политике обновления словаря. На кластере файл монтируется в обе +ноды; над ним стоит подложка, а словарь читает её и живёт в зоне справочников +`dic` вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)). +Последняя колонка — уровень спроса товара, заведена при исполнении #50 (спека генератора, раздел 9): генератор решает по ней, что уходит из карточки в корзину. @@ -402,7 +404,7 @@ README. ## 7. Слои: карта таблиц v2 -| Слой | Объект | Что это | +| Где | Объект | Что это | |---|---|---| | Kafka | `hits`, `orders` | два топика: `hits` — 2 партиции, `orders` — одна | | STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах | @@ -413,7 +415,7 @@ README. | DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую | | DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS | | DDS | `dds.identity_map` | карта кука↔пользователь | -| `dic` | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) | +| DIC | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) | | DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже | У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции @@ -697,9 +699,9 @@ v2, этап 0). `RegionCityID`); - неатомарное обновление словаря на кластере: ноды перезагружают его в случайный момент внутри окна `LIFETIME`, фазы у них независимы, и после - правки каталога две ноды отвечают на один `dictGet` по-разному — почти всё - окно целиком. Обвязка готова ([ADR - 0012](../adr/0012-dictionary-home.md)), урок остаётся на выбор; + правки каталога две ноды почти всё окно отвечают на один `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 6beae61..785b76e 100644 --- a/infra/clickhouse/users.d/access.xml +++ b/infra/clickhouse/users.d/access.xml @@ -44,12 +44,7 @@ GRANT SELECT ON system.settings - + GRANT SELECT ON dic.* diff --git a/scripts/check-clickhouse.sh b/scripts/check-clickhouse.sh index 71cca65..dcb4d6d 100755 --- a/scripts/check-clickhouse.sh +++ b/scripts/check-clickhouse.sh @@ -264,10 +264,13 @@ printf 'ЗЕЛЁНО: временные таблицы удалены; пров # После снятия ловушек: своих объектов эти проверки не заводят и прибирать за # собой им нечего — они только смотрят на то, что стенд произвёл сам. printf 'Проверка 9/10: словарь товаров отвечает на обеих нодах...\n' +# Цену словарь отдаёт в Decimal(18, 2), а каталог хранит целые копейки. Умножаем +# обратно и сравниваем с колонкой файла: так проверка заодно утверждает, что +# приведение в источнике словаря точное, а не только что словарь отвечает. product_sql="SELECT - dictGet('dds.products', 'name', tuple('HOME-0001')), - dictGet('dds.products', 'category', tuple('HOME-0001')), - dictGet('dds.products', 'price', tuple('HOME-0001')) + dictGet('dic.products', 'name', tuple('HOME-0001')), + dictGet('dic.products', 'category', tuple('HOME-0001')), + toInt64(dictGet('dic.products', 'price', tuple('HOME-0001')) * 100) FORMAT TSV" expected_product="$( awk -F, \ diff --git a/sql/ddl/00-databases.sql b/sql/ddl/00-databases.sql index bf9f0f3..a567a12 100644 --- a/sql/ddl/00-databases.sql +++ b/sql/ddl/00-databases.sql @@ -19,6 +19,6 @@ CREATE DATABASE IF NOT EXISTS ods ON CLUSTER clickstream_cluster; CREATE DATABASE IF NOT EXISTS dds ON CLUSTER clickstream_cluster; -- Справочники стоят вне цепочки STG → ODS → DDS → DM: в хранилище их никто не --- производит, а читают их несколько слоёв ([ADR 0012]). Поэтому зона своя, и --- порядок слоёв к ней не применяется — её файл идёт сразу за этим. +-- производит, а читают их несколько слоёв — 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 index 5f4fdfe..e525646 100644 --- a/sql/ddl/05-dic-catalog.sql +++ b/sql/ddl/05-dic-catalog.sql @@ -1,22 +1,14 @@ -- Каталог товаров: подложка на файловом движке и словарь поверх неё. -- --- Словарь читает не файл, а таблицу хранилища — намеренное усложнение --- ([ADR 0012](../../docs/adr/0012-dictionary-home.md)). В бою справочник --- приезжает процессом, и предложение SOURCE с запросом и учётной записью — --- та форма, которую менти встретит; файловый источник работает, но редок. --- --- Зона dic лежит вне цепочки STG → ODS → DDS → DM: справочник в хранилище --- никто не производит, а читают его несколько слоёв. +-- Словарь читает не файл, а таблицу хранилища. Это намеренное усложнение ради +-- урока: в бою справочник приезжает процессом, и предложение SOURCE с запросом +-- и учётной записью — та форма, которую менти встретит. Доводы, отвергнутые +-- варианты и условия пересмотра — ADR 0012. -- Подложка ничего не хранит: движок File перечитывает CSV на каждом запросе, --- поэтому правка каталога доезжает до словаря сама. Compose монтирует один и --- тот же файл в user_files обеих нод только для чтения. --- --- Путь считается ОТ user_files, а не от корня данных: форма --- './user_files/catalog/products.csv' даёт FILE_DOESNT_EXIST. У файлового --- источника словаря база пути была другой — отсюда разница с прежним DDL. --- Типы здесь повторяют файл, а не модель: цена лежит целыми копейками, как её --- пишет генератор. Приведение к деньгам делает словарь. +-- поэтому правка каталога доезжает до словаря сама. Путь считается от +-- user_files, а не от корня данных. Типы повторяют файл, а не модель: цена +-- лежит целыми копейками, приведение делает словарь. CREATE TABLE IF NOT EXISTS dic.products_file ON CLUSTER clickstream_cluster ( sku String, @@ -28,28 +20,16 @@ CREATE TABLE IF NOT EXISTS dic.products_file ON CLUSTER clickstream_cluster ) ENGINE = File(CSVWithNames, './catalog/products.csv'); --- Пользователь dict объявлен файлом настройки и умеет одно — читать dic. --- Назвать его обязательно: без user словарь идёт как default с пустым паролем --- и падает с AUTHENTICATION_FAILED. Хост локальный, поэтому запрос к подложке --- идёт без сети. +-- Пользователь dict умеет одно — читать dic. Назвать его обязательно: без user +-- словарь идёт как default с пустым паролем и падает. Хост локальный, поэтому +-- запрос к подложке идёт без сети. -- --- Окно обновления вместо LIFETIME(0): словарь перезагружается сам в случайный --- момент внутри окна. Случайность разводит обращения разных серверов к --- источнику, и цена у неё заявленная — ноды обновляются вразнобой. Ждать --- правки каталога каждой из них приходится от нуля до верхней границы окна, --- фазы у них независимы, поэтому расходиться они могут почти на все --- полторы минуты: одна перезагрузилась сразу после правки, вторая ещё нет. +-- Форма query, а не table: словарь приводит копейки каталога к Decimal(18, 2) +-- прямо на входе, то есть нормализует, а не зеркалит подложку. Ключ строковый, +-- поэтому COMPLEX_KEY_HASHED: числовой FLAT здесь неприменим. -- --- Ключ строковый, поэтому COMPLEX_KEY_HASHED: числовой FLAT здесь неприменим. --- --- Цена приводится к деньгам прямо в источнике — оттого форма query, а не --- table: словарь нормализует на входе, а не зеркалит подложку. В файле лежат --- целые копейки, наружу словарь отдаёт Decimal(18, 2), как заказы бэкенда. --- Единица в числе становится видна: 129000 против 1290.00. --- --- Стык с событием на этом и стоит: в контракте события productPrice — целые --- РУБЛИ, округление формата. Разрыв между ценой каталога и ценой в событии --- намеренный, на нём держится урок про Float64. +-- Окно вместо LIFETIME(0): словарь обновляется сам, а цена этому — ноды +-- расходятся почти на всё окно (ADR 0012). CREATE DICTIONARY IF NOT EXISTS dic.products ON CLUSTER clickstream_cluster ( sku String, -- 2.54.0