diff --git a/AGENTS.md b/AGENTS.md index 3239c12..092c2cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,10 @@ Airflow) и названия из кода. Если для понятия ес дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие истории, а текущее описание живого формата — и собирается из кода, а не пишется руками. +- Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис + (`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных + заданий. Ни даты, ни номера: задание — не событие истории, а живой документ, + и правится он вместе с тем, о чем говорит. Слаг называет витрину задания. - Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата в имени не нужна. diff --git a/dags/homework_check.py b/dags/homework_check.py new file mode 100644 index 0000000..68a3342 --- /dev/null +++ b/dags/homework_check.py @@ -0,0 +1,138 @@ +"""Ручная самопроверка учебных витрин DM против их источников.""" + +from __future__ import annotations + +import datetime +import logging +from pathlib import Path + +from airflow.exceptions import AirflowException +from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook +from airflow.sdk import dag, task + +START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC) +SQL_ROOT = Path("/opt/airflow/sql/dq") +CLICKHOUSE_CONNECTION = "clickhouse_default" +DETAIL_LIMIT = 20 + + +def _clickhouse_hook() -> ClickHouseHook: + """Подключение к ClickHouse для чтения обеих сторон сверки.""" + return ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION) + + +def _mart_exists(hook: ClickHouseHook, mart: str) -> bool: + """Проверить наличие публичной витрины на ноде подключения.""" + database, name = mart.split(".", maxsplit=1) + rows = hook.get_records( + """ + SELECT count() + FROM system.tables + WHERE database = {database:String} AND name = {name:String} + """, + parameters={"database": database, "name": name}, + ) + return rows[0][0] == 1 + + +@task +def check_mart( + mart: str, + lab_path: str, + sql_name: str, + scope_sql_name: str | None = None, +) -> None: + """Сверить одну домашнюю витрину с независимым ответом из источника.""" + hook = _clickhouse_hook() + if not _mart_exists(hook, mart): + raise AirflowException(f"витрина {mart} не найдена; выполните ТЗ {lab_path}") + + try: + query = (SQL_ROOT / sql_name).read_text(encoding="utf-8") + scope_query = ( + (SQL_ROOT / scope_sql_name).read_text(encoding="utf-8") + if scope_sql_name is not None + else None + ) + except OSError as error: + raise AirflowException( + f"стенд не может прочитать запрос проверки {sql_name}: {error}; " + "сообщите наставнику" + ) from error + + try: + parameter_sets = [None] + if scope_query is not None: + parameter_sets = [{"day": day} for (day,) in hook.get_records(scope_query)] + summaries = [ + summary + for parameters in parameter_sets + for summary in hook.get_records(query, parameters=parameters) + ] + except Exception as error: + raise AirflowException( + f"не удалось проверить витрину {mart}: {error}; " + f"сверьте договор колонок и проверку в ТЗ {lab_path}" + ) from error + + failed_rows = sum(summary[3] for summary in summaries) + if failed_rows == 0: + return + + diagnostics = [detail for summary in summaries for detail in summary[4]][ + :DETAIL_LIMIT + ] + for detail in diagnostics: + logging.error("%s: %s", mart, detail) + days = "; ".join( + f"{data_date}: ключей источника {reference_rows}, " + f"ключей витрины {actual_rows}, расходятся {day_failed_rows}" + for data_date, reference_rows, actual_rows, day_failed_rows, _ in summaries + if day_failed_rows > 0 + ) + compared_sides = diagnostics[0] + raise AirflowException( + f"витрина {mart} расходится с источником: {days}. " + f"Пример сравнения: {compared_sides}. Сверьтесь с ТЗ {lab_path}" + ) + + +@dag( + dag_id="homework_check", + schedule=None, + start_date=START_DATE, + is_paused_upon_creation=False, + max_active_runs=1, + tags=["dm", "labs"], +) +def homework_check(): + """Дать отдельный результат самопроверки по каждой домашней витрине.""" + check_mart.override(task_id="events_enriched")( + "dm.events_enriched_v", + "docs/labs/events-enriched.md", + "events_enriched_vs_dds.sql", + "events_enriched_scope.sql", + ) + check_mart.override(task_id="top_pages_daily")( + "dm.top_pages_daily_v", + "docs/labs/top-pages-daily.md", + "top_pages_daily_vs_dds.sql", + ) + check_mart.override(task_id="session_overview")( + "dm.session_overview_v", + "docs/labs/session-overview.md", + "session_overview_vs_dds.sql", + ) + check_mart.override(task_id="dq_errors_daily")( + "dm.dq_errors_daily_v", + "docs/labs/dq-errors-daily.md", + "dq_errors_daily_vs_ods.sql", + ) + check_mart.override(task_id="utm_effectiveness")( + "dm.utm_effectiveness_v", + "docs/labs/utm-effectiveness.md", + "utm_effectiveness_vs_dds.sql", + ) + + +homework_check() diff --git a/docs/architecture/dds/session.md b/docs/architecture/dds/session.md index b632714..072473e 100644 --- a/docs/architecture/dds/session.md +++ b/docs/architecture/dds/session.md @@ -35,10 +35,9 @@ ломают. `session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка: -витрины сессий читают `dds.session_v`, их зерно совпадает с зерном модели, и в -соединениях id не участвует — события его не несут, мост к заказам живёт на -событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со стендовой и -назвать расхождение одним числом) и разбор DQ-расхождений. +в соединениях он не участвует — события его не несут, мост к заказам живёт +на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со +стендовой и назвать расхождение одним числом) и разбор DQ-расхождений. ## Состав строки @@ -110,8 +109,9 @@ ## Читатели -- Витрина обзора сессий `session_overview_v`: длительности, глубина, - вход/выход; читает `dds.session_v`, зерно совпадает (#135). +- Витрина обзора сессий `session_overview_v`: дневные итоги визитов в + срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод + в ее [ТЗ](../../labs/session-overview.md) (#135). - DQ-сверка с эталоном (см. выше). - Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда, потом сравнивает — с эталоном и со стендовой. diff --git a/docs/architecture/dm.md b/docs/architecture/dm.md index 8c0f702..a3e5bf4 100644 --- a/docs/architecture/dm.md +++ b/docs/architecture/dm.md @@ -39,10 +39,11 @@ образцом. Разминка — представление `events_enriched_v` (зерно события), затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`, последней — самая содержательная `utm_effectiveness` с уроком `declared_*`. -Построенная витрина встаёт в конвейер руками менти: финальный пункт -каждого ТЗ — добавить задачу своей витрины в `dm_transform`. До этого +Построенная витрина встаёт в конвейер руками менти: финальный пункт ТЗ +каждого агрегата — добавить задачу своей витрины в `dm_transform`. До этого конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается -наравне со стендовыми. +наравне со стендовыми. У разминочного представления такого пункта нет: +пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md). Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит от выполненных домашек. Расширение дашборда визуализациями по своим @@ -212,22 +213,17 @@ business_key`. ## Даг самопроверки -Домашки контролирует отдельный даг без расписания: менти запускает его -рукой. Задача витрины красная, пока витрины нет или счёт не сходится, и -зелёная, когда построено верно; сообщение об ошибке говорит, что не так и -куда смотреть. Проверки — инварианты против DDS-источников, золотые -константы не прибиваются. В `dq_summary` даг не пишет: он инструмент лабы, -а не история качества конвейера. +Домашки контролирует `homework_check` — отдельный даг без расписания: менти +запускает его рукой. По задаче на каждое из пяти [заданий](../labs/README.md). +Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда +построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины, +золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент +лабы, а не история качества конвейера. Приём проверен на соседнем учебном стенде Airflow + Greenplum: валидационный даг с группами проверок по слоям давал менти обратную связь по каждому шагу домашки, не трогая рабочий конвейер. -## Остается нарезке - -- Имя и состав дага самопроверки домашних заданий. -- Тексты учебных заданий и точный список проверок учебного контура. - ## Отклонённые варианты - **Весь слой представлениями.** Дёшево и всегда свежо, но главный diff --git a/docs/labs/README.md b/docs/labs/README.md new file mode 100644 index 0000000..363d09c --- /dev/null +++ b/docs/labs/README.md @@ -0,0 +1,104 @@ +# Учебные задания: витрины слоя DM + +Пять домашек. Стенд привозит слой DM наполовину: витрины трех сюжетов +дашборда и контроль качества построены, остальные витрины строит менти. +Каждое задание — самостоятельное ТЗ: что за витрина, какое у нее зерно, +из чего она собирается и как проверить, что собралась верно. + +Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на +вопросы «почему так», задание отвечает на вопрос «что сделать». + +## Порядок + +Задания идут по нарастанию: каждое следующее опирается на то, что уже +усвоено. + +| № | Витрина | Чему учит | +|---|---|---| +| 1 | [`events_enriched_v`](events-enriched.md) | слой — это контракт, а не копия данных | +| 2 | [`top_pages_daily`](top-pages-daily.md) | зерно сменилось — витрина материализуется и встает в конвейер | +| 3 | [`session_overview`](session-overview.md) | зерно решает, на какие вопросы витрина уже не ответит | +| 4 | [`dq_errors_daily`](dq-errors-daily.md) | у брака свой день и свой срок жизни | +| 5 | [`utm_effectiveness`](utm-effectiveness.md) | `declared_` в имени — предупреждение, а не украшение | + +## Общие правила + +Они одни на все пять заданий, поэтому записаны здесь, а не повторяются в +каждом ТЗ. + +**Читать — через `_v`.** Публичный договор сущности DDS — представление с +суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только +его; физические `_rep` и `_dist` служат загрузке и обслуживанию. Соседнюю +домашку витрина источником тоже не берет: витрина слоя DM читает договор +DDS, и задания остаются независимыми. Исключение одно и оно оговорено в +задании 4: у таблиц брака публичного договора нет. + +**Писать — только в `_dist`.** При записи в локальную таблицу раскладку +определяет то, какая нода случайно выполняла код, а не свойство данных. +Правило и его следствия — [дока хранилища](../architecture/storage.md), +«Раскладка по шардам». + +**Форма — по зерну.** Витрина, меняющая зерно источника, материализуется: +`dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` / `_stage_dist` и +публичное `dm.<имя>_v` с явным списком колонок. Витрина, сохраняющая зерно, +остается обычным представлением. Довод — [дока DM](../architecture/dm.md), +«Форма: по зерну, не по цене запроса». + +**Ключ шардирования — твое решение.** Ни одно ТЗ ключа не называет. +Правило выбора и требование к донору — [дока +хранилища](../architecture/storage.md), «Раскладка по шардам»: донор +объявляется через `AS <цель>_rep` и шардируется тем же ключом, что цель, +иначе замена партиции потеряет строку на одном шарде и задвоит на другом. +Названный ключ надо уметь защитить: почему он не дает перекоса и почему +более сложный тут ничего не добавляет. Образец такой защиты — +`dm.revenue_daily_dist` в [`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql). + +**Где лежит код.** DDL домашек — свой файл `sql/ddl/70-labs.sql`: он +применяется после стендовых по порядку имени, и твои объекты не мешаются +со стендовыми. У агрегатов рядом со стендовыми ложатся еще три запроса — +`sql/dm/<витрина>_scope.sql` спрашивает у хранилища, какие дни пересобирает +запуск, `_rebuild.sql` собирает их в донор, `_replace.sql` заменяет +партицию, — и задача в +[`dags/dm_transform.py`](../../dags/dm_transform.py). У разминочного +представления нет ничего из этого: весь его код — одно `CREATE VIEW`. + +**Образец на все случаи** — витрина `revenue_daily`: у нее есть все пять +объектов, донор, сборка, замена дневных партиций и задача в конвейере. +Списывать с нее устройство можно и нужно; списывать состав колонок — нет, +он у каждой витрины свой. + +## Самопроверка + +Даг `homework_check` запускается рукой, расписания у него нет. Задача +витрины красная, пока витрины нет или счет не сходится, и зеленая, когда +собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет: +это инструмент лабы, а не история качества стенда. + +Проверка у всех заданий устроена одинаково и золотых констант не содержит: +рядом с витриной независимым запросом собирается тот же ответ — прямо из +источника витрины, минуя ее саму. У четырех заданий источник эталона тот +же, что у витрины, `dds.session_v` или `dds.event_v`; у задания 4 это +таблицы брака ODS, потому что в DDS у брака представительства нет. Обе +стороны соединяются по деловому ключу через `FULL OUTER JOIN`, и считается +число строк, где стороны разошлись или одной стороны нет. Ноль — зеленый. +Полное соединение здесь обязательно: при обычном пропавший день или +пропавший ключ просто не попал бы в результат и остался бы незамечен. + +Раздел «Как проверить себя» каждого ТЗ называет деловой ключ и сравниваемые +величины. Тот же прием в стендовом исполнении — [даг +`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq). + +**Предупреждение.** Запросы самой проверки лежат в `sql/dq/` открыто, и +имена называют витрину и роль сверки. Проверка на инвариантах неизбежно +близка к ответу: чтобы сверить витрину, надо ее пересчитать. Прятать эти +файлы никто не станет — но открыв их до того, как задание сделано, ты +обменяешь урок на готовый SQL. +Смотреть туда стоит после своей сборки: сравнить два решения полезно, +списать одно — нет. + +## Уборка + +Черновики и опыты за собой сносить: правило проверок стенда — +[дока тестирования](../architecture/testing.md). Снесенная витрина обязана +снова покраснить свою задачу в `homework_check` — если не покраснила, +проверка ничего и не проверяла. diff --git a/docs/labs/dq-errors-daily.md b/docs/labs/dq-errors-daily.md new file mode 100644 index 0000000..2786b9f --- /dev/null +++ b/docs/labs/dq-errors-daily.md @@ -0,0 +1,118 @@ +# Задание 4: `dm.dq_errors_daily` + +**Чему учит:** у брака свой день и свой срок жизни. Витрина качества живет +по времени загрузки, а не по модельному дню, и переживает собственный +источник — поэтому «пересобрать все» здесь означает «стереть историю». + +## Роль витрины + +Сводка приемки: сколько строк за день не прошли разбор и по какой причине. +Читают ее, когда качество потока просело: сначала смотрят, вырос ли класс +брака, потом идут за сырым текстом в саму таблицу ошибок. + +Витрина отвечает на вопрос «сколько и чего», а не «что именно сломалось». +Разбирательство с конкретной строкой начинается в `ods.event_errors_dist` и +`ods.order_errors_dist`, где рядом с классом лежат сырой текст и координаты +доставки. + +## Источник: единственное исключение из правила `_v` + +Общее правило домашек — читать через публичный договор `_v`. У таблиц брака +такого договора нет, и заводить его ради этой витрины не надо: брак — не +сущность бизнес-модели, а диагностика слоя приема. Читать нужно +распределенные таблицы напрямую: + +- `ods.event_errors_dist` — брак разбора событий; +- `ods.order_errors_dist` — брак разбора слепка заказов. + +Именно `_dist`, а не `_rep`: локальная таблица вернет строки одного шарда, и +витрина тихо покажет половину брака. Ошибки при этом не будет — ошибка +появится позже, когда по витрине примут решение. + +## Зерно и форма + +Зерно — **день загрузки × источник × класс брака**. Зерно источника — +строка брака, значит зерно сменилось и витрина материализуется: +`dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`, +публичное `dm.dq_errors_daily_v`. Ключ партиции — день загрузки. + +День здесь — `toDate(_load_ts)`, то есть настоящее время, когда строка +приехала в хранилище, а не модельный день мира. Модельного дня у брака нет +и быть не может: он живет в содержимом, а содержимое как раз и не +разобралось. + +## Состав колонок + +| Колонка | Тип | Что это | +|---|---|---| +| `load_date` | `Date` | день загрузки, в который приехал брак | +| `source` | `LowCardinality(String)` | `event` или `order` | +| `error_class` | `LowCardinality(String)` | класс брака из таблицы ошибок | +| `error_rows` | `UInt64` | число забракованных строк | +| `_load_id` | `String` | запуск, собравший день | +| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран | + +Две похожие колонки рядом различаются намеренно: `load_date` — про день, +когда приехал брак, `_load_ts` — про запуск, который собрал витрину. Первая +деловая, вторая служебная, и совпадать они не обязаны. + +Классы у двух источников свои и не пересекаются по смыслу, поэтому колонка +`source` в зерне обязательна: без нее одинаковое имя класса у событий и у +заказов слилось бы в одну строку. + +## Ритм пересборки: витрина переживает источник + +Таблицы брака живут месяц (`TTL`), сырье — трое суток. Витрина живет без +срока: сводка «сколько брака было в марте» нужна и после того, как сами +строки марта удалены. + +Отсюда правило прогона: пересобираются **только те дни загрузки, которые +еще есть в источнике**. Спроси у источника список живых дней и замени +партиции ровно этих дней. Соблазн «собрать все заново в донор и подменить +таблицу целиком» здесь стоит дороже обычного: истекшие по `TTL` дни исчезли +бы из витрины навсегда, а вместе с ними — единственная оставшаяся память о +них. + +Текущий день загрузки не замерзает до конца суток: поток событий идет, и +брак в него еще добавится. Это нормально, день просто пересоберется на +следующем прогоне. + +## Ключ шардирования + +Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по +шардам». Витрина крошечная: классов брака у событий и заказов вместе меньше +десятка. Учти это в защите ключа — иногда правильный ответ звучит как +«перекос здесь ничего не стоит, и вот почему». + +## Как проверить себя + +Деловой ключ — пара `source` и `error_class` внутри `load_date`. Область +проверки — дни загрузки, которые еще живы хотя бы в одной таблице ошибок: +день за границей `TTL` сверять не с чем, и его отсутствие в источнике не +ошибка. + +Собери прямой счет по обеим таблицам ошибок, соедини с +`dm.dq_errors_daily_v` полным внешним соединением по ключу и посчитай +строки, где ключа нет с одной из сторон или где расходится `error_rows`. +Ноль — зеленый. + +Отдельно: сумма `error_rows` по живым дням равна общему числу строк в +`ods.event_errors_dist` плюс `ods.order_errors_dist`. Если эта сумма вышла +примерно вдвое меньше — витрина почти наверняка собрана с `_rep` вместо +`_dist` и видит один шард из двух. + +**Пустая витрина — верный ответ.** На чистом стенде брака нет, обе стороны +пусты, расхождений ноль, задача зеленая. Проверка, которая требует хотя бы +одной строки, на здоровом стенде краснела бы всегда и приучала бы себя +игнорировать. + +## Приемка + +- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется + ключом цели; +- [ ] источники — `_dist`-таблицы обоих видов брака; +- [ ] прогон заменяет партиции только живых дней источника и не трогает + истекшие; +- [ ] задача `dq_errors_daily` в `homework_check` зеленая — в том числе на + чистом стенде, где брака нет; +- [ ] задача витрины добавлена в `dm_transform`. diff --git a/docs/labs/events-enriched.md b/docs/labs/events-enriched.md new file mode 100644 index 0000000..5081b44 --- /dev/null +++ b/docs/labs/events-enriched.md @@ -0,0 +1,114 @@ +# Задание 1: `dm.events_enriched_v` + +**Чему учит:** слой — это контракт, а не копия данных. Витрина, которая не +меняет зерно источника, остается представлением, и копии событий ради +других имен колонок не заводит ни один боевой склад. + +Это разминка. Строк она не хранит, в конвейер не встает, ключа шардирования +у нее нет. Задача одна: собрать плоское событие для BI и не поддаться +соблазну разложить его на диск. + +## Роль витрины + +Договор события в DDS широкий — 44 колонки, почти весь формат выгрузки: +часть из них технические координаты источника, часть — параллельные массивы +товаров. Аналитику в Superset нужен плоский набор деловых полей и пара +производных, которые иначе каждый пишет заново в каждом запросе. + +Витрина отвечает на вопрос «что вообще происходило»: с нее начинают, когда +надо посмотреть на события глазами человека, а не разбирать широкую строку +источника. + +Источником для витрин следующих заданий она не служит. Витрина слоя DM +читает публичный договор DDS — так устроены и стендовые витрины, и потому +задания независимы: соседнюю домашку можно делать первой, а можно не делать +вовсе. + +## Зерно и форма + +Зерно — **событие**: одна строка витрины равна одной строке `dds.event_v`. +Зерно источника сохранено, значит форма — обычное представление +`CREATE VIEW dm.events_enriched_v`, без физической таблицы, без донора и +без задачи в `dm_transform`. Пересобирать нечего. + +Источник — `dds.event_v`, и только он. + +## Состав колонок + +Явный список обязателен: договор не должен расширяться сам, когда в +источник добавят колонку. + +**Время и посетитель:** `event_date`, `event_time`, `event_hour`, +`client_id`, `event_id`. + +**Действие:** `event_type` (`pageview`, `add_to_cart`, `purchase`), `url`, +`page_path`, `referer`, `referer_host`, `title`. + +**Атрибуция:** `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, +`utm_term`. + +**Паспорт визита:** `device_category`, `browser`, `operating_system`, +`region_country`, `region_city`. + +**Торговый блок, массивами как в источнике:** `product_id`, `product_name`, +`product_category`, `product_price`, `product_quantity`, +`product_event_type`, `purchase_id`, `purchase_revenue`, `purchase_coupon`. + +Три колонки в источнике не лежат и считаются здесь: + +| Колонка | Как считается | Зачем | +|---|---|---| +| `event_hour` | `toHour(event_time)` | суточный профиль спрашивают в каждом отчете | +| `page_path` | путь `url` без домена и параметров | группировка по странице без разбора строки в каждом запросе | +| `referer_host` | домен `referer` | «откуда пришли» одним значением, а не полным адресом | + +Служебных `_load_id` и `_load_ts` у витрины нет, и это не забывчивость. Их +нет и у `dds.event_v`: событие в DDS не грузят, а читают из ODS, поэтому +координат собственной загрузки у него не бывает. Служебная колонка называет +запуск, который положил строку на диск, — здесь на диск никто ничего не +кладет. + +## Ловушка задания + +Массивы `product_*` тянет развернуть через `ARRAY JOIN` — так плоская +таблица становится по-настоящему плоской. Разворачивать нельзя, и вреда +здесь два. + +Очевидный: у события с тремя позициями получилось бы три строки, зерно +витрины перестало бы совпадать с зерном источника, а вместе с ним развалилось +бы и правило формы — такую витрину уже пришлось бы материализовать. + +Неочевидный и куда более разрушительный: `ARRAY JOIN` — соединение +внутреннее, и строку с пустым массивом он выбрасывает. Торговый блок пуст у +всех событий, кроме корзин и покупок, то есть у подавляющего большинства. +Так «плоская таблица событий» превращается в таблицу товарных позиций и +теряет девятнадцать строк из двадцати — а по имени и по составу колонок она +по-прежнему выглядит таблицей событий. Ошибка не падает и ничего не +подсвечивает. + +Массивы едут в договор как есть; разворачивает их тот, кому нужна позиция +заказа, — и делает это уже в своей витрине, с другим зерном и другим именем. + +## Как проверить себя + +Деловой ключ — `event_id` внутри дня. Собери прямой ответ из `dds.event_v` +за тот же день, соедини с витриной полным внешним соединением и посчитай +строки, где ключа нет с одной из сторон или где различаются значения +деловых колонок. Ноль — зеленый. + +Отдельно проверяются три производные колонки: `event_hour`, `page_path` и +`referer_host` должны совпасть с тем же выражением, посчитанным по +`dds.event_v` на лету. + +Если строк в витрине оказалось в разы меньше, чем в источнике, а остались в +ней одни корзины и покупки — это развернутые массивы: `ARRAY JOIN` выбросил +все события с пустым торговым блоком. + +## Приемка + +- [ ] `dm.events_enriched_v` создано, физических таблиц под ним нет; +- [ ] список колонок в представлении явный; +- [ ] число строк и набор `event_id` совпадают с `dds.event_v` за каждый + прожитый день; +- [ ] задача `events_enriched` в `homework_check` зеленая; +- [ ] задачи в `dm_transform` **нет** — и не должно быть. diff --git a/docs/labs/session-overview.md b/docs/labs/session-overview.md new file mode 100644 index 0000000..c0d9b0f --- /dev/null +++ b/docs/labs/session-overview.md @@ -0,0 +1,121 @@ +# Задание 3: `dm.session_overview` + +**Чему учит:** агрегат хранит слагаемые, а не готовые средние и доли. +Среднее по строкам витрины не складывается никогда, а уникальных нельзя +складывать даже тогда, когда на сегодняшних данных сумма сходится. + +## Роль витрины + +Дневной портрет визитов в разрезе устройств: сколько было сессий, какие они +были длинные, сколько страниц смотрели и как часто доходили до покупки. +Вопрос витрины — «как люди ходят по магазину с телефона и с настольного +компьютера, и чем эти визиты отличаются». + +Сессия на стенде — расчетная сущность, ее уже нарезал слой DDS. Собирать +визиты заново из событий не нужно и нельзя: это была бы вторая нарезка, +которая тихо разойдется с первой. + +Витрина с таким именем была и на прошлом стенде, но зерно у нее сменилось, +и сменилось не по вкусу. Там визитов как сущности не было, и витрина +нарезала их сама, строкой на визит. Здесь визит — сущность слоя DDS, и +витрина, повторяющая его зерно, оказалась бы обычным представлением по +[правилу формы](../architecture/dm.md): копией сессий с другими именами +колонок. Чтобы задание осталось про дневной агрегат, витрина поднялась на +зерно выше — до дня и среза. + +## Зерно и форма + +Зерно — **день сессии × категория устройства**. Источник — +`dds.session_v`, у него зерно визита, значит зерно сменилось и витрина +материализуется: `dm.session_overview_rep` / `_dist`, донор `_stage_rep` / +`_stage_dist`, публичное `dm.session_overview_v`. Ключ партиции — день. + +Категория устройства взята из сессии как есть: у куки она постоянна, и слой +DDS уже перевел код источника в слово. Словарь расшифровки шире, чем мир +стенда: слов в нем четыре, а встречаются три — `desktop`, `mobile` и +`tablet`. Строку под `tv` заводить незачем, она возникнет сама, если такие +визиты появятся. + +## Состав колонок + +| Колонка | Тип | Что это | +|---|---|---| +| `report_date` | `Date` | день сессии | +| `device_category` | `LowCardinality(String)` | категория устройства | +| `sessions` | `UInt64` | число визитов | +| `visitors` | `UInt64` | число разных `client_id` | +| `bounce_sessions` | `UInt64` | визиты ровно из одного события | +| `purchase_sessions` | `UInt64` | визиты, в которых была хотя бы одна покупка | +| `duration_seconds_total` | `UInt64` | суммарная длительность визитов | +| `events_total` | `UInt64` | событий во всех визитах | +| `pageviews` | `UInt64` | просмотров страниц | +| `cart_adds` | `UInt64` | добавлений в корзину | +| `purchases` | `UInt64` | покупок | +| `_load_id` | `String` | запуск, собравший день | +| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран | + +Средней длительности и доли отказов в витрине нет намеренно. Хранить +`avg_duration_seconds` было бы удобнее ровно один раз — пока смотрят одну +строку. Тот, кто захочет среднее по дню целиком, сложит средние по трем +устройствам и получит неправду: средние не складываются, они взвешиваются. +Пара «`duration_seconds_total` и `sessions`» дает верный ответ на любом +срезе, а деление стоит потребителю один вызов. То же и с долей отказов: +храним `bounce_sessions` и `sessions`, долю считает тот, кто спрашивает. + +С `visitors` история тоньше, и в ней главная ловушка задания. Складывать +уникальных по строкам нельзя: один и тот же посетитель, попавший в две +строки, будет посчитан дважды. Проверь на стенде — и увидишь, что сумма +`visitors` по устройствам за день **сходится** с `dm.daily_traffic_v`. + +Это не отменяет правила, а показывает, откуда берется совпадение: в мире +стенда устройство приписано куке на всю жизнь ([словарь +понятий](../../CONTEXT.md), «Паспорт куки»), поэтому одна кука лежит ровно +в одной строке дня, и пересекаться строкам не в чем. + +Совпадение это — свойство мира, а не свойство витрины. Появятся визиты +одной куки с двух устройств — сумма поедет, и ни одна проверка об этом не +скажет: отчет просто начнет тихо завышать аудиторию. Отсюда правило, +которое стоит унести с задания: складывать уникальных нельзя даже там, где +сегодня получается. Число посетителей дня спрашивают у +`dm.daily_traffic_v`, где оно посчитано на своем зерне. + +## Ритм пересборки + +Источник — сессии: день замерзает, как только прожит. Объем прогона тот же, +что в задании 2, — хвост от первого несобранного прожитого дня; механика та +же: донор и замена дневных партиций. + +## Ключ шардирования + +Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по +шардам». Здесь у измерения всего три встречающихся значения, и распределены +они неровно: планшеты — тонкая полоса, а телефоны и настольные машины держат +основную массу. Прежде чем брать хеш от одного `device_category`, посчитай, +сколько строк ляжет на каждый шард, и назови, чем тебя устраивает результат. + +## Как проверить себя + +Деловой ключ — `device_category` внутри дня. Собери прямой агрегат из +`dds.session_v` за день, соедини с `dm.session_overview_v` полным внешним +соединением и посчитай строки, где ключа нет с одной из сторон или где +расходится любой счетчик. Ноль — зеленый. + +Две проверки покрупнее ловят грубую ошибку сразу: + +- сумма `sessions` за день равна числу строк `dds.session_v` этого дня: + ни один визит не потерян и ни один не задвоен; +- сумма `events_total` за день равна сумме `events_total` по сессиям того + же дня. + +Если суммы событий сходятся, а сумма сессий больше числа визитов — почти +наверняка витрина собрана из событий, а не из сессий. + +## Приемка + +- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется + ключом цели; +- [ ] `dm.session_overview_v` перечисляет колонки явно; +- [ ] источник витрины — `dds.session_v`, своей нарезки визитов в ней нет; +- [ ] средних и долей в физической таблице нет; +- [ ] задача `session_overview` в `homework_check` зеленая; +- [ ] задача витрины добавлена в `dm_transform`. diff --git a/docs/labs/top-pages-daily.md b/docs/labs/top-pages-daily.md new file mode 100644 index 0000000..350253b --- /dev/null +++ b/docs/labs/top-pages-daily.md @@ -0,0 +1,114 @@ +# Задание 2: `dm.top_pages_daily` + +**Чему учит:** зерно сменилось — значит витрина материализуется, живет +дневными партициями и встает в конвейер. Первая настоящая витрина: пять +объектов, донор, замена партиции, задача в `dm_transform`. + +## Роль витрины + +Популярность страниц по дням: сколько раз страницу открыли, сколько разных +посетителей ее видели и что на ней делали кроме просмотра. + +Торговое событие садится на ту страницу, где случилось: корзина — на +карточку товара, покупка — на страницу подтверждения заказа. Поэтому у +карточки товара рядом стоят просмотры и корзины, и их отношение — +конверсия карточки — главный вопрос к этой витрине: какие товары кладут в +корзину, едва открыв, а какие смотрят и уходят. + +Воронки «просмотр → корзина → покупка» здесь нет и быть не может: корзина и +покупка живут на разных страницах, и связать их шаги в одну цепочку на +зерне страницы не из чего. Путь одного человека собирают из событий или из +сессий, а витрина страниц отвечает про страницу. + +## Зерно и форма + +Зерно — **день события × страница**. Источник — события, у него зерно +события, значит зерно сменилось и витрина материализуется: +`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`, +публичное `dm.top_pages_daily_v`. + +Страница — это `page_path`, путь `url` без домена и параметров запроса; +считается он здесь же, из колонки `url` источника. Полный адрес в зерно не +берется: с параметрами запроса одна и та же страница рассыпалась бы на +сотни строк, и витрина перестала бы отвечать на свой вопрос. + +Ключ партиции — день: он же единица пересборки. + +Источник — `dds.event_v`, публичный договор события. Витрина задания 1 +источником не служит: витрина слоя DM читает договор DDS, как и стендовые +витрины, и не зависит от того, выполнена ли соседняя домашка. + +## Состав колонок + +| Колонка | Тип | Что это | +|---|---|---| +| `report_date` | `Date` | день события | +| `page_path` | `LowCardinality(String)` | путь страницы | +| `pageviews` | `UInt64` | число событий `pageview` на странице | +| `visitors` | `UInt64` | число разных `client_id` на странице | +| `cart_adds` | `UInt64` | число событий `add_to_cart` на странице | +| `purchases` | `UInt64` | число событий `purchase` на странице | +| `_load_id` | `String` | запуск, собравший день | +| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран | + +Счетчики условные: строку дают все три типа событий, а различает их +`countIf`. Отфильтровать источник по `event_type = 'pageview'`, как +подсказывает имя витрины, — значит получить витрину из одних просмотров: +страницы останутся на месте, а `cart_adds` и `purchases` у всех строк +обнулятся. Ошибка тихая: витрина выглядит собранной, и главный ее вопрос — +конверсия карточки — молча отвечает «ноль везде». + +## Ритм пересборки + +Источник — события: день замерзает сразу, как только прожит. Значит +пересобирать нужно только то, что еще не собрано: хвост дней, которых в +витрине нет, — от первого несобранного прожитого дня до последнего +прожитого. Готовые дни трогать незачем, а вот дыру, оставленную частичным +отказом, объем прогона обязан подобрать: ищи не последний собранный день, а +первый несобранный. + +Механика — сборка отрезка в донор одной вставкой и замена дневных партиций +по одной, как у `revenue_daily`. + +## Ключ шардирования + +Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по +шардам». Подумай, чем эта витрина отличается от `revenue_daily`: там кроме +дня было одно измерение из шести близких по объему значений, здесь — +страницы, которых много и которые по популярности очень неровные. + +## Как проверить себя + +Деловой ключ — `page_path` внутри дня. Собери прямой агрегат из +`dds.event_v` за день, соедини с `dm.top_pages_daily_v` полным внешним +соединением по ключу и посчитай строки, где ключа нет с одной из сторон или +где расходится любой из четырех счетчиков. Ноль — зеленый. + +Две проверки попроще, ими удобно ловить грубую ошибку до полного +соединения: + +- сумма `pageviews` за день равна числу событий `pageview` этого дня в + `dds.event_v`; +- сумма `purchases` за день равна числу событий `purchase` этого дня. + +Проверки ловят разное. Фильтр `WHERE event_type = 'pageview'` первая из них +пропускает: отбор строк по типу не меняет счет событий того же типа. +Расхождение покажет только вторая — вместе с `cart_adds` обнулятся и +`purchases`. Проверку выбирают по тому, умеет ли она покраснеть на твоей +ошибке, а не по тому, как быстро она пишется: [проверка, которая не умеет +краснеть, бесполезна](../architecture/testing.md). + +`visitors` складывать по строкам нельзя: один посетитель ходит по разным +страницам и в сумме посчитается несколько раз. Число посетителей дня +спрашивают у `dm.daily_traffic_v`, а не у этой витрины. + +## Приемка + +- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется + ключом цели; +- [ ] `dm.top_pages_daily_v` перечисляет колонки явно, включая `_load_id` и + `_load_ts`; +- [ ] объем прогона выводится из состояния витрины, а не задается руками; +- [ ] задача `top_pages_daily` в `homework_check` зеленая; +- [ ] задача витрины добавлена в `dm_transform`, повторный прогон конвейера + результат не меняет. diff --git a/docs/labs/utm-effectiveness.md b/docs/labs/utm-effectiveness.md new file mode 100644 index 0000000..3ad86b1 --- /dev/null +++ b/docs/labs/utm-effectiveness.md @@ -0,0 +1,150 @@ +# Задание 5: `dm.utm_effectiveness` + +**Чему учит:** приставка `declared_` в имени колонки — предупреждение, а не +украшение. Она говорит: это заявка клиента, а не деньги отчетности. Урок +здесь дает конфликт: суммы не сойдутся, и раскапывать почему предстоит +самому. + +Правило стенда: **поведение и атрибуцию считаем по трекеру, деньги — по +бэкенду**. Эта витрина — единственное разрешенное исключение: атрибуции по +UTM у бэкенда нет, метки живут только в кликстриме. Раз считать иначе +нечем, клиентская сумма берется, но называется своим именем. + +## Роль витрины + +Эффективность рекламных каналов по дням: сколько трафика привел канал, +сколько корзин и покупок в нем случилось и на какую сумму эти покупки, по +словам клиента. + +## Атрибуция: по событию, а не по визиту + +Метки едут на каждом событии, и витрина группирует события по их +собственным меткам. Модели «первое касание» или «последнее касание» здесь +нет: их построение — отдельная тема, а это витрина трафика по меткам. + +`dds.session_v` при этом несет UTM первого события визита, и ответ «какой +канал привел» из сессии и из этой витрины будет разным. Это не +ошибка ни одной из сторон — это два разных вопроса, и в отчете их надо +различать. + +## Прямой трафик из витрины не выбрасывается + +Соблазн отфильтровать строки без меток велик: витрина же про рекламу. +Отфильтровать нельзя по двум причинам. Первая деловая: прямой трафик — +обычно самый крупный «канал» магазина, и отчет, где его нет, врет о доле +рекламы. Вторая техническая: как только часть событий выпала, итог витрины +перестал сходиться с итогом источника, и проверить сборку стало нечем. + +Пустые метки остаются пустыми строками, как в источнике. Своего слова для +«прямого захода» витрина не изобретает: чем меньше выдуманных значений, тем +меньше расшифровывать читателю. + +## Зерно и форма + +Зерно — **день события × `utm_source` × `utm_medium` × `utm_campaign`**. +Зерно источника — событие, значит витрина материализуется: +`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`, +публичное `dm.utm_effectiveness_v`. Ключ партиции — день. + +Источник — `dds.event_v`, публичный договор события: витрина слоя DM читает +договор DDS, а не соседнюю витрину. + +## Состав колонок + +| Колонка | Тип | Что это | +|---|---|---| +| `report_date` | `Date` | день события | +| `utm_source` | `LowCardinality(String)` | источник кампании | +| `utm_medium` | `LowCardinality(String)` | канал кампании | +| `utm_campaign` | `String` | имя кампании | +| `visitors` | `UInt64` | число разных `client_id` | +| `pageviews` | `UInt64` | просмотров страниц | +| `add_to_carts` | `UInt64` | добавлений в корзину | +| `purchases` | `UInt64` | покупок | +| `declared_revenue` | `Decimal(18, 2)` | выручка покупок глазами клиента | +| `_load_id` | `String` | запуск, собравший день | +| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран | + +`declared_revenue` складывает `purchase_revenue[1]` событий `purchase`. +Индекс не описка: блок `purchase*` едет параллельными массивами, и у события +покупки в них ровно один элемент — контракт источника обещает один номер +заказа и одну объявленную сумму на событие. Тем же индексом берет свою +сторону стендовая сверка `purchase_vs_orders`. + +Приводить тип не нужно: в договоре `purchase_revenue` уже +`Array(Decimal(18, 2))`. `Float64` эта сумма была только в выгрузке +источника, и ODS перевел ее в точные деньги на приеме — сложение Decimal +точное, и где стоит `CAST`, до или после `sum`, на результат не влияет. +Витрина просто хранит то, что сложила, в `Decimal(18, 2)`. + +`utm_campaign` оставлена обычной строкой: имен кампаний со временем +становится много, и словарь `LowCardinality` на растущем множестве +перестает окупаться. + +## Ритм пересборки + +Источник — события: день замерзает сразу. Объем прогона и механика те же, +что в задании 2: хвост несобранных дней, донор, замена дневных партиций. + +## Ключ шардирования + +Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по +шардам». Зерно здесь составное, из трех колонок, и решение «взять хеш от +одной из них» надо защищать отдельно: назови, какая доля строк придется на +самое частое значение выбранной колонки. + +## Конфликт, ради которого витрина и написана + +Собери за один и тот же день две суммы: `sum(declared_revenue)` из своей +витрины и `sum(revenue)` из `dm.revenue_daily_v`. Они не сойдутся, и это +верное поведение обеих витрин. + +Базы у двух сумм при этом сравнимые: `revenue_daily` складывает цену позиций +на количество и ни скидки, ни доставки в себя не берет — ровно то, что видит +клиент. Значит все расхождение до копейки объясняется тем, что случилось с +заказом, а не тем, как считали деньги. Ищи ответы среди таких причин: + +- отмененный заказ из кликстрима никуда не девается: событие покупки + случилось, а `revenue_daily` считает только оплаченные; +- событие покупки может потеряться по дороге, а заказ при этом доехать — + и наоборот, событие приехать дважды; +- клиент назвал сумму, отличную от серверной суммы позиций: генератор + делает это намеренно у одного-двух заказов из ста. + +Как эти же расхождения выглядят на зерне заказа, показывает стендовая +витрина `dm.purchase_vs_orders_v` с ее классами расхождений: это готовый +ответ на вопрос «а сколько именно и из-за чего». Запиши свой вывод — +в двух-трех фразах, своими словами. + +Проверять сходимость этих двух сумм не нужно и нельзя: они и не должны +сходиться. + +## Как проверить себя + +Деловой ключ — тройка `utm_source`, `utm_medium`, `utm_campaign` внутри +дня. Собери прямой агрегат из `dds.event_v` за день, соедини с +`dm.utm_effectiveness_v` полным внешним соединением и посчитай строки, где +ключа нет с одной из сторон или где расходится любая из пяти величин. Ноль — +зеленый. + +Две проверки покрупнее ловят самые частые ошибки: + +- сумма `pageviews`, `add_to_carts` и `purchases` за день равна числу + событий соответствующего типа в `dds.event_v` за тот же день; +- сумма `declared_revenue` за день равна сумме `purchase_revenue[1]` по всем + событиям `purchase` этого дня. + +Обе не сойдутся, если прямой трафик отфильтрован, и разойдутся ровно на его +долю — по этому следу отфильтрованные строки и находят. `visitors`, как и в +задании 3, по строкам не складывается: один посетитель приходит по разным +меткам. + +## Приемка + +- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется + ключом цели; +- [ ] строки без UTM-меток в витрине остались; +- [ ] `declared_revenue` хранится в `Decimal(18, 2)`; +- [ ] расхождение с `dm.revenue_daily_v` объяснено письменно; +- [ ] задача `utm_effectiveness` в `homework_check` зеленая; +- [ ] задача витрины добавлена в `dm_transform`. diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index 24690ce..c2015cf 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -512,8 +512,14 @@ Kafka день переигрывается генератором заново: `dds.identity_map_v`: опыт на ClickHouse 26.3 показал, что публичные `_v` скрывают ко-локацию от локальной подстановки. - `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`, - `dq_errors_daily_v` — переезжают на новую модель без смены роли: источник — - `dds.event_v`, не `ods.event`. + `dq_errors_daily_v` — переезжают на новую модель без смены роли. Источник + и зерно у двух из них меняет сама новая модель, и это зафиксировано ТЗ + домашек ([`docs/labs/`](../labs/README.md)): + `session_overview_v` читает `dds.session_v` и поднимается на зерно «день × + срез», потому что визит стал сущностью DDS и витрина его зерна была бы + копией сессий; `dq_errors_daily_v` читает таблицы `ods.*_errors`, у + которых в DDS представительства нет. Остальные две читают `dds.event_v`, + а не `ods.event`. - `dm.dq_summary` хранит текущее качество дня и обновляется партиционной заменой (политика «без TRUNCATE»). @@ -650,9 +656,12 @@ ADR 0002 с приёмочными совпадают почти целиком. расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом. - Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не - переносится. Спека даёт будущим лабам только опорные точки — контрольные - числа описи (сверка, идентичность). Явное следствие: после этапа 9 - стенд работает, но учебного пути на нём ещё нет. + переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг + самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и + конвейер, в который она встает, проектируются вместе. Единый стиль блока + домашек — за работой учебного пути. Спека даёт будущим лабам только + опорные точки — контрольные числа описи (сверка, идентичность). Явное + следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет. - Инкрементальный ETL (#8) — свой issue. - Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд. - TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные diff --git a/sql/dq/dq_errors_daily_vs_ods.sql b/sql/dq/dq_errors_daily_vs_ods.sql new file mode 100644 index 0000000..601aa4c --- /dev/null +++ b/sql/dq/dq_errors_daily_vs_ods.sql @@ -0,0 +1,88 @@ +-- DQ: дневная сводка брака против живого окна таблиц ошибок ODS. +-- +-- Граница окна повторяет TTL источников; более старую историю сверять не с чем. + +WITH + reference AS + ( + SELECT + load_date, + source, + error_class, + count() AS error_rows + FROM + ( + SELECT + toDate(_load_ts) AS load_date, + 'event' AS source, + error_class + FROM ods.event_errors_dist + UNION ALL + SELECT + toDate(_load_ts) AS load_date, + 'order' AS source, + error_class + FROM ods.order_errors_dist + ) + WHERE load_date >= toDate(now() - INTERVAL 1 MONTH) + GROUP BY load_date, source, error_class + ), + actual AS + ( + SELECT + load_date, + source, + error_class, + count() AS physical_rows, + any(error_rows) AS error_rows + FROM dm.dq_errors_daily_v + WHERE load_date >= toDate(now() - INTERVAL 1 MONTH) + GROUP BY load_date, source, error_class + ), + comparison AS + ( + SELECT + coalesce(reference.load_date, actual.load_date) AS data_date, + concat( + coalesce(reference.source, actual.source), + '/', + coalesce(reference.error_class, actual.error_class) + ) AS business_key, + isNotNull(reference.source) AS reference_present, + isNotNull(actual.source) AS actual_present, + reference.error_rows AS reference_value, + actual.error_rows AS actual_value, + actual.physical_rows AS physical_rows, + isNull(reference.source) + OR isNull(actual.source) + OR actual.physical_rows != 1 + OR NOT isNotDistinctFrom(reference.error_rows, actual.error_rows) + AS failed + FROM reference + GLOBAL FULL OUTER JOIN actual USING (load_date, source, error_class) + ) +SELECT + data_date, + countIf(reference_present) AS reference_rows, + countIf(actual_present) AS actual_rows, + countIf(failed) AS failed_rows, + groupArrayIf(20)( + concat( + toString(data_date), + ' source/error_class=', + business_key, + ': источник=', + if(reference_present, toString(reference_value), 'нет ключа'), + ', витрина=', + if( + actual_present, + concat(toString(actual_value), ', rows=', toString(physical_rows)), + 'нет ключа' + ) + ), + failed + ) AS diagnostics +FROM comparison +GROUP BY data_date +ORDER BY data_date +SETTINGS join_use_nulls = 1 diff --git a/sql/dq/events_enriched_scope.sql b/sql/dq/events_enriched_scope.sql new file mode 100644 index 0000000..de84dde --- /dev/null +++ b/sql/dq/events_enriched_scope.sql @@ -0,0 +1,15 @@ +-- DQ: область посуточной сверки обогащенных событий. +-- +-- Объединение сторон оставляет видимым день, потерянный целиком. + +SELECT day +FROM +( + SELECT event_date AS day + FROM dds.event_v + UNION ALL + SELECT event_date AS day + FROM dm.events_enriched_v +) +GROUP BY day +ORDER BY day diff --git a/sql/dq/events_enriched_vs_dds.sql b/sql/dq/events_enriched_vs_dds.sql new file mode 100644 index 0000000..183523e --- /dev/null +++ b/sql/dq/events_enriched_vs_dds.sql @@ -0,0 +1,125 @@ +-- DQ: обогащенные события против прямого ответа DDS за один день. +-- +-- День передает даг, чтобы широкая сверка не собирала всю историю в памяти. + +WITH + reference AS + ( + SELECT + event_date, + event_id, + tuple( + event_time, + toHour(event_time), + client_id, + event_type, + url, + path(url), + referer, + domain(referer), + title, + utm_source, + utm_medium, + utm_campaign, + utm_content, + utm_term, + device_category, + browser, + operating_system, + region_country, + region_city, + product_id, + product_name, + product_category, + product_price, + product_quantity, + product_event_type, + purchase_id, + purchase_revenue, + purchase_coupon + ) AS value + FROM dds.event_v + WHERE event_date = {day:Date} + ), + actual AS + ( + SELECT + event_date, + event_id, + count() AS physical_rows, + any(tuple( + event_time, + event_hour, + client_id, + event_type, + url, + page_path, + referer, + referer_host, + title, + utm_source, + utm_medium, + utm_campaign, + utm_content, + utm_term, + device_category, + browser, + operating_system, + region_country, + region_city, + product_id, + product_name, + product_category, + product_price, + product_quantity, + product_event_type, + purchase_id, + purchase_revenue, + purchase_coupon + )) AS value + FROM dm.events_enriched_v + WHERE event_date = {day:Date} + GROUP BY event_date, event_id + ), + comparison AS + ( + SELECT + coalesce(reference.event_date, actual.event_date) AS data_date, + coalesce(reference.event_id, actual.event_id) AS business_key, + isNotNull(reference.event_id) AS reference_present, + isNotNull(actual.event_id) AS actual_present, + reference.value AS reference_value, + actual.value AS actual_value, + actual.physical_rows AS physical_rows, + isNull(reference.event_id) + OR isNull(actual.event_id) + OR actual.physical_rows != 1 + OR NOT isNotDistinctFrom(reference.value, actual.value) AS failed + FROM reference + GLOBAL FULL OUTER JOIN actual USING (event_date, event_id) + ) +SELECT + data_date, + countIf(reference_present) AS reference_rows, + countIf(actual_present) AS actual_rows, + countIf(failed) AS failed_rows, + groupArrayIf(20)( + concat( + toString(data_date), + ' event_id=', + toString(business_key), + ': источник=', + if(reference_present, toString(reference_value), 'нет ключа'), + ', витрина=', + if( + actual_present, + concat(toString(actual_value), ', rows=', toString(physical_rows)), + 'нет ключа' + ) + ), + failed + ) AS diagnostics +FROM comparison +GROUP BY data_date +ORDER BY data_date +SETTINGS join_use_nulls = 1 diff --git a/sql/dq/session_overview_vs_dds.sql b/sql/dq/session_overview_vs_dds.sql new file mode 100644 index 0000000..a8636ab --- /dev/null +++ b/sql/dq/session_overview_vs_dds.sql @@ -0,0 +1,87 @@ +-- DQ: дневной портрет сессий против прямого агрегата сессий DDS. +-- +-- Полное объединение не прячет день или устройство, исчезнувшие с любой стороны. + +WITH + reference AS + ( + SELECT + session_date AS report_date, + device_category, + tuple( + count(), + uniqExact(client_id), + countIf(events_total = 1), + countIf(purchases > 0), + sum(duration_seconds), + sum(events_total), + sum(pageviews), + sum(cart_adds), + sum(purchases) + ) AS value + FROM dds.session_v + GROUP BY report_date, device_category + ), + actual AS + ( + SELECT + report_date, + device_category, + count() AS physical_rows, + any(tuple( + sessions, + visitors, + bounce_sessions, + purchase_sessions, + duration_seconds_total, + events_total, + pageviews, + cart_adds, + purchases + )) AS value + FROM dm.session_overview_v + GROUP BY report_date, device_category + ), + comparison AS + ( + SELECT + coalesce(reference.report_date, actual.report_date) AS data_date, + coalesce(reference.device_category, actual.device_category) + AS business_key, + isNotNull(reference.device_category) AS reference_present, + isNotNull(actual.device_category) AS actual_present, + reference.value AS reference_value, + actual.value AS actual_value, + actual.physical_rows AS physical_rows, + isNull(reference.device_category) + OR isNull(actual.device_category) + OR actual.physical_rows != 1 + OR NOT isNotDistinctFrom(reference.value, actual.value) AS failed + FROM reference + GLOBAL FULL OUTER JOIN actual USING (report_date, device_category) + ) +SELECT + data_date, + countIf(reference_present) AS reference_rows, + countIf(actual_present) AS actual_rows, + countIf(failed) AS failed_rows, + groupArrayIf(20)( + concat( + toString(data_date), + ' device_category=', + business_key, + ': источник=', + if(reference_present, toString(reference_value), 'нет ключа'), + ', витрина=', + if( + actual_present, + concat(toString(actual_value), ', rows=', toString(physical_rows)), + 'нет ключа' + ) + ), + failed + ) AS diagnostics +FROM comparison +GROUP BY data_date +ORDER BY data_date +SETTINGS join_use_nulls = 1 diff --git a/sql/dq/top_pages_daily_vs_dds.sql b/sql/dq/top_pages_daily_vs_dds.sql new file mode 100644 index 0000000..be38eac --- /dev/null +++ b/sql/dq/top_pages_daily_vs_dds.sql @@ -0,0 +1,71 @@ +-- DQ: дневная популярность страниц против прямого агрегата событий DDS. +-- +-- Полное объединение не прячет страницу, исчезнувшую с любой стороны. + +WITH + reference AS + ( + SELECT + event_date AS report_date, + path(url) AS page_path, + tuple( + countIf(event_type = 'pageview'), + uniqExact(client_id), + countIf(event_type = 'add_to_cart'), + countIf(event_type = 'purchase') + ) AS value + FROM dds.event_v + GROUP BY report_date, page_path + ), + actual AS + ( + SELECT + report_date, + page_path, + count() AS physical_rows, + any(tuple(pageviews, visitors, cart_adds, purchases)) AS value + FROM dm.top_pages_daily_v + GROUP BY report_date, page_path + ), + comparison AS + ( + SELECT + coalesce(reference.report_date, actual.report_date) AS data_date, + coalesce(reference.page_path, actual.page_path) AS business_key, + isNotNull(reference.page_path) AS reference_present, + isNotNull(actual.page_path) AS actual_present, + reference.value AS reference_value, + actual.value AS actual_value, + actual.physical_rows AS physical_rows, + isNull(reference.page_path) + OR isNull(actual.page_path) + OR actual.physical_rows != 1 + OR NOT isNotDistinctFrom(reference.value, actual.value) AS failed + FROM reference + GLOBAL FULL OUTER JOIN actual USING (report_date, page_path) + ) +SELECT + data_date, + countIf(reference_present) AS reference_rows, + countIf(actual_present) AS actual_rows, + countIf(failed) AS failed_rows, + groupArrayIf(20)( + concat( + toString(data_date), + ' page_path=', + business_key, + ': источник=', + if(reference_present, toString(reference_value), 'нет ключа'), + ', витрина=', + if( + actual_present, + concat(toString(actual_value), ', rows=', toString(physical_rows)), + 'нет ключа' + ) + ), + failed + ) AS diagnostics +FROM comparison +GROUP BY data_date +ORDER BY data_date +SETTINGS join_use_nulls = 1 diff --git a/sql/dq/utm_effectiveness_vs_dds.sql b/sql/dq/utm_effectiveness_vs_dds.sql new file mode 100644 index 0000000..769d762 --- /dev/null +++ b/sql/dq/utm_effectiveness_vs_dds.sql @@ -0,0 +1,88 @@ +-- DQ: эффективность UTM-меток против прямого агрегата событий DDS. +-- +-- Полное объединение не прячет набор меток, исчезнувший с любой стороны. + +WITH + reference AS + ( + SELECT + event_date AS report_date, + utm_source, + utm_medium, + utm_campaign, + tuple( + uniqExact(client_id), + countIf(event_type = 'pageview'), + countIf(event_type = 'add_to_cart'), + countIf(event_type = 'purchase'), + sumIf(purchase_revenue[1], event_type = 'purchase') + ) AS value + FROM dds.event_v + GROUP BY report_date, utm_source, utm_medium, utm_campaign + ), + actual AS + ( + SELECT + report_date, + utm_source, + utm_medium, + utm_campaign, + count() AS physical_rows, + any(tuple( + visitors, + pageviews, + add_to_carts, + purchases, + declared_revenue + )) AS value + FROM dm.utm_effectiveness_v + GROUP BY report_date, utm_source, utm_medium, utm_campaign + ), + comparison AS + ( + SELECT + coalesce(reference.report_date, actual.report_date) AS data_date, + concat( + 'utm_source=', coalesce(reference.utm_source, actual.utm_source), + ', utm_medium=', coalesce(reference.utm_medium, actual.utm_medium), + ', utm_campaign=', + coalesce(reference.utm_campaign, actual.utm_campaign) + ) AS business_key, + isNotNull(reference.utm_source) AS reference_present, + isNotNull(actual.utm_source) AS actual_present, + reference.value AS reference_value, + actual.value AS actual_value, + actual.physical_rows AS physical_rows, + isNull(reference.utm_source) + OR isNull(actual.utm_source) + OR actual.physical_rows != 1 + OR NOT isNotDistinctFrom(reference.value, actual.value) AS failed + FROM reference + GLOBAL FULL OUTER JOIN actual + USING (report_date, utm_source, utm_medium, utm_campaign) + ) +SELECT + data_date, + countIf(reference_present) AS reference_rows, + countIf(actual_present) AS actual_rows, + countIf(failed) AS failed_rows, + groupArrayIf(20)( + concat( + toString(data_date), + ' ', + business_key, + ': источник=', + if(reference_present, toString(reference_value), 'нет ключа'), + ', витрина=', + if( + actual_present, + concat(toString(actual_value), ', rows=', toString(physical_rows)), + 'нет ключа' + ) + ), + failed + ) AS diagnostics +FROM comparison +GROUP BY data_date +ORDER BY data_date +SETTINGS join_use_nulls = 1