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

Merged
ddmitry merged 4 commits from feat/105-dictionary-home into main 2026-08-20 12:26:41 +03:00
11 changed files with 110 additions and 114 deletions
Showing only changes of commit 5cc0ede75a - Show all commits
+6 -7
View File
@@ -95,15 +95,14 @@ _Избегать_: «покупатель» про того, кто купил
товара, покупка — на страницу подтверждения заказа.
**Справочник**:
Данные, входящие в хранилище сбоку, а не по цепочке слоёв: в хранилище их
никто не производит, а читают их несколько слоёв. Дом — база `dic`, общая для
всех справочников. Первый и пока единственный — каталог товаров.
_Не путать_: слово «словарь» в проекте значит ещё две вещи — этот глоссарий
понятий и объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник
доступен.
Данные, которые хранилище не производит, а получает готовыми со стороны:
содержимым владеет источник вне хранилища, слои только читают. Живут в базе
`dic`. Первый и пока единственный — каталог товаров.
_Избегать_: словарь — этим словом в проекте зовут и глоссарий понятий, и
объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник доступен.
**Каталог товаров**:
`data/catalog/products.csv` — общий справочник генератора и словаря
`data/catalog/products.csv` — общий файл генератора и словаря
ClickHouse. Форма файла решена, длина — нет: строки дописываются.
**Уровень спроса**:
+7 -3
View File
@@ -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` остаётся служебным: им ходят проверки здоровья и скрипты внутри
контейнеров, но не приложения.
+2 -4
View File
@@ -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` даёт ошибку «нет такой таблицы».
+5 -7
View File
@@ -20,7 +20,8 @@
Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения
на запросы `ON CLUSTER`, на чтение системных таблиц и на каждый движок
внешнего источника, а без второго клиент не открывает соединение вовсе. Состав прав каждой роли — работа тикета
внешнего источника. Без права на системные таблицы клиент вовсе не открывает
соединение. Состав прав каждой роли — работа тикета
реализации.
Пользователи, роли и права объявлены файлами настройки сервера, а не
@@ -54,12 +55,9 @@ DDS под ними, потому что так чаще всего и быва
видны всем читателям и никому на запись: зона входит в хранилище сбоку, и её
содержимым владеет файл репозитория, а не слой.
**Беспарольный `dict` — намеренное исключение.** Пароля у него нет не по
недосмотру: ходить этой учёткой некуда, кроме локального чтения одной базы, а
пароль пришлось бы вписать в текст DDL словаря — то есть положить секрет в git.
Право у него одно, `SELECT` на `dic`. Отдельная учётка под словарь взята из
промышленной практики, где источник словаря читают минимальной служебной
учётной записью, а не общим админом.
**Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то
есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно —
`SELECT` на `dic`, и хост источника локальный.
**Секрет, а не учётные данные по репликам.** У каждой реплики в описании
кластера своя учётка — вписанная явно или подразумеваемая, и тогда это
+35 -18
View File
@@ -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 года.
+20 -20
View File
@@ -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` | словарь товаров поверх подложки |
+11 -9
View File
@@ -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` прямо в хитах; частично
синтетическая постановка — осознанный приём;
+1 -6
View File
@@ -44,12 +44,7 @@
<query>GRANT SELECT ON system.settings</query>
</grants>
</analyst_reader>
<!--
Словарь читает подложку не сам по себе, а от имени пользователя:
без явного user он идёт как default с пустым паролем и падает.
Пароля нет намеренно — ходить этой учёткой некуда, кроме локального
чтения одной базы, а пароль в тексте DDL означал бы секрет в git.
-->
<!-- Учётка словаря: пароля нет намеренно, см. ADR 0007. -->
<dict_reader>
<grants>
<query>GRANT SELECT ON dic.*</query>
+6 -3
View File
@@ -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, \
+2 -2
View File
@@ -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;
+15 -35
View File
@@ -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,