fix(dds): починены мёртвые проверки *_not_found + правки урока 3 по ревью

- Зачем:
  - перечитка урока 3 свежим взглядом нашла баг в его эталонном пути: проверки
    device_not_found/geo_not_found/location_not_found в DDS никогда не срабатывали,
    а текст урока ошибочно утверждал, что метки ставятся
- Что:
  - sql/dds/30_ods_to_dds.sql: добавлен SETTINGS join_use_nulls=1 в оба
    INSERT...SELECT. Без него LEFT JOIN на несовпадении клал в assumeNotNull(click_id)
    нулевой UUID (не NULL), и if(...IS NULL, ['*_not_found'], []) молча давал []
    (мёртвый код). Тот же класс бага про типы/NULL, что kafka_ts в уроке 1
  - docs/course/lessons/03_ods_to_dds.md: убраны ложные claim'ы про geo_not_found/
    location_not_found, формулировки приведены к реальному поведению (клик без гео
    остаётся с пустыми полями NULL; целостность событий — через orphan_events);
    поправлена опечатка «список всех клиентов» → «всех кликов»
- Проверка:
  - синтетический тест join_use_nulls=1: клик в device без geo → в ods_parse_errors
    появляются geo_not_found и geo_country_missing (до фикса — пусто)
  - LIMIT=50 make transform после фикса: dds.click=26, dds.event=50,
    orphan_events=0, ни одной строки с непустым ods_parse_errors (вывод не изменился —
    на чистом срезе несовпадений нет)
This commit is contained in:
2026-06-05 21:58:44 +03:00
parent d55f183fc9
commit 5f5312c70c
2 changed files with 28 additions and 19 deletions
+19 -17
View File
@@ -151,7 +151,7 @@ SELECT click_id FROM ods.geo_by_click ...
`UNION DISTINCT` — это «склей два списка в один и выкинь повторы». Получается полный набор `UNION DISTINCT` — это «склей два списка в один и выкинь повторы». Получается полный набор
уникальных `click_id` из обоих источников — будем называть его **универсумом кликов** (полный уникальных `click_id` из обоих источников — будем называть его **универсумом кликов** (полный
список всех клиентов, по которому дальше идём). Именно от него, а не от одной из таблиц, мы список всех кликов, по которому дальше идём). Именно от него, а не от одной из таблиц, мы
строим карточки: так не потеряется клик, который есть, например, в `geo`, но почему-то не доехал строим карточки: так не потеряется клик, который есть, например, в `geo`, но почему-то не доехал
в `device`. в `device`.
@@ -206,15 +206,15 @@ LEFT JOIN ( ...снапшот geo... ) AS g ON g.click_id = c.click_id
останутся пустыми (`NULL`). останутся пустыми (`NULL`).
Почему именно `LEFT`: левая таблица здесь — это полный список кликов, и **ни один клик терять Почему именно `LEFT`: левая таблица здесь — это полный список кликов, и **ни один клик терять
нельзя**. Не доехало гео — ладно, сохраним клик с пустым гео и пометкой, что гео нет. Эта пометка нельзя**. Не доехало гео — ладно, сохраним клик, а гео-поля (страна, координаты, IP) останутся
тут же и ставится: рядом со сборкой стоит `if(g.click_id IS NULL, ['geo_not_found'], [])` — если пустыми (`NULL`). И эта пустота — уже видимый сигнал: по ней сразу понятно, что контекст по клику
гео не подтянулось, в список ошибок карточки добавится метка `geo_not_found`. Тот же принцип не подтянулся. Тот же принцип, что и в ODS: **не теряем, а оставляем видимый след**, — только
«не теряем и помечаем», что и `parse_errors` в ODS, только теперь про пропавшие связи. теперь не про кривое поле, а про пропавшую связь между таблицами.
> Сущность `dds.event` (события) собирается так же, только проще: `browser` и `location` > Сущность `dds.event` (события) собирается так же, только проще: `browser` и `location`
> связаны по `event_id` один-к-одному, и `LEFT JOIN` приклеивает к каждому событию его страницу > связаны по `event_id` один-к-одному, и `LEFT JOIN` приклеивает к каждому событию его страницу
> и UTM. Если `location` не доехал — событие остаётся, а поля страницы пустые с меткой > и UTM. Если `location` не доехал — событие остаётся, а поля страницы остаются пустыми. Разбирать
> `location_not_found`. Разбирать этот блок построчно не будем — он повторяет ту же логику. > этот блок построчно не будем — он повторяет ту же логику.
### Сироты: событие без клика ### Сироты: событие без клика
@@ -231,10 +231,11 @@ 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);
``` ```
Заметь разницу с предыдущим пунктом. `geo_not_found` — это когда у **клика** нет гео (внутренний Заметь разницу с предыдущим пунктом. Пустое гео — это когда у **клика** не подтянулся свой
пропуск в карточке). А сирота — это когда у **события** нет вообще никакого клика (порвана связь контекст (внутренний пропуск в карточке, но сам клик есть). А сирота — это когда у **события**
между сущностями). Это разные дырки, и следят за ними по отдельности. На чистом срезе сирот ноль — нет вообще никакого клика (порвана связь между сущностями). Это разные дырки: первую видно по
сейчас мы это изменим. пустым полям внутри карточки, вторую — отдельным счётчиком. На чистом срезе сирот ноль — сейчас
мы это изменим.
--- ---
@@ -333,8 +334,8 @@ make transform
- что такое сущность DDS и зачем собирать `dds.click` и `dds.event`, если данные уже есть в ODS; - что такое сущность DDS и зачем собирать `dds.click` и `dds.event`, если данные уже есть в ODS;
- почему соединяем через `LEFT JOIN`, а не обычный `JOIN`, — что было бы с кликами без гео; - почему соединяем через `LEFT JOIN`, а не обычный `JOIN`, — что было бы с кликами без гео;
- что такое сирота и чем разрыв «событие без клика» отличается от пропуска `geo_not_found` внутри - что такое сирота и чем разрыв «событие без клика» отличается от пустого гео внутри карточки
карточки клика. клика.
Если запнёшься на `argMax` — вернись к секции 3: он берёт самую свежую строку на каждый Если запнёшься на `argMax` — вернись к секции 3: он берёт самую свежую строку на каждый
`click_id`, чтобы дубли `ReplacingMergeTree` не пролезли в сборку. `click_id`, чтобы дубли `ReplacingMergeTree` не пролезли в сборку.
@@ -354,10 +355,11 @@ make transform
- **DDS** (этот урок) — склеили кусочки в цельные сущности `dds.click` и `dds.event` и впервые - **DDS** (этот урок) — склеили кусочки в цельные сущности `dds.click` и `dds.event` и впервые
спросили про целостность связей между ними (сироты). спросили про целостность связей между ними (сироты).
Заметь общий принцип всех трёх слоёв — **«не теряем, а помечаем»**. На STG не роняем приём из-за Заметь общий принцип всех трёх слоёв — **«не теряем, а оставляем след»**. На STG не роняем приём
кривого сообщения. На ODS не выкидываем битую запись, а помечаем `parse_errors` и копим в из-за кривого сообщения. На ODS не выкидываем битую запись, а помечаем `parse_errors` и копим в
`*_errors`. На DDS не выкидываем клик без гео и событие без клика, а помечаем (`geo_not_found`) `*_errors`. На DDS не выкидываем клик без гео (оставляем его с пустыми полями) и событие без клика
и считаем (`orphan_events`). Один и тот же подход к качеству, проведённый через весь пайплайн. (считаем такие сироты через `orphan_events`). Один и тот же подход к качеству, проведённый через
весь пайплайн.
> **Короткая заметка про DM.** За DDS есть ещё слой **DM** (Data Marts, витрины для BI) — те самые > **Короткая заметка про DM.** За DDS есть ещё слой **DM** (Data Marts, витрины для BI) — те самые
> `dm.v_events_enriched` и `dm.v_daily_traffic`, которыми ты только что пользовался. Сейчас они > `dm.v_events_enriched` и `dm.v_daily_traffic`, которыми ты только что пользовался. Сейчас они
+9 -2
View File
@@ -101,7 +101,11 @@ LEFT JOIN (
FROM ods.geo_by_click FROM ods.geo_by_click
WHERE click_id IS NOT NULL WHERE click_id IS NOT NULL
GROUP BY click_id GROUP BY click_id
) AS g ON g.click_id = c.click_id; ) AS g ON g.click_id = c.click_id
-- join_use_nulls=1: при несовпадении LEFT JOIN кладёт в правые колонки NULL, а не дефолт.
-- Без этого assumeNotNull(click_id) на несовпавшей строке стал бы нулевым UUID (не NULL),
-- и проверки if(d.click_id IS NULL, ...) / if(g.click_id IS NULL, ...) молча не сработали бы.
SETTINGS join_use_nulls = 1;
-- ---------------------------------------------------------------------------- -- ----------------------------------------------------------------------------
-- Сущность: dds.event (объединяет browser + location) -- Сущность: dds.event (объединяет browser + location)
@@ -165,4 +169,7 @@ LEFT JOIN (
FROM ods.location_event FROM ods.location_event
WHERE event_id IS NOT NULL WHERE event_id IS NOT NULL
GROUP BY event_id GROUP BY event_id
) AS l ON l.event_id = b.event_id; ) AS l ON l.event_id = b.event_id
-- join_use_nulls=1: чтобы при отсутствии location поле l.event_id было NULL, а не нулевым UUID,
-- и проверка if(l.event_id IS NULL, ['location_not_found'], []) реально срабатывала.
SETTINGS join_use_nulls = 1;