docs(course): переведены уроки на стартовую историю

- Зачем:
  - учебный путь должен идти через генерацию и штатный пайплайн, а не через архивный сид.
- Что:
  - обновлены уроки 00-06 и стандарт урока под startup-history/backfill.
  - объяснено, что data/*.jsonl остаются кладовкой значений генератора.
  - тест-план переведён на новый штатный запуск и HITL-приёмку.
- Проверка:
  - rg -n \"make clean/up/ddl/data/transform|make data|kafka_load|LIMIT=|2022-11-28|26 из 50\" docs/course docs/TEST_PLAN.md.
  - git diff --cached --check.
This commit is contained in:
2026-07-04 22:22:36 +03:00
parent de938edb06
commit d76c03655b
10 changed files with 192 additions and 201 deletions
+43 -44
View File
@@ -84,27 +84,27 @@ ODS мы пересобираем целиком, одной задачей Airf
## 2. Руки: смотрим базовый прогон
Поднимаем стенд, создаём схему, заливаем **малый срез** (50 строк на топик) и запускаем
трансформацию:
Поднимаем стенд и создаём стартовую историю. Это штатный путь курса: готовый источник
данных стенда пишет события в Kafka, ClickHouse читает их в STG, затем batch строит
ODS, DDS и DM. Файлы `data/*.jsonl` пока остаются только кладовкой значений для этого
источника, а не источником аналитического контура.
```bash
make up # поднять инфраструктуру
make ddl # создать базы и таблицы (в т.ч. слой ODS)
LIMIT=50 make data # залить по 50 строк каждого файла в Kafka → STG
make transform # батч STG → ODS → DDS → DM (нас интересует первый шаг)
make generated-history-analytics
make up
```
`make transform` прогоняет всю цепочку слоёв сразу, но прямо в консоли печатает то, что нам
нужно сейчас, — блок **«Статистика ODS»**. Это просто счётчики строк по всем восьми таблицам
слоя (четыре основных и четыре с ошибками):
Команда прогоняет всю цепочку слоёв и прямо в консоли печатает то, что нам нужно сейчас, —
блок **«Статистика ODS»**. Это просто счётчики строк по всем восьми таблицам слоя (четыре
основных и четыре с ошибками). Пример формы вывода:
```
Статистика ODS:
┌─table──────────────────────┬─rows─┐
│ ods.browser_event │ 50
│ ods.location_event │ 50
│ ods.device_by_click │ 26
│ ods.geo_by_click │ 26
│ ods.browser_event │ ...
│ ods.location_event │ ...
│ ods.device_by_click │ ...
│ ods.geo_by_click │ ...
│ ods.browser_event_errors │ 0 │
│ ods.location_event_errors │ 0 │
│ ods.device_by_click_errors │ 0 │
@@ -114,35 +114,34 @@ make transform # батч STG → ODS → DDS → DM (нас инте
Прочитаем эту табличку — в ней три вещи, которые стоит заметить.
**Все четыре `*_errors` — по нулям.** Значит, наш срез чистый: ни одна запись не дала ошибки
разбора, столбец `parse_errors` у всех пустой. Это нормально — данные в демо аккуратные.
**Все четыре `*_errors` — по нулям.** Значит, стартовая история чистая: ни одна запись не дала
ошибки разбора, столбец `parse_errors` у всех пустой. Это нормально — данные стенда аккуратные.
Ошибки мы увидим в секции 4, когда сами их устроим.
**`browser` и `location` дали 50 из 50.** Сколько событий пришло столько и легло, один к
одному.
**`browser` и `location` идут в одном зерне события.** Сколько событий пришло, столько строк
и ожидаем увидеть после типизации, если ключи валидны.
**А `device` и `geo` — только 26 из 50.** Вот это уже интересно. Половина куда-то делась? Нет.
И это важно понять, иначе дальше будет казаться, что данные текут.
**А `device` и `geo` обычно меньше, чем событий.** Вот это уже интересно. Часть строк
куда-то делась? Нет. И это важно понять, иначе дальше будет казаться, что данные текут.
Дело в том, что эти две таблицы хранят не события, а **контекст клика**: с какого устройства
был клик и из какой точки на карте. Ключ у них — `click_id`. А в срезе на 50 событий разных
кликов всего 26: на один клик приходится несколько событий, и `click_id` у них повторяется.
Движок таблицы (про него — в секции 3) схлопывает повторы по ключу, оставляя по одной строке
на клик. Отсюда и 26.
был клик и из какой точки на карте. Ключ у них — `click_id`. Разных кликов меньше, чем событий:
на один клик приходится несколько событий, и `click_id` у них повторяется. Движок таблицы
(про него — в секции 3) схлопывает повторы по ключу, оставляя по одной строке на клик.
Проверь это сам, а не верь на слово. Открой SQL-консоль `http://localhost:9123/play`
(пользователь `default`, пароль `123456`) и посчитай, сколько в срезе *различных* `click_id`:
(пользователь `default`, пароль `123456`) и посчитай, сколько в STG *различных* `click_id`:
```sql
-- Всего строк в STG — 50, но различных click_id среди них — ровно 26
-- Строк событий больше, чем различных click_id
SELECT count() AS stg_rows,
uniqExact(toUUIDOrNull(JSONExtractString(raw, 'click_id'))) AS distinct_clicks
FROM stg.geo_raw;
```
Получишь `stg_rows = 50`, `distinct_clicks = 26` — ровно столько, сколько строк в
`ods.geo_by_click`. Значит, 26 — это схлопнутые повторы, а не пропавшие данные. Ничего не
потерялось молча.
`distinct_clicks` должен быть меньше или равен `stg_rows` и совпадать с числом строк в
`ods.geo_by_click`. Значит, это схлопнутые повторы по `click_id`, а не пропавшие данные.
Ничего не потерялось молча.
---
@@ -174,7 +173,7 @@ toFloat64OrNull(JSONExtractString(raw, 'geo_latitude')) AS
Весь смысл — в суффиксе `OrNull`. Если значение **не** приводится к нужному типу (вместо UUID
пришёл мусор), функция не падает с ошибкой, а просто возвращает `NULL`. Это ровно то правило
стенда, что и в STG — «грязная запись не валит пайплайн», — только теперь на уровне типов.
Один кривой `event_id` станет `NULL` и будет помечен, а остальные 49 строк спокойно доедут.
Один кривой `event_id` станет `NULL` и будет помечен, а остальные строки спокойно доедут.
> Кстати, про `AS`: эти строки живут в блоке `WITH` в начале запроса. `WITH` — это просто
> способ заранее посчитать значение и дать ему имя, чтобы ниже по запросу ссылаться на него
@@ -230,7 +229,7 @@ arrayFilter(x -> x != '', [
> если поменять разбор только в одном из двух мест, они разойдутся. В секции 4 мы как раз этим
> воспользуемся — и увидим, чем грозит такой рассинхрон.
### Движок: откуда взялись 26 строк
### Движок: почему строк контекста меньше
И последнее место — строчка про движок основных таблиц:
@@ -241,8 +240,8 @@ ORDER BY (click_id)
`ReplacingMergeTree` — это таблица, которая схлопывает строки с одинаковым ключом (ключ берётся
из `ORDER BY`), оставляя самую свежую по `src_ingest_ts` — времени загрузки в ODS. Вот она,
причина «26 из 50» из секции 2: у `device` и `geo` много строк с одинаковым `click_id`, и
движок оставляет по одной на клик.
причина разницы из секции 2: у `device` и `geo` много строк с одинаковым `click_id`, и движок
оставляет по одной на клик.
---
@@ -276,8 +275,8 @@ make transform
И смотрим на ту же «Статистику ODS». Таблица ошибок гео, которая была пустой, теперь полная:
```
│ ods.geo_by_click │ 26
│ ods.geo_by_click_errors │ 50 │ ← было 0
│ ods.geo_by_click │ ...
│ ods.geo_by_click_errors │ ... │ ← было 0
```
А в самой основной таблице широта пропала — но не молча, рядом стоит метка:
@@ -295,11 +294,10 @@ LIMIT 4;
└──────────────┴──────────────┴───────────────┴──────────────────────┘
```
Вот теперь видно всё разом — и DQ-split, и «двойной учёт» из секции 3 вживую. 26 строк
остались в основной таблице (ключ `click_id` цел) с пометкой `bad_geo_latitude`. И те же
записи попали в число 50 строк `geo_by_click_errors`. Долгота на месте, а широты больше нет:
один неверный тип — и целое поле потеряно по всему слою. Заметили это `parse_errors` и таблица
ошибок — для того DQ-split и нужен.
Вот теперь видно всё разом — и DQ-split, и «двойной учёт» из секции 3 вживую. Строки с валидным
`click_id` остались в основной таблице с пометкой `bad_geo_latitude`. И те же записи попали в
`geo_by_click_errors`. Долгота на месте, а широты больше нет: один неверный тип — и целое поле
потеряно по всему слою. Заметили это `parse_errors` и таблица ошибок — для того DQ-split и нужен.
> **Бывает и хуже — тихо, совсем без метки.** Здесь нас спас суффикс `OrNull`: неверный тип
> дал `NULL`, а `NULL` мы умеем замечать (на него и сработал `parse_errors`). По-настоящему
@@ -319,7 +317,7 @@ make transform
```
После этого `geo_by_click_errors` снова `0`, широта на месте. А если стенд совсем «поплыл» —
всегда есть полный сброс: `make clean && make up && make ddl && LIMIT=50 make data && make transform`.
всегда есть полный чистый прогон: `make generated-history-analytics && make up`.
---
@@ -327,9 +325,9 @@ make transform
| Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------|
| `make transform` (базовый прогон) | блок «Статистика ODS» | `browser`/`location` = 50, `device`/`geo` = 26, все `*_errors` = 0 |
| почему 26, а не 50 | запрос `uniqExact(click_id)` по `stg.geo_raw` | 26 различных `click_id` — это схлопывание повторов, а не потеря |
| правка из секции 4 | блок «Статистика ODS» | `ods.geo_by_click_errors` прыгнул `0 → 50` |
| базовый прогон | блок «Статистика ODS» | основные таблицы не пустые, все `*_errors` = 0 |
| почему `device`/`geo` меньше событий | запрос `uniqExact(click_id)` по `stg.geo_raw` | число различных `click_id` совпадает с `ods.geo_by_click` |
| правка из секции 4 | блок «Статистика ODS» | `ods.geo_by_click_errors` прыгнул с `0` на ненулевое число |
| правка из секции 4 | `SELECT geo_latitude, parse_errors FROM ods.geo_by_click` | широта `NULL`, в `parse_errors``bad_geo_latitude` |
---
@@ -338,7 +336,8 @@ make transform
После урока у тебя на руках — видимый результат (одно на выбор):
- скрин блока «Статистика ODS», где после правки `ods.geo_by_click_errors` ушёл с `0` на `50`;
- скрин блока «Статистика ODS», где после правки `ods.geo_by_click_errors` ушёл с `0`
на ненулевое число;
- либо выборка из `ods.geo_by_click` с пустой широтой и меткой `bad_geo_latitude` рядом.
И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне: