diff --git a/CONTEXT.md b/CONTEXT.md index cfcd9cf..fd73d35 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -95,15 +95,14 @@ _Избегать_: «покупатель» про того, кто купил товара, покупка — на страницу подтверждения заказа. **Справочник**: -Данные, входящие в хранилище сбоку, а не по цепочке слоёв: в хранилище их -никто не производит, а читают их несколько слоёв. Дом — база `dic`, общая для -всех справочников. Первый и пока единственный — каталог товаров. -_Не путать_: слово «словарь» в проекте значит ещё две вещи — этот глоссарий -понятий и объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник -доступен. +Данные, которые хранилище не производит, а получает готовыми со стороны: +содержимым владеет источник вне хранилища, слои только читают. Живут в базе +`dic`. Первый и пока единственный — каталог товаров. +_Избегать_: словарь — этим словом в проекте зовут и глоссарий понятий, и +объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник доступен. **Каталог товаров**: -`data/catalog/products.csv` — общий справочник генератора и словаря +`data/catalog/products.csv` — общий файл генератора и словаря ClickHouse. Форма файла решена, длина — нет: строки дописываются. **Уровень спроса**: diff --git a/README.md b/README.md index 3a9a1b4..68f5f1d 100644 --- a/README.md +++ b/README.md @@ -278,12 +278,16 @@ uv run --project generator python -m clickstream_generator batch \ - Prometheus — `http://127.0.0.1:29090`; - Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`. -В ClickHouse четыре пользователя: +В ClickHouse пять пользователей: - `etl` применяет DDL и подключает Airflow; роль `etl_writer` читает и пишет слои хранилища; -- `bi` подключает Superset; роль `bi_reader` читает будущие слои DDS и DM; -- `analyst` предназначен для подключения человека и читает все слои; +- `bi` подключает Superset; роль `bi_reader` читает справочники `dic` и будущие + слои DDS и DM; +- `analyst` предназначен для подключения человека и читает все слои и + справочники; +- `dict` читает только базу `dic` и пароля не имеет: им словарь товаров ходит + за своей подложкой; - `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри контейнеров, но не приложения. diff --git a/docs/adr/0006-object-naming.md b/docs/adr/0006-object-naming.md index 2c8d41d..24e1bea 100644 --- a/docs/adr/0006-object-naming.md +++ b/docs/adr/0006-object-naming.md @@ -9,10 +9,8 @@ топика, `_file` — чтец файла, `_mv` — материализованное представление, `_v` — обычное представление. -Суффикс `_file` добавлен 20 августа 2026 года вместе с подложкой под словарём -товаров ([ADR 0012](0012-dictionary-home.md)). Он ровно параллелен `_kafka`: -чтец внешнего источника, по копии на каждой ноде, без репликации и без -распределённой пары. +Суффикс `_file` пришёл с подложкой под словарём товаров +([ADR 0012](0012-dictionary-home.md)). Суффикс носит каждый физический объект, поэтому голого имени у таблицы не существует: запрос к `stg.hits_raw` даёт ошибку «нет такой таблицы». diff --git a/docs/adr/0007-clickhouse-access.md b/docs/adr/0007-clickhouse-access.md index 549436e..f38c67f 100644 --- a/docs/adr/0007-clickhouse-access.md +++ b/docs/adr/0007-clickhouse-access.md @@ -20,7 +20,8 @@ Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения на запросы `ON CLUSTER`, на чтение системных таблиц и на каждый движок -внешнего источника, а без второго клиент не открывает соединение вовсе. Состав прав каждой роли — работа тикета +внешнего источника. Без права на системные таблицы клиент вовсе не открывает +соединение. Состав прав каждой роли — работа тикета реализации. Пользователи, роли и права объявлены файлами настройки сервера, а не @@ -54,12 +55,9 @@ DDS под ними, потому что так чаще всего и быва видны всем читателям и никому на запись: зона входит в хранилище сбоку, и её содержимым владеет файл репозитория, а не слой. -**Беспарольный `dict` — намеренное исключение.** Пароля у него нет не по -недосмотру: ходить этой учёткой некуда, кроме локального чтения одной базы, а -пароль пришлось бы вписать в текст DDL словаря — то есть положить секрет в git. -Право у него одно, `SELECT` на `dic`. Отдельная учётка под словарь взята из -промышленной практики, где источник словаря читают минимальной служебной -учётной записью, а не общим админом. +**Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то +есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно — +`SELECT` на `dic`, и хост источника локальный. **Секрет, а не учётные данные по репликам.** У каждой реплики в описании кластера своя учётка — вписанная явно или подразумеваемая, и тогда это diff --git a/docs/adr/0012-dictionary-home.md b/docs/adr/0012-dictionary-home.md index d2b4de2..0a1ad17 100644 --- a/docs/adr/0012-dictionary-home.md +++ b/docs/adr/0012-dictionary-home.md @@ -33,18 +33,19 @@ владение, которого у него нет: DDS этот объект не производит, не обновляет и не проверяет. -**На направление данных ссылаться нельзя, и это важно не спутать.** Чтение из -DM объекта, лежащего в DDS, — течение вниз, ровно то, которое разрешено. -Промах был во владении, а не в направлении. Но зона вне цепочки закрывает и -будущий случай: как только справочник понадобится выше по течению — например, -проверка `sku` позиции при разборе заказа в ODS, — чтение `dds.*` из `ods.*` -стало бы настоящей инверсией. У зоны вне цепочки направления относительно слоёв -нет, и такой запрос законен по построению. +**Промах был во владении, а не в направлении.** Чтение из DM объекта, лежащего +в DDS, — течение вниз, ровно то, которое разрешено; на инверсию слоёв это +решение не опирается. Зато зона закрывает будущий случай: как только справочник +понадобится выше по течению — скажем, проверка `sku` при разборе заказа в ODS — +чтение `dds.*` из `ods.*` стало бы настоящей инверсией. У зоны вне цепочки +направления относительно слоёв нет, и такой запрос законен по построению. -**Отраслевой образец говорит то же самое другим средством.** В хранилищах, где -база одна, а слои выражены префиксами имён, справочники несут собственный -префикс наравне с префиксами стадий — то есть образуют свой слой, а не -приписаны к слою модели. Причём префикс накрывает всю цепочку справочника: и +**Отраслевой образец говорит то же самое другим средством.** Владелец принёс +устройство промышленного хранилища телекома — источник закрытый, проверить по +ссылке нельзя, поэтому вес у довода такой же, как у полевого опыта, а не как у +документации. Там база одна, слои выражены префиксами имён, и справочники несут +собственный префикс наравне с префиксами стадий — то есть образуют свою зону, а +не приписаны к слою модели. Причём префикс накрывает всю цепочку справочника: и таблицу, и представление над ней, и сам объект словаря. Средство разное, утверждение одно. У нас слои выражены базами — значит справочникам полагается база. @@ -82,8 +83,8 @@ DM объекта, лежащего в DDS, — течение вниз, ров **Историзацию и представление «последняя загрузка» из образца не берём.** В промышленном примере таблица копит загрузки с меткой времени, а представление берёт последнюю: источник приезжал извне, и версий у него не было нигде. У нас -версии есть — **история каталога это и есть git**. Дублировать её в ClickHouse значит -поставить конструкцию, у которой на стенде нет причины, а расшифровывать её +версии есть — **история каталога это и есть git**. Дублировать её в ClickHouse +значит поставить конструкцию, у которой на стенде нет причины, а расшифровывать её менти всё равно придётся. **Материализованной таблицы с шагом наполнения тоже не берём.** Шаг наполнения @@ -121,6 +122,12 @@ DM объекта, лежащего в DDS, — течение вниз, ров ## Условия пересмотра +- **Дом.** Появляется справочные данные, которые хранилище производит само — + скажем, таблица соответствий, собранная из DDS. Посылка решения «в хранилище + их никто не производит» на них не распространяется, и зону придётся либо + сузить до пришедших извне, либо переопределить. Дешевле всего это до этапа 4: + как только витрины начнут звать словарь по имени, каждый разворот станет + правкой по всем ссылкам. - Справочник перестаёт быть файлом репозитория и начинает приезжать извне — возвращается вопрос устройства: историзованная таблица, «последняя загрузка», словарь поверх. Адрес это переживает. @@ -142,8 +149,8 @@ DM объекта, лежащего в DDS, — течение вниз, ров почти на все полторы минуты: одна перезагрузилась сразу после правки, вторая ещё нет. Прежнее решение `LIFETIME(0)` окно не закрывало, а сжимало до разброса исполнения `SYSTEM RELOAD DICTIONARY ON CLUSTER`; теперь оно - раскрыто намеренно. Урок записан опорной точкой в мастер-спеке, тащить его в курс — - отдельное решение. + раскрыто намеренно. Урок записан опорной точкой в мастер-спеке, тащить его + в курс — отдельное решение. - **Номенклатура товаров — медленно изменяющийся справочник**, и в предметной области это так. Стенд упрощает: файл в репозитории под версией git. Историзации в хранилище нет, и обещать её этот документ не должен. @@ -178,15 +185,25 @@ DM объекта, лежащего в DDS, — течение вниз, ров - `SHOW DICTIONARIES` без указания базы возвращает пусто; словарь в базе стоит среди таблиц слоя и отличается от них только колонкой движка в `system.tables`. -- До переноса `dds.products` упоминался в репозитории четырежды, в дагах — ноль. +- До переноса `dds.products` стоял в семи строках трёх файлов: дока хранилища, + DDL словаря и проверка `scripts/check-clickhouse.sh`. Первый замер насчитал + четыре и проверку не увидел — искали с фильтром по расширениям, куда `.sh` + не попал. Из недосчёта вырос настоящий дефект: девятая проверка кластера + осталась звать несуществующий объект и упала. Поймало ревью, не автор. + Урок тот же, что в [ADR 0006](0006-object-naming.md): «прогнал по + репозиторию» — такое же утверждение, как утверждение о поведении системы, и + проверять его надо так же. - Движок `File` требует у `etl` права на источник. Отказ называет `TABLE ENGINE ON File`, но права с таким именем не хватает: замер тремя пользователями показал, что работает `GRANT FILE ON *.*`, а `TABLE ENGINE ON File` не нужен вовсе. - Стенд, собранный с нуля этим решением, поднимается зелёным: оба объекта зоны на месте, словарь `LOADED` со 180 строками, база `dds` пуста, `bi` читает - словарь со второй ноды, `analyst` — подложку соединением. `make smoke` — - 20 проверок, 0 ошибок. + словарь со второй ноды, `analyst` — подложку соединением. Словарь спрашивает + `make check-clickhouse` — 10 проверок из 10; девятая берёт известный `sku` на + обеих нодах и сверяет цену с колонкой файла, умножив её обратно на сто, так + что приведение к `Decimal` проверено вместе с ответом. `make smoke` словарь + не трогает вовсе. Сверено по документации ClickHouse через MCP Context7 20 августа 2026 года. diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index f932cbd..2dad3a2 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -431,15 +431,15 @@ kafka_offset)`: смотрят такую таблицу от класса, а Первый и пока единственный справочник — каталог товаров: подложка `dic.products_file` над CSV репозитория и словарь `dic.products` поверх неё. -Реплик у зоны нет: обе ноды читают свой смонтированный файл и держат свою копию -словаря в памяти. Словарь обновляется сам, окном `LIFETIME`, и потому после -правки каталога ноды какое-то время отвечают по-разному. Разогнать его раньше -срока — `SYSTEM RELOAD DICTIONARY ON CLUSTER`; это административная операция, -роль `etl` права на неё не получает. +Распределённых пар у зоны нет: обе ноды читают свой смонтированный файл и +держат свою копию словаря в памяти. Словарь обновляется сам, окном `LIFETIME`, +и потому после правки каталога ноды какое-то время отвечают по-разному. +Разогнать его раньше срока — `SYSTEM RELOAD DICTIONARY ON CLUSTER`; это +административная операция, роль `etl` права на неё не получает. -Деньги каталога стоит держать в голове отдельно от остальных: в CSV лежат целые -копейки, словарь отдаёт `Decimal(18, 2)`, а `productPrice` события — уже целые -рубли. Разрыв намеренный, на нём стоит урок про `Float64` ([описание +Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт +`Decimal(18, 2)`, `productPrice` события — целые рубли. Разрыв намеренный, на +нём стоит урок про `Float64` ([описание выгрузки](../formats/clickstream-event.md)). Устройство обоих объектов — почему такой движок, такая форма пути, такой @@ -525,18 +525,18 @@ Airflow читает и собирает эти файлы штатным шаб | База | Объект | Что это | |---|---|---| -| STG | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` | -| STG | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки | -| STG | `stg.hits_raw_mv` | наполняет сырьё из чтеца | -| STG | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью | -| STG | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` | -| ODS | `ods.event_rep` / `_dist` | типизированное широкое событие | -| ODS | `ods.event_v` | актуальная версия события с полями источника | -| ODS | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём | -| ODS | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки | -| ODS | `ods.order_snapshot_rep` / `_dist` | типизированные версии заказа | -| ODS | `ods.order_v` | текущая версия заказа на языке источника | -| ODS | `ods.order_snapshot_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём | +| `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` | +| `stg` | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки | +| `stg` | `stg.hits_raw_mv` | наполняет сырьё из чтеца | +| `stg` | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью | +| `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` | +| `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие | +| `ods` | `ods.event_v` | актуальная версия события с полями источника | +| `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём | +| `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки | +| `ods` | `ods.order_snapshot_rep` / `_dist` | типизированные версии заказа | +| `ods` | `ods.order_v` | текущая версия заказа на языке источника | +| `ods` | `ods.order_snapshot_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём | | `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором | | `dic` | `dic.products` | словарь товаров поверх подложки | diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index 54270b2..77e715d 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -240,10 +240,12 @@ Ecommerce (заполнены только у торговых событий): ## 3. Каталог товаров CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`, -`brand`, `price`, `demand`) — **словарь ClickHouse** из файла. Тот же файл -использует генератор — расхождений нет по построению. Даёт `dictGet` в -витринах и разговор о политике обновления словаря. На кластере файл -монтируется в обе ноды, словарь создаётся ON CLUSTER. Последняя колонка — +`brand`, `price`, `demand`) — **словарь ClickHouse**. Тот же файл использует +генератор — расхождений нет по построению. Даёт `dictGet` в витринах и +разговор о политике обновления словаря. На кластере файл монтируется в обе +ноды; над ним стоит подложка, а словарь читает её и живёт в зоне справочников +`dic` вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)). +Последняя колонка — уровень спроса товара, заведена при исполнении #50 (спека генератора, раздел 9): генератор решает по ней, что уходит из карточки в корзину. @@ -402,7 +404,7 @@ README. ## 7. Слои: карта таблиц v2 -| Слой | Объект | Что это | +| Где | Объект | Что это | |---|---|---| | Kafka | `hits`, `orders` | два топика: `hits` — 2 партиции, `orders` — одна | | STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах | @@ -413,7 +415,7 @@ README. | DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую | | DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS | | DDS | `dds.identity_map` | карта кука↔пользователь | -| `dic` | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) | +| DIC | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) | | DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже | У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции @@ -697,9 +699,9 @@ v2, этап 0). `RegionCityID`); - неатомарное обновление словаря на кластере: ноды перезагружают его в случайный момент внутри окна `LIFETIME`, фазы у них независимы, и после - правки каталога две ноды отвечают на один `dictGet` по-разному — почти всё - окно целиком. Обвязка готова ([ADR - 0012](../adr/0012-dictionary-home.md)), урок остаётся на выбор; + правки каталога две ноды почти всё окно отвечают на один `dictGet` + по-разному. Обвязка готова ([ADR 0012](../adr/0012-dictionary-home.md)), + урок остаётся на выбор; - лаба сессий: менти сначала собирает сессии сам, и только после — рассказ, что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично синтетическая постановка — осознанный приём; diff --git a/infra/clickhouse/users.d/access.xml b/infra/clickhouse/users.d/access.xml index 6beae61..785b76e 100644 --- a/infra/clickhouse/users.d/access.xml +++ b/infra/clickhouse/users.d/access.xml @@ -44,12 +44,7 @@ GRANT SELECT ON system.settings - + GRANT SELECT ON dic.* diff --git a/scripts/check-clickhouse.sh b/scripts/check-clickhouse.sh index 71cca65..dcb4d6d 100755 --- a/scripts/check-clickhouse.sh +++ b/scripts/check-clickhouse.sh @@ -264,10 +264,13 @@ printf 'ЗЕЛЁНО: временные таблицы удалены; пров # После снятия ловушек: своих объектов эти проверки не заводят и прибирать за # собой им нечего — они только смотрят на то, что стенд произвёл сам. printf 'Проверка 9/10: словарь товаров отвечает на обеих нодах...\n' +# Цену словарь отдаёт в Decimal(18, 2), а каталог хранит целые копейки. Умножаем +# обратно и сравниваем с колонкой файла: так проверка заодно утверждает, что +# приведение в источнике словаря точное, а не только что словарь отвечает. product_sql="SELECT - dictGet('dds.products', 'name', tuple('HOME-0001')), - dictGet('dds.products', 'category', tuple('HOME-0001')), - dictGet('dds.products', 'price', tuple('HOME-0001')) + dictGet('dic.products', 'name', tuple('HOME-0001')), + dictGet('dic.products', 'category', tuple('HOME-0001')), + toInt64(dictGet('dic.products', 'price', tuple('HOME-0001')) * 100) FORMAT TSV" expected_product="$( awk -F, \ diff --git a/sql/ddl/00-databases.sql b/sql/ddl/00-databases.sql index bf9f0f3..a567a12 100644 --- a/sql/ddl/00-databases.sql +++ b/sql/ddl/00-databases.sql @@ -19,6 +19,6 @@ CREATE DATABASE IF NOT EXISTS ods ON CLUSTER clickstream_cluster; CREATE DATABASE IF NOT EXISTS dds ON CLUSTER clickstream_cluster; -- Справочники стоят вне цепочки STG → ODS → DDS → DM: в хранилище их никто не --- производит, а читают их несколько слоёв ([ADR 0012]). Поэтому зона своя, и --- порядок слоёв к ней не применяется — её файл идёт сразу за этим. +-- производит, а читают их несколько слоёв — ADR 0012. Порядок слоёв к зоне не +-- применяется, её файл идёт сразу за этим. CREATE DATABASE IF NOT EXISTS dic ON CLUSTER clickstream_cluster; diff --git a/sql/ddl/05-dic-catalog.sql b/sql/ddl/05-dic-catalog.sql index 5f4fdfe..e525646 100644 --- a/sql/ddl/05-dic-catalog.sql +++ b/sql/ddl/05-dic-catalog.sql @@ -1,22 +1,14 @@ -- Каталог товаров: подложка на файловом движке и словарь поверх неё. -- --- Словарь читает не файл, а таблицу хранилища — намеренное усложнение --- ([ADR 0012](../../docs/adr/0012-dictionary-home.md)). В бою справочник --- приезжает процессом, и предложение SOURCE с запросом и учётной записью — --- та форма, которую менти встретит; файловый источник работает, но редок. --- --- Зона dic лежит вне цепочки STG → ODS → DDS → DM: справочник в хранилище --- никто не производит, а читают его несколько слоёв. +-- Словарь читает не файл, а таблицу хранилища. Это намеренное усложнение ради +-- урока: в бою справочник приезжает процессом, и предложение SOURCE с запросом +-- и учётной записью — та форма, которую менти встретит. Доводы, отвергнутые +-- варианты и условия пересмотра — ADR 0012. -- Подложка ничего не хранит: движок File перечитывает CSV на каждом запросе, --- поэтому правка каталога доезжает до словаря сама. Compose монтирует один и --- тот же файл в user_files обеих нод только для чтения. --- --- Путь считается ОТ user_files, а не от корня данных: форма --- './user_files/catalog/products.csv' даёт FILE_DOESNT_EXIST. У файлового --- источника словаря база пути была другой — отсюда разница с прежним DDL. --- Типы здесь повторяют файл, а не модель: цена лежит целыми копейками, как её --- пишет генератор. Приведение к деньгам делает словарь. +-- поэтому правка каталога доезжает до словаря сама. Путь считается от +-- user_files, а не от корня данных. Типы повторяют файл, а не модель: цена +-- лежит целыми копейками, приведение делает словарь. CREATE TABLE IF NOT EXISTS dic.products_file ON CLUSTER clickstream_cluster ( sku String, @@ -28,28 +20,16 @@ CREATE TABLE IF NOT EXISTS dic.products_file ON CLUSTER clickstream_cluster ) ENGINE = File(CSVWithNames, './catalog/products.csv'); --- Пользователь dict объявлен файлом настройки и умеет одно — читать dic. --- Назвать его обязательно: без user словарь идёт как default с пустым паролем --- и падает с AUTHENTICATION_FAILED. Хост локальный, поэтому запрос к подложке --- идёт без сети. +-- Пользователь dict умеет одно — читать dic. Назвать его обязательно: без user +-- словарь идёт как default с пустым паролем и падает. Хост локальный, поэтому +-- запрос к подложке идёт без сети. -- --- Окно обновления вместо LIFETIME(0): словарь перезагружается сам в случайный --- момент внутри окна. Случайность разводит обращения разных серверов к --- источнику, и цена у неё заявленная — ноды обновляются вразнобой. Ждать --- правки каталога каждой из них приходится от нуля до верхней границы окна, --- фазы у них независимы, поэтому расходиться они могут почти на все --- полторы минуты: одна перезагрузилась сразу после правки, вторая ещё нет. +-- Форма query, а не table: словарь приводит копейки каталога к Decimal(18, 2) +-- прямо на входе, то есть нормализует, а не зеркалит подложку. Ключ строковый, +-- поэтому COMPLEX_KEY_HASHED: числовой FLAT здесь неприменим. -- --- Ключ строковый, поэтому COMPLEX_KEY_HASHED: числовой FLAT здесь неприменим. --- --- Цена приводится к деньгам прямо в источнике — оттого форма query, а не --- table: словарь нормализует на входе, а не зеркалит подложку. В файле лежат --- целые копейки, наружу словарь отдаёт Decimal(18, 2), как заказы бэкенда. --- Единица в числе становится видна: 129000 против 1290.00. --- --- Стык с событием на этом и стоит: в контракте события productPrice — целые --- РУБЛИ, округление формата. Разрыв между ценой каталога и ценой в событии --- намеренный, на нём держится урок про Float64. +-- Окно вместо LIFETIME(0): словарь обновляется сам, а цена этому — ноды +-- расходятся почти на всё окно (ADR 0012). CREATE DICTIONARY IF NOT EXISTS dic.products ON CLUSTER clickstream_cluster ( sku String,