Files
ddadmin 5f5312c70c 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 (вывод не изменился —
    на чистом срезе несовпадений нет)
2026-06-05 21:58:44 +03:00

176 lines
8.4 KiB
SQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ============================================================================
-- Batch-трансформация: ODS → DDS
-- ============================================================================
-- Поток данных:
-- ods.device_by_click + ods.geo_by_click → dds.click (контекст клика)
-- ods.browser_event + ods.location_event → dds.event (контекст события)
--
-- Что делает:
-- Собирает "чистые" сущности из типизированных данных ODS для аналитики.
-- Использует argMax() для получения последней версии строк по ключу.
--
-- Когда запускать:
-- - Периодически (например, каждые N минут)
-- - Через Airflow по расписанию
-- - Вручную после загрузки новых данных
--
-- Стратегия:
-- Сейчас: полная перезагрузка (TRUNCATE + INSERT) — проще для демо
-- В продакшене: инкрементальная загрузка по watermark (src_ingest_ts)
-- ============================================================================
-- ----------------------------------------------------------------------------
-- Сущность: dds.click (объединяет device + geo)
-- ----------------------------------------------------------------------------
-- Проблема: device и geo события приходят независимо (не все click_id есть в обоих источниках)
-- Решение: UNION всех click_id + LEFT JOIN (обрабатываем geo-only и device-only)
-- ----------------------------------------------------------------------------
INSERT INTO dds.click
SELECT
c.click_id,
d.user_domain_id,
d.user_custom_id,
d.device_type,
d.device_is_mobile,
d.os_name,
d.os,
d.os_timezone,
g.geo_country,
g.geo_region_name,
g.geo_timezone,
g.geo_latitude,
g.geo_longitude,
g.ip_address,
now64(3) AS dds_update_ts, -- Время загрузки в DDS для версионирования
-- Собираем все ошибки парсинга из источников + отслеживаем пропущенные данные
arrayFilter(x -> x != '', arrayConcat(
ifNull(d.parse_errors, []),
if(d.click_id IS NULL, ['device_not_found'], []), -- Нет данных об устройстве
if(g.click_id IS NULL, ['geo_not_found'], []), -- Нет гео-данных
if(g.geo_country IS NULL, ['geo_country_missing'], []) -- Гео есть, но страна не определена
)) AS ods_parse_errors
FROM (
-- Шаг 1: Собираем ВСЕ уникальные click_id из обоих источников
-- Это позволяет обработать случаи, когда:
-- - есть device, но нет geo (geo-only клики)
-- - есть geo, но нет device (device-only клики)
SELECT click_id
FROM (
SELECT assumeNotNull(click_id) AS click_id
FROM ods.device_by_click
WHERE click_id IS NOT NULL
GROUP BY click_id
)
UNION DISTINCT
SELECT click_id
FROM (
SELECT assumeNotNull(click_id) AS click_id
FROM ods.geo_by_click
WHERE click_id IS NOT NULL
GROUP BY click_id
)
) AS c
-- Шаг 2: LEFT JOIN с device (может не быть данных)
LEFT JOIN (
-- Снапшот device: последняя версия каждого click_id по времени загрузки
SELECT
assumeNotNull(click_id) AS click_id,
argMax(user_domain_id, src_ingest_ts) AS user_domain_id,
argMax(user_custom_id, src_ingest_ts) AS user_custom_id,
argMax(device_type, src_ingest_ts) AS device_type,
argMax(device_is_mobile, src_ingest_ts) AS device_is_mobile,
argMax(os_name, src_ingest_ts) AS os_name,
argMax(os, src_ingest_ts) AS os,
argMax(os_timezone, src_ingest_ts) AS os_timezone,
argMax(parse_errors, src_ingest_ts) AS parse_errors
FROM ods.device_by_click
WHERE click_id IS NOT NULL
GROUP BY click_id
) AS d ON d.click_id = c.click_id
-- Шаг 3: LEFT JOIN с geo (может не быть данных)
LEFT JOIN (
-- Снапшот geo: последняя версия каждого click_id по времени загрузки
SELECT
assumeNotNull(click_id) AS click_id,
argMax(geo_country, src_ingest_ts) AS geo_country,
argMax(geo_region_name, src_ingest_ts) AS geo_region_name,
argMax(geo_timezone, src_ingest_ts) AS geo_timezone,
argMax(geo_latitude, src_ingest_ts) AS geo_latitude,
argMax(geo_longitude, src_ingest_ts) AS geo_longitude,
argMax(ip_address, src_ingest_ts) AS ip_address
FROM ods.geo_by_click
WHERE click_id IS NOT NULL
GROUP BY 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)
-- ----------------------------------------------------------------------------
-- Логика проще: event_id связывает browser и location 1:1
-- Если location нет — это тоже полезная информация (ошибка или пропуск)
-- ----------------------------------------------------------------------------
INSERT INTO dds.event
SELECT
b.event_id,
b.event_ts,
b.event_type,
b.click_id,
-- Поля из location (могут быть NULL, если location не пришёл)
l.page_url,
l.page_url_path,
l.referer_url,
l.referer_medium,
l.utm_medium,
l.utm_source,
l.utm_content,
l.utm_campaign,
-- Поля из browser
b.browser_name,
b.browser_user_agent,
b.browser_language,
now64(3) AS dds_update_ts,
-- Собираем ошибки парсинга + отмечаем, если location не найден
arrayFilter(x -> x != '', arrayConcat(
b.parse_errors,
if(l.event_id IS NULL, ['location_not_found'], [])
)) AS ods_parse_errors
FROM (
-- Снапшот browser: последняя версия каждого event_id
SELECT
event_id,
argMax(event_ts, src_ingest_ts) AS event_ts,
argMax(event_type, src_ingest_ts) AS event_type,
argMax(click_id, src_ingest_ts) AS click_id,
argMax(browser_name, src_ingest_ts) AS browser_name,
argMax(browser_user_agent, src_ingest_ts) AS browser_user_agent,
argMax(browser_language, src_ingest_ts) AS browser_language,
argMax(parse_errors, src_ingest_ts) AS parse_errors
FROM ods.browser_event
WHERE event_id IS NOT NULL -- Фильтруем битые ключи (они в error_tables)
GROUP BY event_id
) AS b
-- LEFT JOIN с location (может не быть данных для некоторых событий)
LEFT JOIN (
-- Снапшот location: последняя версия каждого event_id
SELECT
event_id,
argMax(page_url, src_ingest_ts) AS page_url,
argMax(page_url_path, src_ingest_ts) AS page_url_path,
argMax(referer_url, src_ingest_ts) AS referer_url,
argMax(referer_medium, src_ingest_ts) AS referer_medium,
argMax(utm_medium, src_ingest_ts) AS utm_medium,
argMax(utm_source, src_ingest_ts) AS utm_source,
argMax(utm_content, src_ingest_ts) AS utm_content,
argMax(utm_campaign, src_ingest_ts) AS utm_campaign
FROM ods.location_event
WHERE event_id IS NOT NULL
GROUP BY 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;