feat(clickhouse): справочники вынесены в зону dic, словарь читает подложку

Зачем: место словаря в 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:37:26 +03:00
co-authored by Claude Opus 5
parent 862dde9fa4
commit abc94ba406
10 changed files with 428 additions and 59 deletions
+8 -2
View File
@@ -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;
+66
View File
@@ -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);
-16
View File
@@ -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);