docs(course): добавлен урок 1 и выровнена рамка под созвоны

- Зачем:
  - нужен первый урок курса по эталонному пути STG, а рамка курса описывала сопровождение как «сессию-сверку», хотя по факту это самостоятельная работа + еженедельный созвон.
- Что:
  - добавлен docs/course/lessons/01_kafka_to_clickhouse.md (Kafka → ClickHouse, слой STG) по шаблону LESSON_STANDARD.
  - в sql/ddl/stg/10_stg.sql исправлен баг kafka_ts во всех 4 MV: toInt64(DateTime64) срезал миллисекунды, kafka_ts по всему стенду был 1970-01-21; теперь _timestamp_ms присваивается напрямую (downstream на kafka_ts не опирается).
  - урок 1 §3/§4 приведены к исправленному коду; врезка про рассинхрон MV↔таблица описывает реальное поведение (молчаливый сброс лишней колонки, не ошибка).
  - «сессия/сессия-сверка» → «созвон» в PRD (датированная поправка), LESSON_STANDARD §6 (секция «Что должно получиться») и README курса; добавлена ссылка на открытый DE-роадмап.
  - в LEARNING_PLAN исправлен вердикт аудита урока 1 (баг найден прогоном), war-story про toInt64(DateTime64) припаркована в урок 2.
- Проверка:
  - прогон на стенде: make up && make ddl && LIMIT=50 make data → kafka_ts = 2026-… с миллисекундами; правка §4 (ALTER + пересоздание MV + TRUNCATE + перезаливка) и откат отработали.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 23:03:24 +03:00
co-authored by Claude Opus 4.8
parent d8c5e61a3a
commit f4d3118a42
6 changed files with 282 additions and 19 deletions
+10 -3
View File
@@ -59,7 +59,7 @@ Kafka из роадмапа — оно даёт словарь терминов.
| Урок | Путь | Файлы | Вердикт |
|------|------|-------|---------|
| 1 | STG (Kafka→CH) | `sql/ddl/stg/10_stg.sql` | **годно как есть** (+1 строка «зачем») |
| 1 | STG (Kafka→CH) | `sql/ddl/stg/10_stg.sql` | **точечно править** (баг конвертации `kafka_ts`, найден на стенде) |
| 2 | STG→ODS | `sql/ods/20_stg_to_ods.sql` + DDL `sql/ddl/ods/20_ods.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` | **точечно править** |
@@ -73,12 +73,19 @@ Kafka из роадмапа — оно даёт словарь терминов.
Правки точечные, делаем не отдельным забегом, а в составе соответствующего урока.
**Урок 1 — STG (`sql/ddl/stg/10_stg.sql`):**
- [ ] Добавить одну строку «зачем» к `fromUnixTimestamp64Milli(toInt64(_timestamp_ms))`
в MV — это единственный неочевидный трюк файла (конвертация Kafka-таймстампа).
- [x] **Баг конвертации `kafka_ts` (найден прогоном на стенде 2026-06-03).**
Было `fromUnixTimestamp64Milli(toInt64(_timestamp_ms))` во всех 4 MV: `_timestamp_ms`
это `DateTime64(3)`, `toInt64()` срезает его до **секунд**, и `fromUnixTimestamp64Milli`
читает секунды как миллисекунды → `kafka_ts` = `1970-01-21` по всему стенду.
Исправлено на `_timestamp_ms AS kafka_ts` (прямое присвоение, мс сохраняются).
Прежний аудит ошибочно пометил путь «годно как есть» — потому что его не прогоняли.
- Колоночные комментарии-пересказы (`-- Партиция`, `-- Имя топика`) безвредны — не трогаем.
- В финале урока — мост к уроку 2: вопрос «почему дальше не MV?».
**Урок 2 — STG→ODS, типизация + DQ-split (`sql/ods/20_stg_to_ods.sql` + DDL `sql/ddl/ods/20_ods.sql`):**
- [ ] **Кандидат на war-story по типам:** баг `kafka_ts` из урока 1 (`toInt64(DateTime64)`
молча срезал миллисекунды → 1970). Это идеальная иллюстрация темы урока — «тихая
потеря данных на неверном типе». Решить при написании, давать ли как пример.
- [ ] Добавить однострочный «поток данных» в шапку `20_stg_to_ods.sql`
(`stg.*_raw → ods.* + ods.*_errors`) — п.3 чеклиста.
- [ ] Добавить строку «зачем»: основная таблица = валидный ключ (но может иметь
+6 -4
View File
@@ -24,10 +24,12 @@
самостоятельный менти не застрял со сломанным стендом без ментора.
(Урок 0 — без этого шага, только наблюдение.)
5. **Проверь себя** — самопроверка (раздел 3).
6. **Принеси на сессию****конкретный артефакт** плюс вопросы: скрин видимого
результата правки (например, красный DAG или `+1` в таблице ошибок), один абзац
«своими словами» про паттерн урока или запрос, который пришлось написать. Артефакт
делает самопроверку проверяемой, а сверку — предметной (критерий успеха `PRD` §5).
6. **Что должно получиться****конкретный видимый результат**, который менти проверяет
сам: скрин эффекта правки (например, красный DAG или `+1` в таблице ошибок) плюс один
абзац «своими словами» про паттерн урока или запрос, который пришлось написать. Это и
есть самопроверка (раздел 3); те же вопросы «своими словами» менти разбирает с ментором
на еженедельном созвоне. (Урок 0 — без правки, поэтому результат здесь — что менти
увидел при наблюдении.)
## 2. Стандарт качества эталонного кода
+7 -4
View File
@@ -6,6 +6,9 @@
> поверхность потребления. Обязательных уроков стало 0–5, опциональный Superset — урок 6.
> Затронуты §4 (скоуп) и §3/§5 (ожидаемый такт — ~день на урок). Это изменение рамки,
> а не план реализации.
> Поправка 2026-06-03 (терминология): режим сопровождения — еженедельный **созвон**, а не
> «сессия»; менти проходит материал сам и ничего «не приносит», а на созвоне ментор
> разбирает затыки и проверяет глубину понимания. Затронуты §1, §3, §5.
> Назначение документа: зафиксировать для будущих сессий, что это за курс, зачем
> он, что входит в скоуп работ, а что нет. Это договорная **рамка**, а не план
> реализации и не стандарт уроков (см. раздел «Связанные документы»).
@@ -30,8 +33,8 @@ data/*.jsonl → Kafka → ClickHouse (Kafka engine + MV → STG) → Airflow ET
прошедших базу (SQL, моделирование, Python, Git, Docker, Airflow).
Цель курса — превратить стенд в **самостоятельный учебный материал**, по которому
продвинутый менти проходит ключевые паттерны сам, а ментор подключается на обычной
еженедельной сессии (что получилось / что нет / вопросы / план на неделю). Долгий
продвинутый менти проходит ключевые паттерны сам, а ментор подключается на обычном
еженедельном созвоне (что получилось / что нет / вопросы / план на неделю). Долгий
разбор кода вживую форматом не предусмотрен — поэтому материал обязан быть
самодостаточным.
@@ -61,7 +64,7 @@ Kafka → ClickHouse → BI).
- **Режим:** самостоятельный, асинхронный. Менти клонирует репозиторий, поднимает
стенд у себя (`make up`) и идёт по урокам из `docs/course/` рядом с кодом.
Уроки короткие и односоставные — ожидаемый срок прохождения одного **около дня**.
- **Роль ментора:** еженедельная сессия-сверка (покрывает несколько уроков), без
- **Роль ментора:** еженедельный созвон-сверка (покрывает несколько уроков), без
построчного разбора кода.
- **Железо:** стек тяжёлый (Kafka + ClickHouse + Airflow + Superset + Prometheus +
Grafana одновременно). Считаем наличие подходящего железа данностью; стек не режем
@@ -103,7 +106,7 @@ Kafka → ClickHouse → BI).
около дня на урок: уроки короткие и односоставные).
- Может своими словами объяснить паттерн урока и привязать его к обычной
кликстрим-аналитике.
- На сессии приносит осмысленные вопросы по сути, а не «застрял на запуске».
- На созвоне задаёт осмысленные вопросы по сути, а не «застрял на запуске».
- Эталонный код проходит «тест одного прохода» (см. `LESSON_STANDARD.md`).
## 6. Связанные документы и порядок работ
+11 -4
View File
@@ -1,8 +1,15 @@
# Курс «Кликстрим на ClickHouse» (со звёздочкой)
Продвинутый курс для менти, уже прошедших базовую программу: ключевые паттерны
инженерии данных на стенде Kafka + ClickHouse + Airflow + мониторинг. Самостоятельный
учебный материал; ментор подключается на еженедельной сессии-сверке.
Продвинутый курс для менти, уже прошедших базовую программу: основные паттерны
инженерии данных на стенде Kafka + ClickHouse + Airflow + мониторинг. Менти проходит
материал самостоятельно, а на еженедельном созвоне с ментором разбирает затыки и отвечает
на вопросы по теме — чтобы проверить глубину понимания.
Сам курс — это блок «со звёздочкой» открытого роадмапа Data Engineer
([de.dementev.space](https://de.dementev.space/), он же
[на GitHub](https://github.com/dementev-dev/de-roadmap)). Роадмап открыт и устроен так,
что идти по нему можно в одиночку; с ментором — ощутимо короче дорога: добавляются
персональный план, разбор домашек и код-ревью.
## Что где искать
@@ -11,7 +18,7 @@
| [`PRD.md`](./PRD.md) | Рамка: зачем курс, цели, аудитория, скоуп, критерии успеха | Чтобы понять «что и зачем». Замороженный документ |
| [`LEARNING_PLAN.md`](./LEARNING_PLAN.md) | План обучения: карта уроков, маршрут, аудит эталонных путей | Чтобы понять «в каком порядке и из чего» |
| [`LESSON_STANDARD.md`](./LESSON_STANDARD.md) | Стандарт уроков: шаблон урока, качество кода, самопроверка | Рабочий чеклист при написании каждого урока |
| `lessons/` *(будет)* | Сами уроки, по одному файлу | Прохождение курса менти |
| [`lessons/`](./lessons/) | Сами уроки, по одному файлу (есть: урок 1) | Прохождение курса менти |
## Порядок чтения
@@ -0,0 +1,244 @@
# Урок 1. Заземление Kafka → ClickHouse (слой STG)
> Статус: черновик. Режим: **руки**.
> Пререквизит: пройден урок 0 (словарь Kafka — топик, партиция, offset, consumer-группа —
> уже знаком и виден в Kafka UI).
> Эталонный путь: [`sql/ddl/stg/10_stg.sql`](../../../sql/ddl/stg/10_stg.sql).
>
> Поток данных одной строкой:
> `Kafka → kafka_*_raw (ENGINE=Kafka) → MV → *_raw (MergeTree)`
---
## 1. Зачем и где в проде
Первое, что делаем с потоком событий — складываем его в таблицу как есть, а рядом пишем
метаданные доставки: из какого топика и партиции пришло сообщение, с каким offset'ом и
временем. Это слой STG — тот самый staging, знакомый тебе по курсовой.
Зачем хранить сырой JSON строкой и не парсить его сразу:
- чтобы отладке было на что опереться: когда дальше по пайплайну что-то «не сходится»,
всегда есть исходное сообщение для сверки;
- чтобы любой следующий слой можно было пересобрать из STG, не вычитывая Kafka заново;
- и, главное, чтобы приём не падал из-за одного кривого поля. Разбор JSON и проверки
качества — это уже следующий слой (урок 2), а STG принимает всё подряд.
`kafka_offset` здесь не просто метаданные: пара «партиция + offset» однозначно указывает
на конкретное сообщение в топике — по ней всегда понятно, та же это запись или другая.
Сама таблица повторы при этом не отсеивает (`MergeTree` ничего не дедуплицирует) — если
понадобится, дубли убирают уже на следующих слоях.
> **В проде иначе.** На потоке в десятки тысяч сообщений в секунду читателей будет
> несколько, и Kafka сама делит работу между ними. В этом уроке — один читатель
> (`kafka_num_consumers = 1`) и маленький срез данных: нам важно понять, как поток вообще
> попадает в базу, а не выжимать скорость.
---
## 2. Руки: убедись, что данные текут
Поднимаем стенд, создаём схему и заливаем **малый срез** (50 строк на топик — этого
хватает, чтобы всё увидеть, и прогон быстрый):
```bash
make up # поднять инфраструктуру (Kafka, ClickHouse, ...)
make ddl # создать базы и таблицы в ClickHouse (в т.ч. слой STG)
LIMIT=50 make data # залить по 50 строк каждого файла в Kafka-топики
```
Теперь смотрим, что доехало до ClickHouse. Открой SQL-консоль:
`http://localhost:9123/play` (или Kafka UI на `http://localhost:8082`, чтобы тем же
взглядом, что в уроке 0, увидеть те же сообщения «с другого конца»).
```sql
-- Сколько сырых событий браузера приземлилось
SELECT count() FROM stg.browser_raw;
-- Как выглядит приземлённая строка: метаданные доставки + сырой JSON
SELECT kafka_topic, kafka_partition, kafka_offset, kafka_ts, raw
FROM stg.browser_raw
ORDER BY kafka_offset
LIMIT 5;
```
Что важно заметить:
- `raw` — это **целый JSON строкой**, никто его пока не разбирал;
- поля `kafka_topic`, `kafka_partition`, `kafka_offset`, `kafka_ts` заполнены: видно,
откуда именно приехала запись;
- offset'ы идут по возрастанию и не повторяются.
---
## 3. Загляни внутрь (`sql/ddl/stg/10_stg.sql`)
Весь слой STG собран из **трёх кирпичей**, и каждый топик повторяет одну и ту же тройку:
| Кирпич | Объект | Движок | Что делает |
|--------|--------|--------|------------|
| 1 | `stg.browser_raw` | `MergeTree` | **хранит** сырьё + метаданные, постоянно |
| 2 | `stg.kafka_browser_raw` | `ENGINE = Kafka` | **читает** топик, ничего не хранит |
| 3 | `stg.mv_kafka_browser_to_stg` | `MATERIALIZED VIEW` | **перекладывает** из (2) в (1) на лету |
Главное: таблица с `ENGINE = Kafka` — это не хранилище, а «кран» к топику. Сама по себе
она данные не копит; данные забирает Materialized View и складывает их в обычную
`MergeTree`-таблицу. Сообщение появилось в топике → MV тут же положило его в `*_raw`.
Стоит задержаться на двух местах файла.
**Таблица-источник Kafka (`kafka_*_raw`)** — здесь живёт вся настройка чтения:
```sql
ENGINE = Kafka
SETTINGS
kafka_broker_list = 'kafka:29092', -- адрес брокера внутри Docker-сети
kafka_topic_list = 'browser_events', -- какой топик читаем
kafka_group_name = 'ch_stg_browser', -- consumer-группа: по ней Kafka помнит offset'ы
kafka_format = 'JSONAsString', -- берём сообщение целиком, как строку
kafka_num_consumers = 1,
kafka_handle_error_mode = 'stream'; -- кривое сообщение не рвёт чтение топика
```
`JSONAsString` — почему мы и можем класть `raw` строкой: ClickHouse не пытается разобрать
JSON на этом этапе. `kafka_handle_error_mode = 'stream'` — ровно то «STG принимает всё»
из секции 1: битое сообщение не уронит консьюмера.
**Materialized View** — здесь сообщение превращается в строку таблицы. Метаданные берутся
из виртуальных колонок Kafka-движка (`_topic`, `_partition`, `_offset`, `_timestamp_ms`):
```sql
SELECT
now64(3) AS ingest_ts,
_topic AS kafka_topic,
_partition AS kafka_partition,
_offset AS kafka_offset,
_timestamp_ms AS kafka_ts,
raw
FROM stg.kafka_browser_raw;
```
Тут без фокусов: каждая виртуальная колонка ложится в свою. `_timestamp_ms` — это уже
готовый `DateTime64(3)` (время сообщения с точностью до миллисекунд), поэтому идёт в
`kafka_ts` как есть, без преобразований. В секции 4 ты положишь рядом ещё одно время из
Kafka и увидишь, чем они отличаются.
---
## 4. Управляемая правка: протащи ещё одно поле метаданных
Задача: добавить в `stg.browser_raw` ещё одну колонку — то же время сообщения, но из
другой виртуальной колонки Kafka: `_timestamp` (тип `DateTime`, точность до секунды) —
и увидеть её заполненной.
Соль урока — в **порядке из двух шагов**: MV не «дотянет» новое поле само, его сначала
нужно добавить в таблицу-приёмник. Схема приёмника и MV меняются вместе.
**Шаг 1 — добавляем колонку в таблицу-приёмник:**
```sql
ALTER TABLE stg.browser_raw ADD COLUMN kafka_msg_ts DateTime;
```
**Шаг 2 — пересоздаём MV, чтобы оно заполняло новую колонку** (поменять SELECT можно и
«на месте» через `ALTER TABLE ... MODIFY QUERY`, но для наглядности пересоздадим целиком):
```sql
DROP VIEW stg.mv_kafka_browser_to_stg;
CREATE MATERIALIZED VIEW stg.mv_kafka_browser_to_stg
TO stg.browser_raw
AS
SELECT
now64(3) AS ingest_ts,
_topic AS kafka_topic,
_partition AS kafka_partition,
_offset AS kafka_offset,
_timestamp_ms AS kafka_ts,
_timestamp AS kafka_msg_ts, -- ещё одно время из Kafka, но с точностью только до секунды
raw
FROM stg.kafka_browser_raw;
```
**Перезаливаем срез, чтобы новая колонка заполнилась.** Сначала чистим таблицу — иначе
рядом останутся строки из секции 2, вставленные ещё *до* `ADD COLUMN`, и в них
`kafka_msg_ts` будет пустой (`1970-01-01`):
```sql
TRUNCATE TABLE stg.browser_raw;
```
Затем перезаливаем — с пересозданием топиков, чтобы Kafka-движок перечитал сообщения
с начала (без этого он считает их уже прочитанными и ничего нового не подхватит):
```bash
LIMIT=50 RESET_TOPICS=1 make data
```
**Смотрим результат:**
```sql
SELECT kafka_ts, kafka_msg_ts
FROM stg.browser_raw
ORDER BY kafka_offset
LIMIT 5;
```
Видно два времени одного и того же сообщения: `kafka_ts` (из `_timestamp_ms`) — с
миллисекундами, `kafka_msg_ts` (из `_timestamp`) — округлённое до секунды. Момент тот же,
точность разная: Kafka отдаёт эту метку через две виртуальные колонки сразу.
> **Порядок важен, а ClickHouse молчит.** Пересоздай MV с `_timestamp`, **не добавив**
> колонку `kafka_msg_ts` в таблицу, — ошибки не будет. ClickHouse сопоставляет колонки MV
> с таблицей по имени и лишнюю просто отбрасывает: данные текут, а твоё новое поле молча
> теряется. Обратный случай — колонку добавил, а в MV не указал — тоже не упадёт: поле
> заполнится дефолтом. Вывод: за синхронность схемы и MV отвечаешь ты, а не движок.
**Верни как было** (откат — тоже две операции, и порядок важен):
```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 clean && make up && make ddl && LIMIT=50 make data`.
---
## 5. Проверь себя
| Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------|
| `LIMIT=50 make data` | `SELECT count() FROM stg.browser_raw` | счётчик > 0 и растёт после загрузки |
| глянуть строку | `SELECT raw FROM stg.browser_raw LIMIT 1` | валидный JSON целиком, неразобранный |
| глянуть offset'ы | `SELECT kafka_offset FROM stg.browser_raw ORDER BY kafka_offset` | идут по возрастанию, без дублей |
| правка из секции 4 | `SELECT kafka_msg_ts FROM stg.browser_raw LIMIT 5` | колонка заполнена временем сообщения |
---
## 6. Что должно получиться
После урока у тебя на руках — видимый результат (одно на выбор):
- строка `stg.browser_raw` с заполненными `kafka_topic`, `kafka_partition`, `kafka_offset`,
`kafka_ts` и `raw`;
- либо результат твоей правки — две колонки времени, `kafka_ts` и `kafka_msg_ts`, рядом.
И проверь себя на словах: сможешь объяснить, чем таблица с `ENGINE = Kafka` отличается от
`MergeTree` и зачем между ними нужен Materialized View? Короткая зацепка для ответа — что
случится с данными, если MV убрать? Примерно такие вопросы по теме урока всплывут на
еженедельном созвоне.
---
## Мост к уроку 2
Мы приземлили поток через Materialized View — почти «в реальном времени». Возникает
честный вопрос: **раз MV так удобно перекладывает данные, почему дальше, в ODS, мы не
продолжаем на MV, а уходим в батч?** Ответ — в уроке 2: когда доходит до типизации и
проверок качества, важнее видеть и держать под контролем каждый пересчёт, чем экономить
доли секунды на задержке.
+4 -4
View File
@@ -128,7 +128,7 @@ SELECT
_topic AS kafka_topic,
_partition AS kafka_partition,
_offset AS kafka_offset,
fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts,
_timestamp_ms AS kafka_ts,
raw
FROM stg.kafka_browser_raw;
@@ -140,7 +140,7 @@ SELECT
_topic AS kafka_topic,
_partition AS kafka_partition,
_offset AS kafka_offset,
fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts,
_timestamp_ms AS kafka_ts,
raw
FROM stg.kafka_location_raw;
@@ -152,7 +152,7 @@ SELECT
_topic AS kafka_topic,
_partition AS kafka_partition,
_offset AS kafka_offset,
fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts,
_timestamp_ms AS kafka_ts,
raw
FROM stg.kafka_device_raw;
@@ -164,6 +164,6 @@ SELECT
_topic AS kafka_topic,
_partition AS kafka_partition,
_offset AS kafka_offset,
fromUnixTimestamp64Milli(toInt64(_timestamp_ms)) AS kafka_ts,
_timestamp_ms AS kafka_ts,
raw
FROM stg.kafka_geo_raw;