From 5f5312c70cef46b0b2c5d2b319b2003732182736 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 5 Jun 2026 21:58:44 +0300 Subject: [PATCH] =?UTF-8?q?fix(dds):=20=D0=BF=D0=BE=D1=87=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8=20*=5Fnot?= =?UTF-8?q?=5Ffound=20+=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D1=83?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B0=203=20=D0=BF=D0=BE=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - перечитка урока 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 (вывод не изменился — на чистом срезе несовпадений нет) --- docs/course/lessons/03_ods_to_dds.md | 36 +++++++++++++++------------- sql/dds/30_ods_to_dds.sql | 11 +++++++-- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/docs/course/lessons/03_ods_to_dds.md b/docs/course/lessons/03_ods_to_dds.md index d06d13e..fe1c8d9 100644 --- a/docs/course/lessons/03_ods_to_dds.md +++ b/docs/course/lessons/03_ods_to_dds.md @@ -151,7 +151,7 @@ SELECT click_id FROM ods.geo_by_click ... `UNION DISTINCT` — это «склей два списка в один и выкинь повторы». Получается полный набор уникальных `click_id` из обоих источников — будем называть его **универсумом кликов** (полный -список всех клиентов, по которому дальше идём). Именно от него, а не от одной из таблиц, мы +список всех кликов, по которому дальше идём). Именно от него, а не от одной из таблиц, мы строим карточки: так не потеряется клик, который есть, например, в `geo`, но почему-то не доехал в `device`. @@ -206,15 +206,15 @@ LEFT JOIN ( ...снапшот geo... ) AS g ON g.click_id = c.click_id останутся пустыми (`NULL`). Почему именно `LEFT`: левая таблица здесь — это полный список кликов, и **ни один клик терять -нельзя**. Не доехало гео — ладно, сохраним клик с пустым гео и пометкой, что гео нет. Эта пометка -тут же и ставится: рядом со сборкой стоит `if(g.click_id IS NULL, ['geo_not_found'], [])` — если -гео не подтянулось, в список ошибок карточки добавится метка `geo_not_found`. Тот же принцип -«не теряем и помечаем», что и `parse_errors` в ODS, только теперь про пропавшие связи. +нельзя**. Не доехало гео — ладно, сохраним клик, а гео-поля (страна, координаты, IP) останутся +пустыми (`NULL`). И эта пустота — уже видимый сигнал: по ней сразу понятно, что контекст по клику +не подтянулся. Тот же принцип, что и в ODS: **не теряем, а оставляем видимый след**, — только +теперь не про кривое поле, а про пропавшую связь между таблицами. > Сущность `dds.event` (события) собирается так же, только проще: `browser` и `location` > связаны по `event_id` один-к-одному, и `LEFT JOIN` приклеивает к каждому событию его страницу -> и UTM. Если `location` не доехал — событие остаётся, а поля страницы пустые с меткой -> `location_not_found`. Разбирать этот блок построчно не будем — он повторяет ту же логику. +> и UTM. Если `location` не доехал — событие остаётся, а поля страницы остаются пустыми. Разбирать +> этот блок построчно не будем — он повторяет ту же логику. ### Сироты: событие без клика @@ -231,10 +231,11 @@ WHERE click_id IS NOT NULL 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; - почему соединяем через `LEFT JOIN`, а не обычный `JOIN`, — что было бы с кликами без гео; -- что такое сирота и чем разрыв «событие без клика» отличается от пропуска `geo_not_found` внутри - карточки клика. +- что такое сирота и чем разрыв «событие без клика» отличается от пустого гео внутри карточки + клика. Если запнёшься на `argMax` — вернись к секции 3: он берёт самую свежую строку на каждый `click_id`, чтобы дубли `ReplacingMergeTree` не пролезли в сборку. @@ -354,10 +355,11 @@ make transform - **DDS** (этот урок) — склеили кусочки в цельные сущности `dds.click` и `dds.event` и впервые спросили про целостность связей между ними (сироты). -Заметь общий принцип всех трёх слоёв — **«не теряем, а помечаем»**. На STG не роняем приём из-за -кривого сообщения. На ODS не выкидываем битую запись, а помечаем `parse_errors` и копим в -`*_errors`. На DDS не выкидываем клик без гео и событие без клика, а помечаем (`geo_not_found`) -и считаем (`orphan_events`). Один и тот же подход к качеству, проведённый через весь пайплайн. +Заметь общий принцип всех трёх слоёв — **«не теряем, а оставляем след»**. На STG не роняем приём +из-за кривого сообщения. На ODS не выкидываем битую запись, а помечаем `parse_errors` и копим в +`*_errors`. На DDS не выкидываем клик без гео (оставляем его с пустыми полями) и событие без клика +(считаем такие сироты через `orphan_events`). Один и тот же подход к качеству, проведённый через +весь пайплайн. > **Короткая заметка про DM.** За DDS есть ещё слой **DM** (Data Marts, витрины для BI) — те самые > `dm.v_events_enriched` и `dm.v_daily_traffic`, которыми ты только что пользовался. Сейчас они diff --git a/sql/dds/30_ods_to_dds.sql b/sql/dds/30_ods_to_dds.sql index 851939d..074eba6 100644 --- a/sql/dds/30_ods_to_dds.sql +++ b/sql/dds/30_ods_to_dds.sql @@ -101,7 +101,11 @@ LEFT JOIN ( FROM ods.geo_by_click WHERE click_id IS NOT NULL 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) @@ -165,4 +169,7 @@ LEFT JOIN ( FROM ods.location_event WHERE event_id IS NOT NULL 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;