Merge pull request 'feat(clickhouse): справочники вынесены в зону dic, словарь читает подложку' (#107) from feat/105-dictionary-home into main
Reviewed-on: #107
This commit was merged in pull request #107.
This commit is contained in:
+8
-1
@@ -94,8 +94,15 @@ _Избегать_: «покупатель» про того, кто купил
|
|||||||
страницы. Садится на ту страницу, где случилось: корзина — на карточку
|
страницы. Садится на ту страницу, где случилось: корзина — на карточку
|
||||||
товара, покупка — на страницу подтверждения заказа.
|
товара, покупка — на страницу подтверждения заказа.
|
||||||
|
|
||||||
|
**Справочник**:
|
||||||
|
Данные, которые хранилище не производит, а получает готовыми со стороны:
|
||||||
|
содержимым владеет источник вне хранилища, слои только читают. Живут в базе
|
||||||
|
`dic`. Первый и пока единственный — каталог товаров.
|
||||||
|
_Избегать_: словарь — этим словом в проекте зовут и глоссарий понятий, и
|
||||||
|
объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник доступен.
|
||||||
|
|
||||||
**Каталог товаров**:
|
**Каталог товаров**:
|
||||||
`data/catalog/products.csv` — общий справочник генератора и словаря
|
`data/catalog/products.csv` — общий файл генератора и словаря
|
||||||
ClickHouse. Форма файла решена, длина — нет: строки дописываются.
|
ClickHouse. Форма файла решена, длина — нет: строки дописываются.
|
||||||
|
|
||||||
**Уровень спроса**:
|
**Уровень спроса**:
|
||||||
|
|||||||
@@ -278,12 +278,16 @@ uv run --project generator python -m clickstream_generator batch \
|
|||||||
- Prometheus — `http://127.0.0.1:29090`;
|
- Prometheus — `http://127.0.0.1:29090`;
|
||||||
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
|
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
|
||||||
|
|
||||||
В ClickHouse четыре пользователя:
|
В ClickHouse пять пользователей:
|
||||||
|
|
||||||
- `etl` применяет DDL и подключает Airflow; роль `etl_writer` читает и пишет
|
- `etl` применяет DDL и подключает Airflow; роль `etl_writer` читает и пишет
|
||||||
слои хранилища;
|
слои хранилища;
|
||||||
- `bi` подключает Superset; роль `bi_reader` читает будущие слои DDS и DM;
|
- `bi` подключает Superset; роль `bi_reader` читает справочники `dic` и будущие
|
||||||
- `analyst` предназначен для подключения человека и читает все слои;
|
слои DDS и DM;
|
||||||
|
- `analyst` предназначен для подключения человека и читает все слои и
|
||||||
|
справочники;
|
||||||
|
- `dict` читает только базу `dic` и пароля не имеет: им словарь товаров ходит
|
||||||
|
за своей подложкой;
|
||||||
- `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри
|
- `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри
|
||||||
контейнеров, но не приложения.
|
контейнеров, но не приложения.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
|
|
||||||
Имя объекта в ClickHouse заканчивается тем, что это за объект: `_rep` —
|
Имя объекта в ClickHouse заканчивается тем, что это за объект: `_rep` —
|
||||||
локальная таблица шарда, `_dist` — `Distributed` поверх неё, `_kafka` — чтец
|
локальная таблица шарда, `_dist` — `Distributed` поверх неё, `_kafka` — чтец
|
||||||
топика, `_mv` — материализованное представление, `_v` — обычное представление.
|
топика, `_file` — чтец файла, `_mv` — материализованное представление, `_v` —
|
||||||
|
обычное представление.
|
||||||
|
|
||||||
|
Суффикс `_file` пришёл с подложкой под словарём товаров
|
||||||
|
([ADR 0012](0012-dictionary-home.md)).
|
||||||
|
|
||||||
Суффикс носит каждый физический объект, поэтому голого имени у таблицы не
|
Суффикс носит каждый физический объект, поэтому голого имени у таблицы не
|
||||||
существует: запрос к `stg.hits_raw` даёт ошибку «нет такой таблицы».
|
существует: запрос к `stg.hits_raw` даёт ошибку «нет такой таблицы».
|
||||||
|
|||||||
@@ -4,19 +4,24 @@
|
|||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
У кластера четыре пользователя и три роли.
|
У кластера пять пользователей и четыре роли.
|
||||||
|
|
||||||
- `default` — с паролем, только служебный: проверки здоровья нод и работа
|
- `default` — с паролем, только служебный: проверки здоровья нод и работа
|
||||||
изнутри контейнеров. Приложения им не ходят.
|
изнутри контейнеров. Приложения им не ходят.
|
||||||
- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходит
|
- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходит
|
||||||
Airflow и применяется DDL при подъёме стенда.
|
Airflow и применяется DDL при подъёме стенда.
|
||||||
- `bi` с ролью `bi_reader` — чтение витрин DM и слоя DDS. Им ходит Superset.
|
- `bi` с ролью `bi_reader` — чтение витрин DM, слоя DDS и справочников `dic`.
|
||||||
- `analyst` с ролью `analyst_reader` — чтение всех слоёв. Им человек
|
Им ходит Superset.
|
||||||
подключается снаружи, из своего клиента.
|
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
|
||||||
|
человек подключается снаружи, из своего клиента.
|
||||||
|
- `dict` с ролью `dict_reader` — чтение одной базы `dic`, пароля нет. Им
|
||||||
|
словарь читает свою подложку, и больше он никем не используется
|
||||||
|
([ADR 0012](0012-dictionary-home.md)).
|
||||||
|
|
||||||
Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения
|
Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения
|
||||||
на запросы `ON CLUSTER` и на чтение системных таблиц, а без второго клиент
|
на запросы `ON CLUSTER`, на чтение системных таблиц и на каждый движок
|
||||||
не открывает соединение вовсе. Состав прав каждой роли — работа тикета
|
внешнего источника. Без права на системные таблицы клиент вовсе не открывает
|
||||||
|
соединение. Состав прав каждой роли — работа тикета
|
||||||
реализации.
|
реализации.
|
||||||
|
|
||||||
Пользователи, роли и права объявлены файлами настройки сервера, а не
|
Пользователи, роли и права объявлены файлами настройки сервера, а не
|
||||||
@@ -46,7 +51,13 @@
|
|||||||
описания. Поэтому пользователей мало, имена у них говорящие, а границы
|
описания. Поэтому пользователей мало, имена у них говорящие, а границы
|
||||||
проходят там, где их обычно проводят в компаниях: `bi` видит витрины и слой
|
проходят там, где их обычно проводят в компаниях: `bi` видит витрины и слой
|
||||||
DDS под ними, потому что так чаще всего и бывает; заодно расхождение витрины
|
DDS под ними, потому что так чаще всего и бывает; заодно расхождение витрины
|
||||||
со слоем ниже остаётся проверяемым одним и тем же пользователем.
|
со слоем ниже остаётся проверяемым одним и тем же пользователем. Справочники
|
||||||
|
видны всем читателям и никому на запись: зона входит в хранилище сбоку, и её
|
||||||
|
содержимым владеет файл репозитория, а не слой.
|
||||||
|
|
||||||
|
**Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то
|
||||||
|
есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно —
|
||||||
|
`SELECT` на `dic`, и хост источника локальный.
|
||||||
|
|
||||||
**Секрет, а не учётные данные по репликам.** У каждой реплики в описании
|
**Секрет, а не учётные данные по репликам.** У каждой реплики в описании
|
||||||
кластера своя учётка — вписанная явно или подразумеваемая, и тогда это
|
кластера своя учётка — вписанная явно или подразумеваемая, и тогда это
|
||||||
@@ -92,10 +103,11 @@ Postgres и Grafana. Цена выбора известна: объявленн
|
|||||||
|
|
||||||
## Следствия
|
## Следствия
|
||||||
|
|
||||||
До появления слоёв DDS и DM читать `bi` нечего, и это намеренно. Дашборды
|
До появления слоёв DDS и DM читать `bi` почти нечего, и это намеренно.
|
||||||
рисуются поверх модели данных, а не поверх типизированных событий, поэтому
|
Дашборды рисуются поверх модели данных, а не поверх типизированных событий,
|
||||||
доступ Superset к ODS не планировался и правами не выдаётся. Пока витрин нет,
|
поэтому доступ Superset к ODS не планировался и правами не выдаётся.
|
||||||
подключение Superset проверяется связью, а не запросом к таблице.
|
Единственное, что ему доступно сегодня, — справочники: с ними зона `dic`
|
||||||
|
пришла раньше своих потребителей.
|
||||||
|
|
||||||
Владелец матвью приёма определяется тем, кто применил DDL. Весь DDL стенда
|
Владелец матвью приёма определяется тем, кто применил DDL. Весь DDL стенда
|
||||||
идёт через `IF NOT EXISTS`, поэтому на существующих томах три матвью
|
идёт через `IF NOT EXISTS`, поэтому на существующих томах три матвью
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# 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 и впишет старый адрес в витрины.
|
||||||
|
Повод пересмотреть не наступает, а цена только растёт.
|
||||||
|
|
||||||
|
## Условия пересмотра
|
||||||
|
|
||||||
|
- **Дом.** Появляется справочные данные, которые хранилище производит само —
|
||||||
|
скажем, таблица соответствий, собранная из DDS. Посылка решения «в хранилище
|
||||||
|
их никто не производит» на них не распространяется, и зону придётся либо
|
||||||
|
сузить до пришедших извне, либо переопределить. Дешевле всего это до этапа 4:
|
||||||
|
как только витрины начнут звать словарь по имени, каждый разворот станет
|
||||||
|
правкой по всем ссылкам.
|
||||||
|
- Справочник перестаёт быть файлом репозитория и начинает приезжать извне —
|
||||||
|
возвращается вопрос устройства: историзованная таблица, «последняя загрузка»,
|
||||||
|
словарь поверх. Адрес это переживает.
|
||||||
|
- Усложнение перестаёт окупаться: если менти проходит мимо подложки не заметив
|
||||||
|
её, режется обратно до файлового источника у словаря.
|
||||||
|
|
||||||
|
## Следствия
|
||||||
|
|
||||||
|
- [ADR 0007](0007-clickhouse-access.md) правится тем же коммитом: у зоны свой
|
||||||
|
блок прав и свой пользователь.
|
||||||
|
- [ADR 0006](0006-object-naming.md) получает шестой суффикс вида — `_file`,
|
||||||
|
чтец файла, ровно параллельный `_kafka`. Исключение для словарей остаётся и
|
||||||
|
читается яснее прежнего: голое имя означает словарь.
|
||||||
|
- **Обновление словаря на кластере не атомарно, и это заявленное свойство.**
|
||||||
|
Случайный момент внутри окна разводит опросы разных серверов, чтобы они не
|
||||||
|
ходили к источнику разом. Побочный эффект — ноды перезагружают словарь в
|
||||||
|
разное время. Ждать правки каталога каждой из них приходится от нуля до
|
||||||
|
верхней границы окна, а фазы у них независимы, поэтому расходиться они могут
|
||||||
|
почти на все полторы минуты: одна перезагрузилась сразу после правки, вторая
|
||||||
|
ещё нет. Прежнее решение `LIFETIME(0)` окно не закрывало, а сжимало до
|
||||||
|
разброса исполнения `SYSTEM RELOAD DICTIONARY ON CLUSTER`; теперь оно
|
||||||
|
раскрыто намеренно. Урок записан опорной точкой в мастер-спеке, тащить его
|
||||||
|
в курс — отдельное решение.
|
||||||
|
- **Номенклатура товаров — медленно изменяющийся справочник**, и в предметной
|
||||||
|
области это так. Стенд упрощает: файл в репозитории под версией git.
|
||||||
|
Историзации в хранилище нет, и обещать её этот документ не должен.
|
||||||
|
- Витрина выручки спроектирована представлением, поэтому категория будет
|
||||||
|
подставляться в момент запроса, и правка каталога изменит отчёт задним
|
||||||
|
числом. Материализуй мы категорию при приёме — не изменила бы. Самой витрины
|
||||||
|
ещё нет, она приходит этапом 4.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
Замерено на живом стенде 20 августа 2026 года, ClickHouse 26.3.17.56.
|
||||||
|
|
||||||
|
- Движок `File` с явным путём внутрь `user_files` работает и раскатывается
|
||||||
|
`ON CLUSTER`: обе ноды читают свой смонтированный файл, по 180 строк.
|
||||||
|
- Относительный путь у этого движка считается **от `user_files`**, а не от
|
||||||
|
корня данных: форма `./user_files/catalog/products.csv`, рабочая для
|
||||||
|
файлового источника словаря, даёт `Code: 107 ... FILE_DOESNT_EXIST`.
|
||||||
|
- Подложка ничего не хранит: строка, дописанная в файл снаружи, меняет
|
||||||
|
`count()` без единой команды перезагрузки.
|
||||||
|
- Словарь подхватывает правку сам. `element_count` изменился без
|
||||||
|
`SYSTEM RELOAD DICTIONARY` через 116 секунд после предыдущей загрузки —
|
||||||
|
внутри окна `MIN 60 MAX 120`, на котором шёл замер.
|
||||||
|
- Без указания пользователя источник `CLICKHOUSE` ходит как `default` с пустым
|
||||||
|
паролем и падает: `Code: 516 ... AUTHENTICATION_FAILED`. Пользователя надо
|
||||||
|
называть явно.
|
||||||
|
- Приведение цены: `toDecimal64(price, 2) / 100` даёт ровно `Decimal(18, 2)`,
|
||||||
|
`129000 → 1290.00`, `128990 → 1289.90`. Вариант через `divideDecimal`
|
||||||
|
возвращает `Decimal(76, 2)` — шире, чем нужно, и не взят.
|
||||||
|
- Беспарольный пользователь, объявленный файлом настройки, принимается молча:
|
||||||
|
словарь грузится, `dictGet` отвечает, в журнале после перечитывания конфига
|
||||||
|
нет ни одного предупреждения про `no_password`.
|
||||||
|
- `SHOW DICTIONARIES` без указания базы возвращает пусто; словарь в базе стоит
|
||||||
|
среди таблиц слоя и отличается от них только колонкой движка в
|
||||||
|
`system.tables`.
|
||||||
|
- До переноса `dds.products` стоял в семи строках трёх файлов: дока хранилища,
|
||||||
|
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 check-clickhouse` — 10 проверок из 10; девятая берёт известный `sku` на
|
||||||
|
обеих нодах и сверяет цену с колонкой файла, умножив её обратно на сто, так
|
||||||
|
что приведение к `Decimal` проверено вместе с ответом. `make smoke` словарь
|
||||||
|
не трогает вовсе.
|
||||||
|
|
||||||
|
Сверено по документации ClickHouse через MCP Context7 20 августа 2026 года.
|
||||||
|
|
||||||
|
- Движок таблиц `Dictionary` существует затем, чтобы выставить словарь явной
|
||||||
|
таблицей — когда нужен доступ к сырым данным или соединение.
|
||||||
|
- Для небольших измерений документация рекомендует словарь вместо `JOIN`:
|
||||||
|
соединение выполняется до фильтрации `WHERE` и между запросами не кэшируется.
|
||||||
|
- Обратный случай назван там же: если фильтровать по подставленному значению на
|
||||||
|
многих строках, лучше обычная колонка с индексом — `dictGet` считается
|
||||||
|
построчно и индексом не поддержан. Отсюда обе формы чтения в решении.
|
||||||
|
- Диапазон `LIFETIME(MIN … MAX …)` перезагружает словарь в случайный момент
|
||||||
|
внутри окна, чтобы разнести обращения разных серверов к источнику.
|
||||||
|
- Если хост источника локальный, запрос идёт без сети.
|
||||||
@@ -12,8 +12,9 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/`
|
|||||||
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой
|
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой
|
||||||
чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего
|
чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего
|
||||||
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью.
|
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью.
|
||||||
Тот же этап принёс первый объект DDS — словарь товаров из общего с генератором
|
Тот же этап принёс первый справочник — словарь товаров из общего с генератором
|
||||||
CSV-файла.
|
CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а
|
||||||
|
подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)).
|
||||||
Дальше по тексту устройство описано так, как оно проектируется; построенное от
|
Дальше по тексту устройство описано так, как оно проектируется; построенное от
|
||||||
заложенного отличает карта таблиц в конце.
|
заложенного отличает карта таблиц в конце.
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ CSV-файла.
|
|||||||
| `_rep` | локальная таблица шарда, движок семейства `Replicated*` |
|
| `_rep` | локальная таблица шарда, движок семейства `Replicated*` |
|
||||||
| `_dist` | `Distributed` поверх одноимённой локальной |
|
| `_dist` | `Distributed` поверх одноимённой локальной |
|
||||||
| `_kafka` | таблица на движке `Kafka` |
|
| `_kafka` | таблица на движке `Kafka` |
|
||||||
|
| `_file` | таблица на движке `File` — чтец файла |
|
||||||
| `_mv` | материализованное представление |
|
| `_mv` | материализованное представление |
|
||||||
| `_v` | обычное представление |
|
| `_v` | обычное представление |
|
||||||
|
|
||||||
@@ -40,7 +42,9 @@ CSV-файла.
|
|||||||
запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым
|
запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым
|
||||||
именем в документах и разговоре понимается сущность, у которой этих объектов
|
именем в документах и разговоре понимается сущность, у которой этих объектов
|
||||||
несколько. Единственное исключение — словари: у них воплощение одно, шардировать
|
несколько. Единственное исключение — словари: у них воплощение одно, шардировать
|
||||||
нечего, и суффикс ничего не различал бы.
|
нечего, и суффикс ничего не различал бы. В зоне `dic` обе формы стоят рядом и
|
||||||
|
правило видно целиком: `dic.products_file` — чтец файла, `dic.products` — сам
|
||||||
|
словарь.
|
||||||
|
|
||||||
Распространённая конвенция, где голое имя означает локальную таблицу, а
|
Распространённая конвенция, где голое имя означает локальную таблицу, а
|
||||||
распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо
|
распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо
|
||||||
@@ -416,22 +420,31 @@ kafka_offset)`: смотрят такую таблицу от класса, а
|
|||||||
разрастается до имени отдельного поля. Точная граница приёма — в
|
разрастается до имени отдельного поля. Точная граница приёма — в
|
||||||
[спецификации заказов](orders/ingestion.md).
|
[спецификации заказов](orders/ingestion.md).
|
||||||
|
|
||||||
## Словарь товаров
|
## Справочники
|
||||||
|
|
||||||
`dds.products` читает `data/catalog/products.csv` напрямую. Compose монтирует
|
Справочные данные живут в базе `dic` — вне цепочки STG → ODS → DDS → DM. В
|
||||||
каталог только для чтения в `user_files` обеих нод, а DDL создаёт словарь
|
хранилище их никто не производит: содержимое приходит из файла репозитория, а
|
||||||
`ON CLUSTER`: имя и форма одни, но каждая нода держит свою копию в памяти.
|
читают его несколько слоёв сразу. Отсюда правило принадлежности: читать зону
|
||||||
|
вправе любой слой, писать — никто. Почему зона, а не прописка в слое модели, и
|
||||||
|
почему словарь читает подложку, а не файл, — [ADR
|
||||||
|
0012](../adr/0012-dictionary-home.md).
|
||||||
|
|
||||||
Источник `FILE` с форматом `CSVWithNames` читает заголовок файла. Строковый ключ
|
Первый и пока единственный справочник — каталог товаров: подложка
|
||||||
`sku` требует `COMPLEX_KEY_HASHED`; числовой `FLAT` здесь неприменим. Цена
|
`dic.products_file` над CSV репозитория и словарь `dic.products` поверх неё.
|
||||||
остаётся целым числом копеек типа `Int64`, как в контракте события.
|
Распределённых пар у зоны нет: обе ноды читают свой смонтированный файл и
|
||||||
|
держат свою копию словаря в памяти. Словарь обновляется сам, окном `LIFETIME`,
|
||||||
|
и потому после правки каталога ноды какое-то время отвечают по-разному.
|
||||||
|
Разогнать его раньше срока — `SYSTEM RELOAD DICTIONARY ON CLUSTER`; это
|
||||||
|
административная операция, роль `etl` права на неё не получает.
|
||||||
|
|
||||||
`LIFETIME(0)` отключает фоновое обновление. Каталог меняется только явной
|
Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт
|
||||||
правкой репозитория, а независимый опрос двух нод позволил бы им временно
|
`Decimal(18, 2)`, `productPrice` события — целые рубли. Разрыв намеренный, на
|
||||||
отвечать разными версиями. Изменение применяют к обеим нодам штатной командой:
|
нём стоит урок про `Float64` ([описание
|
||||||
`SYSTEM RELOAD DICTIONARY ON CLUSTER clickstream_cluster dds.products`.
|
выгрузки](../formats/clickstream-event.md)).
|
||||||
Это административная операция: её выполняют под `default`; роль `etl` права
|
|
||||||
перезагрузки словарей не получает.
|
Устройство обоих объектов — почему такой движок, такая форма пути, такой
|
||||||
|
пользователь и такое окно — расписано в
|
||||||
|
[`sql/ddl/05-dic-catalog.sql`](../../sql/ddl/05-dic-catalog.sql).
|
||||||
|
|
||||||
## Раскладка SQL
|
## Раскладка SQL
|
||||||
|
|
||||||
@@ -455,16 +468,19 @@ Airflow читает и собирает эти файлы штатным шаб
|
|||||||
|
|
||||||
| Файл | Что в нём |
|
| Файл | Что в нём |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `00-databases.sql` | базы слоёв |
|
| `00-databases.sql` | базы слоёв и зона справочников |
|
||||||
|
| `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё |
|
||||||
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников |
|
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников |
|
||||||
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
|
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
|
||||||
| `25-dds-dictionaries.sql` | словарь товаров из общего CSV-каталога |
|
|
||||||
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
|
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
|
||||||
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
|
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
|
||||||
|
|
||||||
Порядок задают два правила. Первое: матвью принадлежит слою своей цели, а не
|
Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и
|
||||||
источника, — разбор из STG в ODS лежит среди файлов ODS, потому что наполняет
|
правила порядка слоёв к ней не применяются.
|
||||||
ODS. Второе: матвью приёма создаётся последней из всех, и потому нарушает
|
|
||||||
|
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
|
||||||
|
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что
|
||||||
|
наполняет ODS. Второе: матвью приёма создаётся последней из всех, и потому нарушает
|
||||||
нумерацию слоёв. Kafka-движок начинает читать топик ровно тогда, когда к нему
|
нумерацию слоёв. Kafka-движок начинает читать топик ровно тогда, когда к нему
|
||||||
привязывают первую матвью; создай её раньше разбора — и всё, что доедет в
|
привязывают первую матвью; создай её раньше разбора — и всё, что доедет в
|
||||||
зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На
|
зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки. На
|
||||||
@@ -507,27 +523,28 @@ ODS. Второе: матвью приёма создаётся последне
|
|||||||
|
|
||||||
Ниже — то, что закладывают этапы 2 и 3; всё перечисленное лежит в `sql/ddl/`.
|
Ниже — то, что закладывают этапы 2 и 3; всё перечисленное лежит в `sql/ddl/`.
|
||||||
|
|
||||||
| Слой | Объект | Что это |
|
| База | Объект | Что это |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| STG | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
|
| `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
|
||||||
| STG | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки |
|
| `stg` | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки |
|
||||||
| STG | `stg.hits_raw_mv` | наполняет сырьё из чтеца |
|
| `stg` | `stg.hits_raw_mv` | наполняет сырьё из чтеца |
|
||||||
| STG | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью |
|
| `stg` | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью |
|
||||||
| STG | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` |
|
| `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` |
|
||||||
| ODS | `ods.event_rep` / `_dist` | типизированное широкое событие |
|
| `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие |
|
||||||
| ODS | `ods.event_v` | актуальная версия события с полями источника |
|
| `ods` | `ods.event_v` | актуальная версия события с полями источника |
|
||||||
| ODS | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём |
|
| `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём |
|
||||||
| ODS | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки |
|
| `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки |
|
||||||
| ODS | `ods.order_snapshot_rep` / `_dist` | типизированные версии заказа |
|
| `ods` | `ods.order_snapshot_rep` / `_dist` | типизированные версии заказа |
|
||||||
| ODS | `ods.order_v` | текущая версия заказа на языке источника |
|
| `ods` | `ods.order_v` | текущая версия заказа на языке источника |
|
||||||
| ODS | `ods.order_snapshot_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
|
| `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`
|
`INSERT SELECT` шага `parse_batch` из файлов `sql/ods/order_snapshot_load.sql`
|
||||||
и `sql/ods/order_snapshot_errors_load.sql`.
|
и `sql/ods/order_snapshot_errors_load.sql`.
|
||||||
|
|
||||||
Таблицы DDS и слой DM появляются на следующих этапах; их состав задан разделом
|
Слой DDS и слой DM появляются на следующих этапах; их состав задан разделом
|
||||||
7 мастер-спеки и переносится сюда по мере постройки.
|
7 мастер-спеки и переносится сюда по мере постройки.
|
||||||
|
|
||||||
## Что проверено
|
## Что проверено
|
||||||
@@ -553,6 +570,14 @@ DDL-словарь с файловым источником внутри `user_f
|
|||||||
`SYSTEM RELOAD DICTIONARY ON CLUSTER`
|
`SYSTEM RELOAD DICTIONARY ON CLUSTER`
|
||||||
изменилась на обеих нодах; после отката и повторной команды вернулась обратно.
|
изменилась на обеих нодах; после отката и повторной команды вернулась обратно.
|
||||||
|
|
||||||
|
**Проверка зоны справочников 20 августа 2026 года (#105).** Подложка на движке
|
||||||
|
`File` перечитывает файл на каждом запросе, словарь поверх неё обновляется сам
|
||||||
|
внутри окна `LIFETIME`, а собранный с нуля стенд поднимает оба объекта и отдаёт
|
||||||
|
словарь читателям. Замеры целиком — в
|
||||||
|
[ADR 0012](../adr/0012-dictionary-home.md), раздел «Что проверено»; там же
|
||||||
|
разобрано, почему отказ в праве на движок называет не то право, которого не
|
||||||
|
хватает.
|
||||||
|
|
||||||
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
|
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
|
||||||
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
|
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
|
||||||
в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3.
|
в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3.
|
||||||
|
|||||||
@@ -240,10 +240,12 @@ Ecommerce (заполнены только у торговых событий):
|
|||||||
## 3. Каталог товаров
|
## 3. Каталог товаров
|
||||||
|
|
||||||
CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`,
|
CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`,
|
||||||
`brand`, `price`, `demand`) — **словарь ClickHouse** из файла. Тот же файл
|
`brand`, `price`, `demand`) — **словарь ClickHouse**. Тот же файл использует
|
||||||
использует генератор — расхождений нет по построению. Даёт `dictGet` в
|
генератор — расхождений нет по построению. Даёт `dictGet` в витринах и
|
||||||
витринах и разговор о политике обновления словаря. На кластере файл
|
разговор о политике обновления словаря. На кластере файл монтируется в обе
|
||||||
монтируется в обе ноды, словарь создаётся ON CLUSTER. Последняя колонка —
|
ноды; над ним стоит подложка, а словарь читает её и живёт в зоне справочников
|
||||||
|
`dic` вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)).
|
||||||
|
Последняя колонка —
|
||||||
уровень спроса товара, заведена при исполнении #50 (спека генератора,
|
уровень спроса товара, заведена при исполнении #50 (спека генератора,
|
||||||
раздел 9): генератор решает по ней, что уходит из карточки в корзину.
|
раздел 9): генератор решает по ней, что уходит из карточки в корзину.
|
||||||
|
|
||||||
@@ -402,7 +404,7 @@ README.
|
|||||||
|
|
||||||
## 7. Слои: карта таблиц v2
|
## 7. Слои: карта таблиц v2
|
||||||
|
|
||||||
| Слой | Объект | Что это |
|
| Где | Объект | Что это |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Kafka | `hits`, `orders` | два топика: `hits` — 2 партиции, `orders` — одна |
|
| Kafka | `hits`, `orders` | два топика: `hits` — 2 партиции, `orders` — одна |
|
||||||
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах |
|
| 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.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
|
||||||
| DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS |
|
| DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS |
|
||||||
| DDS | `dds.identity_map` | карта кука↔пользователь |
|
| 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` | см. ниже |
|
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
|
||||||
|
|
||||||
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
|
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
|
||||||
@@ -695,6 +697,11 @@ v2, этап 0).
|
|||||||
- сцена «разные consumer groups → дубли»;
|
- сцена «разные consumer groups → дубли»;
|
||||||
- словарь регионов из CSV той же машинерией, что каталог товаров (оживляет
|
- словарь регионов из CSV той же машинерией, что каталог товаров (оживляет
|
||||||
`RegionCityID`);
|
`RegionCityID`);
|
||||||
|
- неатомарное обновление словаря на кластере: ноды перезагружают его в
|
||||||
|
случайный момент внутри окна `LIFETIME`, фазы у них независимы, и после
|
||||||
|
правки каталога две ноды почти всё окно отвечают на один `dictGet`
|
||||||
|
по-разному. Обвязка готова ([ADR 0012](../adr/0012-dictionary-home.md)),
|
||||||
|
урок остаётся на выбор;
|
||||||
- лаба сессий: менти сначала собирает сессии сам, и только после — рассказ,
|
- лаба сессий: менти сначала собирает сессии сам, и только после — рассказ,
|
||||||
что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично
|
что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично
|
||||||
синтетическая постановка — осознанный приём;
|
синтетическая постановка — осознанный приём;
|
||||||
|
|||||||
@@ -9,20 +9,28 @@
|
|||||||
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW ON default.*</query>
|
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW ON default.*</query>
|
||||||
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON stg.*</query>
|
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON stg.*</query>
|
||||||
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON ods.*</query>
|
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON ods.*</query>
|
||||||
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, CREATE DICTIONARY, dictGet, DROP TABLE, DROP VIEW, CREATE DATABASE ON dds.*</query>
|
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON dds.*</query>
|
||||||
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON dm.*</query>
|
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON dm.*</query>
|
||||||
|
<!-- Зона справочников: etl применяет её DDL и читает словари из преобразований. -->
|
||||||
|
<query>GRANT SELECT, CREATE TABLE, CREATE DICTIONARY, dictGet, DROP TABLE, CREATE DATABASE ON dic.*</query>
|
||||||
<query>GRANT SELECT ON system.*</query>
|
<query>GRANT SELECT ON system.*</query>
|
||||||
<query>GRANT CLUSTER ON *.*</query>
|
<query>GRANT CLUSTER ON *.*</query>
|
||||||
<query>GRANT REMOTE ON *.*</query>
|
<query>GRANT REMOTE ON *.*</query>
|
||||||
<!-- В 26.3 движок Kafka требует право на внешний источник. -->
|
<!--
|
||||||
|
В 26.3 движки внешних источников требуют права на источник.
|
||||||
|
Отказ при этом называет не источник, а `TABLE ENGINE ON File`
|
||||||
|
— и права с таким именем не хватит: замерено, нужен FILE.
|
||||||
|
-->
|
||||||
<query>GRANT KAFKA ON *.*</query>
|
<query>GRANT KAFKA ON *.*</query>
|
||||||
|
<query>GRANT FILE ON *.*</query>
|
||||||
</grants>
|
</grants>
|
||||||
</etl_writer>
|
</etl_writer>
|
||||||
<!-- Оба читателя: clickhouse-connect читает system.settings при подключении. -->
|
<!-- Оба читателя: clickhouse-connect читает system.settings при подключении. -->
|
||||||
<bi_reader>
|
<bi_reader>
|
||||||
<grants>
|
<grants>
|
||||||
<query>GRANT SELECT, dictGet ON dds.*</query>
|
<query>GRANT SELECT ON dds.*</query>
|
||||||
<query>GRANT SELECT ON dm.*</query>
|
<query>GRANT SELECT ON dm.*</query>
|
||||||
|
<query>GRANT SELECT, dictGet ON dic.*</query>
|
||||||
<query>GRANT SELECT ON system.settings</query>
|
<query>GRANT SELECT ON system.settings</query>
|
||||||
</grants>
|
</grants>
|
||||||
</bi_reader>
|
</bi_reader>
|
||||||
@@ -30,11 +38,18 @@
|
|||||||
<grants>
|
<grants>
|
||||||
<query>GRANT SELECT ON stg.*</query>
|
<query>GRANT SELECT ON stg.*</query>
|
||||||
<query>GRANT SELECT ON ods.*</query>
|
<query>GRANT SELECT ON ods.*</query>
|
||||||
<query>GRANT SELECT, dictGet ON dds.*</query>
|
<query>GRANT SELECT ON dds.*</query>
|
||||||
<query>GRANT SELECT ON dm.*</query>
|
<query>GRANT SELECT ON dm.*</query>
|
||||||
|
<query>GRANT SELECT, dictGet ON dic.*</query>
|
||||||
<query>GRANT SELECT ON system.settings</query>
|
<query>GRANT SELECT ON system.settings</query>
|
||||||
</grants>
|
</grants>
|
||||||
</analyst_reader>
|
</analyst_reader>
|
||||||
|
<!-- Учётка словаря: пароля нет намеренно, см. ADR 0007. -->
|
||||||
|
<dict_reader>
|
||||||
|
<grants>
|
||||||
|
<query>GRANT SELECT ON dic.*</query>
|
||||||
|
</grants>
|
||||||
|
</dict_reader>
|
||||||
</roles>
|
</roles>
|
||||||
|
|
||||||
<users>
|
<users>
|
||||||
@@ -62,5 +77,12 @@
|
|||||||
<query>GRANT analyst_reader</query>
|
<query>GRANT analyst_reader</query>
|
||||||
</grants>
|
</grants>
|
||||||
</analyst>
|
</analyst>
|
||||||
|
<dict>
|
||||||
|
<no_password/>
|
||||||
|
<quota>default</quota>
|
||||||
|
<grants>
|
||||||
|
<query>GRANT dict_reader</query>
|
||||||
|
</grants>
|
||||||
|
</dict>
|
||||||
</users>
|
</users>
|
||||||
</clickhouse>
|
</clickhouse>
|
||||||
|
|||||||
@@ -264,10 +264,13 @@ printf 'ЗЕЛЁНО: временные таблицы удалены; пров
|
|||||||
# После снятия ловушек: своих объектов эти проверки не заводят и прибирать за
|
# После снятия ловушек: своих объектов эти проверки не заводят и прибирать за
|
||||||
# собой им нечего — они только смотрят на то, что стенд произвёл сам.
|
# собой им нечего — они только смотрят на то, что стенд произвёл сам.
|
||||||
printf 'Проверка 9/10: словарь товаров отвечает на обеих нодах...\n'
|
printf 'Проверка 9/10: словарь товаров отвечает на обеих нодах...\n'
|
||||||
|
# Цену словарь отдаёт в Decimal(18, 2), а каталог хранит целые копейки. Умножаем
|
||||||
|
# обратно и сравниваем с колонкой файла: так проверка заодно утверждает, что
|
||||||
|
# приведение в источнике словаря точное, а не только что словарь отвечает.
|
||||||
product_sql="SELECT
|
product_sql="SELECT
|
||||||
dictGet('dds.products', 'name', tuple('HOME-0001')),
|
dictGet('dic.products', 'name', tuple('HOME-0001')),
|
||||||
dictGet('dds.products', 'category', tuple('HOME-0001')),
|
dictGet('dic.products', 'category', tuple('HOME-0001')),
|
||||||
dictGet('dds.products', 'price', tuple('HOME-0001'))
|
toInt64(dictGet('dic.products', 'price', tuple('HOME-0001')) * 100)
|
||||||
FORMAT TSV"
|
FORMAT TSV"
|
||||||
expected_product="$(
|
expected_product="$(
|
||||||
awk -F, \
|
awk -F, \
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-- Базы слоёв хранилища.
|
-- Базы хранилища: слои цепочки и зона справочников.
|
||||||
--
|
--
|
||||||
-- Файлы этой папки применяются по порядку имён с ноды 1 и всегда ON CLUSTER:
|
-- Файлы этой папки применяются по порядку имён с ноды 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;
|
CREATE DATABASE IF NOT EXISTS ods ON CLUSTER clickstream_cluster;
|
||||||
|
|
||||||
-- DDS начинается со словаря товаров; таблицы слоя появятся на следующем этапе.
|
-- Объекты DDS появятся на следующем этапе; база заводится заранее по той же
|
||||||
|
-- причине, что и ODS.
|
||||||
CREATE DATABASE IF NOT EXISTS dds ON CLUSTER clickstream_cluster;
|
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;
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- Каталог товаров: подложка на файловом движке и словарь поверх неё.
|
||||||
|
--
|
||||||
|
-- Словарь читает не файл, а таблицу хранилища. Это намеренное усложнение ради
|
||||||
|
-- урока: в бою справочник приезжает процессом, и предложение SOURCE с запросом
|
||||||
|
-- и учётной записью — та форма, которую менти встретит. Доводы, отвергнутые
|
||||||
|
-- варианты и условия пересмотра — ADR 0012.
|
||||||
|
|
||||||
|
-- Подложка ничего не хранит: движок File перечитывает CSV на каждом запросе,
|
||||||
|
-- поэтому правка каталога доезжает до словаря сама. Путь считается от
|
||||||
|
-- user_files, а не от корня данных. Типы повторяют файл, а не модель: цена
|
||||||
|
-- лежит целыми копейками, приведение делает словарь.
|
||||||
|
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 с пустым паролем и падает. Хост локальный, поэтому
|
||||||
|
-- запрос к подложке идёт без сети.
|
||||||
|
--
|
||||||
|
-- Форма query, а не table: словарь приводит копейки каталога к Decimal(18, 2)
|
||||||
|
-- прямо на входе, то есть нормализует, а не зеркалит подложку. Ключ строковый,
|
||||||
|
-- поэтому COMPLEX_KEY_HASHED: числовой FLAT здесь неприменим.
|
||||||
|
--
|
||||||
|
-- Окно вместо LIFETIME(0): словарь обновляется сам, а цена этому — ноды
|
||||||
|
-- расходятся почти на всё окно (ADR 0012).
|
||||||
|
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);
|
||||||
@@ -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);
|
|
||||||
Reference in New Issue
Block a user