Merge pull request #25 from dementev-dev/feature/course-labs-redesign

Редизайн лаб курса: путь import, лабы next-day и continue (#7)
This commit is contained in:
2026-07-23 18:31:17 +03:00
committed by GitHub
17 changed files with 1075 additions and 225 deletions
@@ -0,0 +1,15 @@
# Handoff: редизайн лаб курса (issue #7) — ЗАВЕРШЁН
Обновлено: 2026-07-23, конец прогона. Работа выполнена целиком.
- PR: https://github.com/dementev-dev/clickstream-ch-kafka-superset-demo/pull/25
(closes #7; ветка `feature/course-labs-redesign`, 5 коммитов от спеки до
сверки на стенде). Дочерние тикеты #21/#22/#23 закрыты, чекбоксы в #7
проставлены. Чистка легаси-тестов — отдельный issue #24.
- Осталось человеку: ревью и merge PR; после merge ветку можно удалить.
- Стенд после сверки НЕ канонический: мир дорос до дня 5 и жил в continue;
генератор остановлен, `world_next_day` на паузе. Перед занятиями менти —
канонический сброс по README курса.
- Урок прогона: пауза DAG во время работающего прогона замораживает его
задачи; `unpause` сразу после `make up` может быть перекрыт
`is_paused_upon_creation` (гонка с первым парсингом).
+5 -3
View File
@@ -63,8 +63,10 @@ user_domain_id (пользователь, постоянный)
### Возвращающийся пользователь (returning user) ### Возвращающийся пользователь (returning user)
Пользователь, открывающий **более одного** визита (`click_id`) во времени, с Пользователь, открывающий **более одного** визита (`click_id`) во времени, с
межсессионными паузами. Именно возвраты дают расхождение `users < sessions` межсессионными паузами. Эталонный мир уже содержит возвраты: это замороженный
то, чего нет на сиде (`users == sessions`) и что отличает поток от статики. LIVE-мир с 4 056 пользователями и 26 083 визитами, поэтому уже при импорте
выполняется `users < sessions`. Режим `continue` продолжает этот мир: итоги
растут дальше и зависят от длительности запуска у каждого менти.
### Три значения слова «сид» ### Три значения слова «сид»
@@ -116,7 +118,7 @@ user_domain_id (пользователь, постоянный)
**Модельное время стенда** отвязано от настенных часов: генератор крутит **Модельное время стенда** отвязано от настенных часов: генератор крутит
внутренние часы, а драйвер задаёт скорость — ×1 (как реальное время), ×K внутренние часы, а драйвер задаёт скорость — ×1 (как реальное время), ×K
(ускоренно, учебная «ручка» урока 7) или `K → ∞` (мгновенная заливка прошлого). (ускоренно, [учебная ручка в лабе 08](./docs/course/lessons/08_lab_continue.md#modelnoe-vremya)) или `K → ∞` (мгновенная заливка прошлого).
При ускорении «сейчас» стенда уходит вперёд настенного времени — это свойство, не При ускорении «сейчас» стенда уходит вперёд настенного времени — это свойство, не
баг (ключ аналитики — `event_timestamp`). Решение и режимы — баг (ключ аналитики — `event_timestamp`). Решение и режимы —
[ADR-0005](./docs/adr/0005-generator-model-clock.md). [ADR-0005](./docs/adr/0005-generator-model-clock.md).
+18 -10
View File
@@ -1,7 +1,9 @@
# План обучения: курс «Кликстрим на ClickHouse» # План обучения: курс «Кликстрим на ClickHouse»
> Дата: 2026-06-03 (аудит путей выполнен; середина расщеплена — > Дата: 2026-06-03 (аудит путей выполнен; середина расщеплена —
> один паттерн на урок, всего 7 уроков, см. §12). > один паттерн на урок, см. §1–2).
> Поправка 2026-07-23: весь маршрут стал обязательным. После урока 6 добавлены
> лабы 07–08 про следующий день и живое продолжение; обе лабы написаны.
> Назначение: высокоуровневый маршрут менти по курсу — карта уроков, порядок, > Назначение: высокоуровневый маршрут менти по курсу — карта уроков, порядок,
> результаты аудита эталонных путей. Рамка курса (зачем/что/скоуп) — в `PRD.md`; > результаты аудита эталонных путей. Рамка курса (зачем/что/скоуп) — в `PRD.md`;
> как устроен отдельный урок — в `LESSON_STANDARD.md`. > как устроен отдельный урок — в `LESSON_STANDARD.md`.
@@ -13,9 +15,11 @@
## 1. Маршрут ## 1. Маршрут
Порядок линейный уроки 1→4 повторяют сам пайплайн (STG → ODS → DDS → оркестрация), Порядок линейный, все уроки и лабы обязательны. Урок 0 — разминка, а уроки 1→4
а урок 0 — разминка перед ним. Мониторинг (5) и Superset (6) более самостоятельны и повторяют сам пайплайн (STG → ODS → DDS → оркестрация). Мониторинг (5) и
работают как надстройки поверх готовых данных. Superset (6) работают как надстройки поверх готовых данных. Лабы 0708 затем
растят импортированный мир: сначала детерминированным следующим днём, потом
живым продолжением.
Принцип нарезки — **один прод-паттерн на урок** (`LESSON_STANDARD` §2). Поэтому Принцип нарезки — **один прод-паттерн на урок** (`LESSON_STANDARD` §2). Поэтому
середина пайплайна разнесена: типизация+DQ (ODS) и сборка сущностей (DDS) — разные середина пайплайна разнесена: типизация+DQ (ODS) и сборка сущностей (DDS) — разные
@@ -36,11 +40,13 @@ Kafka из роадмапа — оно даёт словарь терминов.
| 3 | ODS → DDS: сборка сущностей | `sql/dds/30_ods_to_dds.sql` + DDL `sql/ddl/dds/30_dds.sql` (argMax, UNION, сироты) | обязательный | руки | | 3 | ODS → DDS: сборка сущностей | `sql/dds/30_ods_to_dds.sql` + DDL `sql/ddl/dds/30_dds.sql` (argMax, UNION, сироты) | обязательный | руки |
| 4 | Оркестрация в Airflow | `airflow/dags/etl_pipeline_dag.py` (зависимости, гейты, остановка при нарушениях) | обязательный | руки | | 4 | Оркестрация в Airflow | `airflow/dags/etl_pipeline_dag.py` (зависимости, гейты, остановка при нарушениях) | обязательный | руки |
| 5 | Мониторинг | Prometheus + Grafana + экспортёры | обязательный | наблюдение | | 5 | Мониторинг | Prometheus + Grafana + экспортёры | обязательный | наблюдение |
| 6 | BI-витрина | Superset поверх ClickHouse | опциональный | руки | | 6 | BI-витрина | Superset поверх ClickHouse | обязательный | руки |
| 7 | Лаба: следующий день | `airflow/dags/world_next_day_dag.py` | написана, обязательная | руки |
| 8 | Лаба: живое продолжение | `make generator-continue` и `etl_pipeline` | написана, обязательная | руки |
Урок 0 — обязательная разминка (без правок кода, только наблюдение); уроки 14 — Урок 0 — обязательная разминка без правок кода. В уроках 16 и лабах 0708
с управляемыми правками; урок 5 ближе к наблюдению (глубину уточняем, см. менти запускает стенд или делает управляемую правку; каждый такой эксперимент
«Открытые вопросы» в `PRD.md`). заканчивается явным возвратом к чистому состоянию.
**Где «MV vs батч»:** контраст из цели №2 PRD — это мост уроков 1→2. Урок 1 (STG) **Где «MV vs батч»:** контраст из цели №2 PRD — это мост уроков 1→2. Урок 1 (STG)
заканчивается вопросом «мы приземлили поток через MV — почему дальше не MV?»; урок 2 заканчивается вопросом «мы приземлили поток через MV — почему дальше не MV?»; урок 2
@@ -64,6 +70,7 @@ Kafka из роадмапа — оно даёт словарь терминов.
| 3 | ODS→DDS (+ демоут DM) | `sql/dds/30_ods_to_dds.sql` + DDL `sql/ddl/dds/30_dds.sql`; DM `sql/dm/40_dds_to_dm.sql` + `sql/ddl/dm/40_dm.sql` | **точечно править** | | 3 | ODS→DDS (+ демоут DM) | `sql/dds/30_ods_to_dds.sql` + DDL `sql/ddl/dds/30_dds.sql`; DM `sql/dm/40_dds_to_dm.sql` + `sql/ddl/dm/40_dm.sql` | **точечно править** |
| 4 | Airflow DAG | `airflow/dags/etl_pipeline_dag.py` | **точечно править** | | 4 | Airflow DAG | `airflow/dags/etl_pipeline_dag.py` | **точечно править** |
| 5 | Мониторинг | `configs/*`, экспортёры | **годно как есть** (для режима наблюдения) | | 5 | Мониторинг | `configs/*`, экспортёры | **годно как есть** (для режима наблюдения) |
| 7 | Следующий день | `airflow/dags/world_next_day_dag.py` | **точечно править** |
> Карты путей раздела 2 включают и DDL целевых таблиц (`sql/ddl/{ods,dds}/*`), а не > Карты путей раздела 2 включают и DDL целевых таблиц (`sql/ddl/{ods,dds}/*`), а не
> только батч-трансформации: без формы целевых таблиц слой читается неполно. > только батч-трансформации: без формы целевых таблиц слой читается неполно.
@@ -140,8 +147,9 @@ Kafka из роадмапа — оно даёт словарь терминов.
## 4. Что дальше ## 4. Что дальше
Все уроки 0–6 написаны (`lessons/`), правки кода §3.1 применены в составе уроков — Все уроки 06 и лабы 0708 написаны (`lessons/`). Правки кода §3.1 применены
чекбоксы выше сверены с реальным кодом 2026-06-06. Базовый контент курса собран. в составе уроков; чекбоксы выше сверены с реальным кодом 2026-06-06. Маршрут
курса собран полностью.
Исходный план фазы написания (оставлен как контекст): пишем уроки по одному, по Исходный план фазы написания (оставлен как контекст): пишем уроки по одному, по
шаблону из `LESSON_STANDARD.md`, начиная с урока 1 (Kafka→CH); урок 0 (разминка на шаблону из `LESSON_STANDARD.md`, начиная с урока 1 (Kafka→CH); урок 0 (разминка на
+4 -2
View File
@@ -21,7 +21,8 @@
запись и увидеть `+1` в таблице ошибок `parse_errors`; сменить `kafka_group_name` запись и увидеть `+1` в таблице ошибок `parse_errors`; сменить `kafka_group_name`
и увидеть, как топик читается заново. Менти меняет — видит эффект — объясняет. и увидеть, как топик читается заново. Менти меняет — видит эффект — объясняет.
**Верни как было** — каждая правка завершается явным шагом отката к чистому **Верни как было** — каждая правка завершается явным шагом отката к чистому
состоянию (откатить изменение либо `make generated-history-analytics && make up`), чтобы состоянию (откатить изменение либо пройти
[канонический сброс](./README.md#подготовка-и-канонический-сброс)), чтобы
самостоятельный менти не застрял со сломанным стендом без ментора. самостоятельный менти не застрял со сломанным стендом без ментора.
(Урок 0 — без этого шага, только наблюдение.) (Урок 0 — без этого шага, только наблюдение.)
5. **Проверь себя** — самопроверка (раздел 4). 5. **Проверь себя** — самопроверка (раздел 4).
@@ -112,7 +113,8 @@
- штатные быстрые проверки (smoke) из `docs/TEST_PLAN.md`; - штатные быстрые проверки (smoke) из `docs/TEST_PLAN.md`;
- встроенные проверки в `etl_pipeline_dag.py` (DAG падает на пустой витрине или - встроенные проверки в `etl_pipeline_dag.py` (DAG падает на пустой витрине или
нарушении целостности); нарушении целостности);
- `make generated-history-analytics && make up` для чистого сброса и повтора штатного пути. - [канонический сброс](./README.md#подготовка-и-канонический-сброс) для чистого
возврата и повтора штатного пути.
В каждом уроке — маленькая табличка самопроверки в формате В каждом уроке — маленькая табличка самопроверки в формате
**действие → где смотреть → что ожидать**, а для управляемой правки — какой **действие → где смотреть → что ожидать**, а для управляемой правки — какой
+16 -13
View File
@@ -3,7 +3,7 @@
> Статус: прообраз PRD. Дата: 2026-06-01. > Статус: прообраз PRD. Дата: 2026-06-01.
> Поправка 2026-06-03 (разморозка по делу): середина пайплайна расщеплена — ODS и DDS > Поправка 2026-06-03 (разморозка по делу): середина пайплайна расщеплена — ODS и DDS
> теперь разные уроки (принцип «один паттерн на урок»), витрины DM демотированы в > теперь разные уроки (принцип «один паттерн на урок»), витрины DM демотированы в
> поверхность потребления. Обязательных уроков стало 0–5, опциональный Superset — урок 6. > поверхность потребления. Superset вынесен в урок 6.
> Затронуты §4 (скоуп) и §3/§5 (ожидаемый такт — ~день на урок). Это изменение рамки, > Затронуты §4 (скоуп) и §3/§5 (ожидаемый такт — ~день на урок). Это изменение рамки,
> а не план реализации. > а не план реализации.
> Поправка 2026-06-03 (терминология): режим сопровождения — еженедельный **созвон**, а не > Поправка 2026-06-03 (терминология): режим сопровождения — еженедельный **созвон**, а не
@@ -16,6 +16,10 @@
> стенда больше не является. Схема потока в §1 обновлена под генератор (источник данных > стенда больше не является. Схема потока в §1 обновлена под генератор (источник данных
> сменился, см. ADR-0006). В §7 закрыта развилка про урок о генераторе и добавлена > сменился, см. ADR-0006). В §7 закрыта развилка про урок о генераторе и добавлена
> развилка про кластерную конфигурацию. > развилка про кластерную конфигурацию.
> Поправка 2026-07-23 (маршрут курса): зафиксированы три режима работы с миром —
> импорт эталонной базы, пакетная дозаливка следующего дня и живое продолжение.
> Урок 6 стал обязательным, после него добавлены обязательные лабы 07–08.
> Затронуты §1–4 и §7.
> Назначение документа: зафиксировать для будущих сессий, что это за курс, зачем > Назначение документа: зафиксировать для будущих сессий, что это за курс, зачем
> он, что входит в скоуп работ, а что нет. Это договорная **рамка**, а не план > он, что входит в скоуп работ, а что нет. Это договорная **рамка**, а не план
> реализации и не стандарт уроков (см. раздел «Связанные документы»). > реализации и не стандарт уроков (см. раздел «Связанные документы»).
@@ -30,7 +34,7 @@
Репозиторий — рабочий сквозной стенд кликстрим-DWH: Репозиторий — рабочий сквозной стенд кликстрим-DWH:
``` ```
generator (backfill/live) → Kafka → ClickHouse (Kafka engine + MV → STG) → Airflow ETL (STG→ODS→DDS→DM) → Superset generator (импорт / живой поток) → Kafka → ClickHouse (Kafka engine + MV → STG) → Airflow ETL (STG→ODS→DDS→DM) → Superset
↘ Prometheus / Grafana (мониторинг) ↘ Prometheus / Grafana (мониторинг)
``` ```
@@ -85,7 +89,9 @@ generator (backfill/live) → Kafka → ClickHouse (Kafka engine + MV → STG)
3. Читать и объяснять **оркестрацию в Airflow**: DAG, зависимости задач, проверки 3. Читать и объяснять **оркестрацию в Airflow**: DAG, зависимости задач, проверки
качества данных, остановку пайплайна при нарушениях. качества данных, остановку пайплайна при нарушениях.
4. Понимать, **как устроен мониторинг** пайплайна (метрики, экспортёры, дашборды). 4. Понимать, **как устроен мониторинг** пайплайна (метрики, экспортёры, дашборды).
5. (Опционально) Подключать **BI-витрину** поверх ClickHouse (Superset). 5. Подключать **BI-витрину** поверх ClickHouse (Superset).
6. Различать пакетную дозаливку дня и живой поток, понимать границы времени
и свежесть данных.
Сквозная цель — не «посмотреть, как работает», а **уметь пересказать паттерн Сквозная цель — не «посмотреть, как работает», а **уметь пересказать паттерн
своими словами и привязать его к обычной кликстрим-аналитике** (трекер событий → своими словами и привязать его к обычной кликстрим-аналитике** (трекер событий →
@@ -96,8 +102,8 @@ Kafka → ClickHouse → BI).
- **Аудитория:** продвинутые менти, прошедшие базовую программу. Пишем обобщённо, - **Аудитория:** продвинутые менти, прошедшие базовую программу. Пишем обобщённо,
но затачиваем под реальный первый прогон, а не под гипотетических будущих менти. но затачиваем под реальный первый прогон, а не под гипотетических будущих менти.
- **Режим:** самостоятельный, асинхронный. Менти клонирует репозиторий, готовит - **Режим:** самостоятельный, асинхронный. Менти клонирует репозиторий, готовит
стенд штатным путём (`make generated-history-analytics && make up`) и идёт по стенд по [канонической инструкции](./README.md#подготовка-и-канонический-сброс)
урокам из `docs/course/` рядом с кодом. и идёт по урокам из `docs/course/` рядом с кодом.
Уроки короткие и односоставные — ожидаемый срок прохождения одного **около дня**. Уроки короткие и односоставные — ожидаемый срок прохождения одного **около дня**.
- **Роль ментора:** еженедельный созвон-сверка (покрывает несколько уроков), без - **Роль ментора:** еженедельный созвон-сверка (покрывает несколько уроков), без
построчного разбора кода. построчного разбора кода.
@@ -108,10 +114,12 @@ Kafka → ClickHouse → BI).
## 4. Скоуп ## 4. Скоуп
### Входит ### Входит
- **Уроки 05 (обязательные):** вводный урок по Kafka, заземление Kafka→CH, STG→ODS - **Уроки 06 (обязательные):** вводный урок по Kafka, заземление Kafka→CH, STG→ODS
(типизация + DQ), ODS→DDS (сборка сущностей), Airflow, мониторинг. Принцип нарезки — (типизация + DQ), ODS→DDS (сборка сущностей), Airflow, мониторинг. Принцип нарезки —
один прод-паттерн на урок; контраст «где Materialized View, а где батч» проходит один прод-паттерн на урок; контраст «где Materialized View, а где батч» проходит
мостом уроков 1→2. мостом уроков 1→2. Урок 6 закрывает BI-слой в Superset.
- **Лабы 07–08 (обязательные):** пакетная дозаливка следующего дня, затем живое
продолжение потока.
- Витрины **DM — не отдельный урок**: их показываем в деле там, где их потребляют - Витрины **DM — не отдельный урок**: их показываем в деле там, где их потребляют
(мониторинг и BI). См. `LEARNING_PLAN.md` §12. (мониторинг и BI). См. `LEARNING_PLAN.md` §12.
- **Аудит и точечная полировка эталонных путей** этих уроков до учебного качества - **Аудит и точечная полировка эталонных путей** этих уроков до учебного качества
@@ -121,10 +129,6 @@ Kafka → ClickHouse → BI).
Подробная карта уроков (файлы стенда, статус, режим, вердикты аудита) — в плане Подробная карта уроков (файлы стенда, статус, режим, вердикты аудита) — в плане
обучения `LEARNING_PLAN.md`. обучения `LEARNING_PLAN.md`.
### Опционально
- **Урок 6: Superset (BI-витрина).** Делаем, если останется ресурс; обязательные
уроки он не блокирует.
### Не входит ### Не входит
- Переписывание всего стенда: полируем только эталонные пути обязательных уроков, - Переписывание всего стенда: полируем только эталонные пути обязательных уроков,
остальной код стенда остаётся под капотом. остальной код стенда остаётся под капотом.
@@ -171,8 +175,7 @@ Kafka → ClickHouse → BI).
(зеркало урока 4) — урок 5 даёт «сломал-увидел», а не чистое наблюдение. См. (зеркало урока 4) — урок 5 даёт «сломал-увидел», а не чистое наблюдение. См.
`LEARNING_PLAN.md` §3.1. `LEARNING_PLAN.md` §3.1.
- ~~Нужна ли BI-витрина (Superset) уже в первой версии.~~ **Решено:** урок 6 написан и - ~~Нужна ли BI-витрина (Superset) уже в первой версии.~~ **Решено:** урок 6 написан и
синхронизирован с реальным дашбордом; остаётся опциональным (обязательные уроки не синхронизирован с реальным дашбордом. С 2026-07-23 он входит в обязательный маршрут.
блокирует).
Закрыто позже: Закрыто позже:
+32 -28
View File
@@ -27,30 +27,36 @@
поднимаются одновременно. Нужна машина, которая это потянет. поднимаются одновременно. Нужна машина, которая это потянет.
- **Инструменты:** `Docker` с `docker compose`, `make`, `bash`, `curl`, `git` и `uv`. - **Инструменты:** `Docker` с `docker compose`, `make`, `bash`, `curl`, `git` и `uv`.
`uv` нужен для локальных Python-проверок и команд разработки. `uv` нужен для локальных Python-проверок и команд разработки.
- **Подними стенд и создай стартовую историю** (из корня репозитория) — этого хватит,
чтобы начать, и прогон быстрый:
```bash ## Подготовка и канонический сброс
make generated-history-analytics
make up
```
Эта команда проводит штатный путь стенда: готовый источник данных создаёт стартовую Первый запуск и возврат к чистому эталонному миру идут одним путём. При первом запуске
историю, события попадают в Kafka, затем в STG, ODS, DDS, DM и Superset. Это тот же пропусти `make clean`; для полного сброса выполни все три шага:
путь, что ручной вариант из README (Airflow UI и операция `backfill`), но одной
командой — выбери один из двух, оба ведут к одинаковому стенду. Файлы
`data/*.jsonl` пока остаются только кладовкой готовых значений для генератора
(браузеры, страны, устройства, UTM), а не источником аналитического контура. `make up`
после неё поднимает остальные UI-сервисы курса: Kafka UI, Airflow, Prometheus и Grafana.
Дальше каждый урок в секции «Руки» сам напоминает, что перезапустить. 1. Выполни `make clean`. Команда удалит данные стенда и метаданные Superset: сохранённые
Точные шаги, параметры и troubleshooting — в [`docs/OPERATIONS.md`](../OPERATIONS.md). в нём настройки и дашборды тоже придётся создать заново.
2. Выполни `make up` и открой Airflow на `http://localhost:8080` (`admin/admin`).
На свежем стенде DAG-и стоят на паузе. Подготовь и запусти их по порядку:
- `ddl_init` — сними паузу и запусти с пустой формой;
- `etl_pipeline` — только сними паузу: его вызовет следующий DAG;
- `world_init` — сними паузу и запусти с пустой формой после успешного
`ddl_init`.
3. Когда `world_init` завершится успешно и витрины DM будут готовы, выполни
`make superset-init`.
Так события из эталонного мира попадут в Kafka, затем в STG, ODS, DDS и DM, а Superset
получит готовые наборы данных и дашборд. Файлы `data/*.jsonl` остаются только кладовкой
готовых значений для генератора (браузеры, страны, устройства, UTM), а не источником
аналитического контура.
Точные параметры и разбор ошибок — в [`docs/OPERATIONS.md`](../OPERATIONS.md).
## Уроки ## Уроки
Проходи по порядку — уроки 0→4 повторяют сам пайплайн (Kafka → STG → ODS → DDS → Проходи по порядку — уроки 0→4 повторяют сам пайплайн (Kafka → STG → ODS → DDS →
оркестрация), а 5 и 6 надстраиваются поверх готовых данных. В колонке «режим»: оркестрация), а 5 и 6 надстраиваются поверх готовых данных. Лабы 7 и 8 затем
**наблюдение** — только смотрим, **руки** — запускаешь и меняешь сам. растят эталонный мир двумя способами. В колонке «режим»: **наблюдение** — только
смотрим, **руки** — запускаешь и меняешь сам.
| # | Урок | Режим | О чём | | # | Урок | Режим | О чём |
|---|------|-------|-------| |---|------|-------|-------|
@@ -60,7 +66,9 @@
| 3 | [ODS → DDS: сборка сущностей](./lessons/03_ods_to_dds.md) | руки | Собираем `click` и `event` из кусочков (argMax, JOIN) и встречаем «сирот» | | 3 | [ODS → DDS: сборка сущностей](./lessons/03_ods_to_dds.md) | руки | Собираем `click` и `event` из кусочков (argMax, JOIN) и встречаем «сирот» |
| 4 | [Оркестрация в Airflow](./lessons/04_airflow_orchestration.md) | руки | Всю цепочку — в один DAG с зависимостями и честным гейтом целостности | | 4 | [Оркестрация в Airflow](./lessons/04_airflow_orchestration.md) | руки | Всю цепочку — в один DAG с зависимостями и честным гейтом целостности |
| 5 | [Мониторинг: Prometheus и Grafana](./lessons/05_monitoring.md) | наблюдение + мини-правка | Смотрим систему со стороны; гасим сервис — видим, как краснеет алерт | | 5 | [Мониторинг: Prometheus и Grafana](./lessons/05_monitoring.md) | наблюдение + мини-правка | Смотрим систему со стороны; гасим сервис — видим, как краснеет алерт |
| 6 | [BI-витрина в Superset](./lessons/06_superset_bi.md) | руки (опционально) | Дашборд поверх ClickHouse: KPI, динамика, воронка | | 6 | [BI-витрина в Superset](./lessons/06_superset_bi.md) | руки | Дашборд поверх ClickHouse: KPI, динамика, воронка |
| 7 | [Лаба: следующий день](./lessons/07_lab_next_day.md) | руки | Пакетный инкремент дня и границы времени |
| 8 | [Лаба: живое продолжение](./lessons/08_lab_continue.md) | руки | Живой поток и свежесть данных |
## Как проходить ## Как проходить
@@ -76,22 +84,18 @@
## Проверка чистого маршрута ## Проверка чистого маршрута
Перед проверкой уроков 0, 1 и 5 подними стенд с нуля: Перед проверкой уроков 0, 1 и 5 пройди
[канонический сброс](#подготовка-и-канонический-сброс).
```bash
make generated-history-analytics
make up
```
Что должен подтвердить человек: Что должен подтвердить человек:
- урок 0: в Kafka UI видны четыре топика событий и понятны служебные топики - урок 0: в Kafka UI видны четыре топика событий и понятны служебные топики
генератора; генератора;
- урок 1: после `CLEAN_START=0 make generated-history-analytics` учебная колонка - урок 1: после запуска `world_next_day` учебная колонка `kafka_msg_ts` не пропадает
`kafka_msg_ts` не пропадает и заполняется; и заполняется;
- урок 5: Prometheus targets `clickhouse`, `kafka`, `airflow` находятся в `UP`, - урок 5: Prometheus targets `clickhouse`, `kafka`, `airflow` находятся в `UP`,
а `Kafka No Messages Produced` трактуется с учётом того, запущен live-генератор а `Kafka No Messages Produced` трактуется с учётом того, включён живой поток
или только стартовая история. или стенд работает на импортированной базе.
## Границы ## Границы
+11 -9
View File
@@ -41,12 +41,9 @@ consumer читает в своём темпе, не трогая producer'а.
## 2. Наблюдай: открой Kafka UI ## 2. Наблюдай: открой Kafka UI
Стенд уже должен быть поднят, а в топиках должны лежать события после стартовой истории: Подготовь стенд по
[канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
```bash После успешного `world_init` в топиках уже лежат события эталонного мира.
make generated-history-analytics
make up
```
Открой Kafka UI: Открой Kafka UI:
`http://localhost:8082`. Ходи по нему свободно — это режим чтения, сломать тут ничего нельзя. `http://localhost:8082`. Ходи по нему свободно — это режим чтения, сломать тут ничего нельзя.
@@ -79,15 +76,20 @@ make up
- **Offset** — порядковый номер сообщения в партиции (0, 1, 2, …); - **Offset** — порядковый номер сообщения в партиции (0, 1, 2, …);
- **Timestamp** — когда сообщение легло в Kafka (время *доставки*, не время самого события); - **Timestamp** — когда сообщение легло в Kafka (время *доставки*, не время самого события);
- **Value** — тело: JSON события целиком, например: - **Value** — тело: JSON события целиком. Например, у сообщения с offset `0`:
```json ```json
{"event_id": "8cca1c7d-...", "event_timestamp": "2026-01-01 00:01:00.000000", {"event_id": "b3336fa4-184d-44e5-9878-98c8f8058496",
"event_type": "pageview", "browser_name": "Chrome", "browser_language": "sat_IN"} "event_timestamp": "2026-01-01 00:00:00.000000", "event_type": "pageview",
"click_id": "76a146f2-adc3-4065-8140-9fc42523b554",
"browser_name": "Opera",
"browser_user_agent": "Opera/9.44.(Windows NT 5.0; iw-IL) Presto/2.9.183 Version/12.00",
"browser_language": "ha_NG"}
``` ```
Загляни внутрь Value: у события есть своё `event_timestamp` (когда оно случилось, Загляни внутрь Value: у события есть своё `event_timestamp` (когда оно случилось,
модельное время стенда), и оно отличается от Kafka-Timestamp (когда оно попало в топик). модельное время стенда), и оно отличается от Kafka-Timestamp (когда оно попало в топик).
Kafka-Timestamp зависит от времени импорта, поэтому у тебя будет своё значение.
Два разных времени у одной записи — запомни этот момент, в уроке 1 он всплывёт уже на Два разных времени у одной записи — запомни этот момент, в уроке 1 он всплывёт уже на
стороне ClickHouse. стороне ClickHouse.
+17 -38
View File
@@ -49,15 +49,9 @@
## 2. Руки: убедись, что данные текут ## 2. Руки: убедись, что данные текут
Поднимаем стенд и создаём стартовую историю. Это штатный путь курса: готовый источник Подготовь стенд по
данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит [канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
ODS, DDS и DM. Файлы `data/*.jsonl` в этом пути не источник аналитики; пока это только После успешного `world_init` эталонный мир уже прошёл путь Kafka → STG → ODS → DDS → DM.
кладовка значений для источника данных стенда.
```bash
make generated-history-analytics
make up
```
Теперь смотрим, что доехало до ClickHouse. Открой SQL-консоль: Теперь смотрим, что доехало до ClickHouse. Открой SQL-консоль:
`http://localhost:9123/play` (или Kafka UI на `http://localhost:8082`, чтобы тем же `http://localhost:9123/play` (или Kafka UI на `http://localhost:8082`, чтобы тем же
@@ -189,27 +183,23 @@ SELECT
FROM stg.kafka_browser_raw; FROM stg.kafka_browser_raw;
``` ```
**Перезаливаем срез, чтобы новая колонка заполнилась.** Сначала чистим только таблицу **Дозаливаем следующий день, чтобы новая колонка заполнилась.** Не очищай
приёмника — иначе рядом останутся строки из секции 2, вставленные ещё *до* `stg.browser_raw`: `world_next_day` растит уже импортированный мир, а его финальная
`ADD COLUMN`, и в них `kafka_msg_ts` будет пустой (`1970-01-01`): проверка сверяет весь накопленный результат. В старых строках, вставленных до
`ADD COLUMN`, `kafka_msg_ts` останется пустым (`1970-01-01`) — ниже мы их отфильтруем.
```sql Открой Airflow и кнопкой **Trigger DAG** запусти `world_next_day` с пустой формой.
TRUNCATE TABLE stg.browser_raw; Он дозальёт в Kafka следующий день, а ClickHouse прочитает новые сообщения уже с
``` изменённой схемой. Такой прогон занимает несколько минут — дождись состояния
`success`. Здесь мы только нажимаем готовую кнопку; подробно `world_next_day`
Затем повторно создаём стартовую историю **без очистки volumes**. Это важно: разберём в лабе 07.
`CLEAN_START=0` сохраняет твою новую колонку и пересозданное MV, но добавляет свежие
сообщения в Kafka, чтобы ClickHouse прочитал их уже с новой схемой.
```bash
CLEAN_START=0 make generated-history-analytics
```
**Смотрим результат:** **Смотрим результат:**
```sql ```sql
SELECT kafka_ts, kafka_msg_ts SELECT kafka_ts, kafka_msg_ts
FROM stg.browser_raw FROM stg.browser_raw
WHERE kafka_msg_ts > toDateTime(0)
ORDER BY kafka_offset ORDER BY kafka_offset
LIMIT 5; LIMIT 5;
``` ```
@@ -224,19 +214,8 @@ LIMIT 5;
> теряется. Обратный случай — колонку добавил, а в MV не указал — тоже не упадёт: поле > теряется. Обратный случай — колонку добавил, а в MV не указал — тоже не упадёт: поле
> заполнится дефолтом. Вывод: за синхронность схемы и MV отвечаешь ты, а не движок. > заполнится дефолтом. Вывод: за синхронность схемы и MV отвечаешь ты, а не движок.
**Верни как было** (откат — тоже две операции, и порядок важен): **Верни как было.** После дозаливки мир уже вырос, поэтому верни схему и данные
[каноническим сбросом](../README.md#подготовка-и-канонический-сброс).
```sql
DROP VIEW stg.mv_kafka_browser_to_stg; -- сначала MV, что ссылается на колонку
ALTER TABLE stg.browser_raw DROP COLUMN kafka_msg_ts;
```
```bash
make ddl # пересоздаёт эталонное MV из 10_stg.sql, схема снова как в репозитории
```
Если запутался в состоянии — всегда есть полный чистый прогон:
`make generated-history-analytics && make up`.
--- ---
@@ -244,10 +223,10 @@ make ddl # пересоздаёт эталонное MV из 10_stg.sql, с
| Действие | Где смотреть | Что ожидать | | Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------| |----------|--------------|-------------|
| `make generated-history-analytics && make up` | `SELECT count() FROM stg.browser_raw` | счётчик > 0 | | каноническая подготовка | `SELECT count() FROM stg.browser_raw` | счётчик > 0 |
| глянуть строку | `SELECT raw FROM stg.browser_raw LIMIT 1` | валидный JSON целиком, неразобранный | | глянуть строку | `SELECT raw FROM stg.browser_raw LIMIT 1` | валидный JSON целиком, неразобранный |
| глянуть offset'ы | `SELECT kafka_offset FROM stg.browser_raw ORDER BY kafka_offset` | идут по возрастанию, без дублей | | глянуть offset'ы | `SELECT kafka_offset FROM stg.browser_raw ORDER BY kafka_offset` | идут по возрастанию, без дублей |
| правка из секции 4 | `SELECT kafka_msg_ts FROM stg.browser_raw LIMIT 5` | колонка заполнена временем сообщения | | `world_next_day` после правки из секции 4 | `SELECT kafka_msg_ts FROM stg.browser_raw WHERE kafka_msg_ts > toDateTime(0) LIMIT 5` | новые строки заполнены временем сообщения |
--- ---
+27 -29
View File
@@ -84,32 +84,25 @@ ODS мы пересобираем целиком, одной задачей Airf
## 2. Руки: смотрим базовый прогон ## 2. Руки: смотрим базовый прогон
Поднимаем стенд и создаём стартовую историю. Это штатный путь курса: готовый источник Подготовь стенд по
данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит [канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений для этого После успешного `world_init` эталонный мир уже прошёл путь Kafka → STG → ODS → DDS → DM.
источника, а не источником аналитического контура.
```bash Ниже — форма блока **«Статистика ODS»**, который печатает `make transform`. Это просто
make generated-history-analytics счётчики строк по всем восьми таблицам слоя (четыре основных и четыре с ошибками):
make up
```
Команда прогоняет всю цепочку слоёв и прямо в консоли печатает то, что нам нужно сейчас, —
блок **«Статистика ODS»**. Это просто счётчики строк по всем восьми таблицам слоя (четыре
основных и четыре с ошибками). Пример формы вывода:
``` ```
Статистика ODS: Статистика ODS:
┌─table──────────────────────┬─rows─┐ ┌─table──────────────────────┬───rows─┐
│ ods.browser_event │ ... │ ods.browser_event │ 280437
│ ods.location_event │ ... │ ods.location_event │ 280437
│ ods.device_by_click │ ... │ ods.device_by_click │ 26083
│ ods.geo_by_click │ ... │ ods.geo_by_click │ 26083
│ ods.browser_event_errors │ 0 │ │ ods.browser_event_errors │ 0 │
│ ods.location_event_errors │ 0 │ │ ods.location_event_errors │ 0 │
│ ods.device_by_click_errors │ 0 │ │ ods.device_by_click_errors │ 0 │
│ ods.geo_by_click_errors │ 0 │ │ ods.geo_by_click_errors │ 0 │
└────────────────────────────┴──────┘ └────────────────────────────┴────────
``` ```
Прочитаем эту табличку — в ней три вещи, которые стоит заметить. Прочитаем эту табличку — в ней три вещи, которые стоит заметить.
@@ -139,9 +132,10 @@ SELECT count() AS stg_rows,
FROM stg.geo_raw; FROM stg.geo_raw;
``` ```
`distinct_clicks` должен быть меньше или равен `stg_rows` и совпадать с числом строк в На эталонном мире запрос возвращает `stg_rows = 280437` и
`ods.geo_by_click`. Значит, это схлопнутые повторы по `click_id`, а не пропавшие данные. `distinct_clicks = 26083`. Второе число совпадает с числом строк в
Ничего не потерялось молча. `ods.geo_by_click`. Значит, это схлопнутые повторы по `click_id`, а не пропавшие
данные. Ничего не потерялось молча.
--- ---
@@ -250,9 +244,9 @@ ORDER BY (click_id)
Урок про типы — так давай **намеренно ошибёмся типом** и посмотрим, что будет. Это самый Урок про типы — так давай **намеренно ошибёмся типом** и посмотрим, что будет. Это самый
поучительный момент урока. поучительный момент урока.
Возьмём координату `geo_latitude` — широту. Это дробное число, например `50.82709`. Достаём мы Возьмём координату `geo_latitude` — широту. Это дробное число, например `-7.60361`. Достаём мы
её через `toFloat64OrNull` — «привести к дробному числу». Заменим тип на целочисленный — её через `toFloat64OrNull` — «привести к дробному числу». Заменим тип на целочисленный —
`toInt64OrNull`, «привести к целому». Для строки `"50.82709"` целого числа не получится `toInt64OrNull`, «привести к целому». Для строки `"-7.60361"` целого числа не получится
(там точка, дробная часть), и функция вернёт `NULL`. То есть широта просто исчезнет. (там точка, дробная часть), и функция вернёт `NULL`. То есть широта просто исчезнет.
Из секции 3 помним: разбор продублирован, поэтому правок будет **две** — в обоих `INSERT` Из секции 3 помним: разбор продублирован, поэтому правок будет **две** — в обоих `INSERT`
@@ -275,10 +269,13 @@ make transform
И смотрим на ту же «Статистику ODS». Таблица ошибок гео, которая была пустой, теперь полная: И смотрим на ту же «Статистику ODS». Таблица ошибок гео, которая была пустой, теперь полная:
``` ```
│ ods.geo_by_click │ ... │ ods.geo_by_click │ ≈ 26083
│ ods.geo_by_click_errors │ ... │ ← было 0 │ ods.geo_by_click_errors │ 280437 │ ← было 0
``` ```
В таблице ошибок число точное. В `ods.geo_by_click` после фонового схлопывания
останется `26083` строки, но сразу после прогона число может быть больше.
А в самой основной таблице широта пропала — но не молча, рядом стоит метка: А в самой основной таблице широта пропала — но не молча, рядом стоит метка:
```sql ```sql
@@ -289,8 +286,8 @@ LIMIT 4;
``` ```
┌─click_id─────┬─geo_latitude─┬─geo_longitude─┬─parse_errors─────────┐ ┌─click_id─────┬─geo_latitude─┬─geo_longitude─┬─parse_errors─────────┐
58cdfc1e-... │ ᴺᵁᴸᴸ │ -0.2 │ ['bad_geo_latitude'] │ cee12466-... │ ᴺᵁᴸᴸ │ -8.07257 │ ['bad_geo_latitude'] │
9ffd819b-... │ ᴺᵁᴸᴸ │ 85.37752 │ ['bad_geo_latitude'] │ a8e39850-... │ ᴺᵁᴸᴸ │ 37.92792 │ ['bad_geo_latitude'] │
└──────────────┴──────────────┴───────────────┴──────────────────────┘ └──────────────┴──────────────┴───────────────┴──────────────────────┘
``` ```
@@ -316,8 +313,9 @@ git checkout -- sql/ods/20_stg_to_ods.sql
make transform make transform
``` ```
После этого `geo_by_click_errors` снова `0`, широта на месте. А если стенд совсем «поплыл» — После этого `geo_by_click_errors` снова `0`, широта на месте. А если стенд совсем
всегда есть полный чистый прогон: `make generated-history-analytics && make up`. «поплыл», пройди
[канонический сброс](../README.md#подготовка-и-канонический-сброс).
--- ---
+13 -18
View File
@@ -65,25 +65,19 @@
## 2. Руки: смотрим базовый прогон ## 2. Руки: смотрим базовый прогон
Поднимаем стенд и создаём стартовую историю. Это штатный путь курса: готовый источник Подготовь стенд по
данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит [канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений для этого После успешного `world_init` эталонный мир уже прошёл путь Kafka → STG → ODS → DDS → DM.
источника, а не источником аналитического контура.
```bash Ниже — форма блока **«Статистика DDS»**, который печатает `make transform`: счётчики
make generated-history-analytics строк по двум нашим сущностям.
make up
```
Команда прогоняет всю цепочку слоёв и по дороге печатает в консоль блок **«Статистика DDS»** —
счётчики строк по двум нашим сущностям:
``` ```
Статистика DDS: Статистика DDS:
┌─table─────┬─rows─┐ ┌─table─────┬───rows─┐
│ dds.click │ ... │ dds.click │ 26083
│ dds.event │ ... │ dds.event │ 280437
└───────────┴──────┘ └───────────┴────────
``` ```
Прочитаем эти две строки. Прочитаем эти две строки.
@@ -101,7 +95,7 @@ make up
``` ```
┌─check_date─┬─layer─┬─table_name──────────┬─check_name────┬─check_value─┐ ┌─check_date─┬─layer─┬─table_name──────────┬─check_name────┬─check_value─┐
│ 2026-06-05 │ dds │ event_without_click │ orphan_events │ 0 │ │ 2026-07-23 │ dds │ event_without_click │ orphan_events │ 0 │
└────────────┴───────┴─────────────────────┴───────────────┴─────────────┘ └────────────┴───────┴─────────────────────┴───────────────┴─────────────┘
``` ```
@@ -308,8 +302,9 @@ DDS — `make transform` чистит `dds.event` (`TRUNCATE`) и наполня
make transform make transform
``` ```
После этого `orphan_events` снова `0`, придуманное событие исчезло. А если стенд совсем «поплыл» — После этого `orphan_events` снова `0`, придуманное событие исчезло. А если стенд
полный чистый прогон: `make generated-history-analytics && make up`. совсем «поплыл», пройди
[канонический сброс](../README.md#подготовка-и-канонический-сброс).
--- ---
+21 -22
View File
@@ -56,15 +56,19 @@ Airflow. Главная единица Airflow — **DAG** (Directed Acyclic Gra
## 2. Руки: запускаем DAG и смотрим зелёный прогон ## 2. Руки: запускаем DAG и смотрим зелёный прогон
Подними стенд и создай стартовую историю. Это штатный путь курса: готовый источник Подготовь стенд по
данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит [канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений для этого После успешного `world_init` эталонный мир уже прошёл путь Kafka → STG → ODS → DDS → DM.
источника, а не источником аналитического контура.
```bash Перед разбором `etl_pipeline` вспомни пульт курса как лесенку:
make generated-history-analytics
make up 1. `ddl_init` создаёт схему ClickHouse;
``` 2. `world_init` импортирует эталонный мир и запускает его обработку;
3. `world_next_day` дозаливает следующий день и снова запускает обработку.
Первые две ступени ты уже прошёл при подготовке. Третью пока только запомни: подробно
её разберём в лабе 07. Внутри двух последних ступеней работает тот самый
`etl_pipeline`, который мы сейчас откроем отдельно.
Открой Airflow: `http://localhost:8080` (логин `admin`, пароль `admin`). Найди DAG Открой Airflow: `http://localhost:8080` (логин `admin`, пароль `admin`). Найди DAG
`etl_pipeline` и запусти его через **Trigger DAG with config**: `etl_pipeline` и запусти его через **Trigger DAG with config**:
@@ -77,8 +81,9 @@ make up
заново наполнит их из ODS. Для учебного стенда это удобный чистый прогон: результат повторяемый, заново наполнит их из ODS. Для учебного стенда это удобный чистый прогон: результат повторяемый,
старые эксперименты не мешают. старые эксперименты не мешают.
Когда DAG завершится, открой его граф. На чистой стартовой истории все задачи должны быть Когда DAG завершится, открой его граф. На чистой стартовой истории выбранные задачи
зелёными. Найди внутри группы `transform` две задачи подряд: должны быть зелёными, а невыбранная ветка `skip_truncate` — в состоянии `skipped`.
Найди внутри группы `transform` две задачи подряд:
- `check_dds_integrity` — SQL-задача, которая считает сирот; - `check_dds_integrity` — SQL-задача, которая считает сирот;
- `assert_dds_integrity` — Python-задача, которая решает, можно ли идти дальше. - `assert_dds_integrity` — Python-задача, которая решает, можно ли идти дальше.
@@ -95,8 +100,9 @@ WHERE layer = 'dds'
AND check_name = 'orphan_events'; AND check_name = 'orphan_events';
``` ```
Ожидаем `check_value = 0`. Это тот же смысл, что в уроке 3, только теперь число появилось внутри На проверенном стенде `check_date = 2026-07-23`, `check_value = 0`. Дата берётся
управляемого прогона Airflow. из `today()`, поэтому у тебя будет своя. Это тот же смысл, что в уроке 3, только
теперь число появилось внутри управляемого прогона Airflow.
--- ---
@@ -307,15 +313,8 @@ WHERE click_id IS NOT NULL
AND click_id NOT IN (SELECT click_id FROM dds.click); AND click_id NOT IN (SELECT click_id FROM dds.click);
``` ```
Снова должно быть `0`. Если стенд после экспериментов совсем запутался, сделай штатный Снова должно быть `0`. Если стенд после экспериментов совсем запутался, пройди
чистый прогон: [канонический сброс](../README.md#подготовка-и-канонический-сброс).
```bash
make generated-history-analytics
make up
```
После этого при необходимости запусти `etl_pipeline` с `{"full_refresh": true}`.
--- ---
@@ -323,7 +322,7 @@ make up
| Действие | Где смотреть | Что ожидать | | Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------| |----------|--------------|-------------|
| `etl_pipeline` с `{"full_refresh": true}` | Airflow graph | все задачи зелёные | | `etl_pipeline` с `{"full_refresh": true}` | Airflow graph | выбранная ветка зелёная, `skip_truncate` в `skipped` |
| чистый прогон | `dm.dq_summary`, строка `orphan_events` | `0` | | чистый прогон | `dm.dq_summary`, строка `orphan_events` | `0` |
| вставка события-сироты | прямой SQL-счётчик сирот | `0 → 1` | | вставка события-сироты | прямой SQL-счётчик сирот | `0 → 1` |
| `etl_pipeline` с `{"full_refresh": false}` после вставки | task `transform.assert_dds_integrity` | task красная, DAG failed | | `etl_pipeline` с `{"full_refresh": false}` после вставки | task `transform.assert_dds_integrity` | task красная, DAG failed |
+21 -32
View File
@@ -57,24 +57,10 @@
## 2. Руки: открываем дашборды и targets ## 2. Руки: открываем дашборды и targets
Подними стенд и создай стартовую историю, если он ещё не поднят. Это штатный путь курса: Подготовь стенд по
готовый источник данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем [канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
batch строит ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений После успешного `world_init` эталонный мир уже прошёл путь Kafka → STG → ODS → DDS → DM,
для этого источника, а не источником аналитического контура. а в Airflow есть завершённый прогон `etl_pipeline`.
```bash
make generated-history-analytics
make up
```
Учти: путь `generated-history-analytics` собирает витрины напрямую, без запуска
`etl_pipeline`, поэтому панели про задачи Airflow в `Airflow Overview` останутся
пустыми, пока ты хотя бы раз не запустишь `etl_pipeline` сам (это делалось в
уроке 4). Запустить его можно в Airflow с конфигом:
```json
{"full_refresh": true}
```
Нам нужны не идеальные объёмы, а живой стенд, в котором есть Kafka-топики, строки в ClickHouse Нам нужны не идеальные объёмы, а живой стенд, в котором есть Kafka-топики, строки в ClickHouse
и хотя бы один прогон Airflow. и хотя бы один прогон Airflow.
@@ -92,10 +78,10 @@ make up
| `airflow` | `statsd-exporter:9102` | `statsd-exporter` отдаёт метрики Airflow в формате Prometheus | | `airflow` | `statsd-exporter:9102` | `statsd-exporter` отдаёт метрики Airflow в формате Prometheus |
| `generator` | `generator:9109` | live-генератор отдаёт свои метрики, только когда явно запущен | | `generator` | `generator:9109` | live-генератор отдаёт свои метрики, только когда явно запущен |
У `clickhouse`, `kafka` и `airflow` состояние должно быть `UP`. `generator` на штатном У `clickhouse`, `kafka` и `airflow` состояние должно быть `UP`. `generator` на базе
backfill-only стенде может быть `DOWN`, потому что `make up` не запускает live-генератор. импортированного мира может быть `DOWN`: живой поток включается отдельно через
Это нормально для курса до явного `make generator-continue`. Если один из трёх основных `make generator-continue`. Если один из трёх основных target `DOWN`, Grafana дальше
target `DOWN`, Grafana дальше будет показывать `No data` или старые значения. будет показывать `No data` или старые значения.
То же можно проверить из терминала: То же можно проверить из терминала:
@@ -125,7 +111,7 @@ curl -s http://localhost:9090/api/v1/targets | grep -o '"health":"[^"]*"'
- `ClickHouse Overview`; - `ClickHouse Overview`;
- `Kafka Overview`; - `Kafka Overview`;
- `Airflow Overview`; - `Airflow Overview`;
- `Generator Overview` — про live-генератор; на backfill-only стенде он пуст, - `Generator Overview` — про живой поток; на базе импортированного мира он пуст,
как и target `generator` выше, и в этом уроке не понадобится. как и target `generator` выше, и в этом уроке не понадобится.
Открой каждый и смотри не на красоту графиков, а на смысл: какой слой стенда он показывает и Открой каждый и смотри не на красоту графиков, а на смысл: какой слой стенда он показывает и
@@ -272,11 +258,11 @@ Grafana.
| Airflow Alerts | `High Task Failure Rate` | растёт rate failed tasks | | Airflow Alerts | `High Task Failure Rate` | растёт rate failed tasks |
| Airflow Alerts | `High DAG Parse Time` | DAG-файлы долго парсятся | | Airflow Alerts | `High DAG Parse Time` | DAG-файлы долго парсятся |
Не все эти правила обязаны быть тихими в учебном стенде. По умолчанию `make up` и Не все эти правила обязаны быть тихими в учебном стенде. После импорта эталонной базы
`make generated-history-analytics` **не запускают live-генератор**, поэтому после готовой живой поток не запущен, поэтому новые сообщения не приходят. Из-за этого
стартовой истории новые сообщения перестают приходить. Из-за этого `Kafka No Messages Produced` `Kafka No Messages Produced` может перейти в `Alerting` на полностью здоровом стенде:
может перейти в `Alerting` на полностью здоровом backfill-only стенде. Если хочешь проверить алерт честно говорит, что потока сейчас нет, а не что импорт сломан. Если хочешь
это правило в спокойном состоянии, явно включи live: проверить правило в спокойном состоянии, явно включи живой поток:
```bash ```bash
make generator-continue make generator-continue
@@ -288,6 +274,10 @@ make generator-continue
make generator-down make generator-down
``` ```
Остановка генератора не удаляет уже приехавшие данные и сохранённое состояние.
После этого необязательного опыта верни эталонный мир по
[канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
--- ---
## 4. Управляемая правка: остановим scheduler и увидим алерт ## 4. Управляемая правка: остановим scheduler и увидим алерт
@@ -395,7 +385,6 @@ make recover-monitoring
## Мост к следующему шагу ## Мост к следующему шагу
Теперь стенд закрывает полный учебный маршрут: Kafka принимает поток, ClickHouse раскладывает Теперь ты видишь состояние пайплайна со стороны: где идут данные, где растёт
слои, Airflow управляет порядком, а Prometheus и Grafana показывают состояние системы. Дальше отставание и где сработал алерт. В уроке 6 у готовых витрин появится
этот же стенд можно использовать не как разовый набор уроков, а как тренажёр: менять данные, потребитель — дашборд в Superset.
ломать отдельные места, смотреть, где появляется сигнал, и объяснять по метрикам, что произошло.
+8 -16
View File
@@ -63,17 +63,10 @@ DM-витрина — это SQL-объект в ClickHouse. Она задаёт
## 2. Руки: запускаем Superset и смотрим дашборд ## 2. Руки: запускаем Superset и смотрим дашборд
Подними стенд и создай стартовую историю. Это штатный путь курса: готовый источник Подготовь стенд по
данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит [канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
ODS, DDS, DM и обновляет Superset. Файлы `data/*.jsonl` пока остаются только кладовкой После успешного `world_init` эталонный мир уже прошёл путь Kafka → STG → ODS → DDS → DM,
значений для этого источника, а не источником аналитического контура. а `make superset-init` создал метаданные Superset:
```bash
make generated-history-analytics
make up
```
Команда уже прогоняет цепочку STG → ODS → DDS → DM и создаёт metadata Superset:
- подключение `clickhouse_dwh`; - подключение `clickhouse_dwh`;
- 6 datasets поверх `dm.*`. - 6 datasets поверх `dm.*`.
@@ -432,9 +425,8 @@ metadata Superset.
--- ---
## Мост после курса ## Мост к следующему шагу
Теперь у тебя есть сквозная цепочка: Kafka → ClickHouse STG → ODS → DDS → DM → мониторинг → Теперь у тебя есть сквозная цепочка от Kafka до дашборда в Superset. В лабе 07
Superset. Следующий честный вопрос уже не про этот стенд, а про продакшен: какие витрины стоит ты добавишь в этот мир следующий модельный день и проверишь границу двух
материализовать, какие права дать BI-пользователям и как не превратить dashboard в единственный суточных порций.
источник правды вместо версионированного SQL в репозитории.
+297
View File
@@ -0,0 +1,297 @@
# Лаба 07. Следующий день и границы времени
> Формат: **практика** — добавишь к эталонному миру ещё один модельный день,
> проверишь стык дней и ненадолго включишь расписание.
> Пререквизит: пройден урок 6; ты умеешь запускать DAG в Airflow, читать граф
> задач и смотреть дневную динамику в Superset.
> Эталонный путь:
> [`airflow/dags/world_next_day_dag.py`](../../../airflow/dags/world_next_day_dag.py).
>
> Поток данных одной строкой:
> `эталонный мир (3 дня) → world_next_day → Kafka → STG → etl_pipeline → ODS → DDS → DM (4 дня)`
>
> О чём лаба простыми словами: ночью в хранилище часто приезжает очередной день
> данных. Ты добавишь такой день и проверишь место, где две суточные порции
> соприкасаются.
---
## 1. Зачем и где в проде
Пакетный инкремент — это новая порция данных за ограниченный период. Например,
ночью в хранилище приезжают события за вчера. Старые дни остаются на месте, а
пайплайн добавляет и проверяет новый.
Граница порции требует отдельного внимания. Визит может начаться перед
полуночью, а закончиться после неё. Если обрабатывать каждый день как
изолированный файл, половины такого визита окажутся в разных запусках. Отсюда
появляются проверки стыков, запоздавшие данные и пересчёт хвоста прошлого дня.
В этой лабе `world_next_day` добавляет ровно 24 модельных часа. Сам генератор
накапливает контрольные числа, а затем DAG запускает полный пересчёт
`etl_pipeline`. На маленьком стенде такой путь понятен и повторяем.
> **В проде иначе.** Суточную задачу обычно привязывают к интервалу данных:
> повторный запуск того же интервала должен дать тот же результат. Здесь каждый
> ручной запуск сдвигает правую границу мира ещё на сутки. Это удобно для
> тренажёра, но не делает DAG идемпотентным.
---
## 2. Руки: добавляем день 4
Подготовь стенд по
[канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
После успешного `world_init` в мире есть три модельных дня.
### Запускаем `world_next_day`
Открой Airflow: `http://localhost:8080` (логин `admin`, пароль `admin`).
Найди DAG `world_next_day` и запусти его через **Trigger DAG** с пустой формой.
Параметры ему не нужны.
В графе должны позеленеть задачи:
```text
check_etl_not_paused
→ precheck_next_day
→ run_next_day
→ trigger_etl
→ check_after_etl
```
`run_next_day` дописывает день 4 в Kafka. Затем `trigger_etl` запускает
`etl_pipeline` с полным пересчётом DDS и DM. Последняя задача сверяет ClickHouse
с manifest — паспортом мира, где лежат границы времени и контрольные числа.
Если `check_etl_not_paused` красная, сними паузу с `etl_pipeline` и повтори
запуск. `world_next_day` не может вызвать зависимый DAG, пока тот стоит на паузе.
### Сверяем manifest
После зелёного прогона выполни:
```bash
docker compose run --rm --no-deps generator \
python -m clickstream_generator.startup_history_artifact kafka-manifest-summary
```
Команда печатает одну строку с полями в таком порядке:
```text
events visits users min_event_timestamp max_event_timestamp model_t0 model_t_end profile
```
Сверь результат с контрольными значениями:
| Поле | Ожидаем после дня 4 |
|------|----------------------|
| `events` | `374092` |
| `visits` | `34801` |
| `users` | `5388` |
| `min_event_timestamp` | `2026-01-01 00:00:00.000000` |
| `max_event_timestamp` | `2026-01-04 23:59:58.581616` |
| `model_t0` | `2026-01-01T00:00:00+00:00` |
| `model_t_end` | `2026-01-05T00:00:00+00:00` |
| `profile` | `daily-wave` |
Здесь числа накопительные: `events`, `visits` и `users` относятся ко всему миру
от `model_t0` до `model_t_end`, а не только к четвёртому дню.
Теперь проверь все завершённые порции и их стыки:
```bash
make generated-history-chain-check
```
Проверка должна закончиться без ошибки. Она подтверждает, что каждая порция
не пуста, контрольные числа STG совпадают с manifest, после правой границы нет
хвоста, а переходящие визиты не потеряли связанные записи.
### Находим переходящие визиты
Открой SQL-консоль `http://localhost:9123/play` (пользователь `default`, пароль
`123456`) и выполни:
```sql
SELECT
click_id,
min(event_ts) AS visit_start,
max(event_ts) AS visit_end
FROM dds.event
WHERE event_ts IS NOT NULL
AND click_id IS NOT NULL
GROUP BY click_id
HAVING toDate(min(event_ts)) <> toDate(max(event_ts))
ORDER BY visit_start;
```
Запрос должен найти хотя бы один визит, чьи события лежат по разные стороны
полуночи. На контрольном мире он находит `35`, `22` и `26` визитов на стыках
1→2, 2→3 и 3→4. Для проверки нового дня достаточно увидеть `26` визитов на
стыке 3→4.
> **Как слова связаны со схемой.** В manifest написано «визит», а в SQL такой
> визит обозначен `click_id`. Отдельной таблицы визитов нет: контекст лежит в
> `dds.click`, а события визита — в `dds.event`.
Почему запрос идёт прямо в `dds.event`? Представление
`dm.v_session_overview` группирует строки по `event_date` и `click_id`.
Переходящий визит там уже разрезан на две строки: одна до полуночи, другая
после. Чтобы увидеть визит целиком, сначала собираем все его события по
`click_id`, а потом сравниваем даты самого раннего и самого позднего события.
### Смотрим день-к-дню
Открой в Superset дашборд `E-commerce Analytics Dashboard`. Поставь фильтр
**Date Range → No filter** и найди график `Events over Time`. После обновления
на нём должны появиться непустые пятиминутные интервалы дня 4 после дня 3.
Высоту отдельной точки не сравнивай с накопительными числами manifest:
manifest уже сверили отдельно в начале секции.
---
## 3. Загляни внутрь
Открой
[`airflow/dags/world_next_day_dag.py`](../../../airflow/dags/world_next_day_dag.py).
Это короткая оркестрация вокруг трёх действий: проверить точку продолжения,
дописать сутки и пересчитать аналитику.
### Почему сначала идут проверки
`check_etl_not_paused` убеждается, что Airflow сможет запустить
`etl_pipeline`. `precheck_next_day` проверяет, что:
- живой генератор сейчас не работает;
- состояние генератора согласовано с правой границей manifest;
- накопительные счётчики не повреждены.
Только после этого `run_next_day` добавляет новую порцию. Так два режима,
`next-day` и `continue`, не пишут в один мир одновременно.
### Почему ETL запускается с `full_refresh`
`TriggerDagRunOperator` передаёт в `etl_pipeline`:
```python
conf={"full_refresh": True}
```
Значит, DDS и DM пересобираются из всей накопленной ODS, а не только из нового
дня. Это простой путь для учебного стенда, но его цена растёт вместе с миром.
Открой в Grafana дашборд `Airflow Overview` и панель `Task Duration (avg)`.
Сравни длительности задач двух запусков `world_next_day`: ручного дня 4 и
запланированного дня 5 из следующей секции.
Генерация одной суточной порции должна занимать примерно одинаковое время:
счётчики manifest обновляются только новыми данными. Это исправлено в
[issue #5](https://github.com/dementev-dev/clickstream-ch-kafka-superset-demo/issues/5).
Полный ETL перечитывает растущий мир, поэтому со временем дорожает. Это
осознанный долг из
[issue #8](https://github.com/dementev-dev/clickstream-ch-kafka-superset-demo/issues/8),
а не поломка этой лабы.
### Что делает расписание
У DAG задано расписание `*/30 * * * *`: запуск каждые 30 минут. При создании
DAG стоит на паузе, поэтому мир сам не растёт. `catchup=False` запрещает
Airflow проигрывать все пропущенные интервалы с 2024 года. После снятия паузы
будет ждать только ближайший новый интервал.
---
## 4. Управляемая правка: включаем один запуск по расписанию
Сейчас день 4 был добавлен вручную. На короткое время разрешим Airflow добавить
день 5 автоматически.
### Шаг 1. Зафиксируй правую границу
Ещё раз выполни команду `kafka-manifest-summary` из секции 2 и запиши
`model_t_end`. После запланированного запуска она должна сдвинуться ровно на
24 модельных часа.
### Шаг 2. Сними паузу
В списке DAG-ов Airflow включи переключатель `world_next_day`. Не нажимай
**Trigger DAG**: на этот раз нужен именно запуск по расписанию.
Ближайший запуск появится на следующей получасовой границе. Следи за списком
запусков и графом DAG.
### Шаг 3. Останови расписание после одного успеха
Как только один запланированный запуск станет зелёным, снова поставь
`world_next_day` на паузу. Сделай это до следующей получасовой границы, иначе
приедет ещё один день.
Проверь три результата:
1. `kafka-manifest-summary` показывает новую правую границу и новые
накопительные числа;
2. `make generated-history-chain-check` заканчивается без ошибки;
3. в `Events over Time` появился день 5.
После дня 5 контрольный manifest показывает `468025` событий, `43482` визита,
`6713` пользователей и `model_t_end = 2026-01-06T00:00:00+00:00`.
### Верни как было
Сначала убедись, что `world_next_day` снова стоит на паузе. Но одной паузы
недостаточно: мир уже вырос до пяти дней. Верни чистый трёхдневный эталон по
[канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
---
## 5. Проверь себя
| Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------|
| запустить `world_next_day` с пустой формой | Airflow graph | все пять задач зелёные, `etl_pipeline` вызван с `full_refresh=true` |
| прочитать manifest после дня 4 | `kafka-manifest-summary` | числа совпадают с таблицей секции 2 |
| проверить накопленную цепочку | `make generated-history-chain-check` | команда завершается без ошибки |
| найти переходящие визиты | запрос в `dds.event` | на стыке 3→4 найдено `26` визитов |
| снять паузу с DAG | Airflow runs | на ближайшей получасовой границе приезжает один запланированный день |
| вернуть паузу и пройти сброс | Airflow и Superset | расписание выключено, снова видны три эталонных дня |
Ответь своими словами:
- почему `dm.v_session_overview` не подходит для поиска визита через полночь;
- зачем проверять стык двух суточных порций;
- почему `catchup=False` не означает «у DAG нет расписания»;
- что случится, если дважды вручную запустить `world_next_day`;
- почему промышленная задача «день D» должна повторно обрабатывать тот же день,
а учебный DAG при повторе добавляет день D+1.
Последние два вопроса — проверка идемпотентности. Идемпотентная операция при
повторе с тем же входом оставляет тот же результат. `world_next_day` устроен
иначе: его входом служит текущая правая граница мира, и после каждого успеха
эта граница меняется.
---
## 6. Что должно получиться
После лабы сохрани:
- скрин зелёного ручного запуска `world_next_day`, который добавил день 4;
- скрин зелёного запланированного запуска `world_next_day`, который добавил
день 5;
- вывод `make generated-history-chain-check` без ошибки;
- результат SQL-запроса с переходящим через полночь `click_id`;
- скрин дневного графика с днём 5 после дня 4;
- короткое объяснение своими словами: чем пакетный инкремент отличается от
полного пересчёта и зачем отдельно проверять границу порций.
Перед переходом дальше стенд должен быть возвращён к трёхдневному эталонному
миру, а `world_next_day` — стоять на паузе.
---
## Мост к следующему шагу
Здесь новый день приехал как законченная и повторяемая порция. В следующей
лабе граница исчезнет: события пойдут непрерывно, а разные слои начнут
отставать друг от друга.
+308
View File
@@ -0,0 +1,308 @@
# Лаба 08. Живой поток и свежесть данных
> Формат: **практика** — запустишь живой поток, остановишь его и продолжишь с
> сохранённого состояния.
> Пререквизит: пройдена лаба 07; стенд возвращён к эталонному миру, а
> `world_next_day` стоит на паузе.
> Эталонные пути:
> [`Makefile`](../../../Makefile) и
> [`airflow/dags/etl_pipeline_dag.py`](../../../airflow/dags/etl_pipeline_dag.py).
>
> Поток данных одной строкой:
> `generator-continue → Kafka → STG (сразу) → etl_pipeline (по запуску) → ODS → DDS → DM`
>
> О чём лаба простыми словами: события будут приезжать непрерывно. Ты увидишь,
> что Kafka и STG уже обновились, а аналитические слои ещё ждут пакетного
> запуска.
---
## 1. Зачем и где в проде
У данных есть **свежесть** — задержка между событием в источнике и моментом,
когда его увидит потребитель. Для одного отчёта допустимы сутки, для другого
нужны минуты. Обещание «данные готовы не позже чем через N минут» влияет на
всю цепочку.
В этом стенде приём потоковый: ClickHouse постоянно читает Kafka, а
Materialized View сразу складывает сообщения в STG. Дальнейшая обработка
пакетная: ODS, DDS и DM обновляет только запуск `etl_pipeline`.
Поэтому **потоковый приём не равен потоковой обработке**. Новые строки уже
могут лежать в STG, пока витрина для аналитика показывает старую правую
границу.
> **В проде иначе.** Требование к свежести задают вместе с потребителем данных.
> Под него выбирают расписание, размер порции, проверки и допустимое
> отставание. Слово «поток» само по себе ничего не обещает о времени появления
> данных в отчёте.
---
## 2. Руки: оживляем мониторинг и догоняем витрины
Подготовь стенд по
[канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
После `world_init` живой генератор выключен, а эталонный мир заканчивается на
одной и той же модельной границе у всех менти.
### Запоминаем начальную свежесть
Открой SQL-консоль `http://localhost:9123/play` (пользователь `default`, пароль
`123456`) и выполни:
```sql
SELECT
'STG' AS layer,
max(parseDateTime64BestEffortOrNull(
JSONExtractString(raw, 'event_timestamp'), 6
)) AS max_event_ts
FROM stg.browser_raw
UNION ALL
SELECT 'ODS', max(event_ts)
FROM ods.browser_event
UNION ALL
SELECT 'DDS', max(event_ts)
FROM dds.event
UNION ALL
SELECT 'DM', max(event_ts)
FROM dm.v_events_enriched;
```
На эталонном мире правые границы слоёв должны совпадать.
После импорта трёх эталонных дней все четыре слоя заканчиваются на
`2026-01-03 23:59:59.066766`.
### Запускаем живое продолжение
В терминале выполни:
```bash
make generator-continue
```
Команда запускает генератор в режиме `continue`: он берёт сохранённое состояние
эталонного мира и продолжает его, не создавая новый мир с нуля.
Вернись к экранам из урока 5:
- в Prometheus target `generator` переходит в `UP`;
- в `Generator Overview` оживает `Total Events/min (all 4 topics)`;
- в `Kafka Overview` растут сообщения и конечные offset-ы партиций;
- в Kafka UI из урока 0 у новых сообщений растут offset-ы;
- `Kafka No Messages Produced` выходит из `Alerting`, когда Grafana увидит
устойчивый поток.
У алерта есть окно `for: 10m`: в `Alerting` он переходит только после десяти
минут тишины. Когда поток вернётся, условие перестанет выполняться при
ближайшей оценке. График событий и offset-ы должны начать двигаться раньше.
Подожди несколько минут и снова выполни запрос по слоям. Правая граница STG
уйдёт вперёд. ODS, DDS и DM останутся на границе последнего
`etl_pipeline`. Запиши время по обычным часам и границы STG и DM. Границу STG
считай контрольной.
### Догоняем пакетные слои
Открой Airflow и запусти `etl_pipeline` через **Trigger DAG** с пустой формой.
По умолчанию это полный пересчёт. При запуске включи секундомер.
После зелёного прогона останови секундомер и повтори запрос. Запиши новые
границы STG и DM: DM должна достичь контрольной границы STG. Генератор всё ещё
работает, поэтому STG вскоре снова может оказаться чуть свежее. Это ожидаемое
расслоение, а не потеря строк.
---
## 3. Загляни внутрь
Открой [`Makefile`](../../../Makefile) и найди две команды этой лабы:
```make
generator-continue:
COMPOSE_BIN="$(COMPOSE)" bash ./scripts/run_generator.sh continue
generator-down:
$(COMPOSE) stop generator
```
`generator-down` останавливает только контейнер генератора. Kafka, ClickHouse и
читатели STG продолжают работать. Поэтому они успевают дочитать уже записанные
сообщения.
`generator-continue` запускает генератор с сохранением прежнего состояния. В нём
остаются популяция пользователей, активные визиты, случайное состояние и
модельные часы. После рестарта продолжается тот же мир.
<a id="modelnoe-vremya"></a>
> **Модельное время.** Профиль `daily-wave` ускоряет внутренние часы стенда в
> 60 раз: модельные сутки проходят примерно за полчаса. Поэтому «сейчас» на
> дашборде может обгонять настенные часы. Ручка тренажёра называется
> `GEN_MODEL_TIME_SPEED`; в этой лабе её не меняем.
### Где проходит граница между потоком и пакетом
В уроке 1 ты видел Kafka engine и Materialized View. Пока сервисы работают,
этот путь сам переносит сообщения из Kafka в `stg.*_raw`.
У `etl_pipeline` в
[`airflow/dags/etl_pipeline_dag.py`](../../../airflow/dags/etl_pipeline_dag.py)
стоит `schedule=None`. ODS, DDS и DM не догоняют поток сами: нужен ручной
запуск DAG. Получается две разные гарантии свежести:
- Kafka → STG работает постоянно;
- STG → ODS → DDS → DM обновляется по запуску `etl_pipeline`.
Если потребителю нужна витрина не старше пяти минут, ручной DAG такую
гарантию не даёт. Понадобится расписание или другая обработка, а затем
измерение фактического отставания.
---
## 4. Управляемая правка: останавливаем и продолжаем поток
Проверим, переживает ли мир остановку генератора.
### Шаг 1. Зафиксируй состояние до остановки
Пока генератор работает, запиши:
- правую границу STG из запроса секции 2;
- значение `Total Events/min (all 4 topics)`;
- текущие конечные offset-ы в `Kafka Overview`.
### Шаг 2. Останови генератор
```bash
make generator-down
```
После остановки:
- target `generator` переходит в `DOWN`;
- `Total Events/min (all 4 topics)` падает к нулю или перестаёт обновляться;
- конечные offset-ы перестают расти;
- читатели ClickHouse дочитывают уже записанные сообщения, и отставание
стекает к нулю;
- после окна ожидания `Kafka No Messages Produced` переходит в `Alerting`.
Как и в уроке 5, Kafka UI или панель lag могут не показать прогресс групп
`ch_stg_*`. Тогда смотри `system.kafka_consumers` со стороны ClickHouse.
Главный признак здесь такой: новые offset-ы больше не появляются, а строки,
уже записанные до остановки, доходят в STG.
### Шаг 3. Продолжи тот же мир
```bash
make generator-continue
```
Проверь обратную картину:
- target `generator` снова в `UP`;
- график событий и offset-ы снова растут;
- `Kafka No Messages Produced` возвращается в норму при ближайшей оценке;
- правая граница STG стала больше значения, записанного до остановки.
Генератор взял прежнюю популяцию и прежнюю точку продолжения. Остановка не
превратила поток в новый независимый мир.
### Шаг 4. Догони аналитику и увидь расхождение мира
Запусти `etl_pipeline` с пустой формой. После зелёного прогона выполни:
```sql
WITH parseDateTime64BestEffort(
substring('<model_t_end>', 1, 19), 6
) AS boundary
SELECT
user_domain_id,
countIf(visit_start < boundary) AS visits_before,
countIf(visit_start >= boundary) AS visits_after,
min(visit_start) AS first_visit_ts, max(visit_start) AS last_visit_ts
FROM (
SELECT user_domain_id, click_id, min(event_ts) AS visit_start
FROM dm.v_events_enriched
WHERE user_domain_id IS NOT NULL
GROUP BY user_domain_id, click_id
)
GROUP BY user_domain_id
HAVING min(visit_start) < boundary AND max(visit_start) >= boundary
LIMIT 10;
```
Подставь вместо `<model_t_end>` границу из манифеста. Успех — хотя бы одна
строка. Иначе подожди несколько минут, перезапусти `etl_pipeline` и повтори
запрос. Это симметрия лаб: в лабе 07 визит пересекал полночь, здесь
пользователь с разными визитами пересекает границу замороженного мира.
Мир расходится: итоги превышают эталонные и различаются у менти. Это правильно.
Например, для границы `2026-01-06T00:00:00+00:00` найден пользователь
`5938d296-14cf-49cb-801e-82370518cf59`: `13` визитов до границы и `2` после
неё. Твои числа и идентификатор будут другими.
### Верни как было
Останови живой поток:
```bash
make generator-down
```
После `continue` мир стал недетерминированным: его числа зависят от длительности
запуска. Верни стенд к эталону по
[канонической инструкции курса](../README.md#подготовка-и-канонический-сброс).
Не пересказывай шаги по памяти: эта ссылка остаётся единственным учебным
описанием полного сброса.
---
## 5. Проверь себя
| Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------|
| выполнить `make generator-continue` | Prometheus Targets | `generator` переходит в `UP` |
| измерить свежесть до и после ETL | запрос из секции 2 и часы | например: до ETL — `3:45:01.519535`, после — `0:45:55.977582` модельного времени; настенное ожидание — около `29` секунд |
| выполнить `make generator-down` | Grafana и Kafka | события перестают поступать, отставание читателей стекает к нулю |
| снова выполнить `make generator-continue` | Grafana, Kafka и STG | поток продолжается, offset-ы и правая граница снова растут |
| найти вернувшегося пользователя | запрос по границе `model_t_end` | есть пользователь с визитами до и после границы |
Ответь своими словами:
- почему STG опережает DM, а `etl_pipeline` догоняет лишь очередной снимок;
- что сохраняет `continue` и почему итоги менти расходятся;
- какую свежесть можно честно обещать при ручном запуске и при расписании
каждые 30 минут;
- почему минутная свежесть лежит за пределами этого стенда.
---
## 6. Что должно получиться
После лабы сохрани:
- скрин `generator` в `DOWN` после `make generator-down` и скрин `generator`
в `UP` после повторного `make generator-continue`;
- границы STG до остановки и после продолжения;
- строку пользователя с визитами до и после `model_t_end`;
- замеры до и после `etl_pipeline`: разницу `STG DM` в модельном времени и
настенное ожидание до появления зафиксированной границы STG в DM;
- вывод своими словами: что можно обещать при ручном запуске, что — при
расписании каждые 30 минут и почему этот стенд не обещает минутную свежесть.
В конце `generator` должен быть остановлен, а стенд — возвращён к эталонному
миру.
---
## Мост после курса
Замер отвечает на вопрос из секции 1: ручной запуск не ограничивает ожидание,
а расписание раз в 30 минут дало бы около 30 минут плюс время прогона. Для
минутной свежести нужна другая обработка.
@@ -0,0 +1,219 @@
# Редизайн лаб курса под три режима менти
Статус: принято, в работе (ветка `feature/course-labs-redesign`).
Источник: issue #7; аудит курса против стенда и штурм педагогики лаб
с пользователем 2026-07-23. Опирается на
[спеку редизайна пути менти](./2026-07-19-mentee-path-redesign.md)
(реализована, issue #9 закрыт) и термины из `CONTEXT.md`
(три режима менти, эталонный мир, модельное время).
## Проблема
Курс в `docs/course/` (7 уроков + 4 метадокумента) целиком построен на
старом пути `make generated-history-analytics && make up`: ни один документ
не знает про `world_init`, `import`, эталонный мир и `make superset-init`.
После редизайна пути менти курс в текущем виде не заработает.
При этом педагогическое ядро уроков цело: SQL, DAG'и, мониторинг и
Superset актуальны — ломаются только блоки подготовки стенда и обвязка.
А два новых режима роста мира (`next-day`, `continue`) вообще не имеют
уроков, хотя спека пути менти прямо называет их «разные педагогики и
разные лабы».
## Цели
- Менти проходит курс по новому пути: `make up` → Airflow UI
(`ddl_init``world_init` с пустой формой) → `make superset-init`.
- Оба режима роста мира получают по обязательному уроку-лабе с
собственным учебным паттерном.
- Числа в уроках совпадают у всех менти число-в-число (следствие
`import` эталонного мира) и сверены с живым стендом.
- Смешанных состояний не бывает: вся перестройка — один PR.
## Целевая модель
### Маршрут курса
Маршрут остаётся линейным, все уроки — обязательные:
- **Урок 6 (Superset) становится обязательным.** Его опциональность
родом из PRD («делаем, если останется ресурс») — экономия ресурса на
написание, а урок давно написан; причина истекла. Ожидания
работодателей к дата-инженеру теперь включают витринно-BI-слой.
- **Две новые лабы после урока 6, обе обязательные:**
`07_lab_next_day.md`, затем `08_lab_continue.md`. Порядок осознанный:
next-day детерминирована (числа сойдутся у всех — можно печатать
точные ожидания), continue — живая и недетерминированная, «второй шаг
свободы».
- Обе лабы строятся по шаблону `LESSON_STANDARD.md` (шапка + 6 секций).
### Новая семантика сброса
Для лаб «верни как было» — это не откат правки в git: мир вырос.
Канонический сброс — тот же путь, что и первый вход, и описывается он
в одном месте (README курса), уроки ссылаются:
1. `make clean` (сносит и данные, и Superset — это надо назвать явно);
2. `make up` → Airflow UI: `ddl_init``world_init` с пустой формой;
3. `make superset-init`.
Идём UI-путём менти, а не мейнтейнерским `make startup-history-import`.
Старая команда сброса `make generated-history-analytics && make up`
уходит из учебных текстов (остаётся мейнтейнеру и CI).
### Лаба 07 — next-day
Паттерн одной фразой: **пакетный инкремент дня и его границы**
(прод-аналог — «ночью приехал вчерашний день»).
- **Руки:** триггер беспараметрного `world_next_day` → день 4 в
витринах; сверка чисел manifest (точные ожидаемые значения);
`make generated-history-chain-check` на стыке; день-к-дню на дашборде.
- **Центральное открытие — переходящие визиты:** менти SQL-запросом
находит визиты, чьи события лежат по обе стороны полуночи, и осознаёт,
почему суточная нарезка режет живые сессии — откуда берутся проверки
стыков, late data и пересчёт вчерашнего хвоста. Этого нет ни в одном
уроке 0–6. Важно для текста лабы: запрос идёт в `dds.event` напрямую
(`GROUP BY click_id` + `HAVING toDate(min(event_ts)) <>
toDate(max(event_ts))`) — витрина `dm.v_session_overview` не годится,
она группирует по `event_date` и режет переходящий визит на две
строки. Это новый приём, который лаба сама и учит (уроки давали
argMax/JOIN, но не агрегацию визита целиком). Годится любой стык дней:
стыки 1→2 и 2→3 внутри эталонного мира есть точно; живы ли визиты на
стыке 3→4 (заморозка мира могла закрыть открытые сессии) — проверяется
на стенде при реализации. Здесь же — короткая врезка про соответствие
терминов: «визит» из manifest = `click_id` в SQL (в схеме нет таблицы
«визитов», есть `dds.click`).
- **Управляемая правка:** включить расписание `world_next_day` в
Airflow UI → день 5 приезжает сам → выключить обратно. Спека пути
менти прямо проектировала paused-расписание под этот шаг лабы; заодно
менти трогает paused/`catchup`.
- **Цена роста — коротким наблюдением:** в длительностях задач Airflow
генерация дня ~постоянна (инкрементальные счётчики manifest, issue #5),
а `full_refresh` ETL растёт с миром — осознанный долг, ссылка на #8.
- **Самопроверка — крючок про идемпотентность:** «триггерни дважды —
что будет? почему прод-джобы за день устроены иначе?».
- Эталонный путь: `airflow/dags/world_next_day_dag.py`.
### Лаба 08 — continue
Паттерн одной фразой: **живой поток и свежесть данных**.
- **Ядро — потоковый приём ≠ потоковая обработка:** Kafka и STG
пополняются сами (MV урока 1), витрины DM стоят до прогона
`etl_pipeline`; запустил — догнали и снова отстают. Свежесть данных
как явное понятие: «какую свежесть обещаем потребителю?».
- **Руки:** `make generator-continue` → мониторинг урока 5 оживает
(target генератора в UP, алерт `Kafka No Messages Produced` гаснет,
events/min шевелится, офсеты урока 0 растут) → наблюдение расслоения
свежести → догон через `etl_pipeline`.
- **Управляемая правка — останови поток и продолжи:**
`make generator-down` → алерт срабатывает, потребитель дочитывает лаг
до нуля; `make generator-continue` → мир продолжается с места
остановки (популяция и модельные часы пережили рестарт). Это суть
режима continue и рабочий паттерн устойчивости. По шаблону
LESSON_STANDARD лаба всё равно завершается явным шагом «верни как
было» — канонический сброс (см. выше): мир после continue
недетерминирован, и к следующему прохождению стенд возвращается к
эталону.
- **Финальное наблюдение — «мир расходится»:** после continue числа
менти перестают совпадать с эталонными, и это правильно; возвраты
пользователей вживую разводят `users < sessions` (на статике было
невозможно — см. `CONTEXT.md`).
- **Модельное время — только врезкой:** «стенд ускорен в 60 раз, чтобы
сутки потока уложились в ~полчаса; "сейчас" дашборда может обгонять
настенные часы», ручка `GEN_MODEL_TIME_SPEED` — одной строкой.
Это механизм тренажёра, не рабочий паттерн — секционного веса не даём.
Висячую ссылку `CONTEXT.md` про «урок 7» поправить на эту врезку.
Симметрия курса: 07 — про границы времени в пакетном мире, 08 — про
свежесть в потоковом; обе лабы растят один и тот же импортированный мир
двумя способами.
### Существующие уроки и метадокументы
По вердиктам аудита:
- **Все уроки 06:** блок подготовки в §2 заменить на новый путь
(import); сам блок вынести в одно каноническое место (README курса),
уроки ссылаются на него.
- **Все цифры уроков 0–6 недействительны.** Старые значения считались
на старом мире (другой профиль, другой объём); с переходом на
эталонный мир каждое цитируемое число каждого урока пересчитывается
заново. Это полноправный этап работы, а не финальная галочка — даже в
уроках, где правки текста минимальны.
- **Урок 1:** управляемая правка «добавь колонку и получи свежие
сообщения» переводится с перегенерации backfill на дозаливку через
`world_next_day` (детерминированно). Известная цена: прогон DAG'а
занимает минуты — урок называет её честно; сам DAG подаётся кнопкой-
анонсом без разбора («подробно — в лабе 07»), чтобы не красть у лабы
её материал.
- **Урок 4:** добавить лесенку `ddl_init``world_init`
`world_next_day` как контекст оркестрации (менти уже прошёл её руками).
- **Урок 5:** словарь «backfill-only стенд» заменить на «база import /
живой поток»; сценарий алерта `Kafka No Messages Produced` переписать
в этих терминах.
- **Урок 6:** снять пометку «опционально»; цифры сверить с эталонным
миром.
- **README курса:** переписать блоки подготовки и «Проверка чистого
маршрута»; таблицу уроков дополнить лабами.
- **PRD:** не переписывать — одна датированная поправка по его же
конвенции (три режима менти, урок 6 обязателен, лабы 07–08).
- **LEARNING_PLAN:** переписать маршрут и статус, сохранить таблицу
аудита эталонных путей; дополнить её `world_next_day_dag.py`.
- **LESSON_STANDARD:** заменить команду сброса на import-путь.
## Чего здесь не делаем
- Не трогаем код стенда: DAG'и, SQL, генератор, инфраструктура — вне
скоупа; работа только с документами курса, README курса и `CONTEXT.md`
(одна ссылка).
- Не чиним рост стоимости `full_refresh` (issue #8) — в лабе 07 он
только показывается.
- Не пишем урок про модельное время — понижено до врезки в лабе 08.
- Не переносим приватные менторские материалы — граница из README курса
остаётся.
- Не переделываем эталонный код уроков 0–6 сверх замены обвязки: аудит
подтвердил, что ядро актуально.
## Проверка
- Чистый стенд: менти проходит весь курс 0–8 по текстам уроков, ни разу
не встретив `generated-history-analytics` и `backfill`.
- Каждая цитируемая цифра уроков и лаб сверена с живым стендом после
`import` эталонного мира (обязательный финальный шаг реализации).
- Лаба 07: после `world_next_day` числа manifest совпадают с
напечатанными в лабе; `make generated-history-chain-check` зелёный;
SQL-запрос из лабы находит хотя бы один переходящий визит (на любом
стыке дней).
- Детерминизм инкремента подтверждён до печати чисел: два независимых
прогона `world_next_day` от свежего import дают одинаковые числа
manifest и контрольную сумму.
- Лаба 08: сценарий стоп/продолжение проходит без потерь (лаг стекает к
нулю, после продолжения числа согласованы с manifest).
- Все внутренние ссылки курса живы; `make test` / `make lint` зелёные
(доки код не трогают — проверка от регрессий по касанию).
## Решения и отклонённые варианты
- **Лабы — секциями внутри уроков 4/5** — отклонено: уроки распухают,
лабы теряют самостоятельность; выбраны отдельные файлы.
- **Урок 6 оставить опциональным, финал лабы 07 — только SQL**
отклонено: причина опциональности истекла, рынок ждёт BI-навыков;
урок 6 становится обязательным, лабы опираются на дашборд.
- **Модельное время как секция или отдельный урок** — отклонено:
механизм тренажёра, не рабочий паттерн; врезка.
- **Управляемая правка лабы 08 через ручку ×K** — отклонено по той же
причине; выбран стоп/продолжение потока.
- **Дробить работу на несколько PR** — отклонено: половинчатый курс
(часть уроков про import, часть про backfill) хуже любого из крайних
состояний; этапность — коммитами внутри одной ветки.
- **Цифры «ориентировочно», без сверки со стендом** — отклонено:
воспроизводимость число-в-число — главный козырь эталонного мира.
## Влияние на документацию
Вся работа и есть документация: `docs/course/**` (7 уроков + 2 лабы +
4 метадокумента), одна ссылка в `CONTEXT.md`. Корневой `README.md` и
`docs/OPERATIONS.md` уже описывают новый путь — не трогаем. Issue #7
ссылается на эту спеку и ведёт чек-лист шагов.
+39 -1
View File
@@ -188,7 +188,45 @@ def test_course_readme_lists_uv_before_first_command():
text = (REPO_ROOT / "docs" / "course" / "README.md").read_text(encoding="utf-8") text = (REPO_ROOT / "docs" / "course" / "README.md").read_text(encoding="utf-8")
assert "uv" in text assert "uv" in text
assert text.index("uv") < text.index("make generated-history-analytics") assert text.index("uv") < text.index("make clean")
def test_course_canonical_setup_unpauses_dags_before_world_init():
"""Канонический блок называет три DAG, снятие паузы и порядок etl→world_init."""
# Прозаические формулировки не проверяем — их сторожит ревью, не pytest.
# Держим только структурные факты: якорь-заголовок существует, в блоке
# названы три DAG-а, упомянута пауза, а etl_pipeline идёт до world_init.
text = (REPO_ROOT / "docs" / "course" / "README.md").read_text(encoding="utf-8")
setup = text.split("## Подготовка и канонический сброс", maxsplit=1)[1].split(
"## Уроки",
maxsplit=1,
)[0]
assert "пауз" in setup
for dag_id in ("ddl_init", "etl_pipeline", "world_init"):
assert dag_id in setup
assert setup.index("etl_pipeline") < setup.index("world_init")
def test_next_day_lab_uses_provisioned_superset_dashboard_name():
"""Лаба следующего дня ведёт на существующий дашборд Superset."""
text = (
REPO_ROOT / "docs" / "course" / "lessons" / "07_lab_next_day.md"
).read_text(encoding="utf-8")
assert "E-commerce Analytics Dashboard" in text
assert "Clickstream Analytics" not in text
def test_monitoring_lesson_links_to_canonical_reset():
"""Урок мониторинга ведёт обратно к каноническому сбросу курса."""
# Проверяем живую перекрёстную ссылку: битый якорь — это дефект, а не
# переписанная проза. Точные фразы упражнения не пиним.
text = (
REPO_ROOT / "docs" / "course" / "lessons" / "05_monitoring.md"
).read_text(encoding="utf-8")
assert "../README.md#подготовка-и-канонический-сброс" in text
def test_startup_history_runbook_warns_about_daily_wave_idle_gap(): def test_startup_history_runbook_warns_about_daily_wave_idle_gap():