diff --git a/README.md b/README.md
index d0368ee..3a9a1b4 100644
--- a/README.md
+++ b/README.md
@@ -105,10 +105,10 @@ Kafka по той же причине спрашивают снаружи. Её
`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse:
описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`,
-`Distributed`, очередь распределённых DDL и очистку временных таблиц. Последняя
-из девяти проверок — единственная на настоящих данных: она подневно сверяет
-события стартового мира с описью и при расхождении говорит, где искать —
-в событиях или в браке.
+`Distributed`, очередь распределённых DDL и очистку временных таблиц. Девятая
+из десяти проверок спрашивает словарь товаров на обеих нодах. Последняя —
+единственная на настоящих событиях: она подневно сверяет стартовый мир с описью
+и при расхождении говорит, где искать — в событиях или в браке.
`make config-test` проверяет Compose, синтаксис файлов DAG и пробельные ошибки
в diff без запуска стенда.
diff --git a/compose.yaml b/compose.yaml
index 6e2944b..a8b2218 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -11,6 +11,9 @@ x-clickhouse-common: &clickhouse-common
environment:
# Пользователей объявляет access.xml; настройка учётки образом не нужна.
CLICKHOUSE_SKIP_USER_SETUP: "1"
+ # Каталог товаров смонтирован в user_files только для чтения. Entrypoint
+ # иначе пытается сделать ему chown и первый запуск ноды завершается ошибкой.
+ CLICKHOUSE_DO_NOT_CHOWN: "1"
CLICKHOUSE_CLUSTER_SECRET: ${CLICKHOUSE_CLUSTER_SECRET:?Скопируйте .env.example в .env}
CLICKHOUSE_DEFAULT_PASSWORD: ${CLICKHOUSE_DEFAULT_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
@@ -149,6 +152,7 @@ services:
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
- ./infra/clickhouse/config.d/macros-01.xml:/etc/clickhouse-server/config.d/macros.xml:ro
- ./infra/clickhouse/users.d/access.xml:/etc/clickhouse-server/users.d/access.xml:ro
+ - ./data/catalog:/var/lib/clickhouse/user_files/catalog:ro
- clickhouse_01_data:/var/lib/clickhouse
# Точка подключения Superset.
@@ -163,6 +167,7 @@ services:
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
- ./infra/clickhouse/config.d/macros-02.xml:/etc/clickhouse-server/config.d/macros.xml:ro
- ./infra/clickhouse/users.d/access.xml:/etc/clickhouse-server/users.d/access.xml:ro
+ - ./data/catalog:/var/lib/clickhouse/user_files/catalog:ro
- clickhouse_02_data:/var/lib/clickhouse
kafka:
diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md
index 7523bb2..9c8aca5 100644
--- a/docs/architecture/storage.md
+++ b/docs/architecture/storage.md
@@ -12,6 +12,8 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/`
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой
чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью.
+Тот же этап принёс первый объект DDS — словарь товаров из общего с генератором
+CSV-файла.
Дальше по тексту устройство описано так, как оно проектируется; построенное от
заложенного отличает карта таблиц в конце.
@@ -414,6 +416,23 @@ kafka_offset)`: смотрят такую таблицу от класса, а
разрастается до имени отдельного поля. Точная граница приёма — в
[спецификации заказов](orders/ingestion.md).
+## Словарь товаров
+
+`dds.products` читает `data/catalog/products.csv` напрямую. Compose монтирует
+каталог только для чтения в `user_files` обеих нод, а DDL создаёт словарь
+`ON CLUSTER`: имя и форма одни, но каждая нода держит свою копию в памяти.
+
+Источник `FILE` с форматом `CSVWithNames` читает заголовок файла. Строковый ключ
+`sku` требует `COMPLEX_KEY_HASHED`; числовой `FLAT` здесь неприменим. Цена
+остаётся целым числом копеек типа `Int64`, как в контракте события.
+
+`LIFETIME(0)` отключает фоновое обновление. Каталог меняется только явной
+правкой репозитория, а независимый опрос двух нод позволил бы им временно
+отвечать разными версиями. Изменение применяют к обеим нодам штатной командой:
+`SYSTEM RELOAD DICTIONARY ON CLUSTER clickstream_cluster dds.products`.
+Это административная операция: её выполняют под `default`; роль `etl` права
+перезагрузки словарей не получает.
+
## Раскладка SQL
Исполняемые дагами запросы лежат по правилу
@@ -439,6 +458,7 @@ Airflow читает и собирает эти файлы штатным шаб
| `00-databases.sql` | базы слоёв |
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников |
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
+| `25-dds-dictionaries.sql` | словарь товаров из общего CSV-каталога |
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
@@ -501,13 +521,14 @@ ODS. Второе: матвью приёма создаётся последне
| ODS | `ods.order_snapshot_rep` / `_dist` | типизированные версии заказа |
| ODS | `ods.order_v` | текущая версия заказа на языке источника |
| ODS | `ods.order_snapshot_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
+| DDS | `dds.products` | словарь товаров из общего с генератором CSV-каталога |
Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два
`INSERT SELECT` шага `parse_batch` из файлов `sql/ods/order_snapshot_load.sql`
и `sql/ods/order_snapshot_errors_load.sql`.
-Слои DDS и DM появляются на следующих этапах; их состав задан разделом 7
-мастер-спеки и переносится сюда по мере постройки.
+Таблицы DDS и слой DM появляются на следующих этапах; их состав задан разделом
+7 мастер-спеки и переносится сюда по мере постройки.
## Что проверено
@@ -525,6 +546,13 @@ MCP Context7 подтвердила обычное представление с
`ods.event_v` остался равен `ods.event_dist FINAL`. Имена и типы всех колонок
представления совпали с распределённой таблицей.
+**Проверка словаря товаров 19 августа 2026 года (#95).** Context7 подтвердил
+DDL-словарь с файловым источником внутри `user_files`, хешированную раскладку
+для строкового ключа и отключение фонового обновления через `LIFETIME(0)`.
+На закреплённом ClickHouse 26.3 цена известного `sku` после правки CSV и
+`SYSTEM RELOAD DICTIONARY ON CLUSTER`
+изменилась на обеих нодах; после отката и повторной команды вернулась обратно.
+
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3.
diff --git a/docs/architecture/testing.md b/docs/architecture/testing.md
index 97c28c3..65ad23f 100644
--- a/docs/architecture/testing.md
+++ b/docs/architecture/testing.md
@@ -43,7 +43,7 @@
| `make config-test` | корень | Compose разбирается, файлы DAG синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 9 с |
-| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, счёт событий стартового мира против описи | нужен | 8 с |
+| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с |
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 59 с |
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
@@ -221,6 +221,10 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
## Что проверено
+**Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой
+проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за
+24 секунды. Это цена всей цели, а не одной новой строки.
+
**Замер 9 августа 2026 года при появлении корневого `lint` (#65).** Три прогона
подряд — 0,39, 0,36 и 0,39 секунды на трёх файлах стенда; в таблице 0,4 с. На
машине с непрогретым кешем первый прогон дороже: `uvx` сначала скачивает ruff.
diff --git a/infra/clickhouse/users.d/access.xml b/infra/clickhouse/users.d/access.xml
index c66c711..a744933 100644
--- a/infra/clickhouse/users.d/access.xml
+++ b/infra/clickhouse/users.d/access.xml
@@ -9,7 +9,7 @@
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, DROP TABLE, DROP VIEW, CREATE DATABASE ON dds.*
+ 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 dm.*
GRANT SELECT ON system.*
GRANT CLUSTER ON *.*
@@ -21,7 +21,7 @@
- GRANT SELECT ON dds.*
+ GRANT SELECT, dictGet ON dds.*
GRANT SELECT ON dm.*
GRANT SELECT ON system.settings
@@ -30,7 +30,7 @@
GRANT SELECT ON stg.*
GRANT SELECT ON ods.*
- GRANT SELECT ON dds.*
+ GRANT SELECT, dictGet ON dds.*
GRANT SELECT ON dm.*
GRANT SELECT ON system.settings
diff --git a/scripts/check-clickhouse.sh b/scripts/check-clickhouse.sh
index 4a2acef..71cca65 100755
--- a/scripts/check-clickhouse.sh
+++ b/scripts/check-clickhouse.sh
@@ -2,6 +2,7 @@
set -Eeuo pipefail
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly CATALOG="${ROOT_DIR}/data/catalog/products.csv"
readonly INVENTORY="${ROOT_DIR}/data/world-inventory.json"
readonly CLUSTER="clickstream_cluster"
readonly LOCAL_TABLE="smoke_replicated_local"
@@ -26,6 +27,17 @@ query() {
' _ "$sql" /dev/null
query clickhouse-01 "DROP TABLE default.${LOCAL_TABLE} ON CLUSTER ${CLUSTER} SYNC" >/dev/null
remaining_sql="SELECT count() FROM system.tables WHERE database = 'default' AND name IN ('${LOCAL_TABLE}', '${DISTRIBUTED_TABLE}') FORMAT TSVRaw"
@@ -249,9 +261,25 @@ assert_ddl_queue_completed clickhouse-02 'после DROP'
trap - EXIT INT TERM
printf 'ЗЕЛЁНО: временные таблицы удалены; проверены завершённые задания CREATE и DROP.\n'
-# После снятия ловушек: своих объектов эта проверка не заводит и прибирать за
-# собой ей нечего — она только смотрит на то, что стенд произвёл сам.
-printf 'Проверка 9/9: стартовый мир в ods.event сходится с описью...\n'
+# После снятия ловушек: своих объектов эти проверки не заводят и прибирать за
+# собой им нечего — они только смотрят на то, что стенд произвёл сам.
+printf 'Проверка 9/10: словарь товаров отвечает на обеих нодах...\n'
+product_sql="SELECT
+ dictGet('dds.products', 'name', tuple('HOME-0001')),
+ dictGet('dds.products', 'category', tuple('HOME-0001')),
+ dictGet('dds.products', 'price', tuple('HOME-0001'))
+FORMAT TSV"
+expected_product="$(
+ awk -F, \
+ '$1 == "HOME-0001" { print $2 "\t" $3 "\t" $5; exit }' \
+ "$CATALOG"
+)"
+[[ -n "$expected_product" ]] || fail 'в каталоге нет эталонного sku HOME-0001'
+assert_equal "$expected_product" "$(query_as_etl clickhouse-01 "$product_sql")" "словарь товаров не ответил на первой ноде"
+assert_equal "$expected_product" "$(query_as_etl clickhouse-02 "$product_sql")" "словарь товаров не ответил на второй ноде"
+printf 'ЗЕЛЁНО: известный sku вернул имя, категорию и цену на обеих нодах.\n'
+
+printf 'Проверка 10/10: стартовый мир в ods.event сходится с описью...\n'
check_starting_world
-printf 'ИТОГ: все 9 проверок кластера ClickHouse прошли.\n'
+printf 'ИТОГ: все 10 проверок кластера ClickHouse прошли.\n'
diff --git a/sql/ddl/00-databases.sql b/sql/ddl/00-databases.sql
index 8a15c8d..4d35ddd 100644
--- a/sql/ddl/00-databases.sql
+++ b/sql/ddl/00-databases.sql
@@ -13,3 +13,6 @@ CREATE DATABASE IF NOT EXISTS stg ON CLUSTER clickstream_cluster;
-- База ODS заводится здесь же, хотя её объекты приносит #43: базы дёшевы, а
-- порядок файлов от этого не зависит.
CREATE DATABASE IF NOT EXISTS ods ON CLUSTER clickstream_cluster;
+
+-- DDS начинается со словаря товаров; таблицы слоя появятся на следующем этапе.
+CREATE DATABASE IF NOT EXISTS dds ON CLUSTER clickstream_cluster;
diff --git a/sql/ddl/25-dds-dictionaries.sql b/sql/ddl/25-dds-dictionaries.sql
new file mode 100644
index 0000000..ce70161
--- /dev/null
+++ b/sql/ddl/25-dds-dictionaries.sql
@@ -0,0 +1,16 @@
+-- Общий с генератором каталог товаров. 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);