From bb8605eed7d80e7d3ceef94deee77a0710f7d78e Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Tue, 22 Sep 2026 04:56:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(labs):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=D0=B1=D0=BB=D1=8E=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BF=D0=BE=D0=B4=20=D1=83=D1=87?= =?UTF-8?q?=D0=B5=D0=B1=D0=BD=D0=BE=D0=B9=20=D1=83=D1=87=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: студенту нужен понятный путь от сохраненного состояния к SQL-сравнению без доступа технической учетной записи к изменению хранилища. Что: добавлен student с чтением источников и записью только в default; лабораторная сохраняет заказы и итоги в две локальные таблицы. Обновлены настройка доступа и программа, уточнено округление productPrice. Проверка: DDL и INSERT SELECT под student, пустое наблюдение, восемь переходов сохраненной истории и запрет вставки в пять прикладных баз на обеих нодах. config-test, lint стенда и генератора, 54 ссылки, независимое ревью. --- .env.example | 1 + README.md | 3 + compose.yaml | 1 + docs/adr/0007-clickhouse-access.md | 12 ++- docs/architecture/stand.md | 8 +- docs/formats/clickstream-event.md | 2 +- docs/labs/optional/revenue-over-time.md | 96 +++++++++++++------ docs/specs/2026-09-01-labs-program.md | 8 +- generator/src/clickstream_generator/schema.py | 2 +- infra/clickhouse/users.d/access.xml | 13 +++ 10 files changed, 114 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index 6469fa9..718dbec 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ CLICKHOUSE_ETL_PASSWORD=etl-26-writer CLICKHOUSE_LIFECYCLE_PASSWORD=lifecycle-26-owner CLICKHOUSE_BI_PASSWORD=bi-26-reader CLICKHOUSE_ANALYST_PASSWORD=analyst-26-reader +CLICKHOUSE_STUDENT_PASSWORD=student-26-lab CLICKHOUSE_GRAFANA_PASSWORD=grafana-26-reader KAFKA_EXTERNAL_PORT=29092 PROMETHEUS_PORT=29090 diff --git a/README.md b/README.md index 1cf9769..38cac01 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,9 @@ Kafka, затем заново создаст схему хранилища и Для подключения ко второму узлу укажите порт `28124`. Пользователь `analyst` может читать данные во всех слоях и справочниках. +Для заданий с собственными таблицами наблюдений используйте `student` и пароль +из `CLICKHOUSE_STUDENT_PASSWORD` в `.env`. Он читает те же источники, а создавать, +заполнять и удалять таблицы может только в базе `default`. Для создания учебных витрин используйте подключение, описанное в [инструкции к заданиям](docs/labs/README.md#как-применить-свой-ddl). Нативные порты `29000` и `29001` нужны `clickhouse-client`, но не DBeaver. diff --git a/compose.yaml b/compose.yaml index df87088..16b2a3e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -20,6 +20,7 @@ x-clickhouse-common: &clickhouse-common CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_ANALYST_PASSWORD: ${CLICKHOUSE_ANALYST_PASSWORD:?Скопируйте .env.example в .env} + CLICKHOUSE_STUDENT_PASSWORD: ${CLICKHOUSE_STUDENT_PASSWORD:?Добавьте CLICKHOUSE_STUDENT_PASSWORD из .env.example в .env} CLICKHOUSE_GRAFANA_PASSWORD: ${CLICKHOUSE_GRAFANA_PASSWORD:?Скопируйте .env.example в .env} depends_on: clickhouse-keeper: diff --git a/docs/adr/0007-clickhouse-access.md b/docs/adr/0007-clickhouse-access.md index e322580..a027322 100644 --- a/docs/adr/0007-clickhouse-access.md +++ b/docs/adr/0007-clickhouse-access.md @@ -3,10 +3,11 @@ Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader` и пользователь `grafana` добавлены 21 августа 2026 года (#114), права `bi_reader` расширены до ODS, DDS и DM 25 августа 2026 года (#150). +Пользователь `student` и роль `student_writer` добавлены 22 сентября 2026 года. ## Решение -У кластера семь пользователей и шесть ролей. +У кластера восемь пользователей и семь ролей. - `default` — с паролем, только служебный: проверки здоровья нод и работа изнутри контейнеров. Приложения им не ходят. @@ -20,6 +21,10 @@ шире. - `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им человек подключается снаружи, из своего клиента. +- `student` с ролями `analyst_reader` и `student_writer` — то же чтение и + работа со своими таблицами в `default`: создание, вставка и удаление. + Сохранение учебных наблюдений не требует прав технической учётной записи + `etl` на изменение слоёв. Таблицы локальные; работа ведётся на одной ноде. - `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и системных таблиц, `CLUSTER` и `REMOTE`. Им ходит Grafana, и больше никто. - `dict` с ролью `dict_reader` — чтение одной базы `dic`, пароля нет. Им @@ -148,6 +153,11 @@ DDL для матвью приёма выполняется от имени `lif ## Что проверено +Права `student` сверены 22 сентября 2026 года через MCP Context7 и на стенде +26.3.17.56: создание локальных `MergeTree`, `INSERT SELECT` из DDS и DM, +чтение и удаление своих таблиц работают. На обеих нодах вставка в STG, ODS, +DDS, DM и `dic` завершается `ACCESS_DENIED`. + Сверено 9 августа 2026 года по документации ClickHouse через MCP Context7 и на живом стенде 26.3.17.56. diff --git a/docs/architecture/stand.md b/docs/architecture/stand.md index ada8bff..e1637ce 100644 --- a/docs/architecture/stand.md +++ b/docs/architecture/stand.md @@ -22,7 +22,7 @@ ## Пользователи и роли ClickHouse -В ClickHouse семь пользователей и шесть ролей: +В ClickHouse восемь пользователей и семь ролей: - `etl` — учётная запись рабочих дагов Airflow; роль `etl_writer` читает и пишет слои хранилища; @@ -33,6 +33,9 @@ справочники `dic`, а запросы Superset обращаются к публичным `_v`; - `analyst` — учётная запись аналитика с правом чтения всех слоёв и справочников; +- `student` — учебная учётная запись: роль `analyst_reader` дает чтение + источников, а `student_writer` — создание, заполнение и удаление своих + таблиц в `default`. Записи в слои хранилища у нее нет; - `grafana` — учётная запись Grafana; роль `monitoring_reader` читает все слои, системные таблицы и данные со всех реплик кластера; - `dict` — беспарольная учётная запись словаря товаров с правом чтения базы @@ -49,6 +52,9 @@ Superset получает пароль `bi`, а Grafana — пароль `grafan ## Применение настроек +Для существующего стенда сначала добавьте `CLICKHOUSE_STUDENT_PASSWORD` +из `.env.example` в свой `.env`. + Чтобы применить новые значения ClickHouse из `.env` или изменения файлов в `infra/clickhouse/config.d/` и `infra/clickhouse/users.d/`, пересоздайте ноды: diff --git a/docs/formats/clickstream-event.md b/docs/formats/clickstream-event.md index 0f9028e..bdeeb42 100644 --- a/docs/formats/clickstream-event.md +++ b/docs/formats/clickstream-event.md @@ -98,7 +98,7 @@ DDS складывает свою модель и называет атрибу | 41 | `productID` | `Array(String)` | `object` | `product_id` | id товаров события | | 42 | `productName` | `Array(String)` | `object` | `product_name` | названия тех же товаров | | 43 | `productCategory` | `Array(String)` | `object` | `product_category` | категории тех же товаров | -| 44 | `productPrice` | `Array(Int64)` | `int64` | `product_price` | цена за штуку целым числом: деньги генератор считает целыми | +| 44 | `productPrice` | `Array(Int64)` | `int64` | `product_price` | цена за штуку, округленная до целых рублей | | 45 | `productQuantity` | `Array(UInt64)` | `uint64` | `product_quantity` | количество штук каждого товара | | 46 | `productEventType` | `Array(String)` | `object` | `product_event_type` | действие с товаром: стенд шлёт add и purchase, полный словарь Метрики (detail, remove, impressions) не берём | | 47 | `ecommerce` | `String` | `object` | `ecommerce` | сырой JSON события, как отдаёт Метрика — материал лабы про разбор JSON внутри колонки | diff --git a/docs/labs/optional/revenue-over-time.md b/docs/labs/optional/revenue-over-time.md index 2b177a0..469236e 100644 --- a/docs/labs/optional/revenue-over-time.md +++ b/docs/labs/optional/revenue-over-time.md @@ -4,7 +4,8 @@ заказов по наблюдаемым опозданиям и изменениям статусов. Факультатив доступен после ступени 1. Понадобятся Airflow и DBeaver с -подключением `analyst` по [инструкции](../../../README.md#подключиться-из-dbeaver). +подключением `student` по [инструкции](../../../README.md#подключиться-из-dbeaver). +Он читает данные стенда и записывает ваши наблюдения в базу `default`. Вы будете наблюдать один день оформления заказов, продвигая мир по одному модельному дню. Проверьте, можно ли при загрузке выручки каждый раз считать только новый день. Новые данные останутся в стенде. @@ -32,28 +33,65 @@ SELECT хранилище еще нет. Проверьте это для D в `dds.order_v` и `dm.purchase_vs_orders_v`. -## Подготовьте запросы и сохраняйте результаты - -Напишите запросы для следующих наблюдений: - -| Что сохранить | Откуда взять | -|---|---| -| Дату полученного слепка S | `max(snapshot_date)` из `ods.order_v` | -| Номер, статус и `items_total` каждого заказа дня D | `dds.order_v`, отбор по `order_date` | -| Число заказов в каждом статусе и сумму `items_total` только у `paid` | те же заказы дня D | -| Выручку дня D | сумма `revenue` из `dm.revenue_daily_v`, отбор по `report_date` | -| Номера покупок, еще ожидающих заказ | строки `awaiting_order` из `dm.purchase_vs_orders_v`, отбор по `order_day` | - -Выручку складывайте по всем категориям. В этой витрине она означает стоимость -позиций оплаченных заказов, без скидки и доставки, поэтому должна сходиться -с суммой `items_total` у заказов в статусе `paid`. - -Сохраняйте результаты запросов отдельными файлами, например выгрузками CSV -из DBeaver. Подписывайте D и S. До поступления первого слепка список заказов -пуст, а выручка равна нулю; сохраните и это наблюдение. +## Подготовьте место для наблюдений Публичное представление заказа показывает текущее состояние, а сырые слепки -хранятся ограниченное время. Сохраненные выгрузки — история для расследования. +хранятся ограниченное время. Сохраните историю в двух своих таблицах: потом +вы сможете сравнивать наблюдения обычными SQL-запросами в том же DBeaver. + +Выполните DDL ниже. Это локальные таблицы на узле вашего подключения; +всю работу выполняйте через одно подключение. Если имена уже заняты вашей +прошлой работой, выберите другие и используйте их в дальнейших запросах. + +```sql +CREATE TABLE default.lab_order_history +( + order_date Date, + snapshot_date Date, + order_id String, + status String, + items_total Decimal(18, 2) +) +ENGINE = MergeTree +ORDER BY (order_date, snapshot_date, order_id); + +CREATE TABLE default.lab_revenue_history +( + order_date Date, + snapshot_date Date, + revenue Decimal(18, 2), + awaiting_order_ids Array(String) +) +ENGINE = MergeTree +ORDER BY (order_date, snapshot_date); +``` + +## Сохраните первое наблюдение + +Напишите два `INSERT INTO ... SELECT` с явным списком колонок назначения: + +| Куда записать | Что взять из стенда | +|---|---| +| `default.lab_order_history` | По строке на заказ дня D из `dds.order_v`: `order_date`, `order_id`, `status`, `items_total`; добавьте дату наблюдения S | +| `default.lab_revenue_history` | Одну строку с D, S, суммой `revenue` за `report_date = D` из `dm.revenue_daily_v` и массивом номеров `awaiting_order` за `order_day = D` из `dm.purchase_vs_orders_v` | + +Во втором запросе сумму и массив получите отдельными агрегатами, чтобы +заказы не умножили строки выручки при соединении. Для массива пригодится +`groupArray(order_id)`. Дату S берите из `max(snapshot_date)` в `ods.order_v`. + +Сначала выполните части `SELECT` отдельно и проверьте результаты, затем +сохраните их. До первого слепка заказов дня D еще нет: в таблицу заказов +строки не добавятся, но в таблице итогов должна появиться строка с нулевой +выручкой. Она зафиксирует, что наблюдение сделано. + +Каждую пару D и S сохраняйте один раз: повторный `INSERT` добавит копии строк. +После записи проверьте, что на эту пару приходится одна строка итогов, +а число заказов и число разных `order_id` совпадают с `dds.order_v` за D. + +Выручка этой витрины — стоимость позиций оплаченных заказов без скидки и +доставки. Для каждого сохраненного наблюдения посчитайте число заказов по +статусам и сумму `items_total` только у `paid`. Эта сумма должна сходиться +с сохраненной выручкой, сложенной по всем категориям. ## Продвигайте мир по одному дню @@ -62,7 +100,7 @@ SELECT 1. Запустите `world_next_day` с `days = 1` и дождитесь успеха. 2. Запустите `etl_pipeline` и дождитесь успеха: `world_next_day` сам витрины не обновляет. -3. Повторите свои запросы и сохраните новый комплект результатов с датой S. +3. Повторите два запроса сохранения с новой датой S и проверьте записанные строки. Во всех запросах остается прежний день D. За один запуск проигрывайте один день, чтобы сохранить промежуточные состояния. Пакетный `world_next_day` @@ -75,8 +113,10 @@ SELECT ## Найдите изменения в уже загруженном дне -После каждого шага сравнивайте заказы по `order_id`, а не по положению строк -в выгрузке. Найдите: +Для сравнения прочитайте `default.lab_order_history` дважды: в одном +подзапросе отберите прежнюю S, в другом — новую; день D остается общим. +Соедините состояния по `order_id`, сохранив и заказы без пары. Итоги для этих +же D и S возьмите из `default.lab_revenue_history`. Найдите: - заказы, отсутствовавшие в первом слепке D, но появившиеся в следующем; - переходы из `created` в `paid`; @@ -103,7 +143,7 @@ SELECT Возьмите отмененный заказ, у которого в вашей истории был статус `paid`. Укажите два наблюдения, доказывающих, что он был оплачен, а затем отменен. -Теперь возьмите отмененный заказ, для которого ни в одной вашей выгрузке +Теперь возьмите отмененный заказ, для которого ни в одном вашем наблюдении нет `paid`. Проверьте утверждение «этот заказ никогда не был оплачен»: что известно о его состоянии между двумя слепками? Достаточно ли сохраненных наблюдений для такого вывода? @@ -111,7 +151,7 @@ SELECT Сформулируйте требования к данным для двух задач: восстановить состояние витрины на момент прошлого расчета и узнать, был ли заказ когда-либо оплачен. Какие сведения для каждой задачи должен передавать источник и сохранять -хранилище? Проверьте предложение на своих выгрузках и на промежутке между ними. +хранилище? Проверьте, каких нужных сведений нет в ваших таблицах наблюдений. ## Задайте правило пересчета @@ -143,4 +183,6 @@ SELECT и правилом доставки слепков. После работы оставьте `world_live` на паузе либо верните автоматическое -продвижение, если пользовались им до наблюдения. +продвижение, если пользовались им до наблюдения. Таблицы истории останутся +для повторной проверки выводов. Когда они больше не нужны, удалите только +свои `default.lab_order_history` и `default.lab_revenue_history`. diff --git a/docs/specs/2026-09-01-labs-program.md b/docs/specs/2026-09-01-labs-program.md index 9a88319..369e665 100644 --- a/docs/specs/2026-09-01-labs-program.md +++ b/docs/specs/2026-09-01-labs-program.md @@ -185,7 +185,13 @@ и требования к истории для воспроизведения расчета и проверки факта оплаты. В четвертой лабораторной выбрано собственное наблюдение: учащийся продвигает -мир по одному дню через Airflow и сохраняет результаты после обновления витрин. +мир по одному дню через Airflow и сохраняет результаты после обновления витрин +в две локальные таблицы ClickHouse под `student`: состояния заказов и итоги +наблюдений. Сравнение выполняется SQL-запросами в том же подключении DBeaver. +Учащийся может писать только в `default`; источники доступны ему на чтение. +Отдельная строка итогов фиксирует и наблюдение без заказов. Экспорт в CSV +отклонен как основной способ: он требовал неописанного перехода к другому +инструменту или обратному импорту файлов. Готовые слепки и сочетание готового примера с собственным наблюдением отклонены: нужную историю можно получить на стенде и самому увидеть, как новые данные меняют уже рассчитанный отчет. diff --git a/generator/src/clickstream_generator/schema.py b/generator/src/clickstream_generator/schema.py index d31d3f4..0082f80 100644 --- a/generator/src/clickstream_generator/schema.py +++ b/generator/src/clickstream_generator/schema.py @@ -420,7 +420,7 @@ COLUMNS: tuple[Column, ...] = ( numpy_dtype="int64", normalized_name="product_price", group=ColumnGroup.ECOMMERCE, - comment="цена за штуку целым числом: деньги генератор считает целыми", + comment="цена за штуку, округленная до целых рублей", ), Column( name="productQuantity", diff --git a/infra/clickhouse/users.d/access.xml b/infra/clickhouse/users.d/access.xml index 04cc4a0..5cf2bd5 100644 --- a/infra/clickhouse/users.d/access.xml +++ b/infra/clickhouse/users.d/access.xml @@ -61,6 +61,11 @@ GRANT SELECT ON system.settings + + + GRANT SELECT, INSERT, CREATE TABLE, DROP TABLE ON default.* + + GRANT SELECT ON stg.* @@ -113,6 +118,14 @@ GRANT analyst_reader + + + default + + GRANT analyst_reader + GRANT student_writer + + default