docs(course): маркеры «сверить-на-стенде» заменены живыми числами (#23)

Зачем: тикет #23 — каждое цитируемое число курса сверено с живым
стендом; до этого в уроках стояли заглушки.

Что:
- уроки 00–04: 18 маркеров заполнены числами свежего импорта
  (280 437 событий, нули в таблицах ошибок, счётчики ODS/DDS);
- лаба 07: таблица manifest после дня 4 (374 092 / 34 801 / 5 388),
  переходящие визиты по стыкам (35/22/26), числа после дня 5;
- лаба 08: каноническая граница трёх дней, пример замера свежести
  (лаг 3:45 модельного времени до догона, ETL ~29 с) и вернувшегося
  пользователя; две живые поправки разбора времени: убран
  принудительный UTC в разборе STG и суффикс +00:00 в сравнении
  границы (ловились только на живом стенде).

Проверка: детерминизм подтверждён двумя независимыми циклами
сброс→импорт→инкремент (числа manifest и checksum_sha256 дней 4 и 5
совпали бит в бит); chain-check зелёный; сценарий лабы 08 прогнан
вживую, включая стоп/продолжение и красный full_refresh=false из
урока 4; grep «сверить-на-стенде» пуст; ссылки и якоря целы;
make test (219+31) и make lint зелёные; /ai-text-lint по лабам — без
существенных находок.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 18:15:46 +03:00
co-authored by Claude Opus 4.8
parent cbf8b2d770
commit 8f03d8b347
6 changed files with 70 additions and 70 deletions
+8 -4
View File
@@ -76,16 +76,20 @@ consumer читает в своём темпе, не трогая producer'а.
- **Offset** — порядковый номер сообщения в партиции (0, 1, 2, …);
- **Timestamp** — когда сообщение легло в Kafka (время *доставки*, не время самого события);
- **Value** — тело: JSON события целиком, например:
- **Value** — тело: JSON события целиком. Например, у сообщения с offset `0`:
```json
{"event_id": "8cca1c7d-...", "event_timestamp": "2026-01-01 00:01:00.000000",
"event_type": "pageview", "browser_name": "Chrome", "browser_language": "sat_IN"}
{"event_id": "b3336fa4-184d-44e5-9878-98c8f8058496",
"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` (когда оно случилось,
модельное время стенда), и оно отличается от Kafka-Timestamp (когда оно попало в топик).
Kafka-Timestamp зависит от времени импорта, поэтому у тебя будет своё значение.
Два разных времени у одной записи — запомни этот момент, в уроке 1 он всплывёт уже на
стороне ClickHouse.
+23 -27
View File
@@ -93,25 +93,23 @@ ODS мы пересобираем целиком, одной задачей Airf
```
Статистика ODS:
┌─table──────────────────────┬─rows─┐
│ 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 │
│ ods.geo_by_click_errors │ 0 │
└────────────────────────────┴──────┘
┌─table──────────────────────┬───rows─┐
│ ods.browser_event │ 280437
│ ods.location_event │ 280437
│ ods.device_by_click │ 26083
│ ods.geo_by_click │ 26083
│ ods.browser_event_errors │ 0 │
│ ods.location_event_errors │ 0 │
│ ods.device_by_click_errors │ 0 │
│ ods.geo_by_click_errors │ 0 │
└────────────────────────────┴────────
```
<!-- сверить-на-стенде -->
Прочитаем эту табличку — в ней три вещи, которые стоит заметить.
**Все четыре `*_errors` — по нулям.** Значит, стартовая история чистая: ни одна запись не дала
ошибки разбора, столбец `parse_errors` у всех пустой. Это нормально — данные стенда аккуратные.
Ошибки мы увидим в секции 4, когда сами их устроим.
<!-- сверить-на-стенде -->
**`browser` и `location` идут в одном зерне события.** Сколько событий пришло, столько строк
и ожидаем увидеть после типизации, если ключи валидны.
@@ -134,9 +132,10 @@ SELECT count() AS stg_rows,
FROM stg.geo_raw;
```
`distinct_clicks` должен быть меньше или равен `stg_rows` и совпадать с числом строк в
`ods.geo_by_click`. Значит, это схлопнутые повторы по `click_id`, а не пропавшие данные.
Ничего не потерялось молча.
На эталонном мире запрос возвращает `stg_rows = 280437` и
`distinct_clicks = 26083`. Второе число совпадает с числом строк в
`ods.geo_by_click`. Значит, это схлопнутые повторы по `click_id`, а не пропавшие
данные. Ничего не потерялось молча.
---
@@ -245,11 +244,10 @@ ORDER BY (click_id)
Урок про типы — так давай **намеренно ошибёмся типом** и посмотрим, что будет. Это самый
поучительный момент урока.
Возьмём координату `geo_latitude` — широту. Это дробное число, например `50.82709`. Достаём мы
Возьмём координату `geo_latitude` — широту. Это дробное число, например `-7.60361`. Достаём мы
её через `toFloat64OrNull` — «привести к дробному числу». Заменим тип на целочисленный —
`toInt64OrNull`, «привести к целому». Для строки `"50.82709"` целого числа не получится
`toInt64OrNull`, «привести к целому». Для строки `"-7.60361"` целого числа не получится
(там точка, дробная часть), и функция вернёт `NULL`. То есть широта просто исчезнет.
<!-- сверить-на-стенде -->
Из секции 3 помним: разбор продублирован, поэтому правок будет **две** — в обоих `INSERT`
блока `GEO EVENTS`. Открой `sql/ods/20_stg_to_ods.sql`, найди оба вхождения и в каждом замени
@@ -271,10 +269,13 @@ make transform
И смотрим на ту же «Статистику ODS». Таблица ошибок гео, которая была пустой, теперь полная:
```
│ ods.geo_by_click │ ...
│ ods.geo_by_click_errors │ ... │ ← было 0
│ ods.geo_by_click │ ≈ 26083
│ ods.geo_by_click_errors │ 280437 │ ← было 0
```
В таблице ошибок число точное. В `ods.geo_by_click` после фонового схлопывания
останется `26083` строки, но сразу после прогона число может быть больше.
А в самой основной таблице широта пропала — но не молча, рядом стоит метка:
```sql
@@ -285,11 +286,10 @@ LIMIT 4;
```
┌─click_id─────┬─geo_latitude─┬─geo_longitude─┬─parse_errors─────────┐
58cdfc1e-... │ ᴺᵁᴸᴸ │ -0.2 │ ['bad_geo_latitude'] │
9ffd819b-... │ ᴺᵁᴸᴸ │ 85.37752 │ ['bad_geo_latitude'] │
cee12466-... │ ᴺᵁᴸᴸ │ -8.07257 │ ['bad_geo_latitude'] │
a8e39850-... │ ᴺᵁᴸᴸ │ 37.92792 │ ['bad_geo_latitude'] │
└──────────────┴──────────────┴───────────────┴──────────────────────┘
```
<!-- сверить-на-стенде -->
Вот теперь видно всё разом — и DQ-split, и «двойной учёт» из секции 3 вживую. Строки с валидным
`click_id` остались в основной таблице с пометкой `bad_geo_latitude`. И те же записи попали в
@@ -304,7 +304,6 @@ LIMIT 4;
> это молча срезало миллисекунды, и время по всему стенду уехало в `1970-01-21`. Ничто на это
> не указывало — поймали только прогоном на стенде. Мораль урока: тип выбирают осознанно, даже
> когда функция «не падает».
<!-- сверить-на-стенде -->
**Верни как было.** Откати обе правки — верни `toFloat64OrNull` в оба места. Если запутался,
проще одной командой откатить весь файл к версии из репозитория:
@@ -317,7 +316,6 @@ make transform
После этого `geo_by_click_errors` снова `0`, широта на месте. А если стенд совсем
«поплыл», пройди
[канонический сброс](../README.md#подготовка-и-канонический-сброс).
<!-- сверить-на-стенде -->
---
@@ -329,7 +327,6 @@ make transform
| почему `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` |
<!-- сверить-на-стенде -->
---
@@ -340,7 +337,6 @@ make transform
- скрин блока «Статистика ODS», где после правки `ods.geo_by_click_errors` ушёл с `0`
на ненулевое число;
- либо выборка из `ods.geo_by_click` с пустой широтой и меткой `bad_geo_latitude` рядом.
<!-- сверить-на-стенде -->
И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне:
+5 -11
View File
@@ -74,10 +74,10 @@
```
Статистика DDS:
┌─table─────┬─rows─┐
│ dds.click │ ...
│ dds.event │ ...
└───────────┴──────┘
┌─table─────┬───rows─┐
│ dds.click │ 26083
│ dds.event │ 280437
└───────────┴────────
```
Прочитаем эти две строки.
@@ -95,10 +95,9 @@
```
┌─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 │
└────────────┴───────┴─────────────────────┴───────────────┴─────────────┘
```
<!-- сверить-на-стенде -->
В колонке `check_date` стоит `today()` из кода витрины, так что у тебя там будет сегодняшняя
дата — не пугайся, если она не совпадёт с примером.
@@ -106,7 +105,6 @@
`orphan_events = 0` — ни одной сироты. Каждое событие нашло свой клик в `dds.click`. На
чистой стартовой истории так и должно быть: данные аккуратные, ничего не потерялось. В секции
4 мы сироту устроим сами — и эта строка оживёт.
<!-- сверить-на-стенде -->
Проверь нолик сам, не верь на слово. Открой SQL-консоль `http://localhost:9123/play`
(пользователь `default`, пароль `123456`) и посчитай сирот напрямую:
@@ -122,7 +120,6 @@ WHERE click_id IS NOT NULL
`NOT IN (SELECT ...)` читается прямо по словам: «click_id события **не входит** в список всех
click_id из `dds.click`». То есть событие ссылается на клик, которого в карточках кликов нет.
Сейчас таких ноль — запомни этот запрос, в секции 4 он покажет другое число.
<!-- сверить-на-стенде -->
---
@@ -308,7 +305,6 @@ make transform
После этого `orphan_events` снова `0`, придуманное событие исчезло. А если стенд
совсем «поплыл», пройди
[канонический сброс](../README.md#подготовка-и-канонический-сброс).
<!-- сверить-на-стенде -->
---
@@ -321,7 +317,6 @@ make transform
| почему `click` меньше `event` | запрос `count()` по `dds.click` и `dds.event` | много событий ссылаются на меньшее число кликов — норма |
| правка из секции 4 (вставили сироту) | запрос `count()` сирот в play-консоли | `0 → 1` |
| та же сирота через `dm.v_events_enriched` | `SELECT device_type, geo_country ...` | поля клика пустые (`NULL`) — это `LEFT JOIN` |
<!-- сверить-на-стенде -->
---
@@ -332,7 +327,6 @@ make transform
- скрин запроса со счётчиком сирот: было `0`, после вставки стало `1`;
- либо выборка из `dm.v_events_enriched` по событию-сироте, где `device_type` и `geo_country`
пусты, — `LEFT JOIN` сохранил событие без клика.
<!-- сверить-на-стенде -->
И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне:
@@ -81,8 +81,9 @@ Airflow. Главная единица Airflow — **DAG** (Directed Acyclic Gra
заново наполнит их из ODS. Для учебного стенда это удобный чистый прогон: результат повторяемый,
старые эксперименты не мешают.
Когда DAG завершится, открой его граф. На чистой стартовой истории все задачи должны быть
зелёными. Найди внутри группы `transform` две задачи подряд:
Когда DAG завершится, открой его граф. На чистой стартовой истории выбранные задачи
должны быть зелёными, а невыбранная ветка `skip_truncate` — в состоянии `skipped`.
Найди внутри группы `transform` две задачи подряд:
- `check_dds_integrity` — SQL-задача, которая считает сирот;
- `assert_dds_integrity` — Python-задача, которая решает, можно ли идти дальше.
@@ -99,9 +100,9 @@ WHERE layer = 'dds'
AND check_name = 'orphan_events';
```
Ожидаем `check_value = 0`. Это тот же смысл, что в уроке 3, только теперь число появилось внутри
управляемого прогона Airflow.
<!-- сверить-на-стенде -->
На проверенном стенде `check_date = 2026-07-23`, `check_value = 0`. Дата берётся
из `today()`, поэтому у тебя будет своя. Это тот же смысл, что в уроке 3, только
теперь число появилось внутри управляемого прогона Airflow.
---
@@ -314,7 +315,6 @@ WHERE click_id IS NOT NULL
Снова должно быть `0`. Если стенд после экспериментов совсем запутался, пройди
[канонический сброс](../README.md#подготовка-и-канонический-сброс).
<!-- сверить-на-стенде -->
---
@@ -322,12 +322,11 @@ WHERE click_id IS NOT NULL
| Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------|
| `etl_pipeline` с `{"full_refresh": true}` | Airflow graph | все задачи зелёные |
| `etl_pipeline` с `{"full_refresh": true}` | Airflow graph | выбранная ветка зелёная, `skip_truncate` в `skipped` |
| чистый прогон | `dm.dq_summary`, строка `orphan_events` | `0` |
| вставка события-сироты | прямой SQL-счётчик сирот | `0 → 1` |
| `etl_pipeline` с `{"full_refresh": false}` после вставки | task `transform.assert_dds_integrity` | task красная, DAG failed |
| откат через `{"full_refresh": true}` | прямой SQL-счётчик сирот | снова `0` |
<!-- сверить-на-стенде -->
---
+16 -15
View File
@@ -82,19 +82,18 @@ docker compose run --rm --no-deps generator \
events visits users min_event_timestamp max_event_timestamp model_t0 model_t_end profile
```
Сверь результат с таблицей. Значения будут вписаны после контрольного прогона
эталонного мира:
Сверь результат с контрольными значениями:
| Поле | Ожидаем после дня 4 |
|------|----------------------|
| `events` | `<события-день-4>` <!-- сверить-на-стенде --> |
| `visits` | `<визиты-день-4>` <!-- сверить-на-стенде --> |
| `users` | `<пользователи-день-4>` <!-- сверить-на-стенде --> |
| `min_event_timestamp` | `<минимальное-время>` <!-- сверить-на-стенде --> |
| `max_event_timestamp` | `<максимальное-время>` <!-- сверить-на-стенде --> |
| `model_t0` | `<левая-граница>` <!-- сверить-на-стенде --> |
| `model_t_end` | `<правая-граница-дня-4>` <!-- сверить-на-стенде --> |
| `profile` | `<профиль>` <!-- сверить-на-стенде --> |
| `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`, а не только к четвёртому дню.
@@ -128,7 +127,9 @@ ORDER BY visit_start;
```
Запрос должен найти хотя бы один визит, чьи события лежат по разные стороны
полуночи. Подойдёт любой стык внутри мира: 1→2, 2→3 или 3→4.
полуночи. На контрольном мире он находит `35`, `22` и `26` визитов на стыках
1→2, 2→3 и 3→4. Для проверки нового дня достаточно увидеть `26` визитов на
стыке 3→4.
> **Как слова связаны со схемой.** В manifest написано «визит», а в SQL такой
> визит обозначен `click_id`. Отдельной таблицы визитов нет: контекст лежит в
@@ -233,8 +234,8 @@ Airflow проигрывать все пропущенные интервалы
2. `make generated-history-chain-check` заканчивается без ошибки;
3. в `Events over Time` появился день 5.
Точные числа после дня 5 не фиксируем: это результат учебной правки.
<!-- сверить-на-стенде -->
После дня 5 контрольный manifest показывает `468025` событий, `43482` визита,
`6713` пользователей и `model_t_end = 2026-01-06T00:00:00+00:00`.
### Верни как было
@@ -249,9 +250,9 @@ Airflow проигрывать все пропущенные интервалы
| Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------|
| запустить `world_next_day` с пустой формой | Airflow graph | все пять задач зелёные, `etl_pipeline` вызван с `full_refresh=true` |
| прочитать manifest после дня 4 | `kafka-manifest-summary` | числа совпадают с таблицей секции 2 <!-- сверить-на-стенде --> |
| прочитать manifest после дня 4 | `kafka-manifest-summary` | числа совпадают с таблицей секции 2 |
| проверить накопленную цепочку | `make generated-history-chain-check` | команда завершается без ошибки |
| найти переходящие визиты | запрос в `dds.event` | хотя бы один `click_id` пересекает полночь <!-- сверить-на-стенде --> |
| найти переходящие визиты | запрос в `dds.event` | на стыке 3→4 найдено `26` визитов |
| снять паузу с DAG | Airflow runs | на ближайшей получасовой границе приезжает один запланированный день |
| вернуть паузу и пройти сброс | Airflow и Superset | расписание выключено, снова видны три эталонных дня |
+11 -5
View File
@@ -55,7 +55,7 @@ Materialized View сразу складывает сообщения в STG. Д
SELECT
'STG' AS layer,
max(parseDateTime64BestEffortOrNull(
JSONExtractString(raw, 'event_timestamp'), 6, 'UTC'
JSONExtractString(raw, 'event_timestamp'), 6
)) AS max_event_ts
FROM stg.browser_raw
@@ -76,7 +76,8 @@ FROM dm.v_events_enriched;
```
На эталонном мире правые границы слоёв должны совпадать.
<!-- сверить-на-стенде -->
После импорта трёх эталонных дней все четыре слоя заканчиваются на
`2026-01-03 23:59:59.066766`.
### Запускаем живое продолжение
@@ -217,7 +218,9 @@ make generator-continue
Запусти `etl_pipeline` с пустой формой. После зелёного прогона выполни:
```sql
WITH parseDateTime64BestEffort('<model_t_end>', 6, 'UTC') AS boundary
WITH parseDateTime64BestEffort(
substring('<model_t_end>', 1, 19), 6
) AS boundary
SELECT
user_domain_id,
countIf(visit_start < boundary) AS visits_before,
@@ -233,7 +236,6 @@ GROUP BY user_domain_id
HAVING min(visit_start) < boundary AND max(visit_start) >= boundary
LIMIT 10;
```
<!-- сверить-на-стенде -->
Подставь вместо `<model_t_end>` границу из манифеста. Успех — хотя бы одна
строка. Иначе подожди несколько минут, перезапусти `etl_pipeline` и повтори
@@ -241,6 +243,10 @@ LIMIT 10;
пользователь с разными визитами пересекает границу замороженного мира.
Мир расходится: итоги превышают эталонные и различаются у менти. Это правильно.
Например, для границы `2026-01-06T00:00:00+00:00` найден пользователь
`5938d296-14cf-49cb-801e-82370518cf59`: `13` визитов до границы и `2` после
неё. Твои числа и идентификатор будут другими.
### Верни как было
Останови живой поток:
@@ -262,7 +268,7 @@ make generator-down
| Действие | Где смотреть | Что ожидать |
|----------|--------------|-------------|
| выполнить `make generator-continue` | Prometheus Targets | `generator` переходит в `UP` |
| измерить свежесть до и после ETL | запрос из секции 2 и часы | записаны модельное отставание и настенное ожидание <!-- сверить-на-стенде --> |
| измерить свежесть до и после 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` | есть пользователь с визитами до и после границы |