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, …); - **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.
+19 -23
View File
@@ -93,25 +93,23 @@ ODS мы пересобираем целиком, одной задачей Airf
``` ```
Статистика 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 │
└────────────────────────────┴──────┘ └────────────────────────────┴────────
``` ```
<!-- сверить-на-стенде -->
Прочитаем эту табличку — в ней три вещи, которые стоит заметить. Прочитаем эту табличку — в ней три вещи, которые стоит заметить.
**Все четыре `*_errors` — по нулям.** Значит, стартовая история чистая: ни одна запись не дала **Все четыре `*_errors` — по нулям.** Значит, стартовая история чистая: ни одна запись не дала
ошибки разбора, столбец `parse_errors` у всех пустой. Это нормально — данные стенда аккуратные. ошибки разбора, столбец `parse_errors` у всех пустой. Это нормально — данные стенда аккуратные.
Ошибки мы увидим в секции 4, когда сами их устроим. Ошибки мы увидим в секции 4, когда сами их устроим.
<!-- сверить-на-стенде -->
**`browser` и `location` идут в одном зерне события.** Сколько событий пришло, столько строк **`browser` и `location` идут в одном зерне события.** Сколько событий пришло, столько строк
и ожидаем увидеть после типизации, если ключи валидны. и ожидаем увидеть после типизации, если ключи валидны.
@@ -134,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`, а не пропавшие
данные. Ничего не потерялось молча.
--- ---
@@ -245,11 +244,10 @@ 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`
блока `GEO EVENTS`. Открой `sql/ods/20_stg_to_ods.sql`, найди оба вхождения и в каждом замени блока `GEO EVENTS`. Открой `sql/ods/20_stg_to_ods.sql`, найди оба вхождения и в каждом замени
@@ -271,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
@@ -285,11 +286,10 @@ 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'] │
└──────────────┴──────────────┴───────────────┴──────────────────────┘ └──────────────┴──────────────┴───────────────┴──────────────────────┘
``` ```
<!-- сверить-на-стенде -->
Вот теперь видно всё разом — и DQ-split, и «двойной учёт» из секции 3 вживую. Строки с валидным Вот теперь видно всё разом — и DQ-split, и «двойной учёт» из секции 3 вживую. Строки с валидным
`click_id` остались в основной таблице с пометкой `bad_geo_latitude`. И те же записи попали в `click_id` остались в основной таблице с пометкой `bad_geo_latitude`. И те же записи попали в
@@ -304,7 +304,6 @@ LIMIT 4;
> это молча срезало миллисекунды, и время по всему стенду уехало в `1970-01-21`. Ничто на это > это молча срезало миллисекунды, и время по всему стенду уехало в `1970-01-21`. Ничто на это
> не указывало — поймали только прогоном на стенде. Мораль урока: тип выбирают осознанно, даже > не указывало — поймали только прогоном на стенде. Мораль урока: тип выбирают осознанно, даже
> когда функция «не падает». > когда функция «не падает».
<!-- сверить-на-стенде -->
**Верни как было.** Откати обе правки — верни `toFloat64OrNull` в оба места. Если запутался, **Верни как было.** Откати обе правки — верни `toFloat64OrNull` в оба места. Если запутался,
проще одной командой откатить весь файл к версии из репозитория: проще одной командой откатить весь файл к версии из репозитория:
@@ -317,7 +316,6 @@ make transform
После этого `geo_by_click_errors` снова `0`, широта на месте. А если стенд совсем После этого `geo_by_click_errors` снова `0`, широта на месте. А если стенд совсем
«поплыл», пройди «поплыл», пройди
[канонический сброс](../README.md#подготовка-и-канонический-сброс). [канонический сброс](../README.md#подготовка-и-канонический-сброс).
<!-- сверить-на-стенде -->
--- ---
@@ -329,7 +327,6 @@ make transform
| почему `device`/`geo` меньше событий | запрос `uniqExact(click_id)` по `stg.geo_raw` | число различных `click_id` совпадает с `ods.geo_by_click` | | почему `device`/`geo` меньше событий | запрос `uniqExact(click_id)` по `stg.geo_raw` | число различных `click_id` совпадает с `ods.geo_by_click` |
| правка из секции 4 | блок «Статистика ODS» | `ods.geo_by_click_errors` прыгнул с `0` на ненулевое число | | правка из секции 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` | | правка из секции 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», где после правки `ods.geo_by_click_errors` ушёл с `0`
на ненулевое число; на ненулевое число;
- либо выборка из `ods.geo_by_click` с пустой широтой и меткой `bad_geo_latitude` рядом. - либо выборка из `ods.geo_by_click` с пустой широтой и меткой `bad_geo_latitude` рядом.
<!-- сверить-на-стенде -->
И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне: И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне:
+5 -11
View File
@@ -74,10 +74,10 @@
``` ```
Статистика DDS: Статистика DDS:
┌─table─────┬─rows─┐ ┌─table─────┬───rows─┐
│ dds.click │ ... │ dds.click │ 26083
│ dds.event │ ... │ dds.event │ 280437
└───────────┴──────┘ └───────────┴────────
``` ```
Прочитаем эти две строки. Прочитаем эти две строки.
@@ -95,10 +95,9 @@
``` ```
┌─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 │
└────────────┴───────┴─────────────────────┴───────────────┴─────────────┘ └────────────┴───────┴─────────────────────┴───────────────┴─────────────┘
``` ```
<!-- сверить-на-стенде -->
В колонке `check_date` стоит `today()` из кода витрины, так что у тебя там будет сегодняшняя В колонке `check_date` стоит `today()` из кода витрины, так что у тебя там будет сегодняшняя
дата — не пугайся, если она не совпадёт с примером. дата — не пугайся, если она не совпадёт с примером.
@@ -106,7 +105,6 @@
`orphan_events = 0` — ни одной сироты. Каждое событие нашло свой клик в `dds.click`. На `orphan_events = 0` — ни одной сироты. Каждое событие нашло свой клик в `dds.click`. На
чистой стартовой истории так и должно быть: данные аккуратные, ничего не потерялось. В секции чистой стартовой истории так и должно быть: данные аккуратные, ничего не потерялось. В секции
4 мы сироту устроим сами — и эта строка оживёт. 4 мы сироту устроим сами — и эта строка оживёт.
<!-- сверить-на-стенде -->
Проверь нолик сам, не верь на слово. Открой SQL-консоль `http://localhost:9123/play` Проверь нолик сам, не верь на слово. Открой SQL-консоль `http://localhost:9123/play`
(пользователь `default`, пароль `123456`) и посчитай сирот напрямую: (пользователь `default`, пароль `123456`) и посчитай сирот напрямую:
@@ -122,7 +120,6 @@ WHERE click_id IS NOT NULL
`NOT IN (SELECT ...)` читается прямо по словам: «click_id события **не входит** в список всех `NOT IN (SELECT ...)` читается прямо по словам: «click_id события **не входит** в список всех
click_id из `dds.click`». То есть событие ссылается на клик, которого в карточках кликов нет. click_id из `dds.click`». То есть событие ссылается на клик, которого в карточках кликов нет.
Сейчас таких ноль — запомни этот запрос, в секции 4 он покажет другое число. Сейчас таких ноль — запомни этот запрос, в секции 4 он покажет другое число.
<!-- сверить-на-стенде -->
--- ---
@@ -308,7 +305,6 @@ make transform
После этого `orphan_events` снова `0`, придуманное событие исчезло. А если стенд После этого `orphan_events` снова `0`, придуманное событие исчезло. А если стенд
совсем «поплыл», пройди совсем «поплыл», пройди
[канонический сброс](../README.md#подготовка-и-канонический-сброс). [канонический сброс](../README.md#подготовка-и-канонический-сброс).
<!-- сверить-на-стенде -->
--- ---
@@ -321,7 +317,6 @@ make transform
| почему `click` меньше `event` | запрос `count()` по `dds.click` и `dds.event` | много событий ссылаются на меньшее число кликов — норма | | почему `click` меньше `event` | запрос `count()` по `dds.click` и `dds.event` | много событий ссылаются на меньшее число кликов — норма |
| правка из секции 4 (вставили сироту) | запрос `count()` сирот в play-консоли | `0 → 1` | | правка из секции 4 (вставили сироту) | запрос `count()` сирот в play-консоли | `0 → 1` |
| та же сирота через `dm.v_events_enriched` | `SELECT device_type, geo_country ...` | поля клика пустые (`NULL`) — это `LEFT JOIN` | | та же сирота через `dm.v_events_enriched` | `SELECT device_type, geo_country ...` | поля клика пустые (`NULL`) — это `LEFT JOIN` |
<!-- сверить-на-стенде -->
--- ---
@@ -332,7 +327,6 @@ make transform
- скрин запроса со счётчиком сирот: было `0`, после вставки стало `1`; - скрин запроса со счётчиком сирот: было `0`, после вставки стало `1`;
- либо выборка из `dm.v_events_enriched` по событию-сироте, где `device_type` и `geo_country` - либо выборка из `dm.v_events_enriched` по событию-сироте, где `device_type` и `geo_country`
пусты, — `LEFT JOIN` сохранил событие без клика. пусты, — `LEFT JOIN` сохранил событие без клика.
<!-- сверить-на-стенде -->
И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне: И проверь себя на словах — примерно эти вопросы всплывут на еженедельном созвоне:
@@ -81,8 +81,9 @@ Airflow. Главная единица Airflow — **DAG** (Directed Acyclic Gra
заново наполнит их из 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-задача, которая решает, можно ли идти дальше.
@@ -99,9 +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.
--- ---
@@ -314,7 +315,6 @@ WHERE click_id IS NOT NULL
Снова должно быть `0`. Если стенд после экспериментов совсем запутался, пройди Снова должно быть `0`. Если стенд после экспериментов совсем запутался, пройди
[канонический сброс](../README.md#подготовка-и-канонический-сброс). [канонический сброс](../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` | | чистый прогон | `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 |
| откат через `{"full_refresh": true}` | прямой SQL-счётчик сирот | снова `0` | | откат через `{"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 events visits users min_event_timestamp max_event_timestamp model_t0 model_t_end profile
``` ```
Сверь результат с таблицей. Значения будут вписаны после контрольного прогона Сверь результат с контрольными значениями:
эталонного мира:
| Поле | Ожидаем после дня 4 | | Поле | Ожидаем после дня 4 |
|------|----------------------| |------|----------------------|
| `events` | `<события-день-4>` <!-- сверить-на-стенде --> | | `events` | `374092` |
| `visits` | `<визиты-день-4>` <!-- сверить-на-стенде --> | | `visits` | `34801` |
| `users` | `<пользователи-день-4>` <!-- сверить-на-стенде --> | | `users` | `5388` |
| `min_event_timestamp` | `<минимальное-время>` <!-- сверить-на-стенде --> | | `min_event_timestamp` | `2026-01-01 00:00:00.000000` |
| `max_event_timestamp` | `<максимальное-время>` <!-- сверить-на-стенде --> | | `max_event_timestamp` | `2026-01-04 23:59:58.581616` |
| `model_t0` | `<левая-граница>` <!-- сверить-на-стенде --> | | `model_t0` | `2026-01-01T00:00:00+00:00` |
| `model_t_end` | `<правая-граница-дня-4>` <!-- сверить-на-стенде --> | | `model_t_end` | `2026-01-05T00:00:00+00:00` |
| `profile` | `<профиль>` <!-- сверить-на-стенде --> | | `profile` | `daily-wave` |
Здесь числа накопительные: `events`, `visits` и `users` относятся ко всему миру Здесь числа накопительные: `events`, `visits` и `users` относятся ко всему миру
от `model_t0` до `model_t_end`, а не только к четвёртому дню. от `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 такой > **Как слова связаны со схемой.** В manifest написано «визит», а в SQL такой
> визит обозначен `click_id`. Отдельной таблицы визитов нет: контекст лежит в > визит обозначен `click_id`. Отдельной таблицы визитов нет: контекст лежит в
@@ -233,8 +234,8 @@ Airflow проигрывать все пропущенные интервалы
2. `make generated-history-chain-check` заканчивается без ошибки; 2. `make generated-history-chain-check` заканчивается без ошибки;
3. в `Events over Time` появился день 5. 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` | | запустить `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` | команда завершается без ошибки | | проверить накопленную цепочку | `make generated-history-chain-check` | команда завершается без ошибки |
| найти переходящие визиты | запрос в `dds.event` | хотя бы один `click_id` пересекает полночь <!-- сверить-на-стенде --> | | найти переходящие визиты | запрос в `dds.event` | на стыке 3→4 найдено `26` визитов |
| снять паузу с DAG | Airflow runs | на ближайшей получасовой границе приезжает один запланированный день | | снять паузу с DAG | Airflow runs | на ближайшей получасовой границе приезжает один запланированный день |
| вернуть паузу и пройти сброс | Airflow и Superset | расписание выключено, снова видны три эталонных дня | | вернуть паузу и пройти сброс | Airflow и Superset | расписание выключено, снова видны три эталонных дня |
+11 -5
View File
@@ -55,7 +55,7 @@ Materialized View сразу складывает сообщения в STG. Д
SELECT SELECT
'STG' AS layer, 'STG' AS layer,
max(parseDateTime64BestEffortOrNull( max(parseDateTime64BestEffortOrNull(
JSONExtractString(raw, 'event_timestamp'), 6, 'UTC' JSONExtractString(raw, 'event_timestamp'), 6
)) AS max_event_ts )) AS max_event_ts
FROM stg.browser_raw 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` с пустой формой. После зелёного прогона выполни: Запусти `etl_pipeline` с пустой формой. После зелёного прогона выполни:
```sql ```sql
WITH parseDateTime64BestEffort('<model_t_end>', 6, 'UTC') AS boundary WITH parseDateTime64BestEffort(
substring('<model_t_end>', 1, 19), 6
) AS boundary
SELECT SELECT
user_domain_id, user_domain_id,
countIf(visit_start < boundary) AS visits_before, 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 HAVING min(visit_start) < boundary AND max(visit_start) >= boundary
LIMIT 10; LIMIT 10;
``` ```
<!-- сверить-на-стенде -->
Подставь вместо `<model_t_end>` границу из манифеста. Успех — хотя бы одна Подставь вместо `<model_t_end>` границу из манифеста. Успех — хотя бы одна
строка. Иначе подожди несколько минут, перезапусти `etl_pipeline` и повтори строка. Иначе подожди несколько минут, перезапусти `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` | | выполнить `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-down` | Grafana и Kafka | события перестают поступать, отставание читателей стекает к нулю |
| снова выполнить `make generator-continue` | Grafana, Kafka и STG | поток продолжается, offset-ы и правая граница снова растут | | снова выполнить `make generator-continue` | Grafana, Kafka и STG | поток продолжается, offset-ы и правая граница снова растут |
| найти вернувшегося пользователя | запрос по границе `model_t_end` | есть пользователь с визитами до и после границы | | найти вернувшегося пользователя | запрос по границе `model_t_end` | есть пользователь с визитами до и после границы |