Compare commits

Author SHA1 Message Date
ddadmin 3556566f64 docs(labs): обращение «вы» во всех учебных заданиях
- Зачем:
  - README и путеводитель обращаются к читателю на «вы», а ТЗ в docs/labs/
    были на «ты»; после пересборки индекса смесь стала заметна.
- Что:
  - во всех восьми файлах docs/labs/ местоимения, глаголы и повелительное
    наклонение переведены во второе лицо множественного числа.
  - названия ступеней в таблице README оставлены как в спеке.
- Проверка:
  - grep по формам «ты/твой», 'ё' и 'менти' по docs/labs/ молчит;
    git diff --check чистый; ревью диффа свежей сессией.
2026-09-03 17:28:51 -04:00
62 changed files with 2119 additions and 3809 deletions

No files matched your search

+9 -28
View File
@@ -8,26 +8,15 @@
Стенд строится по спеке Стенд строится по спеке
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md). [«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
Стенд рассчитан на начинающих инженеров данных. Они недавно изучили ClickHouse, Отсюда вопрос к любой доработке — коду, проверке, документу: **чему на ней
но почти не работали с ним руками. Здесь они разбирают и достраивают хранилище научится менти?** Ответ называется одной фразой до того, как писать. Не
небольшого интернет-магазина: проходят путь от загрузки данных до витрин и BI.
Программа дает практику общепринятых инженерных приемов. После нее участники
должны уметь построить похожее хранилище без готового образца. Состав и порядок
работы задает [программа учебных заданий](docs/specs/2026-09-01-labs-program.md).
Стенд существует только ради обучения. Его поддержка имеет смысл, когда
сохраняет возможность пройти программу или сама становится учебной задачей.
Отдельной эксплуатационной ценности у стенда нет.
Отсюда вопрос к любой доработке — коду, проверке, документу: **чему она
учит?** Ответ называется одной фразой до того, как писать. Не
складывается фраза — это вопрос владельцу, а не строчка кода: проверки в этом складывается фраза — это вопрос владельцу, а не строчка кода: проверки в этом
репозитории однажды уже переросли продукт. репозитории однажды уже переросли продукт.
**Усложнение не бесплатно, и платит за него учебная ценность.** Чем сложнее **Усложнение не бесплатно, и платит за него учебная ценность.** Чем сложнее
стенд, тем хуже он как учебный материал: внимание читателя конечно, и каждая стенд, тем хуже он как учебный материал: внимание менти конечно, и каждая
конструкция, которую он обязан расшифровать по дороге к уроку, списывается с конструкция, которую он обязан расшифровать по дороге к уроку, списывается с
этого счёта. Поэтому вопрос выше имеет вторую половину: **что читатель платит, этого счёта. Поэтому вопрос выше имеет вторую половину: **что менти платит,
чтобы это прочесть, и что получает взамен?** Ответ «вопрос — и ничего» чтобы это прочесть, и что получает взамен?** Ответ «вопрос — и ничего»
означает, что писать не нужно. означает, что писать не нужно.
@@ -81,9 +70,6 @@ Airflow) и названия из кода. Если для понятия ес
писал: простые слова, короткие фразы, сложную мысль поясняй при первом писал: простые слова, короткие фразы, сложную мысль поясняй при первом
упоминании. Если понятность и буквальная точность спорят — выбирай понятность. упоминании. Если понятность и буквальная точность спорят — выбирай понятность.
При создании или редактировании лабораторных следуй
[правилам учебного текста](docs/specs/2026-09-01-labs-program.md#как-писать-задания).
Внутренние рассуждения и промежуточные пометки по ходу работы веди на Внутренние рассуждения и промежуточные пометки по ходу работы веди на
английском — он экономнее по токенам. На русском остаётся всё, что видит и английском — он экономнее по токенам. На русском остаётся всё, что видит и
хранит проект: итоговые ответы, документы, комментарии в коде и SQL, сообщения хранит проект: итоговые ответы, документы, комментарии в коде и SQL, сообщения
@@ -171,18 +157,13 @@ Airflow) и названия из кода. Если для понятия ес
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
истории, а текущее описание живого формата — и собирается из кода, а не истории, а текущее описание живого формата — и собирается из кода, а не
пишется руками. пишется руками.
- Имена файлов основной программы в `docs/labs/` — `S-N-slug.md`: - Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис
номер ступени, номер задания внутри ступени и слаг строчными латинскими (`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных
буквами через дефис (`1-2-top-pages-daily.md`). Здесь живут ТЗ заданий. Ни даты, ни номера: задание — не событие истории, а живой документ,
учебных заданий. Номера повторяют порядок программы из `README.md`, чтобы и правится он вместе с тем, о чем говорит. Слаг называет предмет задания.
папка читалась как оглавление; даты нет: задание — живой документ и
правится вместе с тем, о чем говорит. Слаг называет предмет задания.
Факультативы живут в `docs/labs/optional/`, без номеров, под одним слагом
(`superset-dashboard.md`). Общий индекс обеих частей — `docs/labs/README.md`;
отдельного индекса в `optional/` нет.
- Имена файлов в `docs/handbook/` — слаг строчными латинскими буквами через - Имена файлов в `docs/handbook/` — слаг строчными латинскими буквами через
дефис, индекс папки — `README.md`. Здесь живет путеводитель по стенду: дефис, индекс папки — `README.md`. Здесь живет путеводитель по стенду:
учебные главы для читателя. Ни дат, ни номеров: глава — живой документ и учебные главы для менти. Ни дат, ни номеров: глава — живой документ и
правится вместе с тем, что описывает. Исходники интерактивной карты лежат правится вместе с тем, что описывает. Исходники интерактивной карты лежат
подпапкой `architecture-map/`, а ее рендер — файлом `architecture-map.html` подпапкой `architecture-map/`, а ее рендер — файлом `architecture-map.html`
рядом с главами. рядом с главами.
-6
View File
@@ -1,7 +1 @@
@AGENTS.md @AGENTS.md
# Читабельность языка
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
Please remove all mannered prose.
+206 -252
View File
@@ -1,275 +1,229 @@
# Кликстрим-платформа (стенд v2) # Кликстрим-платформа (стенд v2)
Словарь понятий, которые встречаются в учебных заданиях, документации и коде Словарь понятий проекта: одни и те же слова для одних и тех же вещей —
стенда. у владельца, кода, документов и агентов. Только язык, никаких решений.
## Язык ## Язык
- [Люди и события](#люди-и-события)
- [Хранилище и форматы данных](#хранилище-и-форматы-данных)
- [Заказы](#заказы)
- [Модельное время и запуск стенда](#модельное-время-и-запуск-стенда)
- [Устройство генератора](#устройство-генератора) — для чтения его кода
и технических справочников.
### Люди и события
**Человек**:
Участник модели магазина, который может заходить с одного или двух
устройств. Генератор знает, какие идентификаторы посетителя ему принадлежат,
но в событиях сайта этой связи нет.
**Посетитель**:
Браузер на устройстве, который счетчик сайта узнает по куке — сохраненному
в браузере идентификатору. В кликстриме он обозначается `ClientID`:
`uniq(ClientID)` считает посетителей, и у одного человека их может быть несколько.
_Избегать_: «посетитель» про человека после объединения его идентификаторов.
**Пользователь магазина**:
Человек, известный серверной части магазина под идентификатором `user_id`.
Он указан в заказах и позволяет связать разные `ClientID` одного человека;
в кликстриме поля `user_id` нет. Например, покупки с телефона и ноутбука
могут иметь разные `ClientID`, но один `user_id` в заказах.
**Визит**:
Последовательность событий одного посетителя без пауз длиннее 30 минут
и без перехода через полночь по часовому поясу счетчика. Сессия означает
то же самое; поле `VisitID` служит эталоном в задании по нарезке сессий.
**День активности**:
День, в который посетитель появился на сайте хотя бы раз: впервые или
вернувшись. За один день активности у него может быть несколько визитов.
_Избегать_: визит в значении целого дня активности.
**Паспорт куки**:
Устройство и город, закрепленные за посетителем во все дни его активности.
Генератор записывает их в поля событий. У двух посетителей одного человека
город совпадает, а устройства различаются.
**Торговое событие**:
Запись о добавлении товара в корзину или о покупке: `add_to_cart` или
`purchase`. Это отдельная строка кликстрима со страницей, где произошло
действие: карточкой товара для корзины или страницей подтверждения для покупки.
### Хранилище и форматы данных
**Хранилище**: **Хранилище**:
Аналитическая база стенда в ClickHouse. Данные проходят слои STG, ODS, DDS Аналитическая база стенда — кластер ClickHouse со слоями STG/ODS/DDS/DM.
и DM: от исходных сообщений до витрин для отчетов. Хранилище принимает Принимающая сторона границы «трекер | хранилище»: нормализует имена и стили
данные по документации источников и приводит их к своей модели и именам. источников, строится по их документации.
_Избегать_: склад, склад данных. _Избегать_: склад, склад данных
**Сырые данные**: **Сырые данные**:
Исходные сообщения Kafka, сохраненные в слое STG без разбора: одно сообщение Слой STG хранилища и его содержимое: сообщение топика, положенное строкой,
занимает одну строку таблицы. Слой STG также называют сырым слоем. как пришло, без разбора. В прозе — сырые данные и сырой слой; про
_Избегать_: сырьё. конкретную таблицу или базу — STG.
_Избегать_: сырьё
**Справочник**:
Готовые сведения из внешнего источника, которыми хранилище дополняет
свои данные. На стенде они находятся в базе `dic`; пока это только каталог
товаров. Объект `DICTIONARY` ClickHouse — одна из форм доступа к справочнику.
_Избегать_: словарь в значении справочных данных: это слово уже обозначает
`DICTIONARY` и словарь понятий.
**Каталог товаров**:
Список товаров с артикулами, названиями, категориями, брендами, ценами
и уровнями спроса. Файл `data/catalog/products.csv` общий для генератора
и справочника ClickHouse; товары можно добавлять строками в этом формате.
**Нормализованное имя**:
Имя поля источника, записанное в стиле `snake_case`: например,
`ClientID` становится `client_id`. Оно указано в описании выгрузки и
контракте схемы. Слой DDS выбирает имена по своей модели данных, поэтому
они не обязаны повторять нормализованные имена источника.
**Описание выгрузки**:
Документ, который объясняет поля и формат событий источника:
[`docs/formats/clickstream-event.md`](docs/formats/clickstream-event.md).
По нему строится прием данных в хранилище. Документ собирается из
описаний колонок генератора: правят их, затем пересобирают документ.
_Избегать_: описание схемы, документация контракта.
**Техническая проверка**:
Проверка по внутреннему эталону стенда, например сравнение числа событий
с паспортом мира. Проверка качества данных (DQ) опирается на данные
магазина и связи между ними; ей не нужен эталон генератора, которого
у настоящего магазина нет.
_Избегать_: DQ-проверка в значении проверки по паспорту мира.
### Заказы
**Слепок**:
Дневная выгрузка состояния заказов из серверной части магазина.
Она содержит заказы, оформленные за последние семь модельных дней,
с учетом изменений к концу дня. Опоздавший заказ может отсутствовать
в первых слепках.
**Дата слепка**:
Дата модельного дня, на конец которого снято состояние заказов;
поле `snapshot_date` одинаково во всех строках выгрузки. Даты оформления
заказов могут различаться; выгрузка отправляется на следующий модельный день.
**Время аудита источника**:
Время создания и последнего изменения записи заказа по часам базы
магазина: поля `created_at` и `updated_at`. Время покупки в событии
`purchase` записывает счетчик сайта, и оно может отличаться от этих полей.
**Окно изменяемости**:
Семь модельных дней, включая день оформления, в течение которых заказ
может измениться и входит в слепки. После этого его состояние окончательно.
Например, заказ от 1 июня входит в окно слепков за 1–7 июня; опоздание
сокращает число его появлений, но не сдвигает конец окна.
**Пакетный забор**:
Чтение сообщений Kafka отдельным запросом по команде. Так хранилище
принимает заказы после отправки всего слепка. События сайта оно принимает
непрерывно через материализованное представление.
_Избегать_: смешивать с пакетным режимом генератора: забор — чтение
на стороне хранилища, режим генератора — отправка.
### Модельное время и запуск стенда
**Мир**:
Модель интернет-магазина: люди, их посещения сайта, товары и заказы,
которые создает генератор.
**Ось модельного времени**:
Календарь магазина в генераторе. Первый день, D0, — 1 июня 2026 года,
понедельник (`world.ORIGIN`); следующие дни отсчитываются от него.
Реальное время — часы компьютера, на котором работает стенд. Дата запуска
не меняет даты событий: D0 остается 1 июня, даже если стенд запущен осенью.
**Граница суток**:
Конец одного модельного дня и начало следующего, полночь по часовому
поясу счетчика. Здесь заканчиваются визиты и снимается состояние заказов
для слепка. Даги времени продвигают мир только на целые дни.
**Стартовый мир**:
Данные первых восьми модельных дней, с понедельника по следующий
понедельник, которыми стенд наполняется при первом `make up`.
Команда вызывает `world_initialize`: если мир уже готов и сохранен,
повторный запуск оставляет данные и позицию времени прежними.
`make rebuild-storage` пересоздает стартовый мир. При неизменных коде,
каталоге и настройках его данные повторяются и служат эталоном проверок.
_Избегать_: зерновой мир.
**Паспорт мира**:
Файл для сверки с эталонными данными: `data/world-inventory.json`.
В нем записаны зерно генерации, версия генератора, контрольная сумма
каталога, число событий и контрольная сумма каждого начального дня,
контрольные суммы слепков и счетчики расхождений по заказам.
В git хранится паспорт; сами события и слепки пересчитываются генератором.
_Избегать_: опись мира, манифест, мини-манифест.
**Пошаговый режим**:
Продвижение модельного времени по явной команде: запустить генерацию
следующего дня или нескольких дней.
**Пакетный режим**:
Отправка событий готового модельного дня без пауз между ними.
Используется для начального наполнения, пересборок и проверок.
**Живой день**:
Постепенная отправка событий по времени их появления в модельном дне,
с ускорением: по умолчанию сутки занимают около 24 реальных минут.
Чтобы следующие дни запускались автоматически, снимают с паузы `world_live`.
**Даги модельного времени**:
Наборы связанных задач Airflow для генерации следующих дней магазина:
`world_next_day`, `world_live_day` и `world_live`. Первые два выполняют
шаг по времени, третий автоматически запускает живые дни, пока снят с паузы.
Короткое название — даги времени.
_Избегать_: пульт мира, управляющие даги.
**Шаговый даг**:
Даг, который отправляет данные следующих модельных дней и после успеха
записывает новую позицию времени. `world_next_day` отправляет их пачкой,
`world_live_day` — один день с ускорением. У них нет расписания;
их запускают вручную или из другого дага.
_Избегать_: работник.
### Устройство генератора
**Зерно генерации**:
Число, которое задает повторяемую последовательность случайных выборов
генератора. Одинаковые зерно и день дают одинаковые данные при неизменных
коде, каталоге и настройках. Эталон использует `CANONICAL_SEED` из `seeds.py`.
**Состав мира**: **Состав мира**:
Люди в модели, их идентификаторы посетителей и заранее заданные привычки: Постоянная часть мира генератора — популяция посетителей, их привычки,
когда они приходят, как часто возвращаются, склонны ли покупать. двухкуковые пары. По дням его выдаёт план состава.
У части людей две куки — это двухкуковые пары. _Избегать_: состояние мира
_Избегать_: состояние мира.
**План состава**: **План состава**:
Расчет того, какие посетители придут в каждый день и какие свойства у них Способ спросить состав мира: функция зерна, выдающая его по дням —
будут. В `plan.py` из зерна и номера дня получаются новые посетители, когорту новых кук, их возвраты, назначенные заказы двухкуковых пар.
их возвраты и назначенные заказы людей с двумя куками.
**Когорта дня**:
Группа людей, впервые пришедших в один день, со всеми их куками и днями
активности. Генератор рассчитывает ее по зерну и номеру первого дня.
**Приток**:
Появление новых посетителей (`ClientID`) по мере продвижения модельного
времени. Поэтому число разных посетителей за весь период растет и
не совпадает с числом посетителей за отдельный день.
**Хвост возвратов**:
Срок от первого появления человека, в пределах которого его куки могут
возвращаться на сайт. У человека с двумя куками конец срока общий.
**Предыстория**:
Люди, впервые пришедшие до D0, и план их активности. До D0 события
не создаются; возвраты этих людей дают повторных посетителей уже в первый
день генерируемых данных.
**Покупатель**:
Человек, которому генератор заранее назначил повышенную склонность
покупать. Он чаще доходит до заказа, но иногда покупают и остальные.
Из таких людей выбираются те, у кого будут две куки.
_Избегать_: покупатель в значении любого посетителя с покупкой за день;
для этого случая — «визит с заказом».
**Уровень спроса**:
Свойство товара, от которого зависит, как часто после просмотра карточки
его добавляют в корзину. В колонке `demand` каталога три значения:
`магнит`, `обычный`, `залёживается`. Они постоянны по дням и задают
различия в конверсии товаров и брендов.
**Конфигурация мира**:
Числовые настройки модели магазина в `world.py`: приток, частота возвратов,
доли покупателей, начальная дата и другие параметры. Изменение этих
настроек меняет генерируемые данные.
**Подпоток**: **Подпоток**:
Отдельная последовательность случайных выборов для части генератора, Ветвь дерева случайности генератора: своё зерно у состава мира, у каждого
например для торговли в заданный день. В `seeds.py` она определяется дня и у каждого компонента дня. Подпоток задан позицией в дереве, а не
общим зерном, днем и компонентом; для состава мира есть отдельные порядком вычислений.
последовательности. Результат не зависит от порядка расчета частей.
**Конфигурация мира**:
Модуль чистых данных со всеми числами мира: приток, профили возвратов, доли
покупателей, D0. Правка модуля — смена мира. Модуль-близнец контракта схемы:
там колонки, здесь числа.
**Когорта дня**:
Люди, впервые пришедшие в мир в один день, со всеми их куками и днями
активности. Единица плана состава: когорта — функция зерна и номера дня.
**Человек**:
Устойчивая личность в составе мира, которой принадлежат одна или две куки.
Не поле источника: кликстрим видит куку, а заказ — пользователя магазина.
**Посетитель**:
Кука, наблюдаемая в кликстриме и обозначенная `ClientID`; единица
`uniq(ClientID)`. Один человек может быть представлен несколькими посетителями.
_Избегать_: «посетитель» про человека после склейки
**Пользователь магазина**:
Представление человека со стороны заказов; в записи заказа обозначается
`user_id`. Через заказы связывает разные куки человека, в кликстрим напрямую
не попадает.
**Приток**:
Появление новых кук на всём протяжении оси модельного времени; единица —
кука (`ClientID`). Из-за притока накопленная аудитория растёт с
горизонтом и не совпадает с дневной.
**Хвост возвратов**:
Окно активности, отсчитанное от первого дня человека и общее на обе
его куки; дольше окна кука не возвращается.
**Предыстория**:
Когорты плана с первым днём активности до D0; событий не порождают.
**День активности**:
День, в который кука хоть раз появилась в мире: день её рождения или день
возврата. Единица плана состава: за день он у куки один, а визитов внутри
него бывает несколько.
_Избегать_: визит (визит — про сессию)
**Визит**:
Подряд идущие события одной куки без пауз длиннее 30 минут, не пересекающие
границу суток. Поле `VisitID` — эталон для лабы сессий. «Сессия» — то же
понятие словами аналитики: в данных и в коде оно зовётся визитом, но имена
вроде «лаба сессий» и «сборка сессий» остаются.
**Паспорт куки**:
Устройство и город, приписанные куке на всю жизнь: кука — это браузер на
устройстве. Держит их план состава, разворачивает в поля события
день-функция; у двухкуковой пары город один на две куки, устройства разные.
**Покупатель**:
Человек, которого план состава пометил склонным покупать. Метка значима:
помеченный доходит до заказа заметно чаще прочих, но и непомеченный иногда
покупает. Из покупателей отбираются двухкуковые пары.
_Избегать_: «покупатель» про того, кто купил в конкретный день — это визит
с заказом.
**День-функция**: **День-функция**:
Расчет событий одних модельных суток по зерну и номеру дня, без генерации Функция (зерно, номер дня), выдающая упорядоченный поток событий этих
предыдущих дней. Функция `day.stream(seed, day)` возвращает события модельных суток. Состояния между днями нет: день D не зависит от того,
в порядке времени. прожиты ли дни до него.
**Судьба заказа**: **Торговое событие**:
Заранее рассчитанные изменения заказа: итоговый статус, время оплаты Событие корзины или покупки — отдельная строка потока, а не просмотр
или отмены, удаленная позиция и задержка первого появления в слепке. страницы. Садится на ту страницу, где случилось: корзина — на карточку
Они укладываются в семидневное окно заказа. Слепок показывает состояние товара, покупка — на страницу подтверждения заказа.
на выбранный день без проигрывания предыдущих изменений.
**Справочник**:
Данные, которые хранилище не производит, а получает готовыми со стороны:
содержимым владеет источник вне хранилища, слои только читают. Живут в базе
`dic`. Первый и пока единственный — каталог товаров.
_Избегать_: словарь — этим словом в проекте зовут и глоссарий понятий, и
объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник доступен.
**Каталог товаров**:
`data/catalog/products.csv` — общий файл генератора и словаря
ClickHouse. Форма файла решена, длина — нет: строки дописываются.
**Уровень спроса**:
Свойство товара в каталоге: как часто открытую карточку кладут в корзину.
Три значения — магнит, обычный, залёживается. Постоянная часть мира, а не
поведение дня: он и делает конверсию «просмотр → корзина» по товарам и
брендам различимой.
**Ось модельного времени**:
Собственный календарь мира генератора. Дни считаются от фиксированного
первого дня D0; реальный календарь в модели не участвует. Между прогонами
живут только зерно и позиция на оси.
**Стартовый мир**:
Первые восемь дней оси (понедельник по понедельник), которыми стенд
наполняется при каждом `make up`. Он же эталонный мир, всегда один и тот же:
на нем принимаются следующие этапы.
_Избегать_: зерновой мир
**Паспорт мира**:
`data/world-inventory.json` — единственное, что о мире хранится в git: шапка
(зерно, версия генератора, хеш каталога), строка на каждый день событий
(число и хеш байтов) и строка на каждый отправленный слепок (хеш байтов).
Счётчик сверх этого заводится только с названным читателем. Сам мир не
хранится, а пересчитывается; паспорт отвечает на один вопрос: тот ли это мир.
_Избегать_: опись мира, манифест, мини-манифест
**Пошаговый режим**:
Базовый способ движения по оси модельного времени: «прожить следующий
день» — явное действие.
**Живой день**:
Проигрывание текущего модельного дня в реальном времени с ускорением;
включается по требованию — постоянным фоном идёт, только пока включён
выключатель `world_live`.
**Пакетный режим**:
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
стенда, пересборки и проверки.
**Даги модельного времени**:
Даги Airflow, которыми двигают ось модельного времени: шаговые даги играют
день — пачкой или в темпе, — а выключатель держит стенд живущим, пока включён.
Коротко — даги времени.
_Избегать_: пульт мира, управляющие даги
**Шаговый даг**:
Даг модельного времени, который играет день и ставит позицию на оси:
`world_next_day` — пачкой, `world_live_day` — в темпе. Запускается руками,
расписания и паузы не имеет; автоматику поверх него даёт выключатель.
_Избегать_: работник
**Граница суток**:
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
снимается на ней, день проживается только целиком.
**Контракт схемы**: **Контракт схемы**:
Описания колонок события в модуле генератора `schema.py`: имена, Python-модуль с описателями колонок события — собственность генератора.
типы и ограничения. По ним генератор создает и проверяет события, Из него выводятся генератор, валидация и документация формата; хранилище
а `schema_doc.py` собирает описание выгрузки для хранилища. строится по документации, не по модулю.
**Нормализованное имя**:
Имя колонки источника, приведённое к нашему стилю (snake_case). Живёт в
контракте схемы и в описании выгрузки. Не то же, что имя атрибута в модели
данных: слой DDS складывает модель и называет атрибуты по ней.
**Описание выгрузки**:
Публичная документация формата события: таблица колонок, собранная из
контракта схемы. По ней пишется сторона хранилища — как в бою по документации
источника. Правится только контракт, документ пересобирается.
_Избегать_: описание схемы, документация контракта
**Канонический сериализатор**: **Канонический сериализатор**:
Код в `serialize.py`, который превращает события и записи заказов в JSON Единственная граница, где запись генератора превращается в байты. Фиксированный
для отправки. Порядок ключей и формат значений фиксированы, поэтому порядок ключей и строк — основа побайтовой воспроизводимости.
одинаковые записи дают одинаковые байты.
**Проигрыватель**: **Проигрыватель**:
Код в `player.py`, который доставляет готовые события дня в приемник, Компонент доставки готового потока дня в приёмник. Два режима: пакетный
например в Kafka или файл. В пакетном режиме он отправляет их без пауз, (пачкой, без темпа) и живой день.
в режиме живого дня — с ускорением по времени событий.
**Слепок**:
Полная выгрузка заказов окна изменяемости, снятая бэкендом на границе суток:
состояние заказов на этот момент, а не поток их изменений. Опоздавший заказ
может отсутствовать в ранних слепках; после первого появления ездит до конца
своего окна.
**Дата слепка**:
Дата завершившегося модельного дня, состояние которого снято на исходящей
границе суток. Одна для всех записей выгрузки; не дата заказа и не время
отправки сообщения.
**Время аудита источника**:
Время создания или последнего изменения строки заказа по часам базы источника.
В записи слепка это `created_at` и `updated_at`; бизнес-время покупки живёт в
событии `purchase` отдельно.
**Судьба заказа**:
Исход, моменты изменений, вычеркнутая позиция и опоздание заказа — всё
решается бросками при его рождении и укладывается в окно изменяемости целиком.
Слепок любого дня — чтение готовой судьбы, а не накопление состояния.
**Окно изменяемости**:
Сколько модельных дней заказ ещё может измениться и потому продолжает ездить
в слепках. Константа мира: семь дней. За окном заказ замёрз, выручка дня
перестала «дышать».
**Пакетный забор**:
Способ приёма топика, при котором данные тянет запрос по команде, а не
матвью непрерывно. Так стенд принимает заказы: у слепка есть начало и конец,
и забирать его уместно тогда, когда он собран целиком. Противоположность —
потоковый приём событий.
_Избегать_: путать с приёмником и пакетным режимом — те про сторону
генератора, про отправку; забор — про сторону хранилища, про чтение.
**Техническая проверка**:
Проверка, чей оракул — внутренний эталон стенда (паспорт мира), которого у
настоящего магазина нет. За учебный урок DQ не выдаётся.
_Избегать_: DQ-проверка — та работает на наблюдаемых данных магазина.
+77 -98
View File
@@ -5,153 +5,132 @@
> курсовая работа. Если вам нужна помощь, обратитесь к автору материалов — > курсовая работа. Если вам нужна помощь, обратитесь к автору материалов —
> ментору [@dementev_dev](https://t.me/dementev_dev). > ментору [@dementev_dev](https://t.me/dementev_dev).
Здесь вы будете работать с хранилищем данных небольшого интернет-магазина: Этот стенд — для изучения и экспериментов с кликстримом и ClickHouse. Здесь
загружать и обрабатывать данные в ClickHouse, строить витрины, проверять можно проследить весь путь данных: от кликстрима и заказов до витрин, проверок
качество данных и создавать графики. Стенд рассчитан на тех, кто уже изучил и графиков. Это преемник учебного стенда
основы ClickHouse и хочет попрактиковаться. [clickstream-ch-kafka-superset-demo](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo).
Генератор создает заказы и кликстрим — записи о действиях посетителей В стенде подняты кластер ClickHouse из двух шардов, отдельный keeper, Kafka,
магазина. Данные поступают через Kafka в ClickHouse и проходят четыре слоя Airflow, Superset, Prometheus и Grafana; события кликстрима и заказы бэкенда
обработки: STG, ODS, DDS и DM. В кластере ClickHouse два шарда и отдельный приезжают из генератора и идут по слоям STG → ODS → DDS → DM. `make up`
ClickHouse Keeper. Airflow управляет загрузкой и обработкой, Superset наполняет стенд стартовым миром — первой неделей модельного времени. Чтобы
показывает отчеты, а Prometheus и Grafana помогают следить за работой стенда. разобраться в устройстве, начните с [путеводителя](docs/handbook/README.md), а
учиться — с [уроков](docs/labs/README.md).
При первом запуске стенд загрузит данные за неделю работы магазина.
После запуска начните с [путеводителя](docs/handbook/README.md): он объясняет
устройство хранилища. Затем переходите к [учебным заданиям](docs/labs/README.md).
## Быстрый старт ## Быстрый старт
Для запуска нужны Docker с Compose, `make`, `curl` и `jq`. Нужны Docker с Compose и около 8 ГБ памяти, доступной Docker. Это не объём
Выделите Docker около 8 ГБ оперативной памяти. В Docker Desktop и WSL2 ноутбука, а то, что отдано самому Docker: в Docker Desktop и WSL2 он живёт
проверьте лимит памяти виртуальной машины: он может быть меньше, чем общий внутри виртуальной машины и получает лишь часть памяти хозяина. Сколько выдано
объем памяти компьютера. Команда `docker info --format '{{.MemTotal}}'` сейчас, в байтах, покажет `docker info --format '{{.MemTotal}}'`. В WSL2 это
покажет доступный Docker объем в байтах. В WSL2 лимит задается параметром поднимается параметром `memory` в файле `.wslconfig` домашнего каталога
`memory` в файле `.wslconfig` в домашнем каталоге пользователя Windows. пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не
После изменения выполните `wsl --shutdown`. Если ресурсов компьютера хватает, стенд одинаково хорошо живёт на недорогом VPS.
недостаточно, стенд можно запустить на VPS.
По умолчанию должны быть свободны порты `make up` нужны `curl` и `jq`: первым он ходит в Airflow, вторым читает
`23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090` паспорт мира, по которому ждет заливки. По умолчанию должны быть свободны
и `29092`. Для работы с командами `make` в каталоге `generator/` также порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
понадобится `uv`. и `29092`. Всем целям генератора в `generator/` нужен `uv`.
В каталоге репозитория скопируйте образец настроек: Сначала скопируйте настройки стенда:
```bash ```bash
cp .env.example .env cp .env.example .env
```
В `.env` задаются имя экземпляра стенда, внешние порты, учебные логины,
пароли и ключи. Проверьте `DOCKER_GID`: это числовой идентификатор группы
`docker` на вашей машине. Узнать его можно командой `getent group docker`.
Если число отличается от образца, замените значение в `.env`. От этой
настройки зависит запуск генератора из Airflow.
Запустите стенд:
```bash
make up make up
``` ```
Команда запустит службы и дождется загрузки начальных данных. Порядок запуска `make up` сам ждет заливки стартового мира; что он делает по шагам и какие
и проверки результата описаны в [справочнике проверок](docs/architecture/testing.md). проверки есть у стенда, описано в [карте
Если обязательной настройки в `.env` не хватает, Compose сообщит об ошибке. проверок](docs/architecture/testing.md).
Внутренние адреса служб, имена топиков и версии образов заданы в `compose.yaml`
и Dockerfile.
Логины и пароли Postgres и Grafana сохраняются при первом запуске в томах `.env.example` — образец обязательной локальной настройки. В нём живут имя
Docker. Простая правка `.env` их не изменит. Чтобы задать новые значения экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца
через `.env`, потребуется `make clean` и повторный `make up`. При этом верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и
будут удалены все локальные данные стенда. свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь,
кроме [дагов модельного
времени](docs/architecture/world.md#даги-модельного-времени). `compose.yaml` не
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
Учётные данные Postgres и Grafana применяются при создании их томов.
После первого запуска меняйте их только вместе с `make clean`: команда удалит
все локальные данные стенда, а следующий `make up` создаст их с новыми
значениями.
## Адреса и пароли ## Адреса и пароли
С настройками из `.env.example` порты доступны только с локальной машины: С настройками из `.env.example` порты доступны только с локальной машины:
- узел ClickHouse 1 — `http://127.0.0.1:28123`, нативный порт `29000`; - нода 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
- узел ClickHouse 2 — `http://127.0.0.1:28124`, нативный порт `29001`; - нода 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
- Kafka — `127.0.0.1:29092`; - Kafka — `127.0.0.1:29092`;
- Airflow — `http://127.0.0.1:28080`, пользователь `admin`, пароль `airflow`; - Airflow — `http://127.0.0.1:28080`, пользователь `admin`, пароль `airflow`;
- Superset — `http://127.0.0.1:28088`, пользователь `admin`, пароль `superset`; - Superset — `http://127.0.0.1:28088`, пользователь `admin`, пароль `superset`;
- Prometheus — `http://127.0.0.1:29090`; - Prometheus — `http://127.0.0.1:29090`;
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`. - Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
Подробнее о службах, правах пользователей и изменении настроек — Службы, пользователи и роли, применение настроек и результаты сверки
в [справочнике стенда](docs/architecture/stand.md). конфигурации описаны в [справочнике стенда](docs/architecture/stand.md).
## Добавить данные за следующие дни ## Сдвинуть мир вперед
Время в учебном магазине идет отдельно от календарного. В документации оно В Airflow запустите даг `world_next_day`: по умолчанию он сыграет один день, а
называется модельным временем, а сам магазин с его посетителями и заказами — в форме запуска можно задать другое число дней. Снимите с паузы `world_live`,
миром. и мир поедет сам в темпе модельного времени. Подробности — в [справочнике
мира](docs/architecture/world.md#даги-модельного-времени).
В Airflow запустите `world_next_day`. Это даг — набор связанных задач, ## Вернуть мир к началу
которые Airflow выполняет в заданном порядке. Он создаст и загрузит данные
за следующий день работы магазина. В форме запуска можно задать число дней.
Чтобы новые данные поступали непрерывно, снимите с паузы даг `world_live`. `make rebuild-storage` запустит в Airflow явную пересборку: удалит прикладные
Он будет запускать генерацию день за днем; один модельный день занимает базы ClickHouse, топики и позиции потребителей Kafka, затем создаст схему
около 24 минут. Подробности — в [справочнике генератора и модельного и данные заново. Службы и их тома останутся на месте. Перед пересборкой
времени](docs/architecture/world.md#даги-модельного-времени). поставьте `world_live` на паузу и дайте уже начатому шаговому дагу закончить
день.
## Восстановить начальные данные ## Выключить или сбросить целиком
Если хотите начать заново, поставьте `world_live` на паузу и дождитесь `make down` остановит контейнеры без удаления данных. `make clean` удалит весь
завершения уже запущенного дага генерации. Затем выполните стенд вместе с именованными томами и для обычной работы не нужен.
`make rebuild-storage`.
Команда удалит прикладные базы ClickHouse, топики и позиции потребителей
Kafka, затем заново создаст схему хранилища и загрузит начальные данные.
Службы и их тома останутся на месте.
## Остановить или удалить стенд
`make down` остановит стенд и сохранит данные. Для повторного запуска
выполните `make up`.
`make clean` удалит контейнеры и все данные в томах Docker. Для обычного
завершения работы достаточно `make down`.
## Подключиться из DBeaver ## Подключиться из DBeaver
Драйвер ClickHouse в DBeaver подключается по JDBC через HTTP-порт. Укажите хост Драйвер ClickHouse в DBeaver подключается по JDBC через HTTP-порт. Укажите хост
`127.0.0.1`, порт `28123`, пользователя `analyst` и пароль из переменной `127.0.0.1`, порт `28123`, пользователя `analyst` и пароль из переменной
`CLICKHOUSE_ANALYST_PASSWORD` в `.env`; в образце это `analyst-26-reader`. `CLICKHOUSE_ANALYST_PASSWORD` в `.env`; в образце это `analyst-26-reader`.
Для подключения ко второму узлу укажите порт `28124`. Ко второй ноде — порт `28124`.
Пользователь `analyst` может читать данные во всех слоях и справочниках. Пользователь `analyst` читает все слои и справочники. Нативные порты `29000` и
Для создания учебных витрин используйте подключение, описанное `29001` нужны `clickhouse-client`, но не DBeaver. Остальные пользователи и роли
в [инструкции к заданиям](docs/labs/README.md#как-применить-свой-ddl). описаны в [справочнике
Нативные порты `29000` и `29001` нужны `clickhouse-client`, но не DBeaver. стенда](docs/architecture/stand.md#пользователи-и-роли-clickhouse).
Остальные пользователи и роли описаны в
[справочнике стенда](docs/architecture/stand.md#пользователи-и-роли-clickhouse).
## Документация ## Документация
- [docs/handbook/](docs/handbook/) — путеводитель по стенду и порядок чтения. - [docs/handbook/](docs/handbook/) — путеводитель по стенду и порядок чтения.
- [docs/labs/](docs/labs/) — учебные задания с порядком прохождения и - [docs/labs/](docs/labs/) — учебные задания с порядком прохождения и
самопроверкой. самопроверкой.
- [docs/specs/](docs/specs/) — требования к стенду и программа обучения. - [docs/specs/](docs/specs/) — спеки: источник истины о задуманном.
- [docs/adr/](docs/adr/) — решения об устройстве стенда: что выбрали, - [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми
какие варианты отклонили и почему. вариантами: почему сделано так, а не иначе.
- [docs/architecture/](docs/architecture/) — технические справочники: - [docs/architecture/](docs/architecture/) — рабочие справочники по зонам
- [хранилище](docs/architecture/storage.md) — правила именования, ответственности:
распределение данных по шардам, прием событий и список таблиц. - [хранилище](docs/architecture/storage.md) — конвенции имён, раскладка по
шардам, приём событий, карта таблиц.
- [конвейер](docs/architecture/etl.md) — движение данных между слоями и - [конвейер](docs/architecture/etl.md) — движение данных между слоями и
работа дагов. работа дагов.
- [мир и модельное время](docs/architecture/world.md) — начальные данные, - [мир и модельное время](docs/architecture/world.md) — начальные данные,
создание данных за следующие дни и управление генератором. ход дней и управление генератором.
- [стенд](docs/architecture/stand.md) — службы, доступ и настройка. - [стенд](docs/architecture/stand.md) — службы, доступ и настройка.
- [проверки](docs/architecture/testing.md) — команды `make` для проверки - [проверки](docs/architecture/testing.md) — что утверждает каждая цель
стенда и правила добавления новых проверок. `make` и куда класть новую проверку.
- [мониторинг](docs/architecture/monitoring/README.md) — сбор метрик и - [мониторинг](docs/architecture/monitoring/README.md) — сбор метрик и
устройство дашборда. устройство дашборда.
- [Superset](docs/architecture/superset.md) — подключения, наборы данных и - [Superset](docs/architecture/superset.md) — подключения, наборы данных и
автоматическая настройка при запуске. граница автоматической настройки.
- [docs/research/](docs/research/) — исследования; сейчас это формат - [docs/research/](docs/research/) — исследования; сейчас это формат
кликстрима Яндекса, по которому строится модель события. кликстрима Яндекса, по которому строится модель события.
- [docs/formats/](docs/formats/) — форматы исходных данных, необходимые - [docs/formats/](docs/formats/) — описания форматов источников: по ним
для их загрузки и обработки. Эти документы собираются из кода командой пишется сторона хранилища. Собираются из кода командой `make docs` в
`make docs` в `generator/`; вручную их менять не нужно. `generator/`, руками не правятся.
- [AGENTS.md](AGENTS.md) — правила работы в репозитории для ИИ-агентов. - [AGENTS.md](AGENTS.md) — контракт работы в репозитории.
+32 -24
View File
@@ -1,17 +1,18 @@
"""Трансформации DDS: заказы, сессии и карта идентичностей. """Трансформации слоя DDS: сборка моделей.
Запросы рассчитывают заказы и сессии за выбранные дни и записывают результат Заказ и сессия собираются заменой дневных партиций. Карта идентичностей
в промежуточные таблицы. Затем готовые дневные разделы этих таблиц заменяют пересчитывается целиком после заказа, потому что читает его текущую модель.
разделы основных таблиц. Пока идет расчет, читатели видят прежние данные.
Карта идентичностей пересчитывается целиком после заказов. Новые строки Расписания у дага нет и параметров тоже: объём каждого прогона он выводит сам
добавляются с меткой времени сборки; представление с FINAL выбирает последнюю из состояния слоёв и позиции мира, поэтому один и тот же запуск одинаково
версию каждой строки. верен из триггера дневного конвейера, с кнопки менти и после падения
(docs/architecture/etl.md, «Объём прогона»).
Даг запускается без расписания и параметров. Даты он выбирает по состоянию Заказ и сессия гасят повтор заменой партиции целиком: день собирается в
таблиц и позиции мира. Новый запуск заново определяет диапазон: наличие даты таблицу-двойник и подменяет собой партицию цели. Карта пишет полный набор пар
не подтверждает полноту дня и не гарантирует повтор его сборки после сбоя. новой версией, а точное чтение даёт FINAL. Замена партиции разобрана в
Подробности — docs/architecture/etl.md, «Объём прогона» и «Идемпотентность». sql/dds/order_replace.sql; точное чтение карты описано в
sql/ddl/50-dds-views.sql.
""" """
from __future__ import annotations from __future__ import annotations
@@ -256,11 +257,16 @@ def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
@task @task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]: def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Подготовить параметры отдельной задачи replace для каждого дня. """Разложить дни в аргументы размноженной задачи замены.
expand_kwargs получает список словарей и создает по одному экземпляру Шаг существует ради ограничения Airflow: размножать задачу можно
задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja: только по целому возврату другой задачи — ни по ключу словаря, ни по
параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается. карте поверх него. Отсюда отдельный возврат под один список.
День едет в запрос подстановкой Jinja, а не параметром ClickHouse:
в ALTER … PARTITION сервер параметр не подставляет — замерено,
отвечает синтаксической ошибкой. Значение при этом не внешнее, его
вернул сам ClickHouse запросом объёма.
""" """
return [{"params": {"day": day}} for day in scope["days"]] return [{"params": {"day": day}} for day in scope["days"]]
@@ -270,8 +276,8 @@ def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
schedule=None, schedule=None,
start_date=START_DATE, start_date=START_DATE,
is_paused_upon_creation=False, is_paused_upon_creation=False,
# Одновременные запуски используют одни промежуточные таблицы. # Два прогона разом собирали бы один и тот же двойник: второй затёр бы
# Второй мог бы очистить результат первого до замены партиций. # чужую сборку между вставкой и заменой партиций.
max_active_runs=1, max_active_runs=1,
template_searchpath=str(SQL_ROOT), template_searchpath=str(SQL_ROOT),
tags=["dds"], tags=["dds"],
@@ -292,7 +298,7 @@ def dds_transform():
@task_group(group_id="order") @task_group(group_id="order")
def order(): def order():
"""Заказ: изменяемые дни последнего слепка и пропущенные дни.""" """Заказ: дышащее окно плюс дни, которых в слое ещё нет."""
@task @task
def scope() -> dict[str, object]: def scope() -> dict[str, object]:
@@ -300,7 +306,8 @@ def dds_transform():
query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8") query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8")
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION) hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query) row = hook.get_first(query)
# Без заказов ODS расчет невозможен: задача сообщает об ошибке. # Пустой ответ означает пустой источник: собирать нечего, и
# зеленеть на этом нельзя — прогон встаёт с внятной причиной.
if row is None: if row is None:
raise AirflowException( raise AirflowException(
"в ods.order нет ни одного заказа: собирать нечего; " "в ods.order нет ни одного заказа: собирать нечего; "
@@ -335,9 +342,9 @@ def dds_transform():
do_xcom_push=False, do_xcom_push=False,
) )
# На каждый день создается отдельная задача замены партиции. # Замена идёт по партиции за раз — другой формы у операции нет. День
# Неудачную замену можно повторить, пока промежуточная таблица # становится отдельной задачей: в интерфейсе видно, сколько дней несёт
# сохраняет рассчитанные строки этого запуска. # прогон, а повтор после отказа стоит одного дня, а не всей пересборки.
replace = SQLExecuteQueryOperator.partial( replace = SQLExecuteQueryOperator.partial(
task_id="replace", task_id="replace",
conn_id=CLICKHOUSE_CONNECTION, conn_id=CLICKHOUSE_CONNECTION,
@@ -363,8 +370,9 @@ def dds_transform():
"проверьте, дошёл ли поток до хранилища" "проверьте, дошёл ли поток до хранилища"
) )
first_day, last_day = row first_day, last_day = row
# Для каждого прожитого дня уже есть строки сессий. # Пропуск, а не отказ: слой полон по последний прожитый день, а
# Новых дней для расчета нет: группа задач будет пропущена. # сегодняшний ещё идёт. Это обычное состояние повторного прогона,
# и в интерфейсе оно видно пропущенной группой.
if first_day is None: if first_day is None:
raise AirflowSkipException( raise AirflowSkipException(
f"сессии собраны по {last_day} включительно, " f"сессии собраны по {last_day} включительно, "
+8 -12
View File
@@ -1,12 +1,9 @@
"""Трансформации слоя DM: сборка стендовых витрин. """Трансформации слоя DM: сборка стендовых витрин.
Даты для расчета выручки берутся из последнего слепка заказов и списка Выручка наследует ритм заказов. Сверка соединяет окно заказов с прожитым
пропущенных дней. Сверка покупок с заказами учитывает также прожитые дни хвостом событий. Обе заменяют дневные партиции. Трафик наследует
событий и дни, где заказ еще не найден. Запросы записывают результаты ретроспективность карты идентичностей и пересчитывает всю историю новой
в промежуточные таблицы, затем заменяют дневные разделы основных таблиц. версией. Объем каждого прогона выводится из состояния слоев.
Трафик пересчитывается за всю историю: новая связь посетителя с пользователем
магазина меняет число известных пользователей и за прошлые дни.
""" """
from __future__ import annotations from __future__ import annotations
@@ -65,11 +62,10 @@ def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
@task @task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]: def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Подготовить параметры отдельной задачи replace для каждого дня. """Разложить дни в аргументы размноженной задачи замены.
expand_kwargs получает список словарей и создает по одному экземпляру Отдельный словарь нужен каждому экземпляру ``expand_kwargs``; тот же
задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja: прием разобран в ``dds_transform.py`` на замене партиций заказов.
параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается.
""" """
return [{"params": {"day": day}} for day in scope["days"]] return [{"params": {"day": day}} for day in scope["days"]]
@@ -145,7 +141,7 @@ def dm_transform():
@task_group(group_id="purchase_vs_orders") @task_group(group_id="purchase_vs_orders")
def purchase_vs_orders(): def purchase_vs_orders():
"""Сверка: изменяемые заказы, прожитые покупки и дни ожидания заказа.""" """Сверка: окно заказов, прожитый хвост и предварительные дни."""
@task @task
def scope() -> dict[str, object]: def scope() -> dict[str, object]:
+5 -5
View File
@@ -109,28 +109,28 @@ def homework_check():
"""Дать отдельный результат самопроверки по каждой домашней витрине.""" """Дать отдельный результат самопроверки по каждой домашней витрине."""
check_mart.override(task_id="events_enriched")( check_mart.override(task_id="events_enriched")(
"dm.events_enriched_v", "dm.events_enriched_v",
"docs/labs/1-1-events-enriched.md", "docs/labs/events-enriched.md",
"events_enriched_vs_dds.sql", "events_enriched_vs_dds.sql",
"events_enriched_scope.sql", "events_enriched_scope.sql",
) )
check_mart.override(task_id="top_pages_daily")( check_mart.override(task_id="top_pages_daily")(
"dm.top_pages_daily_v", "dm.top_pages_daily_v",
"docs/labs/1-2-top-pages-daily.md", "docs/labs/top-pages-daily.md",
"top_pages_daily_vs_dds.sql", "top_pages_daily_vs_dds.sql",
) )
check_mart.override(task_id="session_overview")( check_mart.override(task_id="session_overview")(
"dm.session_overview_v", "dm.session_overview_v",
"docs/labs/1-3-session-overview.md", "docs/labs/session-overview.md",
"session_overview_vs_dds.sql", "session_overview_vs_dds.sql",
) )
check_mart.override(task_id="dq_errors_daily")( check_mart.override(task_id="dq_errors_daily")(
"dm.dq_errors_daily_v", "dm.dq_errors_daily_v",
"docs/labs/1-4-dq-errors-daily.md", "docs/labs/dq-errors-daily.md",
"dq_errors_daily_vs_ods.sql", "dq_errors_daily_vs_ods.sql",
) )
check_mart.override(task_id="utm_effectiveness")( check_mart.override(task_id="utm_effectiveness")(
"dm.utm_effectiveness_v", "dm.utm_effectiveness_v",
"docs/labs/1-5-utm-effectiveness.md", "docs/labs/utm-effectiveness.md",
"utm_effectiveness_vs_dds.sql", "utm_effectiveness_vs_dds.sql",
) )
+85 -92
View File
@@ -1,124 +1,117 @@
# Модель сессии в DDS # Модель сессии в DDS
Сессия объединяет события одного визита. На стенде вы учитесь определять Учебный результат: менти строит расчётную сущность, которой нет в потоке, —
ее границы по событиям, вычислять устойчивый идентификатор и сверять режет события на сессии по правилу визита, чеканит идентификатор и сверяет
результат с эталоном. Поэтому сборка читает `dds.event_v`, где нет готового свою нарезку с эталоном. Стенд здесь играет роль сессионизатора: в бою её
`VisitID`. Этот идентификатор источника остается в ODS для проверки. играет Метрика, которая режет визиты у себя и с октября 2025 отдаёт готовый
`VisitID` прямо в хитах. Поэтому стенд считает сессии сам, а `VisitID`
генератора использует только как эталон самопроверки — иначе сборка сессий
выродилась бы в `GROUP BY` по чужому полю и центральный урок
слоя пропал бы. Без этого абзаца действия стенда выглядят странно: «зачем
считать то, что уже дано колонкой».
## Зерно ## Зерно
Зерно описывает, чему соответствует одна строка: здесь это один Одна строка — один визит ([CONTEXT.md](../../../CONTEXT.md)): подряд идущие
[визит](../../../CONTEXT.md#люди-и-события). В него входят подряд идущие события одной куки без пауз длиннее 30 минут, не пересекающие границу суток.
события одного `client_id` без пауз длиннее 30 минут и без перехода через В нарезку входят все три типа событий — торговые садятся на свои страницы
модельную полночь. Пауза ровно в 30 минут новую сессию не открывает. и держат сессию наравне с просмотрами. События внутри сессии
Учитываются все три типа событий: `pageview`, `add_to_cart`, `purchase`. упорядочиваются по метке времени события (`UTCEventTime` — опорная колонка
приёма).
День сессии `session_date` берется из `event_date` событий DDS — это Граница суток проходит по модельному дню счётчика, и он у события уже есть:
`EventDate` источника, дата по часовому поясу счетчика. У всех событий `EventDate` — свойство содержимого. Раз сессия сутки не пересекает, у всех
одной сессии она одинакова. Пересчитывать ее из времени UTC не нужно. её событий `EventDate` один; он и становится **днём сессии** — колонкой
партиции, без пересчёта.
SQL рассчитывает границы визитов двумя оконными функциями: первая отмечает
начало нового визита, вторая по этим отметкам присваивает событиям номер
визита. Обе используют порядок `(event_time, event_id)`: при равном времени
события идут по идентификатору. Иначе два расчета могли бы по-разному
упорядочить события и отнести отметку начала к разным строкам.
## Идентификатор ## Идентификатор
`session_id` вычисляется как `cityHash64(client_id, min(event_time))`. Идентификатор сессии в природе не существует: сессия — расчётная сущность,
При повторной сборке тех же событий посетитель и начало визита остаются и id ей чеканит тот, кто сессионизирует. `VisitID` — такой же суррогат,
прежними, поэтому идентификатор не меняется. Счетчик или случайный UUID отчеканенный генератором в роли Метрики; наш — `session_id`,
выдали бы другие значения. детерминированный хеш от пары «кука + начало сессии» (`cityHash64`).
Детерминированность обязательна: партиция дня пересобирается заменой, и
повторный прогон обязан отчеканить те же id — счётчик или UUID это правило
ломают.
По `session_id` можно сопоставить сессии до и после пересборки и назвать `session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка:
конкретную строку при разборе расхождения. Он не используется для сортировки в соединениях он не участвует — события его не несут, мост к заказам живёт
или шардирования. У событий этого поля нет; связь с заказами строится на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со
по событиям покупки. стендовой и назвать расхождение одним числом) и разбор DQ-расхождений.
## Состав строки ## Состав строки
- `client_id`, `session_id`, `session_date` — посетитель, сессия и ее день. Боевой образец — таблица визитов Метрики, парная к нашему формату хита; по
- `started_at`, `finished_at`, `duration_seconds` — время первого и последнего ней состав и собран:
события и разница между ними в секундах. Времена хранятся в часовом поясе
счетчика.
- `events_total`, `pageviews`, `cart_adds`, `purchases` — число всех событий
и счетчики трех типов.
- `entry_url`, `exit_url` — страницы первого и последнего события.
- `utm_*` — метки первого события: по ним визит относят к рекламной кампании.
- `device_category`, `region_city` — устройство и город посетителя.
На стенде они постоянны для одной куки.
- `_load_id`, `_load_ts` — идентификатор запуска и время сборки строки.
В готовом SQL страницы выбираются через `argMin` и `argMax`, а UTM, - **ключи и времена**: кука, `session_id`, начало и конец сессии,
устройство и город — через `argMin`, только по `event_time`. При одинаковом длительность, день сессии. Времена — в поясе счётчика по правилу слоя;
времени выбор между событиями не определен. Полный порядок окон нарезки - **счётчики**: событий всего, просмотров, корзин, покупок;
сам по себе эту неоднозначность не устраняет. Это ограничение учитывается - **страницы**: входная и выходная — как `StartURL`/`EndURL` визита; полного
в [задании ступени 4](../../labs/4-1-session-pipeline.md). списка страниц нет и у Метрики, топы считаются по событиям;
- **атрибуция**: UTM-метки первого события сессии — так визит атрибутирует
Метрика. В v1 витрина брала последнее касание — заменено осознанно;
- **паспорт куки**: категория устройства (расшифрованная, язык бизнес-модели)
и город — у куки они постоянны, берутся с первого события;
- **служебные**: `_load_id` и `_load_ts` собравшего запуска — правило слоя
([модель заказа](order.md), «Служебные колонки»).
В сессии нет суммы выручки: ее считают по заказам магазина. Клиентская Осознанные расхождения с образцом: **денег в сессии нет** — визит Метрики
сумма покупки используется в сверке с заказами и в учебной UTM-витрине. несёт клиентскую выручку, у нас деньги считает только бэкенд (спека, раздел
7), а клиентская сумма живёт в сверке; **целей нет** — их место заняли
счётчики торговых событий.
## Движок и наполнение ## Движок и наполнение
`dds.session_rep` хранит готовые сессии в `ReplicatedMergeTree` без колонки `dds.session_rep` — `ReplicatedMergeTree` без колонки версии, по образцу
версии. При чтении не нужно заново выполнять оконный расчет. Партиция — заказа: партиция — день сессии, ключ сортировки — кука и начало сессии.
раздел таблицы за один `session_date`; ключ сортировки — посетитель `dds.session_dist` шардирует по `cityHash64(client_id)`, тем же выражением,
и начало сессии. Читайте результат через `dds.session_v`: представление что события, — это ко-локация с `dds.identity_map_rep` ([дока
над `dds.session_dist` явно перечисляет деловые и служебные колонки. хранилища](../storage.md), «Раскладка по шардам»).
Совпадение ключа с событиями не случайно: все события куки лежат на одном
шарде, поэтому сессионизация локальна по природе — капкан смены ключа при
вставке, который дока хранилища обещает сборкам DDS, у сессий не стреляет.
Источник сборки — `dds.event_v`. События, сессии и карта идентичностей Сборка — дневной батч: хранилище выше ODS по смыслу батчевое, раз второй
распределены по одному ключу `cityHash64(client_id)`: данные посетителя источник приходит раз в сутки. Собирается день, который мир уже прожил, и
находятся на одном шарде. Роли объектов и правило записи через `_dist` собирается один раз и целиком; какой день прожит, слой спрашивает у позиции
описаны в [справочнике хранилища](../storage.md#раскладка-по-шардам). мира ([дневной конвейер](../etl.md), «Объём прогона»). Идемпотентность держит
замена партиции. В отличие от заказов, где батч пересобирает до K «дышащих»
дней, у сессии дышащего окна нет: прожитый день самодостаточен (правило
спеки, раздел 6). Второй раз он соберётся только
вынужденно — когда упавшая замена оставила позади дыру и хвост от неё накрыл
соседние дни. Дней в одном прогоне при этом бывает несколько — столько,
сколько мир успел прожить без слоя. Внутри живого дня
модель сессии про сегодня честно молчит — «сегодня» смотрят через
`dds.event_v`; сессия и по смыслу не финальна, пока день не прожит.
Три SQL-файла выполняются последовательно в группе `session` дага Публичная поверхность — `dds.session_v`: обычное представление с явным
`dds_transform`: списком деловых и собственных служебных колонок над `dds.session_dist`.
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
объектов».
1. `session_scope.sql` находит первую дату событий, за которую еще нет Источник сборки — `dds.event_v` ([контракт события](event.md)): сессии
сессий. Последний день для расчета — первый день событий ODS нужны от него ключи нарезки, тип события, страница, UTM и паспорт куки.
плюс `world_position` минус один; `world_position` — номер следующего Механика запуска — где собирается партиция и как подменяется на кластере —
дня для генерации. Так в расчет попадают только полностью не входит: это устройство [дневного конвейера](../etl.md).
прожитые дни. При пустом ODS запрос не возвращает строк и задача падает;
если пропущенных прожитых дней нет, `first_day` равен `NULL` и группа
пропускается.
2. `session_rebuild.sql` очищает промежуточную таблицу
`dds.session_stage_rep`. Затем рассчитывает сессии выбранных дней
и записывает строки через `dds.session_stage_dist`. Пока идет расчет,
в основной таблице остаются прежние данные. Вставка ждет доставки
строк на шарды благодаря `distributed_foreground_insert = 1`.
3. `session_replace.sql` заменяет в `dds.session_rep` раздел за каждый
выбранный день готовым разделом `dds.session_stage_rep`. Для каждого
дня Airflow создает отдельную задачу `replace`.
Когда все события завершенного дня приняты в ODS, их набор на стенде
больше не меняется. Поэтому готовые сессии обычно не пересчитываются. Если пропущен ранний
день, диапазон включает и все последующие прожитые дни, даже уже собранные.
Если за день сохранилась хотя бы одна сессия, поиск пропущенных дней
не обнаружит потерю остальных. Условия повторного запуска и замены
разделов описаны в [справочнике конвейера](../etl.md#идемпотентность).
Сессий еще идущего дня в модели нет; его события доступны через
`dds.event_v`. Перед выбором дней даг ждет завершения доставки событий
в ODS — см. [готовность источника](../etl.md#готовность-источника).
## Сверка с эталоном ## Сверка с эталоном
Проверка `sessions_vs_reference` в `dq_check` сравнивает для каждого Счётная DQ-проверка с зерном «кука × день»: число сессий нашей нарезки
посетителя и дня число строк `dds.session_v` с `uniqExact(VisitID)` равно `uniqExact(VisitID)` по тем же событиям. Счёт по Distributed ловит и
из `ods.event_v`. Эталон читается из ODS, потому что `dds.event_v` склеенную, и разрезанную сессию — мимо него проходит лишь взаимная
скрывает `VisitID`. компенсация ошибок внутри одной куки за день; эталон в модель не тащим —
строка сессии остаётся нашей, а не генераторной. Поштучное сопоставление
Нулевое число расхождений подтверждает только число сессий каждого границ — материал лабы, где менти разбирает найденное расхождение руками. Дом проверки —
посетителя за день. Например, ошибочное объединение двух визитов контур DQ этапа 4. Сверке нужен `VisitID`, а сборке сессий — нет; контракт
и разделение другого на два сохранят их общее число. Поэтому в заданиях `dds.event_v` его скрывает, и за эталоном проверка идёт в `ods.event_v`
вы дополнительно сопоставляете границы сессий по исходным событиям и проверяете страницы, ([контракт события](event.md)).
UTM, устройство и город.
## Читатели ## Читатели
- Витрина обзора сессий `session_overview_v`: дневные итоги визитов в - Витрина обзора сессий `session_overview_v`: дневные итоги визитов в
срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод
в ее [ТЗ](../../labs/1-3-session-overview.md) (#135). в ее [ТЗ](../../labs/session-overview.md) (#135).
- DQ-сверка с эталоном (см. выше). - DQ-сверка с эталоном (см. выше).
- Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда, - Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда,
потом сравнивает — с эталоном и со стендовой. потом сравнивает — с эталоном и со стендовой.
+145 -175
View File
@@ -1,202 +1,176 @@
# Слой DM: витрины # Слой DM: витрины
Витрины готовят данные DDS для отчетов и дашбордов. Здесь описаны форма Как модель DDS превращается в витрины для потребителя BI: какую форму
хранения, правила обновления и проверки. Состав колонок задан в принимает витрина, кто какие витрины строит и как слой проверяется. Состав
[спецификации](../specs/2026-07-30-stand-v2-realism.md#витрины-dm) колонок каждой витрины задаёт раздел 7 мастер-спеки; здесь — форма,
и [учебных заданиях](../labs/README.md). наполнение и контроль.
## Таблица или представление ## Форма: по зерну, не по цене запроса
Зерно — то, чему соответствует одна строка. Например, в заказах это заказ, Витрина, меняющая зерно источника — дневной агрегат, сверка по заказам, —
а в дневной выручке — день и категория товара. На стенде результат материализуется: `dm.<имя>_rep` / `_dist` и публичное `dm.<имя>_v` с явным
с новым зерном хранится в таблице: отчет читает готовые итоги без списком колонок, по общим [конвенциям хранилища](storage.md). Наполняет её
повторного расчета. Таблицу наполняет `dm_transform`. `dm_transform`; ритм и механика пересборки — ниже. Витрина, сохраняющая зерно
источника — переименование, обогащение строки, — остаётся обычным
представлением: копию событий ради других имён колонок не держит ни один
боевой склад.
У такой витрины есть локальная `_rep`, распределенная `_dist` и публичное Правило выбрано учебной целью, а не ценой запроса. На объёмах стенда
представление `_v` с явным списком колонок. Для обновления по дням представлениями обошлось бы всё: условия сквозь цепочку представлений
нужна промежуточная таблица `_stage_rep`, в которую пишут через `_stage_dist`. проходят ([исследование](../research/2026-08-22-view-predicate-pushdown.md)).
В нее записывают рассчитанные строки, пока основная таблица хранит прежние. Но пересборка агрегатов конвейером — производственный паттерн, ради
Затем дневные разделы основной таблицы заменяют готовыми разделами которого стенд и строится, а пара «таблица рядом с представлением»
промежуточной. Такие разделы называются партициями. Подробности — в показывает: форма — решение по роли витрины, у одного слоя законны обе.
[справочнике хранилища](storage.md#имена-объектов).
Если запрос только переименовывает или дополняет поля и сохраняет одну ## Кто строит: стенд и менти
строку на событие, достаточно обычного представления. Оно выполняет
запрос при чтении, не хранит вторую копию событий и не требует пересборки.
На небольших данных стенда и агрегаты могли бы вычисляться при чтении Стенд привозит готовыми витрины трёх сюжетов дашборда этапа 8 и
([исследование](../research/2026-08-22-view-predicate-pushdown.md)). инфраструктуру контроля:
Готовые таблицы нужны в программе для практики загрузки, обновления
и включения витрин в конвейер.
## Что готово в стенде, а что остаётся заданием - `revenue_daily` — выручка по дням и категориям; образец материализованной
витрины, на него ссылаются учебные задания;
- `purchase_vs_orders` — сверка A+C с классами расхождений; зерно — заказ,
шардирование сохраняет ко-локацию заказов по `cityHash64(order_id)`;
- `daily_traffic` — пара «посетители» / «известные пользователи», обогащение
[картой идентичностей](dds/identity.md);
- `dq_summary` — сводка проверок качества (ниже).
Готовы три витрины дашборда и сводка качества: Остальные витрины спеки — учебные задания: менти строит их сам по ТЗ с
образцом. Разминка — представление `events_enriched_v` (зерно события),
затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`,
последней — самая содержательная `utm_effectiveness` с уроком `declared_*`.
Построенная витрина встаёт в конвейер руками менти: финальный пункт ТЗ
каждого агрегата — добавить задачу своей витрины в `dm_transform`. До этого
конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается
наравне со стендовыми. У разминочного представления такого пункта нет:
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
- `revenue_daily` — одна строка на день заказа и категорию товара; Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит
образец дневной пересборки для учебных заданий. от выполненных домашек. Свой дашборд по витринам менти —
- `purchase_vs_orders` — одна строка на заказ: сопоставление покупки [необязательная лаба](../labs/superset-dashboard.md) после этапа 8. Устройство
из кликстрима с заказом магазина и класс расхождения. стендового дашборда — в [доке зоны Superset](superset.md).
- `daily_traffic` — одна строка на день: число посетителей и известных
пользователей с учетом [карты идентичностей](dds/identity.md).
- `dq_summary` — одна строка на день данных и проверку качества.
В [заданиях ступени 1](../labs/README.md) вы строите `events_enriched_v`,
`top_pages_daily`, `session_overview`, `dq_errors_daily` и `utm_effectiveness`.
Для каждой физической витрины нужно добавить сборку в `dm_transform`.
Представление `events_enriched_v` отдельной задачи не требует.
Стендовый дашборд читает готовые витрины и работает до выполнения заданий.
Дашборд по своим витринам можно собрать в
[факультативе](../labs/optional/superset-dashboard.md) после ступени 2.
Устройство стендового дашборда — в [справочнике Superset](superset.md).
## Ритм пересборки ## Ритм пересборки
Дни для пересчета выбирают по тому, как меняется источник: День витрины пересобирается, пока может измениться его источник, —
поэтому ритмов три, по числу источников:
- События и сессии прожитого дня на стенде больше не меняются. Витрины - **от событий и сессий** — день замерзает сразу; пересборка догоняет
по ним рассчитывают показатели от первого пропущенного дня до последнего хвост несобранных дней заменой дневных партиций;
прожитого и заменяют дневные партиции. - **от заказов** — день дышит, пока входит в последний слепок; пересборка
- Заказы могут меняться в течение семи модельных дней. Последний слепок ODS заменяет партиции этого отрезка, дни за ним закрыты по построению;
задает диапазон дат для пересборки; пропущенные дни могут расширить его. - **от карты идентичностей** — не замерзает никогда: склейка
- Новая связь в карте идентичностей позволяет определить пользователя ретроспективна, покупка сегодня делает куку известной во всех её прошлых
по его прежним визитам: один `client_id` мог встречаться и до покупки, днях, а карта пересчитывается целиком.
которая связала его с `user_id`.
Поэтому `daily_traffic`, как и карта, пересчитывается целиком.
`daily_traffic` каждый раз добавляет всю историю новой версией Поэтому `daily_traffic` наследует у своего источника и ритм, и механику:
в `ReplacingMergeTree(_load_ts)`. Дневных партиций у нее нет, а текущие полный пересчёт всех дней каждый прогон в `ReplacingMergeTree(_load_ts)`,
строки выбирает представление через `FINAL`. как у самой карты. Дневных партиций у неё нет: партиция — единица замены,
а эта витрина заменяется только целиком. На стенде такая работа остается
быстрой; витрина читает сессии и карту через их публичные договоры.
Пересчитывать точечно только затронутые дни не из чего: для этого надо
знать, какие связки новые, а полный пересчёт карты этот след затирает —
восстановление следа оставлено лабе повышенной сложности (issue #149).
У `purchase_vs_orders` два источника. Начало диапазона — самая ранняя У `purchase_vs_orders` два источника и одна непрерывная пересборка. Заказная
из трех дат: первая дата оформления заказа в последнем слепке, первый сторона задает изменяемое окно последним принятым слепком ODS, событийная —
пропущенный день и первый день с `awaiting_order` — покупкой, для которой последний прожитый день с `purchase`. Левая граница возвращает первую дыру и
заказ пока не поступил. День ожидания проверяется отдельно: строки за него самый ранний день с `awaiting_order`: предварительная строка уже есть в
уже есть в витрине, поэтому он не считается пропущенным. витрине, поэтому поиск отсутствующего дня ее не заметит. Правая граница
Конец диапазона — более поздняя из последней даты заказов в слепке и последнего доходит до более позднего из двух хвостов. Так один прогон догоняет пакетный
прожитого дня с покупкой. Один запуск пересобирает весь отрезок, разгон длиннее K и не оставляет старое ожидание замороженным.
даже если с предыдущего запуска прошло больше семи модельных дней.
Для учебной сводки брака действует отдельное правило: дни выбираются
по времени загрузки, а прежние итоги сохраняются после удаления
источника по TTL. Оно разобрано в
[задании `dq_errors_daily`](../labs/1-4-dq-errors-daily.md).
## Витрины стенда ## Витрины стенда
Представления `_v` явно перечисляют поля для отчетов, а также `_load_id` Публичные представления перечисляют деловые колонки и координаты запуска
и `_load_ts` — идентификатор запуска и время записи. Новая колонка `_load_id`, `_load_ts` явно. Новая колонка физической таблицы сама в договор не
физической таблицы появится в представлении только после изменения его SQL. попадет.
### `dm.revenue_daily_v` ### `dm.revenue_daily_v`
Одна строка содержит итоги оплаченных заказов за день по категории товара. Зерно: день заказа × категория товара. Витрина читает только оплаченные
Запрос читает `dds.order_v` и разворачивает массивы артикулов, количеств заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает
и цен в строки позиций заказа. Артикул, количество и цена с одним индексом категорию артикула через `dic.products`. Один заказ с несколькими позициями
становятся одной строкой. Категория артикула берется из `dic.products`. категории считается в `orders` один раз. `units` складывает количество единиц,
`revenue` складывает цену позиции × количество. Скидка, доставка и итог заказа
между категориями не делятся. `aov` равен `revenue / orders` в том же срезе и
хранит два знака после запятой.
`orders` считает разные заказы: несколько позиций одной категории Первый запуск строит всю доступную историю. Следующие берут границы дней из
в заказе не увеличивают этот счетчик. `units` — сумма количества товаров, последнего слепка ODS: только источник знает, какие даты еще может изменить.
`revenue` — сумма произведений цены на количество. Скидка и доставка Пропущенный оплаченный день расширяет левую границу, чтобы частичный отказ
в нее не входят. `aov` равен `revenue / orders` для того же дня и категории не оставил дыру после следующего прогона. Агрегат при этом читает DDS; ODS
с округлением до двух знаков после запятой. отвечает только на вопрос об объеме.
Первый запуск рассчитывает выручку за всю доступную историю. Следующие `dm.revenue_daily_rep` нарезана по `report_date`; день собирается в
берут диапазон дат оформления заказов из последнего слепка ODS. таблице-доноре и заменяется через `REPLACE PARTITION`. Цель и донор шардируются
Если в DDS есть оплаченные заказы за день, отсутствующий в витрине, по `cityHash64(product_category)`. Кроме дня, у витрины только категория, а
левая граница расширяется до него. Агрегат читает DDS; ODS отвечает шесть категорий стартового мира близки по объему. Более сложный составной ключ
только на вопрос об объеме. распределил бы эту маленькую таблицу без нового урока.
В группе `revenue_daily` дага `dm_transform` три шага:
1. [`revenue_daily_scope.sql`](../../sql/dm/revenue_daily_scope.sql)
возвращает `first_day` и `last_day`. Без заказов в ODS или DDS
запрос не возвращает строк, и задача завершается ошибкой.
2. [`revenue_daily_rebuild.sql`](../../sql/dm/revenue_daily_rebuild.sql)
очищает `dm.revenue_daily_stage_rep`, рассчитывает показатели за выбранные
дни и записывает строки через `dm.revenue_daily_stage_dist`.
Вставка ждет доставки строк на шарды.
3. [`revenue_daily_replace.sql`](../../sql/dm/revenue_daily_replace.sql)
заменяет в `dm.revenue_daily_rep` каждую дневную партицию `report_date`
готовой партицией `dm.revenue_daily_stage_rep`.
Повтор замены не добавляет копий строк. Ограничения при частичном отказе
описаны в [конвейере](etl.md#идемпотентность).
Основная и промежуточная таблицы распределяют строки по
`cityHash64(product_category)`. На каждый день приходится не больше шести
категорий стартового мира. Для такой маленькой таблицы достаточно простого
ключа по категории.
### `dm.purchase_vs_orders_v` ### `dm.purchase_vs_orders_v`
Одна строка сопоставляет события покупки с заказом магазина. События Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по
из `dds.event_v` группируются по номеру заказа `purchase_id[1]`: `purchase_id[1]`: контракт источника обещает один номер и одну объявленную
по формату источника у покупки один номер и одна заявленная сумма. сумму, а отдельный счет событий оставляет класс `duplicate_event`
Отдельный счет событий позволяет обнаружить повтор покупки наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны
и присвоить класс `duplicate_event`. Заказы берутся из `dds.order_v`. подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда
Полное соединение `FULL OUTER GLOBAL JOIN` сохраняет и покупки без заказа,
и заказы без покупки. Обе стороны записаны подзапросами: при прямом чтении
левой Distributed-таблицы каждый непарный заказ повторился бы по числу шардов
([исследование](../research/2026-08-22-distributed-join.md)). ([исследование](../research/2026-08-22-distributed-join.md)).
Если для строки нет пары, поля отсутствующей стороны получают `NULL` Непарная сторона остается `NULL` благодаря `join_use_nulls = 1`. При
благодаря `join_use_nulls = 1`. При `join_use_nulls = 0` они заполнились бы `join_use_nulls = 0` ClickHouse заполнил бы ее нулем или пустой строкой и скрыл
нулями или пустыми строками, которые сложнее отличить от данных источника. отсутствие источника. Клиентская `declared_revenue` и серверная `items_total`
Клиентская `declared_revenue` и серверная `items_total` сравниваются сравниваются как `Decimal(18, 2)`. Классы проверяются по порядку:
как `Decimal(18, 2)`. Классы проверяются по порядку:
`awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`, `awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`,
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без `duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
события остается отмененным, а не потерянным. события остается отмененным, а не потерянным.
Партиции витрины разделены по `order_day`. Основная и промежуточная таблицы Витрина нарезана по `order_day`, цель и донор шардируются по
распределяют строки по `cityHash64(order_id)`. Запрос рассчитывает весь `cityHash64(order_id)`. Диапазон собирается в доноре одной вставкой и
выбранный диапазон и одной вставкой записывает строки в промежуточную заменяется по дням. Первичная сборка охватывает всю историю обоих источников;
таблицу. Затем ее дневные разделы заменяют разделы основной таблицы. следующие прогоны идут по двустороннему ритму, описанному выше.
Первичная сборка охватывает всю историю обоих источников;
следующие прогоны выбирают дни по правилам обоих источников, описанным выше.
### `dm.daily_traffic_v` ### `dm.daily_traffic_v`
В строке один день. `visitors` считает разные `client_id` из В строке один день. `visitors` считает разные `client_id` из
`dds.session_v`; `known_users` считает разные `user_id` после левого соединения `dds.session_v`; `known_users` считает разные `user_id` после левого соединения
с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине. с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине.
Несколько кук, связанных с одним пользователем магазина, учитываются Несколько кук одного человека увеличивают `visitors`, но дают одного
в `visitors` по отдельности, а в `known_users` дают одного пользователя. `known_user`, поэтому `known_users <= visitors`.
Поэтому `known_users <= visitors`.
Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3 Сессии и карта лежат по `client_id`, но публичные `_v` скрывают
чтение через публичные `_v` с `distributed_product_mode = 'local'` `Distributed`-таблицы от локальной подстановки. На ClickHouse 26.3
не дало локального соединения. Поэтому в запросе явно указан `EXPLAIN PLAN` показал чтение обоих шардов на инициаторе: настройка
`GLOBAL LEFT JOIN`. Он сохраняет сессии без известного пользователя; `distributed_product_mode = 'local'` локального соединения не дала. Поэтому
в `known_users` учитываются только ненулевые `user_id`. витрина использует явный `GLOBAL LEFT JOIN` через публичные договоры, а не
Обновление всей истории новой версией описано в разделе подменяет их физическими таблицами. Это дороже по сети, зато граница слоев
[«Ритм пересборки»](#ритм-пересборки). остается явной. Новая связь куки меняет прошлые дни, поэтому каждый запуск
пишет полную историю новой версией в `ReplacingMergeTree(_load_ts)`, а
публичное представление выбирает текущие строки через `FINAL`.
## `dm.dq_summary` ## `dm.dq_summary`
Одна строка `dm.dq_summary_v` — день данных и одна проверка. Зерно публичного `dm.dq_summary_v` — «день данных × атомарная проверка».
Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow. Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`. Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
Деловые колонки сводки: Деловые колонки сводки:
- `data_date` — проверяемый день; - `data_date` — проверяемый день;
- `check_name` — имя проверяемого правила; - `check_name` — устойчивое имя инварианта;
- `reference_rows` — число деловых ключей на эталонной стороне; - `reference_rows` — число деловых ключей на эталонной стороне;
- `actual_rows` — число деловых ключей в проверяемом объекте; - `actual_rows` — число деловых ключей в проверяемом объекте;
- `failed_rows` — число отсутствующих или различающихся ключей; - `failed_rows` — число отсутствующих или различающихся ключей;
- `status` — `pass` при нуле нарушений, иначе `fail`. - `status` — `pass` при нуле нарушений, иначе `fail`.
В `_load_id` и `_load_ts` записаны идентификатор запуска и время расчета Служебные `_load_id` и `_load_ts` называют собравший запуск. Представление
сводки. Даже одно расхождение дает `fail`. перечисляет колонки явно. Порогов и промежуточного состояния нет.
## Звено DQ конвейера ## Звено DQ конвейера
Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять
правил: атомарных договоров:
- `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон — - `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон —
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк `uniqExact(VisitID)` из `ods.event_v`, объект — число строк
@@ -215,47 +189,43 @@
сравниваются с прямыми агрегатами `dds.session_v` и сравниваются с прямыми агрегатами `dds.session_v` и
`dds.identity_map_v`. `dds.identity_map_v`.
Каждая проверка берет дни и ключи из обоих сравниваемых наборов данных. Область каждой проверки выводится из объединения сторон. Полностью потерянный
Поэтому потерянная строка остается видна на другой стороне сравнения. день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут
Расхождения считаются по ключам: завышение одного показателя не скроет скомпенсировать друг друга общим итогом: `failed_rows` считается после
занижение другого в общей сумме за день. сопоставления деловых ключей.
`classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая `classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
проверка доставки и обработки эталонных данных стенда. Она не проверяет подтверждает, что до витрины доехал записанный в паспорте мир, но не проверяет
сам генератор; у настоящего магазина такого эталона нет. сам генератор: у настоящего магазина такого оракула нет.
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
сводку и несколько примеров расхождений. Техническая сверка с паспортом читает небольшой сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой
файл JSON в Python. Результаты всех пяти проверок записываются в общую файл JSON в Python. Все пять сборок пишут в общий донор. После их успеха даг
промежуточную таблицу. Когда расчеты завершились, ее дневные разделы заменяет проверенные дневные партиции. Затем именованные задачи читают
заменяют разделы `dm.dq_summary_rep`. Затем отдельные задачи читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
сохраненную сводку и завершаются ошибкой при `failed_rows > 0`. сохраниться до красного завершения, а ошибка сборки не затирает прежний
Так расхождение сохраняется в таблице до отказа задачи. Если сам расчет результат. Красная задача печатает не больше двадцати нарушенных ключей за
не выполнен, прежняя сводка остается на месте. Задача с расхождениями проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
выводит в журнал не больше двадцати ключей со значениями обеих сторон. Для полного списка итоговую агрегацию SQL-сверки
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date, заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
business_key`. business_key`.
Если день исчез из обоих сравниваемых наборов, новый расчет не создаст Если после починки день исчез с обеих сторон, его больше нет и в новом доноре.
для него строк. Даг отдельно удаляет прежний результат за этот день Такой день удаляется из сводки отдельно: старая красная строка не должна
из сводки. превращаться в историю попыток.
## Даг самопроверки ## Даг самопроверки
`homework_check` запускается вручную и содержит задачу для каждого из Домашки контролирует `homework_check` — отдельный даг без расписания: менти
[пяти заданий по витринам](../labs/README.md). Если витрины нет или значения запускает его рукой. По задаче на каждое из
расходятся с источником, задача завершается ошибкой. Журнал показывает [пяти заданий по витринам](../labs/README.md).
причину и примеры несовпадений. Успех означает, что проверяемые значения Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
совпали; остальные требования задания вы проверяете сами. построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
лабы, а не история качества конвейера.
Ожидаемые значения рассчитываются по исходным данным текущего стенда. Приём проверен на соседнем учебном стенде Airflow + Greenplum:
Для четырех заданий это события или сессии DDS, для сводки брака — валидационный даг с группами проверок по слоям давал менти обратную связь
таблицы ошибок ODS. Сравнение по деловому ключу учитывает отсутствующие по каждому шагу домашки, не трогая рабочий конвейер.
строки с обеих сторон. Результаты не пишутся в `dq_summary`:
самопроверка учебных витрин отделена от качества готового конвейера.
Готовые SQL-проверки открывайте после собственного решения: они содержат
расчет существенной части задания.
## Отклонённые варианты ## Отклонённые варианты
@@ -263,7 +233,7 @@ business_key`.
производственный паттерн слоя — пересборка агрегатов — остался бы производственный паттерн слоя — пересборка агрегатов — остался бы
невидим, а `dm_transform` — без работы. невидим, а `dm_transform` — без работы.
- **Материализовать всё.** Копия событий ради переименования колонок — - **Материализовать всё.** Копия событий ради переименования колонок —
лишняя; такой пример приучал бы без нужды дублировать исходные данные. антипаттерн, который менти увёз бы в бой.
- **Гибрид по цене запроса.** Выбор формы по стоимости, которой на объёмах - **Гибрид по цене запроса.** Выбор формы по стоимости, которой на объёмах
стенда нет: сторож без настоящей опасности. стенда нет: сторож без настоящей опасности.
- **Проверки домашек в конвейерном звене DQ.** Конвейер стоял бы красным до - **Проверки домашек в конвейерном звене DQ.** Конвейер стоял бы красным до
@@ -272,8 +242,8 @@ business_key`.
- **«Жёлтый» статус недостроенной витрины.** У задачи Airflow нет такого - **«Жёлтый» статус недостроенной витрины.** У задачи Airflow нет такого
состояния; изображать его логами — семантика, которую пришлось бы состояния; изображать его логами — семантика, которую пришлось бы
расшифровывать. Красное «ещё не построено» честнее. расшифровывать. Красное «ещё не построено» честнее.
- **Самопроверка пишет в `dq_summary`.** Результаты учебных попыток смешались бы - **Самопроверка пишет в `dq_summary`.** Попытки менти смешались бы с
с историей качества конвейера. историей качества конвейера.
- **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили - **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили
бы число известных на момент закрытия — витрина тихо расходится с картой бы число известных на момент закрытия — витрина тихо расходится с картой
и протаскивает обратно «знание на дату», отвергнутое развилкой карты и протаскивает обратно «знание на дату», отвергнутое развилкой карты
+82 -107
View File
@@ -1,23 +1,28 @@
# Дневной конвейер трансформаций # Дневной конвейер трансформаций
Даг `etl_pipeline` собирает модель DDS, обновляет витрины DM и проверяет Как принятые источники превращаются в модель DDS и витрины DM: кто запускает
результат. Здесь описаны запуск, выбор дней и запись результата в таблицы. трансформации, когда и в каком объёме. Правила самих сущностей — зерно,
Состав моделей — в [справочниках DDS](dds/README.md) и [DM](dm.md). движки, что именно пересобирается — живут в доках слоя
([карта модели DDS](dds/README.md)); здесь — только конвейер.
Дневная сборка вперёд появляется на этапе 4 и остаётся основным режимом
конвейера. На этапе 5 к ней добавляется партиционная переобработка: запуск
произвольного прошлого дня по команде.
## Форма ## Форма
`etl_pipeline` последовательно запускает `dds_transform`, `dm_transform` Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы
и `dq_check`. Каждый следующий даг начинается после успеха предыдущего. у него нет: он дёргает слойные даги последовательно и ждёт каждый —
Для этого `TriggerDagRunOperator` ждет завершения запущенного им дага. `dds_transform`, затем `dm_transform` и `dq_check`. Состав проверок описан в
Если тот завершился ошибкой, задача ожидания в конвейере тоже завершится [доке слоя DM](dm.md).
ошибкой.
Поведение проверено через Context7 по документации Airflow 3.x (3.1.6).
У этих дагов нет расписания. Их вызывает конвейер; для отдельного разбора Ждущие вызовы сделаны `TriggerDagRunOperator`. Проверка через Context7 по
можно запустить даг слоя вручную. У каждого разрешен один активный запуск: ближайшей доступной документации Airflow 3.x (версия 3.1.6) подтвердила, что
два запуска одного дага используют одни промежуточные таблицы и могли бы оператор опрашивает точный созданный им прогон и краснеет вслед за потомком.
стереть результаты друг друга. Расчет каждой модели выполняется отдельной
задачей или группой задач. Слойные даги устроены по образцу `orders_ingest`: без расписания, один
активный прогон, запускаются триггером конвейера или рукой при разборе
происшествий. Витрины внутри `dm_transform` — отдельные задачи.
## Три пути запуска ## Три пути запуска
@@ -29,113 +34,83 @@ world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transfor
world_next_day ─────> источники без ETL world_next_day ─────> источники без ETL
``` ```
- `world_live_day` запускает конвейер после успешной генерации, приема - **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
заказов и обновления `world_position`. Если трансформация упала, день шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
уже прожит и позиция не откатывается. После исправления причины упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
запустите `etl_pipeline` вручную. зеленеть, не узнав, разложился ли день, шаговый даг не должен ([ADR
- `world_next_day` отправляет дни пачкой и ждет приема заказов, но конвейер 0003](../adr/0003-dag-run-state.md)). Такой отказ чинится перезапуском
не запускает. После него запустите `etl_pipeline` сами. `etl_pipeline` рукой — перезапуску ничего не нужно помнить, см. «Объём
- `world_initialize` запускает конвейер последним шагом: при успешном прогона».
первом `make up` начальные данные уже собраны в DDS и DM. - **Разгон пачкой.** `world_next_day` конвейер не дёргает: даги модельного
времени управляют генератором и только им. После разгона менти запускает
конвейер сам — и видит, как слои догоняют уехавший вперёд мир.
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
ручного запуска начинаются со следующих дней.
## Объём прогона ## Объём прогона
Объем прогона — дни, которые обрабатывает один запуск. Даты передавать Параметров у конвейера нет: объём он выводит сам из `world_position` и
не нужно: даги определяют их по состоянию таблиц и `world_position`. состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по
Например, после генерации семи дней без ETL один запуск рассчитает сессии последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта
за все семь дней. идентичностей пересчитывается целиком; правила витрин — у
[слоя DM](dm.md).
Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и
после падения, а разгону на семь дней хватает одного прогона.
Для расчета сессий выбираются дни от первого, за который в DDS еще нет Окно заказов задаёт последний слепок, наблюдаемый в ODS. У каждого заказа
сессий, до последнего прожитого. `snapshot_date` равен последнему слепку, в котором источник его показал.
`world_position` хранит номер следующего дня для генерации, поэтому Трансформация выбирает строки с максимальной `snapshot_date` и берёт среди них
последний прожитый день равен `min(EventDate) + world_position - 1`. минимальный и максимальный `order_date`. Это границы дней, состояние которых
Начало календаря берется из ODS; события ранних дней там не удаляются по TTL. источник ещё может изменить
Один `max(EventDate)` не годится: события за день есть и во время его ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Кроме живого окна,
генерации, когда последние визиты еще не закончились. трансформация закрывает пропуски: находит самый ранний день, представленный в
Точный запрос — [`session_scope.sql`](../../sql/dds/session_scope.sql). ODS и ещё не собранный в DDS, затем при необходимости расширяет до него левую
границу.
Для заказов диапазон задает последний принятый слепок ODS. Среди строк Позиция мира заказам не отвечает: она говорит, какой день прожит, а не какой
с максимальной `snapshot_date` выбираются самая ранняя и самая поздняя доехал. Прогон дня D везёт слепок дня D − 1, да и ставится позиция раньше
даты оформления заказа. Состояние заказов за эти дни еще может измениться. трансформации, так что после неудачного приёма обгоняет хранилище. Слой обязан
Если в ODS есть заказы за более ранний день, а в DDS за него нет строк, считать объём по тому, что у него есть, а не по тому, что мир о себе заявляет.
начало диапазона сдвигается до этого дня. Запрос заново рассчитывает
заказы за все дни выбранного отрезка.
Позиция мира для выбора дней заказов не подходит: с событиями дня D Для сессий границу задаёт `world_position`: она отличает полностью прожитый
приходит слепок за D−1. Объем рассчитывается по принятым строкам ODS; день от ещё идущего. Сами события этой границы не показывают: и посреди дня D,
их наличие еще не доказывает полноту слепка. и сразу после его конца их позднейший день равен D. Если собрать сессии посреди
дня, визит, начатый в 23:50, останется обрезанным: завершённый день слой
Карта идентичностей пересчитывается целиком. Витрины выбирают дни повторно не собирает. Поэтому последний прожитый день равен первому дню
по своим источникам — см. [ритм пересборки DM](dm.md#ритм-пересборки). событий плюс позиция минус один
Если за день сохранилась хотя бы одна строка, запрос не считает его ([`sql/dds/session_scope.sql`](../../sql/dds/session_scope.sql)).
пропущенным. Полноту данных проверяют отдельно. Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не
позиция, а «Готовность источника».
## Готовность источника ## Готовность источника
Перед сборкой DDS задача `wait_for_source` последовательно ждет двух Первый шаг `dds_transform` проходит два барьера за общий срок в 300 секунд.
условий: Сначала клиент `confluent-kafka` сверяет подтвержденные смещения группы
`clickstream_hits` с верхними смещениями обоих разделов `hits`. Группа, оба
раздела и все смещения должны существовать. Суммарное отставание должно стать
нулевым. Каждый опрос пишет в журнал оба смещения и отставание по разделу.
1. Группа `clickstream_hits` дочитала оба раздела топика `hits`. Только после этого шаг проверяет обе ноды ClickHouse через
Подтвержденные смещения сравниваются с верхними смещениями Kafka. `clusterAllReplicas`: у `ods.event_dist` в `system.distribution_queue` не
Группа и смещения должны существовать, суммарное отставание — равняться нулю. должно остаться файлов. Каждый опрос пишет в журнал ноды, число файлов и
2. В `system.distribution_queue` у `ods.event_dist` не осталось файлов `last_exception` очереди. Оба барьера опрашиваются раз в три секунды и делят
доставки на шарды. Запрос через `clusterAllReplicas` читает обе ноды. один крайний срок. После Kafka срок не отсчитывается заново.
Опросы идут с паузой в три секунды; на обе стадии задан общий срок Зелёный приём заказов о событиях не говорит ничего — топики разные и
300 секунд. Журнал показывает смещения и отставание Kafka, затем число потребители разные. Одного нулевого отставания Kafka тоже мало: чтец мог
файлов и `last_exception` очереди на каждой ноде. Если готовность не подтвердить сообщение раньше, чем Distributed-таблица доставила файл на шард.
подтверждена за срок ожидания, задача завершается ошибкой. Два барьера закрывают обе части пути до того, как сессии выберут свой объем.
Одного нулевого отставания Kafka мало: сообщение может быть подтверждено,
пока Distributed-таблица еще доставляет его на шард. Успех `orders_ingest`
тоже не заменяет эти проверки: у заказов другой топик и потребитель.
Эти условия проверяют доставку на момент опроса. Они не останавливают
поступление новых событий и не сверяют содержимое таблиц. Поэтому
`world_position` отдельно ограничивает сборку сессий прожитыми днями,
а качество результата проверяет `dq_check`.
## Идемпотентность ## Идемпотентность
Идемпотентность означает, что повторная обработка тех же исходных данных Партиция не заменяется простой вставкой. День собирается в таблицу-двойник —
не добавляет копии деловых строк. Служебные `_load_id` и `_load_ts` вставка через Distributed раскладывает строки по шардам, — затем
при новой сборке меняются. `REPLACE PARTITION` на кластере атомарно подменяет кусок каждого шарда. Эта
механика — часть самих трансформаций и строится этапом 4: без неё дневной
Результат дневного расчета сначала записывается в промежуточную таблицу. прогон не повторяем. Сущности, пересчитываемые целиком, идемпотентны и без
Она нужна, чтобы читатели продолжали видеть прежние данные, пока новые замены: полная перезапись в `ReplacingMergeTree` — так живут карта
еще рассчитываются. Например, для сессий это `dds.session_stage_rep`, идентичностей и витрина `daily_traffic` ([слой DM](dm.md)).
а основная таблица — `dds.session_rep`.
Задачи выполняются в таком порядке:
1. `scope` выбирает даты для расчета.
2. `rebuild` очищает промежуточную таблицу, рассчитывает строки за выбранные
дни и записывает их через `_stage_dist`. Настройка
`distributed_foreground_insert = 1` заставляет вставку дождаться
доставки строк на шарды.
3. `replace` заменяет в основной таблице раздел за каждый выбранный день
готовым разделом промежуточной таблицы. Раздел таблицы называется
партицией; для замены используется `REPLACE PARTITION`.
Структура и распределение строк по шардам у двух таблиц должны совпадать.
Замена выполняется на локальных `_rep` с `ON CLUSTER`. На каждом шарде
читатель видит либо старый, либо новый раздел целиком — это атомарность
замены. Общей транзакции для двух шардов и всех дней нет: при сбое один
шард может уже содержать новый результат, а другой — прежний. Повторить
`replace` можно, пока промежуточная таблица хранит тот же результат;
копии строк в основной таблице от этого не появятся.
Новый запуск всего конвейера заново выбирает дни. Он не обязан повторить
все дни неудачного запуска: если дата уже есть в целевой таблице, поиск
пропусков ее не выберет. В частности, изменение SQL сессий само по себе
не заставляет пересчитать заполненные дни. Повторная сборка выбранного
дня разобрана в [задании ступени 4](../labs/4-1-session-pipeline.md).
Карта идентичностей и `daily_traffic` обновляются иначе: каждый запуск
добавляет полный результат новой версией в `ReplacingMergeTree(_load_ts)`.
Публичные представления читают текущие строки через `FINAL`. Старые версии
могут физически оставаться до фонового слияния; повторяемость относится
к результату чтения, а не к числу строк на диске.
## Чего конвейер о себе не знает ## Чего конвейер о себе не знает
+91 -72
View File
@@ -30,96 +30,115 @@ keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/d
## Имена объектов ## Имена объектов
Суффикс показывает назначение объекта: Имя объекта заканчивается тем, что это за объект:
| Суффикс | Что это | | Суффикс | Что это |
|---|---| |---|---|
| `_rep` | локальная таблица: хранит данные своего шарда, движок семейства `Replicated*` | | `_rep` | локальная таблица шарда, движок семейства `Replicated*` |
| `_dist` | таблица `Distributed`: читает данные шардов и распределяет запись между ними | | `_dist` | `Distributed` поверх одноимённой локальной |
| `_kafka` | таблица на движке `Kafka`: читает сообщения топика | | `_kafka` | таблица на движке `Kafka` |
| `_file` | таблица на движке `File`: читает файл | | `_file` | таблица на движке `File` — чтец файла |
| `_mv` | материализованное представление: обрабатывает вставляемые данные | | `_mv` | материализованное представление |
| `_v` | обычное представление: выполняет сохраненный запрос при чтении | | `_v` | обычное представление, публичная поверхность чтения |
Для запросов к моделям DDS и витринам DM используйте `_v`. Представления Суффикс носит каждый физический объект. Голого имени у таблицы не существует:
явно перечисляют колонки, поэтому добавление поля в физическую таблицу запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым
не меняет набор полей для читателя. У `dds.event_v` нет отдельной таблицы именем в документах и разговоре понимается сущность, у которой этих объектов
DDS: представление читает события ODS. несколько. Единственное исключение — словари: у них воплощение одно, шардировать
нечего, и суффикс ничего не различал бы. В зоне `dic` обе формы стоят рядом и
правило видно целиком: `dic.products_file` — чтец файла, `dic.products` — сам
словарь.
В документации имя без суффикса, например `stg.hits_raw`, обозначает Распространённая конвенция, где голое имя означает локальную таблицу, а
сущность с несколькими объектами. Таблицы с таким именем нет: в запросе распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо
нужно указать суффикс. Исключение — словари ClickHouse: `dic.products` возвращает данные одного шарда. Правило спеки «проверки и контрольные суммы —
обращается к каталогу товаров, а `dic.products_file` читает его файл. только по `Distributed`» такую тишину переживает плохо.
В STG имя берется из топика: `hits_raw`, `orders_raw`. В типизированных Второе свойство — в списке по алфавиту объекты группируются по сущности, а не по
слоях оно называет сущность в единственном числе: `event`, `order`, технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются
`session`. Объекты одной сущности различаются суффиксом и стоят рядом хвостом, а не началом имени. Речь про дерево в клиенте и про `ORDER BY name`:
при сортировке имен по алфавиту. порядок выдачи `SHOW TABLES` документация не оговаривает.
`_rep` и `_dist` нужны для загрузки, диагностики и обслуживания. Роль Начало имени — сущность, и берётся она в разных слоях из разных мест. В STG имя
`bi_reader` может читать их в ODS, DDS и DM, но для обычного анализа приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем
принято использовать `_v`. Это соглашение о чтении, а не ограничение прав; топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях
причина широкого доступа описана в имя приходит от предметной области и стоит в единственном числе: `event`,
[ADR 0007](../adr/0007-clickhouse-access.md) и `order`, `session`. Граница между «как привезли» и «что это такое»
проходит по STG, и имена её показывают.
В DDS публичный договор сущности заканчивается на `_v`. Потребительские запросы
и витрины называют только представление; `_rep` и `_dist` служат загрузке,
диагностике и обслуживанию. Представление перечисляет колонки явно, даже если
его запрос — простая проекция над `_dist`: договор не выдаёт устройство
кластера и случайно добавленные в физическую таблицу колонки. У события
физической копии в DDS нет, но правило то же: `dds.event_v` — его публичный
договор.
Право доступа здесь намеренно шире договора. Роль `bi_reader` может читать
любые объекты ODS, DDS и DM, но физические объекты от этого не становятся
публичной поверхностью. Конвенцию держат явный список колонок, документация и
ревью, а не запрет на физические таблицы.
Широкое право принято в [ADR 0007](../adr/0007-clickhouse-access.md): узкая
граница поверх `Distributed` потребовала бы отдельной механики. Механизм
ClickHouse и отклонённые варианты записаны в
[исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md). [исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md).
## Раскладка по шардам ## Раскладка по шардам
Шард хранит часть строк. При записи через `_dist` место строки выбирает Пишем только в `_dist`. Локальные таблицы остаются для чтения и обслуживания —
ключ шардирования. При прямой записи в `_rep` она остается на той ноде, операций с партициями, ручной переобработки. Правило не про удобство: при записи
где выполнена вставка. Поэтому данные записывают через `_dist`, через распределённую таблицу раскладку определяет ключ шардирования, то есть
а операции с партициями выполняют на локальных `_rep`. свойство данных, а при записи в локальную — то, какая нода случайно выполняла
код. Отсюда урок стенда: какая нода читала топик, меняется между прогонами
(видно в колонке `consumer_host`), а куда легли данные — нет.
У событий ключ — `cityHash64(ClientID)`, у заказов — Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
`cityHash64(order_id)`, у STG и таблиц ошибок — хеш сырой строки. заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
Хеш распределяет числовые идентификаторы по шардам и направляет одинаковые выбран против перекоса: структурированный числовой идентификатор распределяется
сообщения на один шард. У сырой строки и разобранного события разные по остатку от деления неравномерно. У сырого слоя выбора нет — строку иначе не
ключи: они могут оказаться на разных шардах. Счетчики STG и ODS сравнивают разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
по всему кластеру через `_dist`, а не отдельно по каждой ноде. шард.
При `INSERT INTO цель_dist SELECT … FROM источник_dist` важна настройка Ключи у слоёв разные, и это имеет наблюдаемое следствие: сырая строка и
`parallel_distributed_insert_select`. При значении `2` и одном кластере разобранное из неё событие почти всегда оказываются на разных шардах.
вставка может выполняться локально на шардах, сохраняя раскладку источника Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны —
вместо ключа цели. Если ключи различаются, укажите сверять слои можно только через `_dist`.
`parallel_distributed_insert_select = 0`: нода, принявшая запрос,
соберет результат и распределит его по ключу цели. Так сделано в
[`sql/ods/order_parse.sql`](../../sql/ods/order_parse.sql) и при сборке
выручки. Неверную раскладку проверяют по `hostName()` и ожидаемому шарду
строки; общий счет по `_dist` ее не показывает.
События, `dds.session_dist` и `dds.identity_map_dist` распределены Разные ключи у слоёв ставят капкан на вставке `INSERT INTO цель_dist SELECT …
по `cityHash64(client_id)` (в ODS поле называется `ClientID`). Данные FROM источник_dist`. Заводское значение `parallel_distributed_insert_select` —
одного посетителя лежат вместе — это ко-локация. Заказы DDS и их сверка 2: если обе таблицы на одном кластере, вставка исполняется локально на каждом
сохраняют раскладку по `cityHash64(order_id)`. Обычное соединение допускается шарде, минуя ключ шардирования цели. Строки наследуют раскладку источника, и
по ключу ко-локации; для остальных соединений применяется `GLOBAL`. ошибки при этом нет — расхождение видно только пересчётом ключей по
Само совпадение ключей еще не гарантирует локального выполнения запроса `hostName()`. Так половина заказов и легла на оба шарда, где
через представления — пример есть в [описании `daily_traffic`](dm.md#dmdaily_traffic_v). `ReplacingMergeTree` через границу шарда дубли не схлопывает (замер — «Что
проверено», #137). Лекарство — явное `parallel_distributed_insert_select = 0`
в `SETTINGS` такой вставки: раскладку делает инициатор, по ключу цели
(`sql/ods/order_parse.sql`). Капкан ждёт и сборки DDS: они тоже будут читать
`_dist` и писать в `_dist` с другим ключом.
Для дневной пересборки DDS и DM результат сначала записывают Ключи ко-локации названы заранее, потому что на них стоит политика соединений из
в промежуточную таблицу. Основная таблица в это время остается доступна раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё
для чтения с прежними данными. Например, заказы записываются через прочее — через `GLOBAL`. Значит `dds.session_dist` и `dds.identity_map_dist`
`dds.order_stage_dist` в `dds.order_stage_rep`, затем ее дневные разделы шардируются по `cityHash64(client_id)`. Объекты DDS с зерном заказа сохраняют
заменяют разделы основной `dds.order_rep`. ко-локацию по `cityHash64(order_id)`; ключи остальных частей модели и витрин
появляются вместе с ними.
У промежуточной и основной таблиц должны совпадать структура, ключи Замена партиций не используется для `ods.order`: версии прошлых дней
партиции и сортировки, первичный ключ и политика хранения. Поэтому доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR
`dds.order_stage_rep` объявлена через `AS dds.order_rep` с совпадающими 0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка
ключами. Требования сверены через Context7 с понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее
[документацией ClickHouse](https://clickhouse.com/docs/sql-reference/statements/alter/partition#replace-partition) разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким
для схемы на ClickHouse 26.3. DDL показан в объектом, а не переносить на ODS заранее.
Первым такой донор понадобился заказу DDS, и правило на нём видно целиком:
`dds.order_stage_rep` объявлен через `AS dds.order_rep` и шардируется тем же
`cityHash64(order_id)`. Совпадение структуры требует сама операция, а совпадение
ключа — устройство замены: она идёт по локальным таблицам, шард за шардом, и
строка, попавшая в доноре не на свой шард, при подмене пропала бы с одного шарда
и задвоилась на другом. Доводы целиком — в
[`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql). [`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql).
Промежуточная таблица должна также распределять строки по тому же ключу,
здесь — `cityHash64(order_id)`. `REPLACE PARTITION` копирует раздел между
локальными таблицами каждого шарда и не перераспределяет строки.
Поэтому уже в промежуточной таблице заказ должен находиться на нужном шарде.
Перед заменой вставка с `distributed_foreground_insert = 1` дожидается
доставки данных на шарды.
В `ods.order` этот способ не применяется: туда добавляют версии заказов
из слепков, а актуальные строки выбираются при чтении
([ADR 0010](../adr/0010-order-versions-in-ods.md)).
## Служебные колонки ## Служебные колонки
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок: Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
+4 -93
View File
@@ -36,7 +36,7 @@ Airflow. В ClickHouse он не пишет ничего.
| `metadata.yaml` | версия формата, `1.0.0`; больше в нем ничего нет | | `metadata.yaml` | версия формата, `1.0.0`; больше в нем ничего нет |
| `databases/clickhouse.yaml` | подключение к ClickHouse: URI без пароля и постоянный uuid | | `databases/clickhouse.yaml` | подключение к ClickHouse: URI без пароля и постоянный uuid |
| `datasets/ClickHouse/*.yaml` | три витрины: `revenue_daily_v`, `purchase_vs_orders_v`, `daily_traffic_v` | | `datasets/ClickHouse/*.yaml` | три витрины: `revenue_daily_v`, `purchase_vs_orders_v`, `daily_traffic_v` |
| `charts/*.yaml` | девять графиков: четыре крупных числа, столбики выручки и кольцо долей, сводная таблица сверки, линии трафика и доля известных | | `charts/*.yaml` | три графика: столбики выручки, сводная таблица сверки, линии трафика |
| `dashboards/business_stories.yaml` | дашборд «Бизнес-сюжеты» и раскладка панелей | | `dashboards/business_stories.yaml` | дашборд «Бизнес-сюжеты» и раскладка панелей |
Связаны файлы не путями, а uuid. Датасет называет `database_uuid`, график — Связаны файлы не путями, а uuid. Датасет называет `database_uuid`, график —
@@ -46,51 +46,20 @@ Airflow. В ClickHouse он не пишет ничего.
Uuid у стенда розданы рукой: общее начало Uuid у стенда розданы рукой: общее начало
`19600000-0000-4000-8000-`, дальше номер объекта в последнем блоке — `1`–`3` `19600000-0000-4000-8000-`, дальше номер объекта в последнем блоке — `1`–`3`
у датасетов, `101`–`109` у графиков, `201` у дашборда у датасетов, `101`–`103` у графиков, `201` у дашборда
(`…-000000000101`). Экспорт из интерфейса приносит свои, сгенерированные; (`…-000000000101`). Экспорт из интерфейса приносит свои, сгенерированные;
их заменяют на эти, иначе повторный импорт заведет вторую копию объекта вместо их заменяют на эти, иначе повторный импорт заведет вторую копию объекта вместо
обновления первой. обновления первой.
Датасет несет русские имена колонок, формат чисел и метрики — а график только Датасет несет русские имена колонок, формат денег и метрику — а график только
ссылается на них по имени. Значит, подпись или формат правятся в одном месте, а ссылается на них по имени. Значит, подпись или формат правятся в одном месте, а
не в каждом графике. Два исключения проверены на живом стенде, Superset 6.1. не в каждом графике.
Подписи оси график берет не из датасета, а из своего `y_axis_format`: без него
ось выручки уходит в «1.2M». И разрез сводной таблицы подписан именем колонки
(`mismatch_class`), хотя колонке даты в той же таблице русское имя достается.
Датасеты объявлены физическими (`schema: dm`, `sql: null`), хотя за именем стоит Датасеты объявлены физическими (`schema: dm`, `sql: null`), хотя за именем стоит
представление ClickHouse. Импорт в источник не ходит и разницы между таблицей и представление ClickHouse. Импорт в источник не ходит и разницы между таблицей и
представлением не знает, а виртуальный датасет со своим SQL просто повторил бы представлением не знает, а виртуальный датасет со своим SQL просто повторил бы
запрос, который уже живет в `sql/ddl/60-dm-views.sql`. запрос, который уже живет в `sql/ddl/60-dm-views.sql`.
## Из чего собран экран
Четыре строки панелей:
- ряд крупных чисел — выручка, заказы, средний чек, посетители;
- выручка по дням и категориям, рядом доля категорий кольцом;
- сводная сверки purchase и заказов;
- посетители и известные пользователи, рядом доля известных.
Правая панель пары отвечает на вопрос «сколько из целого» и берет цвета левой:
категория окрашена одинаково на столбиках и в кольце, доля известных — как
линия известных пользователей рядом. Цвет закрепляет `label_colors` в
`metadata` дашборда. Шесть категорий выписаны в него руками по каталогу
генератора (`generator/src/clickstream_generator/catalog.py`): седьмая
останется без закрепленного цвета, пока файл не поправят.
Средний чек и доля известных считаются метриками датасета —
`SUM(revenue) / nullIf(SUM(orders), 0)` и то же для доли известных. Знаменатель
обернут не зря: на периоде без заказов деление Decimal на ноль в ClickHouse —
не `nan`, а исключение, и панель показала бы ошибку вместо пустоты. У витрины
выручки есть готовая колонка `aov`, но среднее по ней дало бы другое число:
день с тремя заказами получил бы тот же вес, что день с тремя сотнями.
«Период» — единственный фильтр дашборда, тип `filter_time` с пустой целью
(`targets: [{}]`). Такой фильтр колонку не называет: он подставляет выбранный
период в то временное условие, которое уже стоит у каждого графика. Поэтому
сверка сужается по `order_day`, а остальные панели — по `report_date`.
## Импорт при каждой инициализации ## Импорт при каждой инициализации
Последняя строка `infra/superset/init.sh`: Последняя строка `infra/superset/init.sh`:
@@ -183,17 +152,6 @@ superset import-directory /app/import --overwrite
завершается нулем в любом случае. Поэтому импорт проверяют глазами или завершается нулем в любом случае. Поэтому импорт проверяют глазами или
запросом к API — но не тем, что команда отработала. запросом к API — но не тем, что команда отработала.
**Знак рубля приходит не из формата валюты.** Символ Superset берет у
`Intl.NumberFormat` по коду валюты и английской локали стенда: код `RUB`
печатается как «RUB», а знак «₽» вызов роняет, и форматирование молча
откатывается к числу без валюты. Поэтому единица стоит в названии панели —
«Выручка, ₽», — а метрика несет обычный формат `,d`.
**`label_colors` знает имя ряда, а не подпись.** Для разреза имя ряда — это
значение колонки («Бытовая техника»), для графика без разреза — имя метрики
(`known_share`), а не ее русская подпись. Цвет, закрепленный за подписью
«Доля известных», не применяется.
**`--overwrite` синхронизирует датасет разрушительно.** Колонки и метрики **`--overwrite` синхронизирует датасет разрушительно.** Колонки и метрики
берутся из YAML целиком: добавленная в интерфейсе метрика или подпись колонки берутся из YAML целиком: добавленная в интерфейсе метрика или подпись колонки
исчезнет при следующем старте контейнера. Правка датасета — это правка файла. исчезнет при следующем старте контейнера. Правка датасета — это правка файла.
@@ -205,50 +163,3 @@ superset import-directory /app/import --overwrite
живое подключение и хранилище пароля. Сами графики и дашборд ни одна цель не живое подключение и хранилище пароля. Сами графики и дашборд ни одна цель не
проверяет: их смотрят глазами после переинициализации. Полная карта целей — в проверяет: их смотрят глазами после переинициализации. Полная карта целей — в
[доке проверок](testing.md). [доке проверок](testing.md).
## Отклоненные варианты
Оживить дашборд предложат снова, поэтому отказы записаны с доводами. Замерено
на живом стенде 7 сентября 2026 года, Superset 6.1.
**Карта регионов России.** Замысел стоял на клике: щелчок по региону
перестраивает весь экран. Плагин Country Map кросс-фильтр не отдает вовсе —
`behaviors` не объявлены, `setDataMask` не вызывается, а щелчок приближает
карту. Без клика остается картинка, и она рассказывает меньше, чем кажется: 16
городов стенда сворачиваются в 14 регионов из 83, и больше половины сессий
приходится на один. Карта отдана факультативу «Добавь названия регионов в
отчет»: там читатель собирает справочник сам, и она становится наградой за
него.
**Регион как общее измерение витрин.** Чтобы фильтр по региону действовал на
весь экран, регион пришлось бы завести в каждой витрине: Superset датасеты
между собой не соединяет, а фильтр по колонке, которой нет в датасете панели,
отбрасывается с причиной `COL_NOT_IN_DATASOURCE`. У заказа географии нет — она
бралась бы от человека через карту идентичностей и приписывалась бы сущности, у
которой ее в источнике не было. Вдобавок не сходятся ритмы: витрина от карты
идентичностей не замерзает никогда, а выручка живет окном последнего слепка
([дока DM](dm.md)). Стендовый словарь регионов отклонен раньше и по своей
причине — он повторил бы урок `dictGet` каталога вторым разом.
**Кросс-фильтрация между панелями.** Общих измерений у витрин почти нет:
категория есть только у выручки, а день у сверки называется `order_day` против
`report_date` у остальных. Клик перестроил бы часть экрана и оставил другую
нетронутой, а вывод из этого читатель делает про данные, а не про устройство
витрин. Панель фильтров этой болезнью не болеет — там колонка задается явно для
каждой витрины, — поэтому фильтр периода дашборду разрешен, а кросс-фильтрация
нет.
**Вкладки и блоки «что смотреть».** Вкладки были условным вариантом: их
заводят, когда экран перестает читаться одним взглядом. Четыре строки панелей
читаются сразу, поэтому вкладки спрятали бы половину экрана за щелчком без
выигрыша. Блок с текстом «что смотреть» рядом с панелью означал
бы, что панель сама себя не объясняет: тогда чинить надо название и подписи, а
не дописывать к ним пояснение.
**Пороги и раскраска панелей.** Правило
[спеки](../specs/2026-07-30-stand-v2-realism.md), раздел 9: на дашборд смотрят
глазами, красное и зеленое живет в проверках.
Поводы вернуться к разговору: плагин карты начинает отдавать кросс-фильтр; у
заказа появляется адрес доставки; витрины получают общее измерение по своей
причине, не ради дашборда.
+22 -19
View File
@@ -1,47 +1,50 @@
# Путеводитель по стенду # Путеводитель по стенду
Путеводитель объясняет, как данные поступают в хранилище, обрабатываются Если вы впервые видите стенд, начните отсюда. Путеводитель подскажет, на что
и попадают в отчеты. В главах есть короткие примеры, которые можно выполнить смотреть и в каком порядке, а за подробностями отправит в справочники. Так вы
на запущенном стенде перед переходом к учебным заданиям. разберетесь в устройстве, прежде чем браться за уроки.
## Как читать ## Как читать
Читайте главы в порядке ниже. Незнакомые термины можно найти в Тропа такая: [словарь понятий](../../CONTEXT.md) → главы путеводителя →
[словаре понятий](../../CONTEXT.md), а подробности устройства — [справочники `docs/architecture/`](../architecture/) →
в [технических справочниках](../architecture/). К ним достаточно обращаться [уроки](../labs/README.md).
по мере необходимости. После путеводителя переходите к
[учебным заданиям](../labs/README.md). Словарь не надо читать подряд. Возвращайтесь к нему за незнакомым словом.
Первая статья словаря объясняет хранилище. Следом идет несколько статей об
устройстве генератора; они понадобятся нескоро.
## Карта хранилища ## Карта хранилища
[Интерактивная карта](architecture-map.html) показывает путь данных через слои. [Интерактивная карта](architecture-map.html) показывает путь данных через слои.
Gitea показывает HTML как текст. Откройте файл из своей копии репозитория Gitea показывает HTML как текст. Откройте файл из своей копии репозитория
или скачайте его кнопкой «Raw» и откройте в браузере. или скачайте его кнопкой «Raw» и откройте в браузере.
Сверяйтесь с картой при чтении главы «Хранилище и его слои». Держите карту рядом с главой «Хранилище и его слои».
## Главы ## Главы
### [Хранилище и его слои](storage-layers.md) ### [Хранилище и его слои](storage-layers.md)
Как меняются данные при переходе из Kafka в слои STG, ODS, DDS и DM. От события в Kafka до витрины: глава разбирает путь через STG, ODS, DDS и DM
В конце главы вы посмотрите события, сессии и выручку с помощью SQL. и показывает, за что отвечает каждый слой.
### [Потоки Airflow и даги модельного времени](airflow-dags.md) ### [Потоки Airflow и даги модельного времени](airflow-dags.md)
Какие даги создают новые данные, а какие обрабатывают их в хранилище. Шаговый даг играет модельный день, а дневной конвейер раскладывает данные по
Вы запустите генерацию и обработку за день и посмотрите результат в витринах. слоям. Глава поможет увидеть их связь и роль каждого дага.
### [Мониторинг: куда смотреть](monitoring.md) ### [Мониторинг: куда смотреть](monitoring.md)
Что показывают панели Grafana и как меняются их показания, пока в стенд Свежесть и поток данных, брак и отставание собраны на дашборде «Данные».
поступают события и заказы. Глава научит находить нужную панель и разбирать ее показания.
### [Немного Superset](superset.md) ### [Немного Superset](superset.md)
Как Superset строит графики по витринам и что происходит с вашими отчетами Глава покажет, как витрина становится графиком и где проходит граница между
при перезапуске стенда. своими объектами и теми, которые поднимает стенд.
### [Генератор: откуда берутся данные](generator.md) ### [Генератор: откуда берутся данные](generator.md)
Откуда берутся события и заказы, почему начальные данные повторяются Здесь генератор — источник данных, а не предмет изучения. Глава в общих
и как сверить число загруженных событий с ожидаемым. чертах объяснит, откуда берутся события и заказы и в каком виде их получает
хранилище.
+41 -52
View File
@@ -1,8 +1,7 @@
# Потоки Airflow и даги модельного времени # Потоки Airflow и даги модельного времени
Airflow управляет генерацией данных и их обработкой в хранилище. Это разные В Airflow два рода дагов. Даги мира создают мир и проигрывают модельные дни,
этапы: сначала в Kafka поступают события и заказы за день работы магазина, а дневной конвейер раскладывает сыгранный день по слоям хранилища.
затем по ним рассчитываются данные DDS и витрины DM.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -15,72 +14,64 @@ flowchart LR
## Два потока входят по-разному ## Два потока входят по-разному
События поступают в топик `hits` и обрабатываются по мере поступления: События приходят в топик `hits`. Хранилище разбирает их само и непрерывно:
материализованные представления переносят строки из STG в ODS без участия материализованные представления переносят строки из STG в ODS без участия
Airflow. Airflow.
Заказы поступают в топик `orders` раз в модельные сутки одной выгрузкой. Заказы приходят в топик `orders` один раз в модельные сутки, целым слепком. У
Она содержит состояние заказов на конец дня и называется слепком. слепка есть начало и конец, поэтому даг `orders_ingest` забирает его одной
Даг `orders_ingest` загружает ее целиком. Такой способ приема называется порцией. Такой способ приема называется [пакетным](../../CONTEXT.md).
[пакетным](../../CONTEXT.md).
## Даги мира ## Даги мира
В документации миром называется учебный магазин с его посетителями, Два дага с тегом «жизненный цикл мира» отвечают за стартовый мир:
товарами и заказами. Время в нем идет отдельно от календарного.
Два дага с тегом «жизненный цикл мира» создают начальные данные:
| Даг | Что делает | | Даг | Что делает |
|---|---| |---|---|
| `world_initialize` | Создает начальные данные, если их еще нет | | `world_initialize` | Создает стартовый мир, если его еще нет |
| `world_recreate` | Удаляет прикладные данные и создает их заново | | `world_recreate` | Стирает данные мира и собирает стартовый мир заново |
Для добавления следующих дней есть три дага с тегом «модельное время»: Три дага с тегом «модельное время» проигрывают следующие дни. Именно их
называют дагами модельного времени.
| Даг | Что делает | | Даг | Что делает |
|---|---| |---|---|
| `world_next_day` | Генерирует данные за следующие дни без пауз между событиями | | `world_next_day` | Играет следующие дни пачкой |
| `world_live_day` | Генерирует данные за следующий день постепенно, примерно за 24 минуты | | `world_live_day` | Играет следующий день в темпе модельного времени |
| `world_live` | Запускает `world_live_day` день за днем, пока снят с паузы | | `world_live` | Запускает живые дни один за другим, пока снят с паузы |
В переменной Airflow `world_position` хранится номер следующего дня для Сколько дней уже сыграно, стенд помнит в переменной Airflow `world_position`:
генерации. Например, при значении `8` генератор уже отправил события в ней лежит номер первого несыгранного дня. `world_next_day` и `world_live_day`
за дни с 0 по 7. записывают новое значение только после успешно сыгранного дня; оборванный
прогон его не меняет.
`world_next_day` и `world_live_day` сначала отправляют события дня D, Сыграв день D, эти два дага отправляют слепок заказов за день D−1 и ждут, пока
затем — слепок заказов за предыдущий день D−1. Они ждут, пока `orders_ingest` `orders_ingest` его примет. Эти связи разобраны в [справочнике
загрузит слепок, и только после успешного завершения дня обновляют мира](../architecture/world.md#даги-модельного-времени).
`world_position`. Если генерация или прием данных завершились с ошибкой,
номер дня остается прежним. Подробности — в [справочнике генератора
и модельного времени](../architecture/world.md#даги-модельного-времени).
## Дневной конвейер ## Дневной конвейер
Даг `etl_pipeline` по очереди запускает `dds_transform`, `dm_transform` У `etl_pipeline` нет своей обработки. Он по очереди запускает `dds_transform`,
и `dq_check`, дожидаясь завершения каждого. Они рассчитывают данные DDS, `dm_transform` и `dq_check` и ждет каждый даг. Параметров у него нет: сколько
обновляют витрины DM и проверяют качество результата. дней обработать, он сам определяет по `world_position` и по тому, что уже лежит
в слоях. Поэтому один и тот же запуск верен и когда его запустил другой даг, и
При запуске `etl_pipeline` не нужно указывать даты. Дни для обработки когда вы нажали кнопку, и когда его повторяют после сбоя. Детали есть в
определяются по `world_position` и по уже загруженным данным в слоях.
Детали есть в
[справочнике конвейера](../architecture/etl.md#объём-прогона). [справочнике конвейера](../architecture/etl.md#объём-прогона).
После `world_next_day` новые события и заказы уже загружены, но данные DDS `world_next_day` не запускает конвейер. После него слои хранилища отстают от
и DM еще не пересчитаны: запустите `etl_pipeline` вручную. мира, пока вы не запустите конвейер сами. `world_live_day`, наоборот,
`world_live_day` запускает его автоматически и ждет завершения. Поэтому запускает `etl_pipeline` и ждет его. Поэтому даг остается в работе и после
`world_live_day` может продолжать работу после окончания генерации за день. конца модельного дня.
## Если даг завершился с ошибкой ## Что значит красный
Ошибка в `etl_pipeline` не меняет `world_position`: генерация и прием Красный `etl_pipeline` не отменяет сыгранного дня: `world_position` уже
данных за день уже завершились. После устранения причины ошибки записана. Достаточно перезапустить конвейер рукой; разбираться, на чем
перезапустите конвейер вручную. Даты для повторной обработки он определит сам. остановился прошлый прогон, не нужно. Кто запускает конвейер, описано в
Кто запускает конвейер, описано в
[разделе о трех путях запуска](../architecture/etl.md#три-пути-запуска). [разделе о трех путях запуска](../architecture/etl.md#три-пути-запуска).
За качество данных отвечают проверки DQ (Data Quality) в даге `dq_check`. Качество данных проверяет контур DQ. В дневном конвейере это даг `dq_check`:
Если проверка находит расхождение, задача завершается с ошибкой и записывает упавшая задача печатает в лог ключи строк, не прошедших проверку. Состав
в журнал ключи строк, которые ее не прошли. Состав
проверок описан в [разделе «Звено DQ проверок описан в [разделе «Звено DQ
конвейера»](../architecture/dm.md#звено-dq-конвейера). конвейера»](../architecture/dm.md#звено-dq-конвейера).
@@ -96,9 +87,8 @@ Airflow.
Откройте Airflow по адресу из раздела [«Адреса и Откройте Airflow по адресу из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли). В Admin → Variables найдите пароли»](../../README.md#адреса-и-пароли). В Admin → Variables найдите
`world_position`. Затем запустите `world_next_day` на один день по [инструкции `world_position`. Затем запустите `world_next_day` на один день по [инструкции
из README](../../README.md#добавить-данные-за-следующие-дни). Убедитесь, что из README](../../README.md#сдвинуть-мир-вперед). Убедитесь, что `orders_ingest`
`orders_ingest` завершился успешно, а в витринах нового дня еще нет. отработал, а витрины еще ничего не получили. Запустите `etl_pipeline` рукой.
Запустите `etl_pipeline` вручную и дождитесь его завершения.
В DBeaver, подключенном по В DBeaver, подключенном по
[инструкции](../../README.md#подключиться-из-dbeaver), выполните два запроса: [инструкции](../../README.md#подключиться-из-dbeaver), выполните два запроса:
@@ -116,10 +106,9 @@ ORDER BY report_date DESC
LIMIT 3; LIMIT 3;
``` ```
В витрине трафика уже есть новый день, а в выручке последняя дата — В трафике уже есть сыгранный день, а в выручке только предыдущий: вместе с этим
предыдущий день. Причина в выгрузке заказов: вместе с событиями дня D днем пришел слепок заказов за предыдущий. Выручка сыгранного дня появится после
приходит слепок за день D−1. Выручка дня D появится после генерации следующего.
дня D+1 и запуска конвейера.
## Куда дальше ## Куда дальше
+154 -152
View File
@@ -149,14 +149,15 @@
<script> <script>
const MAP = { const MAP = {
"title": "Карта слоев хранилища", "title": "Карта слоев хранилища",
"lead": "Проследите путь событий и заказов от Kafka до отчетов.", "lead": "Проследите, как один смысл меняет имя и поверхность между слоями.",
"chapter_url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои", "chapter_url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои",
"intro": [ "intro": [
"Выберите объект, чтобы узнать, что он хранит, как его читать и откуда в него поступают данные.", "Эта карта показывает путь событий и заказов от Kafka до витрин DM.",
"Выберите стрелку, чтобы посмотреть способ обработки и открыть SQL-запрос.", "Щелкните по узлу, чтобы увидеть его назначение, устройство и ключи.",
"Для выбранного объекта подсвечиваются все пути, по которым данные поступают в него и используются дальше.", "Щелкните по стрелке, чтобы узнать, кто и каким запросом переносит данные.",
"Кнопки «События» и «Заказы» выделяют нужный поток. Остальные объекты остаются на месте, но становятся бледнее.", "Когда вы выбираете узел, карта подсвечивает все пути к нему и от него.",
"Если вы впервые знакомитесь со слоями хранилища, начните с главы по ссылке ниже." "Переключатель потока приглушает другую часть карты, но не меняет раскладку.",
"Сначала прочитайте главу «Хранилище и его слои», затем вернитесь к карте и рассмотрите объекты."
], ],
"streams": { "streams": {
"all": "Все", "all": "Все",
@@ -165,39 +166,39 @@
}, },
"dictionary": { "dictionary": {
"title": "Словарь", "title": "Словарь",
"caption": "справочные данные" "caption": "сбоку цепочки: справочники"
}, },
"layers": [ "layers": [
{ {
"id": "kafka", "id": "kafka",
"title": "Kafka", "title": "Kafka",
"caption": "сообщения источников" "caption": "откуда приходит"
}, },
{ {
"id": "stg", "id": "stg",
"title": "STG", "title": "STG",
"caption": "исходные сообщения" "caption": "как поступило"
}, },
{ {
"id": "ods", "id": "ods",
"title": "ODS", "title": "ODS",
"caption": "данные разобраны по полям" "caption": "язык источника"
}, },
{ {
"id": "dds", "id": "dds",
"title": "DDS", "title": "DDS",
"caption": "события, заказы и сессии" "caption": "язык бизнеса"
}, },
{ {
"id": "dm", "id": "dm",
"title": "DM", "title": "DM",
"caption": "витрины для отчетов" "caption": "данные для дашбордов"
} }
], ],
"mechanisms": [ "mechanisms": [
{ {
"id": "materialized_view", "id": "materialized_view",
"label": "материализованное представление", "label": "матвью",
"line": "solid", "line": "solid",
"rhythm": "непрерывно, по мере прихода сообщений" "rhythm": "непрерывно, по мере прихода сообщений"
}, },
@@ -205,13 +206,13 @@
"id": "airflow_dag", "id": "airflow_dag",
"label": "даг Airflow", "label": "даг Airflow",
"line": "dashed", "line": "dashed",
"rhythm": "пакетно: раз в модельные сутки (`orders_ingest`) или при каждом запуске конвейера" "rhythm": "пачкой: раз в модельные сутки (`orders_ingest`) или на каждом прогоне конвейера"
}, },
{ {
"id": "view", "id": "view",
"label": "представление", "label": "представление",
"line": "dotted", "line": "dotted",
"rhythm": "при запросе к представлению, без копирования данных" "rhythm": "без копии, данные читаются на месте"
} }
], ],
"nodes": [ "nodes": [
@@ -224,9 +225,9 @@
"events" "events"
], ],
"what": "Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие.", "what": "Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие.",
"read": "Для просмотра загруженных сообщений используйте `stg.hits_raw_dist`. Таблица `stg.hits_raw_kafka` служит для приема из топика.", "read": "Топик напрямую не читают: таблица `stg.hits_raw_kafka` забирает сообщения из топика. Для данных событий используйте `stg.hits_raw_dist`.",
"fill": [ "fill": [
"Генератор отправляет события без пауз в пакетном режиме или постепенно в живом режиме." "Источник событий пишет сообщения в топик в темпе выбранного режима мира."
], ],
"links": [ "links": [
{ {
@@ -247,10 +248,10 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Топик Kafka с дневными выгрузками заказов — слепками. Каждый слепок содержит состояние заказов за последние семь модельных дней.", "what": "Топик Kafka со слепками заказов магазина. После модельного дня приходит снимок изменяемого окна.",
"read": "Для просмотра загруженных сообщений используйте `stg.orders_raw_dist`. Таблица `stg.orders_raw_kafka` служит для приема из топика.", "read": "Топик напрямую не читают: таблица `stg.orders_raw_kafka` забирает сообщения из топика. Для заказов используйте `stg.orders_raw_dist`.",
"fill": [ "fill": [
"После генерации событий дня D источник отправляет слепок заказов за предыдущий день D−1." "Источник заказов публикует слепок пачкой после каждого модельного дня."
], ],
"links": [ "links": [
{ {
@@ -271,17 +272,17 @@
"streams": [ "streams": [
"events" "events"
], ],
"what": "Исходное сообщение события и сведения о его доставке из Kafka. Содержимое сохраняется целой строкой, без разбора на поля.", "what": "Сырая строка события и метаданные доставки. STG сохраняет сообщение без разбора.",
"read": "Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.", "read": "Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.",
"parts": [ "parts": [
"`stg.hits_raw_kafka` — таблица для чтения сообщений топика `hits`.", "`stg.hits_raw_kafka` — чтец топика `hits`.",
"`stg.hits_raw_rep` — реплицируемая таблица на шарде.", "`stg.hits_raw_rep` — реплицируемая таблица на шарде.",
"`stg.hits_raw_dist` — распределенная таблица для записи и чтения.", "`stg.hits_raw_dist` — распределенная поверхность записи и чтения.",
"`stg.hits_raw_mv` — материализованное представление, которое записывает сообщения из Kafka в STG." "`stg.hits_raw_mv` — матвью приема сообщений из чтеца."
], ],
"fill": [ "fill": [
"`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.", "`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.",
"Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL." "Сырые строки живут трое суток реального времени плюс хвост до суток."
], ],
"links": [ "links": [
{ {
@@ -293,7 +294,7 @@
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql" "url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql"
}, },
{ {
"label": "DDL материализованного представления", "label": "DDL матвью",
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql" "url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql"
} }
] ]
@@ -306,16 +307,16 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Исходные строки выгрузки заказов, сведения о доставке и идентификатор загруженной партии `_load_id`.", "what": "Сырые строки слепка заказов, метаданные доставки и идентификатор пачки `_load_id`.",
"read": "Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.", "read": "Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.",
"parts": [ "parts": [
"`stg.orders_raw_kafka` — таблица для чтения топика `orders` на первом узле.", "`stg.orders_raw_kafka` — чтец топика `orders` на первой ноде.",
"`stg.orders_raw_rep` — реплицируемая таблица на шарде.", "`stg.orders_raw_rep` — реплицируемая таблица на шарде.",
"`stg.orders_raw_dist` — распределенная таблица для записи и чтения." "`stg.orders_raw_dist` — распределенная поверхность записи и чтения."
], ],
"fill": [ "fill": [
"Задача `pull_batch` дага `orders_ingest` загружает слепок раз в модельные сутки.", "Задача `pull_batch` дага `orders_ingest` забирает слепок пачкой раз в модельные сутки.",
"Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL." "Сырые строки живут трое суток реального времени плюс хвост до суток."
], ],
"links": [ "links": [
{ {
@@ -336,13 +337,13 @@
"streams": [ "streams": [
"events" "events"
], ],
"what": "События, разобранные на колонки с типами данных. Имена полей сохранены как в источнике.", "what": "Типизированное широкое событие с именами полей источника. Запрос представления с `FINAL` возвращает актуальную версию события.",
"read": "Читайте `ods.event_v`: представление использует `FINAL` и возвращает последнюю версию каждого события.", "read": "Читайте `ods.event_v`: запрос представления с `FINAL` возвращает актуальные строки вместо физических версий.",
"parts": [ "parts": [
"`ods.event_rep` — реплицируемая таблица версий события на шарде.", "`ods.event_rep` — реплицируемая таблица версий события на шарде.",
"`ods.event_dist` — распределенная таблица записи и диагностики.", "`ods.event_dist` — распределенная поверхность записи и диагностики.",
"`ods.event_v` — представление актуального состояния с `FINAL`.", "`ods.event_v` — представление актуального состояния с `FINAL`.",
"`ods.event_mv` — материализованное представление для разбора сообщений STG без ошибок." "`ods.event_mv` — матвью разбора годных строк STG."
], ],
"keys": [ "keys": [
"`WatchID` — тот же идентификатор, что `event_id` в DDS.", "`WatchID` — тот же идентификатор, что `event_id` в DDS.",
@@ -376,16 +377,16 @@
"streams": [ "streams": [
"events" "events"
], ],
"what": "Сообщения событий, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и сведения о доставке.", "what": "Сырые события, которые не прошли строгий прием, с классом ошибки и метаданными доставки.",
"read": "Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.", "read": "Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.",
"parts": [ "parts": [
"`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.", "`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.",
"`ods.event_errors_dist` — распределенная таблица чтения и записи.", "`ods.event_errors_dist` — распределенная поверхность чтения и записи.",
"`ods.event_errors_mv` — материализованное представление для отбора сообщений STG с ошибками." "`ods.event_errors_mv` — матвью, которое отбирает негодные строки STG."
], ],
"fill": [ "fill": [
"`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.", "`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.",
"Ошибки хранятся один месяц. Остальные события продолжают загружаться." "Ошибки живут один месяц и не останавливают прием годных событий."
], ],
"links": [ "links": [
{ {
@@ -397,7 +398,7 @@
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql" "url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql"
}, },
{ {
"label": "DDL материализованного представления", "label": "DDL матвью",
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql" "url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql"
} }
] ]
@@ -410,11 +411,11 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Последняя загруженная версия заказа с именами полей из источника. Состав заказа пока хранится как фрагмент JSON.", "what": "Текущая версия заказа на языке источника. Позиции пока остаются сырым фрагментом JSON.",
"read": "Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.", "read": "Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.",
"parts": [ "parts": [
"`ods.order_rep` — реплицируемая таблица версий заказа на шарде.", "`ods.order_rep` — реплицируемая таблица версий заказа на шарде.",
"`ods.order_dist` — распределенная таблица записи и диагностики.", "`ods.order_dist` — распределенная поверхность записи и диагностики.",
"`ods.order_v` — представление текущего состояния с `FINAL`." "`ods.order_v` — представление текущего состояния с `FINAL`."
], ],
"keys": [ "keys": [
@@ -422,7 +423,7 @@
"`user_id` — пользователь магазина, который переходит в заказ DDS." "`user_id` — пользователь магазина, который переходит в заказ DDS."
], ],
"fill": [ "fill": [
"Задача `parse_batch` дага `orders_ingest` разбирает загруженный слепок раз в модельные сутки.", "Задача `parse_batch` дага `orders_ingest` разбирает пачку раз в модельные сутки.",
"`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка." "`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка."
], ],
"links": [ "links": [
@@ -448,15 +449,15 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Строки выгрузки заказов, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и `_load_id`.", "what": "Сырые строки слепка, которые не прошли строгий прием, с классом ошибки и `_load_id`.",
"read": "Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.", "read": "Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.",
"parts": [ "parts": [
"`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.", "`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.",
"`ods.order_errors_dist` — распределенная таблица чтения и записи." "`ods.order_errors_dist` — распределенная поверхность чтения и записи."
], ],
"fill": [ "fill": [
"Задача `parse_batch` дага `orders_ingest` сохраняет строки с ошибками при разборе той же выгрузки, из которой загружаются заказы.", "Задача `parse_batch` дага `orders_ingest` пишет ошибки из той же пачки, что и годные заказы.",
"Ошибки хранятся один месяц. Остальные заказы продолжают загружаться." "Ошибки живут один месяц и не останавливают прием годных заказов."
], ],
"links": [ "links": [
{ {
@@ -477,8 +478,8 @@
"streams": [ "streams": [
"events" "events"
], ],
"what": "События с названиями полей, принятыми в DDS. Например, `WatchID` переименован в `event_id`, а числовой код устройства заменен названием категории.", "what": "Публичное представление события на языке бизнеса. Его запрос переименовывает поля и расшифровывает категорию устройства.",
"read": "Читайте `dds.event_v`: представление возвращает явно перечисленные колонки DDS.", "read": "Читайте `dds.event_v`: запрос представления возвращает явный список колонок DDS.",
"parts": [ "parts": [
"`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет." "`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет."
], ],
@@ -489,7 +490,7 @@
"`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM." "`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM."
], ],
"fill": [ "fill": [
"При запросе к `dds.event_v` данные читаются из актуальных событий ODS. Отдельная загрузка в DDS не нужна." "Данные для `dds.event_v` читаются из актуальных событий ODS без копии и отдельного расписания."
], ],
"links": [ "links": [
{ {
@@ -510,22 +511,22 @@
"streams": [ "streams": [
"events" "events"
], ],
"what": "Сессии, рассчитанные дагом `dds_transform` по событиям посетителя. Новая сессия начинается после паузы дольше 30 минут или при смене модельной даты.", "what": "Сессия, которую запрос дага `dds_transform` собирает из последовательных событий одной куки. В источнике готовой сессии нет.",
"read": "Читайте `dds.session_v`: здесь есть начало, конец, длительность сессии, показатели событий и служебные поля загрузки.", "read": "Читайте `dds.session_v`: запрос представления возвращает деловые и служебные колонки.",
"parts": [ "parts": [
"`dds.session_rep` — реплицируемая таблица сессий на шарде.", "`dds.session_rep` — реплицируемая таблица сессий на шарде.",
"`dds.session_dist` — распределенная таблица загрузки и диагностики.", "`dds.session_dist` — распределенная поверхность загрузки и диагностики.",
"`dds.session_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.", "`dds.session_stage_rep` — донор дневных партиций на шарде.",
"`dds.session_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.", "`dds.session_stage_dist` — распределенная поверхность донора.",
"`dds.session_v` — представление для чтения готовых данных." "`dds.session_v` — публичное представление."
], ],
"keys": [ "keys": [
"`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.", "`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.",
"`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`." "`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`."
], ],
"fill": [ "fill": [
"При каждом запуске `dds_transform` выбирает дни от первого пропущенного в DDS до последнего дня с завершенной генерацией.", "Даг `dds_transform` пересобирает данные прожитых дней при каждом прогоне конвейера.",
"Задача `session.rebuild` рассчитывает данные в промежуточной таблице (доноре). `session.replace` заменяет ими дневные партиции основной таблицы." "Задачи `session.rebuild` и `session.replace` собирают день в доноре и заменяют партицию."
], ],
"links": [ "links": [
{ {
@@ -550,14 +551,14 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Последнее известное состояние заказа, подготовленное для анализа. Артикулы, количества и цены товаров хранятся в массивах; элементы с одинаковым индексом относятся к одной позиции заказа.", "what": "Заказ в текущем состоянии на языке бизнеса. Артикулы, количества и цены позиций хранятся в согласованных массивах.",
"read": "Читайте `dds.order_v`, чтобы получить готовые заказы из основной таблицы.", "read": "Читайте `dds.order_v`: запрос представления возвращает данные без таблиц и донора партиций.",
"parts": [ "parts": [
"`dds.order_rep` — реплицируемая таблица заказов на шарде.", "`dds.order_rep` — реплицируемая таблица заказов на шарде.",
"`dds.order_dist` — распределенная таблица загрузки и диагностики.", "`dds.order_dist` — распределенная поверхность загрузки и диагностики.",
"`dds.order_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.", "`dds.order_stage_rep` — донор дневных партиций на шарде.",
"`dds.order_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.", "`dds.order_stage_dist` — распределенная поверхность донора.",
"`dds.order_v` — представление для чтения готовых данных." "`dds.order_v` — публичное представление."
], ],
"keys": [ "keys": [
"`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.", "`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.",
@@ -566,8 +567,8 @@
"`order_date` — день, который в витринах называется `report_date` или `order_day`." "`order_date` — день, который в витринах называется `report_date` или `order_day`."
], ],
"fill": [ "fill": [
"При каждом запуске `dds_transform` пересчитывает дни заказов из последнего слепка и заполняет пропуски в DDS.", "Даг `dds_transform` пересобирает изменяемое окно при каждом прогоне конвейера.",
"Задача `order.rebuild` рассчитывает данные в промежуточной таблице (доноре). `order.replace` заменяет ими дневные партиции основной таблицы." "Задачи `order.rebuild` и `order.replace` собирают дни в доноре и заменяют партиции."
], ],
"links": [ "links": [
{ {
@@ -593,20 +594,20 @@
"events", "events",
"orders" "orders"
], ],
"what": "Связи между посетителем сайта и пользователем магазина. Они появляются, когда событие покупки сопоставлено с заказом по его номеру.", "what": "Известные пары куки трекера и пользователя магазина. Связь появляется после сопоставленной покупки.",
"read": "Читайте `dds.identity_map_v`: представление с `FINAL` возвращает последнюю версию каждой пары посетителя и пользователя.", "read": "Читайте `dds.identity_map_v`: запрос представления с `FINAL` возвращает актуальные строки полного пересчета.",
"parts": [ "parts": [
"`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.", "`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.",
"`dds.identity_map_dist` — распределенная таблица записи.", "`dds.identity_map_dist` — распределенная поверхность записи.",
"`dds.identity_map_v` — представление последних версий строк с `FINAL`." "`dds.identity_map_v` — точное публичное представление с `FINAL`."
], ],
"keys": [ "keys": [
"`client_id` — идентификатор посетителя из cookie браузера, как в событии и сессии DDS.", "`client_id` — кука из события и сессии DDS.",
"`user_id` — пользователь магазина из заказа DDS." "`user_id` — пользователь магазина из заказа DDS."
], ],
"fill": [ "fill": [
"Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов.", "Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов.",
"При каждом запуске записывается новая версия набора связей." "Новая версия полного набора пишется при каждом прогоне; `FINAL` скрывает физические повторы."
], ],
"links": [ "links": [
{ {
@@ -631,18 +632,19 @@
"streams": [ "streams": [
"events" "events"
], ],
"what": "Число посетителей и известных пользователей за каждый день. Известными считаются пользователи магазина, связанные с посетителями сайта через карту идентичностей.", "what": "Дневные счетчики посетителей и известных пользователей после склейки идентичностей.",
"read": "Читайте `dm.daily_traffic_v`: представление с `FINAL` возвращает последний результат расчета за каждый день.", "read": "Читайте `dm.daily_traffic_v`: запрос представления с `FINAL` возвращает актуальные строки полного пересчета.",
"parts": [ "parts": [
"`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.", "`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.",
"`dm.daily_traffic_dist` — распределенная таблица записи.", "`dm.daily_traffic_dist` — распределенная поверхность записи.",
"`dm.daily_traffic_v` — представление последних версий строк с `FINAL`." "`dm.daily_traffic_v` — точное публичное представление с `FINAL`."
], ],
"keys": [ "keys": [
"`report_date` — тот же день, что `session_date` в сессии DDS." "`report_date` — тот же день, что `session_date` в сессии DDS."
], ],
"fill": [ "fill": [
"Задача `daily_traffic` дага `dm_transform` при каждом запуске пересчитывает всю историю и записывает новую версию." "Задача `daily_traffic` дага `dm_transform` при каждом прогоне пересчитывает всю историю и записывает новую версию.",
"`FINAL` оставляет текущую строку каждого дня."
], ],
"links": [ "links": [
{ {
@@ -667,22 +669,22 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Выручка, число заказов и единиц товара за день по категориям. В расчет входят оплаченные заказы.", "what": "Дневная выручка, число заказов и единиц товара по категориям.",
"read": "Читайте `dm.revenue_daily_v`, чтобы получить готовые показатели по дням и категориям товаров.", "read": "Читайте `dm.revenue_daily_v`: это публичное представление готовой дневной витрины.",
"parts": [ "parts": [
"`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.", "`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.",
"`dm.revenue_daily_dist` — распределенная таблица загрузки.", "`dm.revenue_daily_dist` — распределенная поверхность загрузки.",
"`dm.revenue_daily_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.", "`dm.revenue_daily_stage_rep` — донор дневных партиций на шарде.",
"`dm.revenue_daily_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.", "`dm.revenue_daily_stage_dist` — распределенная поверхность донора.",
"`dm.revenue_daily_v` — представление для чтения готовых данных." "`dm.revenue_daily_v` — публичное представление."
], ],
"keys": [ "keys": [
"`report_date` — тот же день, что `order_date` в заказе DDS.", "`report_date` — тот же день, что `order_date` в заказе DDS.",
"`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря." "`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря."
], ],
"fill": [ "fill": [
"При каждом запуске `dm_transform` пересчитывает дни заказов из последнего слепка и заполняет пропущенные дни витрины.", "Даг `dm_transform` пересобирает изменяемое окно заказов при каждом прогоне конвейера.",
"Задача `revenue_daily.rebuild` рассчитывает данные в промежуточной таблице (доноре). `revenue_daily.replace` заменяет ими дневные партиции витрины." "Задачи `revenue_daily.rebuild` и `revenue_daily.replace` собирают дни в доноре и заменяют партиции."
], ],
"links": [ "links": [
{ {
@@ -708,22 +710,22 @@
"events", "events",
"orders" "orders"
], ],
"what": "Результат сверки покупок из кликстрима с заказами магазина. Для каждого номера заказа указано, совпали ли данные двух источников и какое расхождение найдено.", "what": "Сверка события покупки с заказом магазина. Одна строка показывает совпадение или класс расхождения.",
"read": "Читайте `dm.purchase_vs_orders_v`: если покупка или заказ отсутствует, поля этой стороны содержат `NULL`.", "read": "Читайте `dm.purchase_vs_orders_v`: в результате запроса отсутствующей стороне соответствует `NULL`.",
"parts": [ "parts": [
"`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.", "`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.",
"`dm.purchase_vs_orders_dist` — распределенная таблица загрузки.", "`dm.purchase_vs_orders_dist` — распределенная поверхность загрузки.",
"`dm.purchase_vs_orders_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.", "`dm.purchase_vs_orders_stage_rep` — донор дневных партиций на шарде.",
"`dm.purchase_vs_orders_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.", "`dm.purchase_vs_orders_stage_dist` — распределенная поверхность донора.",
"`dm.purchase_vs_orders_v` — представление для чтения готовых данных." "`dm.purchase_vs_orders_v` — публичное представление."
], ],
"keys": [ "keys": [
"`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS.", "`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS.",
"`order_day` — дата заказа; если заказа еще нет, берется дата события покупки." "`order_day` — общий день события покупки и заказа."
], ],
"fill": [ "fill": [
"При каждом запуске `dm_transform` пересчитывает дни последнего слепка, добавляет новые дни покупок, заполняет пропуски и повторяет сверку дней с покупками без заказа.", "Даг `dm_transform` пересобирает изменяемый хвост обоих источников при каждом прогоне конвейера.",
"Задача `purchase_vs_orders.rebuild` рассчитывает данные в промежуточной таблице (доноре). `purchase_vs_orders.replace` заменяет ими дневные партиции витрины." "Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` собирают дни в доноре и заменяют партиции."
], ],
"links": [ "links": [
{ {
@@ -749,19 +751,19 @@
"events", "events",
"orders" "orders"
], ],
"what": "Последние результаты пяти проверок качества данных за каждый день. Историю запусков можно посмотреть в Airflow.", "what": "Текущее состояние пяти атомарных проверок качества по дням. История запусков остается в Airflow.",
"read": "Читайте `dm.dq_summary_v`, чтобы увидеть результат каждой проверки за день.", "read": "Читайте `dm.dq_summary_v`: запрос к публичному представлению возвращает последнюю опубликованную сводку дня.",
"parts": [ "parts": [
"`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.", "`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.",
"`dm.dq_summary_dist` — распределенная таблица чтения и записи.", "`dm.dq_summary_dist` — распределенная поверхность чтения и записи.",
"`dm.dq_summary_stage_rep` — промежуточная таблица для результатов всех проверок за день.", "`dm.dq_summary_stage_rep` — общий донор дневных партиций.",
"`dm.dq_summary_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.", "`dm.dq_summary_stage_dist` — распределенная поверхность донора.",
"`dm.dq_summary_v` — представление для чтения готовых данных." "`dm.dq_summary_v` — публичное представление."
], ],
"fill": [ "fill": [
"Даг `dq_check` сначала рассчитывает результаты всех пяти проверок в промежуточной таблице, затем заменяет ими дневные партиции сводки.", "Даг `dq_check` при каждом прогоне сначала выполняет все пять проверок, затем заменяет готовые дневные партиции.",
"`sessions_vs_reference` — `sql/dq/sessions_vs_reference.sql`.", "`sessions_vs_reference` — `sql/dq/sessions_vs_reference.sql`.",
"`classes_vs_inventory` — `sql/dq/classes_vs_inventory.sql` и эталонные числа из паспорта мира `data/world-inventory.json`.", "`classes_vs_inventory` — `sql/dq/classes_vs_inventory.sql` и паспорт мира.",
"`revenue_daily_vs_dds` — `sql/dq/revenue_daily_vs_dds.sql`.", "`revenue_daily_vs_dds` — `sql/dq/revenue_daily_vs_dds.sql`.",
"`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.", "`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.",
"`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`." "`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`."
@@ -789,17 +791,17 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Словарь товаров. По артикулу можно получить название, бренд, категорию и цену. Эти данные доступны для расчетов в разных слоях.", "what": "Словарь товаров вне цепочки слоев. По артикулу доступны название, бренд, категория и цена.",
"read": "Читайте `dic.products`: к словарю обращаются напрямую, суффикса `_v` у него нет.", "read": "Читайте `dic.products`: у словаря одна поверхность без суффикса.",
"parts": [ "parts": [
"`dic.products_file` — таблица, которая читает каталог товаров из CSV-файла репозитория.", "`dic.products_file` — чтец CSV-каталога из файла репозитория.",
"`dic.products` — словарь в памяти, загруженный из этой таблицы." "`dic.products` — словарь в памяти поверх подложки."
], ],
"keys": [ "keys": [
"`sku` — тот же артикул, что `item_sku` в позиции заказа DDS." "`sku` — тот же артикул, что `item_sku` в позиции заказа DDS."
], ],
"fill": [ "fill": [
"ClickHouse обновляет словарь из `dic.products_file` каждые 60–90 секунд согласно настройке `LIFETIME`." "ClickHouse перечитывает подложку каждые 60–90 секунд по настройке `LIFETIME`."
], ],
"links": [ "links": [
{ {
@@ -823,8 +825,8 @@
"events" "events"
], ],
"rhythm": "непрерывно, по мере прихода сообщений", "rhythm": "непрерывно, по мере прихода сообщений",
"actor": "Таблица `stg.hits_raw_kafka` и материализованное представление `stg.hits_raw_mv`.", "actor": "Чтец `stg.hits_raw_kafka` и матвью `stg.hits_raw_mv`.",
"join": "Каждое сообщение Kafka записывается целой строкой, без разбора на поля.", "join": "Соединения нет: сообщение кладется строкой целиком.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -845,8 +847,8 @@
"events" "events"
], ],
"rhythm": "непрерывно, по мере прихода сообщений", "rhythm": "непрерывно, по мере прихода сообщений",
"actor": "Материализованное представление `ods.event_mv` разбирает сообщения без ошибок.", "actor": "Матвью `ods.event_mv` разбирает годные строки.",
"join": "Материализованное представление разбирает сообщение из STG на колонки с нужными типами данных.", "join": "Соединения нет: матвью разбирает строку в колонки.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -867,8 +869,8 @@
"events" "events"
], ],
"rhythm": "непрерывно, по мере прихода сообщений", "rhythm": "непрерывно, по мере прихода сообщений",
"actor": "Материализованное представление `ods.event_errors_mv` отбирает сообщения с ошибками.", "actor": "Матвью `ods.event_errors_mv` отбирает строки, которые не прошли строгий прием.",
"join": "Сообщение, которое не удалось разобрать по правилам ODS, сохраняется целиком вместе с классом ошибки.", "join": "Соединения нет: негодная строка уходит целиком вместе с классом ошибки.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -888,9 +890,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "одной выгрузкой, раз в модельные сутки", "rhythm": "пачкой, раз в модельные сутки",
"actor": "Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`.", "actor": "Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`.",
"join": "Строки выгрузки сохраняются целиком. Общий `_load_id` позволяет найти все строки одной загрузки.", "join": "Соединения нет: сообщение слепка кладется целиком, пачка помечена `_load_id`.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -910,9 +912,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "одной выгрузкой, раз в модельные сутки", "rhythm": "пачкой, раз в модельные сутки",
"actor": "Задача `parse_batch` дага `orders_ingest` записывает версии заказов без ошибок разбора.", "actor": "Задача `parse_batch` дага `orders_ingest` пишет годные версии заказа.",
"join": "Строка разбирается на колонки заказа. Версия определяется датой слепка `snapshot_date`.", "join": "Соединения нет: строка разбирается в колонки, версия задается `snapshot_date`.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -932,9 +934,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "одной выгрузкой, раз в модельные сутки", "rhythm": "пачкой, раз в модельные сутки",
"actor": "Задача `parse_batch` дага `orders_ingest` записывает строки с ошибками из загруженной выгрузки.", "actor": "Задача `parse_batch` дага `orders_ingest` пишет негодные строки той же пачки.",
"join": "Строка с ошибкой сохраняется целиком вместе с классом ошибки и идентификатором загрузки.", "join": "Соединения нет: негодная строка пачки уходит целиком.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -954,9 +956,9 @@
"streams": [ "streams": [
"events" "events"
], ],
"rhythm": "при запросе к представлению, без копирования данных", "rhythm": "без копии, данные читаются на месте",
"actor": "Представление `dds.event_v` читает данные из `ods.event_v`.", "actor": "Запрос `dds.event_v` читает данные из `ods.event_v` и переименовывает поля на языке бизнеса.",
"join": "Колонки события переименовываются по правилам DDS. Данные не копируются в отдельную таблицу.", "join": "Соединения нет: представление переименовывает колонки той же строки.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -976,9 +978,9 @@
"streams": [ "streams": [
"events" "events"
], ],
"rhythm": "при каждом запуске `dds_transform`", "rhythm": "пачкой, при каждом прогоне `dds_transform`",
"actor": "Задачи `session.rebuild` и `session.replace` дага `dds_transform`.", "actor": "Задачи `session.rebuild` и `session.replace` дага `dds_transform`.",
"join": "События одного `client_id` за день `event_date` упорядочиваются по времени. Пауза дольше 30 минут начинает новую сессию. Ее `session_id` рассчитывается как `cityHash64(client_id, min(event_time))`.", "join": "События одной `client_id` за `event_date` режутся по паузам; `session_id` - `cityHash64(client_id, min(event_time))`.",
"links": [ "links": [
{ {
"label": "SQL сборки", "label": "SQL сборки",
@@ -1002,9 +1004,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "при каждом запуске `dds_transform`", "rhythm": "пачкой, при каждом прогоне `dds_transform`",
"actor": "Задачи `order.rebuild` и `order.replace` дага `dds_transform`.", "actor": "Задачи `order.rebuild` и `order.replace` дага `dds_transform`.",
"join": "Поля заказа приводятся к типам DDS, а позиции из JSON разбираются в массивы. Дата `order_date` рассчитывается из `created_at`.", "join": "Соединения нет: из заказа берутся поля и позиции, `order_date` выводится из `created_at`.",
"links": [ "links": [
{ {
"label": "SQL сборки", "label": "SQL сборки",
@@ -1028,9 +1030,9 @@
"streams": [ "streams": [
"events" "events"
], ],
"rhythm": "при каждом запуске `dds_transform`", "rhythm": "пачкой, при каждом прогоне `dds_transform`",
"actor": "Задача `identity_map` дага `dds_transform` соединяет покупки с заказами.", "actor": "Задача `identity_map` дага `dds_transform` соединяет покупки с заказами.",
"join": "Событие покупки соединяется с заказом по `purchase_id[1] = order_id`. В результате связываются посетитель сайта и пользователь магазина.", "join": "Строки покупки и заказа соединяются по `purchase_id[1] = order_id`.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -1050,9 +1052,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "при каждом запуске `dds_transform`", "rhythm": "пачкой, при каждом прогоне `dds_transform`",
"actor": "Задача `identity_map` дага `dds_transform` соединяет заказы с покупками.", "actor": "Задача `identity_map` дага `dds_transform` соединяет заказы с покупками.",
"join": "Заказ соединяется с событием покупки по `order_id = purchase_id[1]`. Из заказа берется `user_id` пользователя магазина.", "join": "Строки заказа и покупки соединяются по `order_id = purchase_id[1]`; берется `user_id`.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -1072,9 +1074,9 @@
"streams": [ "streams": [
"events" "events"
], ],
"rhythm": "при каждом запуске `dm_transform`", "rhythm": "пачкой, при каждом прогоне `dm_transform`",
"actor": "Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям.", "actor": "Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям.",
"join": "К сессиям присоединяется карта идентичностей по `client_id`, затем данные группируются по `session_date`. Эта дата записывается в `report_date`.", "join": "Соединения нет: сессии группируются по `session_date`, который становится `report_date`.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -1094,9 +1096,9 @@
"streams": [ "streams": [
"events" "events"
], ],
"rhythm": "при каждом запуске `dm_transform`", "rhythm": "пачкой, при каждом прогоне `dm_transform`",
"actor": "Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`.", "actor": "Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`.",
"join": "`GLOBAL LEFT JOIN` присоединяет карту идентичностей к сессиям по `client_id`. Сессии без известного пользователя тоже участвуют в расчете посетителей.", "join": "GLOBAL LEFT JOIN по `client_id`; сессии без пары остаются в витрине.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -1116,9 +1118,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "при каждом запуске `dm_transform`", "rhythm": "пачкой, при каждом прогоне `dm_transform`",
"actor": "Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`.", "actor": "Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`.",
"join": "`ARRAY JOIN` разворачивает массивы `item_sku`, `item_quantity`, `item_price` в строки позиций заказа. Выручка считается по оплаченным заказам; `order_date` записывается в `report_date`.", "join": "Соединения таблиц нет: ARRAY JOIN раскрывает `item_sku`, `item_quantity`, `item_price`; `order_date` становится `report_date`.",
"links": [ "links": [
{ {
"label": "SQL сборки", "label": "SQL сборки",
@@ -1142,9 +1144,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "при каждом запуске `dm_transform`", "rhythm": "пачкой, при каждом прогоне `dm_transform`",
"actor": "Задача `revenue_daily.rebuild` дага `dm_transform`.", "actor": "Даг `dm_transform` получает категорию через `dictGet` по `item_sku`; ключ словаря — `sku`.",
"join": "Функция `dictGet` получает категорию товара по артикулу `item_sku` из заказа. В словаре этому артикулу соответствует ключ `sku`.", "join": "Категория читается через `dictGet` по `item_sku`; ключ справочника - `sku`.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -1164,9 +1166,9 @@
"streams": [ "streams": [
"events" "events"
], ],
"rhythm": "при каждом запуске `dm_transform`", "rhythm": "пачкой, при каждом прогоне `dm_transform`",
"actor": "Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки.", "actor": "Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки.",
"join": "События покупки группируются по номеру `purchase_id[1]`, который получает имя `purchase_order_id`. Дата `event_date` записывается в `purchase_day`. Затем `GLOBAL FULL OUTER JOIN` сопоставляет покупки с заказами по `purchase_order_id = backend_order_id`.", "join": "Покупки группируются по `purchase_id[1]` как `purchase_order_id`; `event_date` становится `purchase_day`, затем GLOBAL FULL OUTER JOIN `purchase_order_id = backend_order_id`.",
"links": [ "links": [
{ {
"label": "SQL сборки", "label": "SQL сборки",
@@ -1190,9 +1192,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "при каждом запуске `dm_transform`", "rhythm": "пачкой, при каждом прогоне `dm_transform`",
"actor": "Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают заказы.", "actor": "Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают заказы.",
"join": "`order_id` заказа получает имя `backend_order_id`, а `purchase_id[1]` покупки — `purchase_order_id`. `GLOBAL FULL OUTER JOIN` соединяет их по `purchase_order_id = backend_order_id`. Покупки без заказа и заказы без покупки сохраняются; поля отсутствующей стороны содержат `NULL`.", "join": "GLOBAL FULL OUTER JOIN: `purchase_order_id = backend_order_id` (`purchase_id[1]` и `order_id`); отсутствующая сторона получает NULL.",
"links": [ "links": [
{ {
"label": "SQL сборки", "label": "SQL сборки",
@@ -1306,9 +1308,9 @@
details.append(heading, meta); details.append(heading, meta);
addSection("Что это", node.what); addSection("Что это", node.what);
addSection("Как читать", node.read); addSection("Как читать", node.read);
addSection("Связанные таблицы и представления", node.parts); addSection("Из чего состоит", node.parts);
addSection("Как связать данные", node.keys); addSection("Ключи", node.keys);
addSection("Как обновляются данные", node.fill); addSection("Кто наполняет и как часто", node.fill);
addLinks(node.links); addLinks(node.links);
} }
function renderEdgeCard(edge) { function renderEdgeCard(edge) {
@@ -1316,10 +1318,10 @@
const heading = document.createElement("h2"); const heading = document.createElement("h2");
heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`; heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`;
details.append(heading); details.append(heading);
addSection("Способ обработки", mechanismById.get(edge.mechanism).label); addSection("Механизм", mechanismById.get(edge.mechanism).label);
addSection("Когда выполняется", edge.rhythm); addSection("Ритм", edge.rhythm);
addSection("Что выполняет обработку", edge.actor); addSection("Кто именно", edge.actor);
addSection("Что происходит с данными", edge.join); addSection("Как соединяет", edge.join);
addLinks(edge.links); addLinks(edge.links);
} }
function layoutMap() { function layoutMap() {
+152 -150
View File
@@ -1,12 +1,13 @@
title: Карта слоев хранилища title: Карта слоев хранилища
lead: Проследите путь событий и заказов от Kafka до отчетов. lead: Проследите, как один смысл меняет имя и поверхность между слоями.
chapter_url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои chapter_url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои
intro: intro:
- Выберите объект, чтобы узнать, что он хранит, как его читать и откуда в него поступают данные. - Эта карта показывает путь событий и заказов от Kafka до витрин DM.
- Выберите стрелку, чтобы посмотреть способ обработки и открыть SQL-запрос. - Щелкните по узлу, чтобы увидеть его назначение, устройство и ключи.
- Для выбранного объекта подсвечиваются все пути, по которым данные поступают в него и используются дальше. - Щелкните по стрелке, чтобы узнать, кто и каким запросом переносит данные.
- Кнопки «События» и «Заказы» выделяют нужный поток. Остальные объекты остаются на месте, но становятся бледнее. - Когда вы выбираете узел, карта подсвечивает все пути к нему и от него.
- Если вы впервые знакомитесь со слоями хранилища, начните с главы по ссылке ниже. - Переключатель потока приглушает другую часть карты, но не меняет раскладку.
- Сначала прочитайте главу «Хранилище и его слои», затем вернитесь к карте и рассмотрите объекты.
streams: streams:
all: Все all: Все
@@ -15,38 +16,38 @@ streams:
dictionary: dictionary:
title: Словарь title: Словарь
caption: справочные данные caption: 'сбоку цепочки: справочники'
layers: layers:
- id: kafka - id: kafka
title: Kafka title: Kafka
caption: сообщения источников caption: откуда приходит
- id: stg - id: stg
title: STG title: STG
caption: исходные сообщения caption: как поступило
- id: ods - id: ods
title: ODS title: ODS
caption: данные разобраны по полям caption: язык источника
- id: dds - id: dds
title: DDS title: DDS
caption: события, заказы и сессии caption: язык бизнеса
- id: dm - id: dm
title: DM title: DM
caption: витрины для отчетов caption: данные для дашбордов
mechanisms: mechanisms:
- id: materialized_view - id: materialized_view
label: материализованное представление label: матвью
line: solid line: solid
rhythm: непрерывно, по мере прихода сообщений rhythm: непрерывно, по мере прихода сообщений
- id: airflow_dag - id: airflow_dag
label: даг Airflow label: даг Airflow
line: dashed line: dashed
rhythm: 'пакетно: раз в модельные сутки (`orders_ingest`) или при каждом запуске конвейера' rhythm: 'пачкой: раз в модельные сутки (`orders_ingest`) или на каждом прогоне конвейера'
- id: view - id: view
label: представление label: представление
line: dotted line: dotted
rhythm: при запросе к представлению, без копирования данных rhythm: без копии, данные читаются на месте
nodes: nodes:
- id: kafka_hits - id: kafka_hits
@@ -55,9 +56,9 @@ nodes:
order: 0 order: 0
streams: [events] streams: [events]
what: Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие. what: Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие.
read: "Для просмотра загруженных сообщений используйте `stg.hits_raw_dist`. Таблица `stg.hits_raw_kafka` служит для приема из топика." read: 'Топик напрямую не читают: таблица `stg.hits_raw_kafka` забирает сообщения из топика. Для данных событий используйте `stg.hits_raw_dist`.'
fill: fill:
- "Генератор отправляет события без пауз в пакетном режиме или постепенно в живом режиме." - Источник событий пишет сообщения в топик в темпе выбранного режима мира.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
@@ -69,10 +70,10 @@ nodes:
layer: kafka layer: kafka
order: 1 order: 1
streams: [orders] streams: [orders]
what: "Топик Kafka с дневными выгрузками заказов — слепками. Каждый слепок содержит состояние заказов за последние семь модельных дней." what: Топик Kafka со слепками заказов магазина. После модельного дня приходит снимок изменяемого окна.
read: "Для просмотра загруженных сообщений используйте `stg.orders_raw_dist`. Таблица `stg.orders_raw_kafka` служит для приема из топика." read: 'Топик напрямую не читают: таблица `stg.orders_raw_kafka` забирает сообщения из топика. Для заказов используйте `stg.orders_raw_dist`.'
fill: fill:
- "После генерации событий дня D источник отправляет слепок заказов за предыдущий день D−1." - Источник заказов публикует слепок пачкой после каждого модельного дня.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных
@@ -84,22 +85,22 @@ nodes:
layer: stg layer: stg
order: 0 order: 0
streams: [events] streams: [events]
what: "Исходное сообщение события и сведения о его доставке из Kafka. Содержимое сохраняется целой строкой, без разбора на поля." what: Сырая строка события и метаданные доставки. STG сохраняет сообщение без разбора.
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.' read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
parts: parts:
- "`stg.hits_raw_kafka` — таблица для чтения сообщений топика `hits`." - '`stg.hits_raw_kafka` — чтец топика `hits`.'
- "`stg.hits_raw_rep` — реплицируемая таблица на шарде." - '`stg.hits_raw_rep` — реплицируемая таблица на шарде.'
- "`stg.hits_raw_dist` — распределенная таблица для записи и чтения." - '`stg.hits_raw_dist` — распределенная поверхность записи и чтения.'
- "`stg.hits_raw_mv` — материализованное представление, которое записывает сообщения из Kafka в STG." - '`stg.hits_raw_mv` — матвью приема сообщений из чтеца.'
fill: fill:
- '`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.' - '`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.'
- Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL. - Сырые строки живут трое суток реального времени плюс хвост до суток.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
- label: DDL таблиц - label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql
- label: DDL материализованного представления - label: DDL матвью
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql
- id: stg_orders - id: stg_orders
@@ -107,15 +108,15 @@ nodes:
layer: stg layer: stg
order: 1 order: 1
streams: [orders] streams: [orders]
what: "Исходные строки выгрузки заказов, сведения о доставке и идентификатор загруженной партии `_load_id`." what: Сырые строки слепка заказов, метаданные доставки и идентификатор пачки `_load_id`.
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.' read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
parts: parts:
- "`stg.orders_raw_kafka` — таблица для чтения топика `orders` на первом узле." - '`stg.orders_raw_kafka` — чтец топика `orders` на первой ноде.'
- "`stg.orders_raw_rep` — реплицируемая таблица на шарде." - '`stg.orders_raw_rep` — реплицируемая таблица на шарде.'
- "`stg.orders_raw_dist` — распределенная таблица для записи и чтения." - '`stg.orders_raw_dist` — распределенная поверхность записи и чтения.'
fill: fill:
- Задача `pull_batch` дага `orders_ingest` загружает слепок раз в модельные сутки. - Задача `pull_batch` дага `orders_ingest` забирает слепок пачкой раз в модельные сутки.
- Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL. - Сырые строки живут трое суток реального времени плюс хвост до суток.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных
@@ -127,13 +128,13 @@ nodes:
layer: ods layer: ods
order: 0 order: 0
streams: [events] streams: [events]
what: "События, разобранные на колонки с типами данных. Имена полей сохранены как в источнике." what: Типизированное широкое событие с именами полей источника. Запрос представления с `FINAL` возвращает актуальную версию события.
read: "Читайте `ods.event_v`: представление использует `FINAL` и возвращает последнюю версию каждого события." read: 'Читайте `ods.event_v`: запрос представления с `FINAL` возвращает актуальные строки вместо физических версий.'
parts: parts:
- '`ods.event_rep` — реплицируемая таблица версий события на шарде.' - '`ods.event_rep` — реплицируемая таблица версий события на шарде.'
- '`ods.event_dist` — распределенная таблица записи и диагностики.' - '`ods.event_dist` — распределенная поверхность записи и диагностики.'
- '`ods.event_v` — представление актуального состояния с `FINAL`.' - '`ods.event_v` — представление актуального состояния с `FINAL`.'
- '`ods.event_mv` — материализованное представление для разбора сообщений STG без ошибок.' - '`ods.event_mv` — матвью разбора годных строк STG.'
keys: keys:
- '`WatchID` — тот же идентификатор, что `event_id` в DDS.' - '`WatchID` — тот же идентификатор, что `event_id` в DDS.'
- '`ClientID` — тот же посетитель, что `client_id` в DDS.' - '`ClientID` — тот же посетитель, что `client_id` в DDS.'
@@ -154,21 +155,21 @@ nodes:
layer: ods layer: ods
order: 1 order: 1
streams: [events] streams: [events]
what: "Сообщения событий, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и сведения о доставке." what: Сырые события, которые не прошли строгий прием, с классом ошибки и метаданными доставки.
read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.' read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.'
parts: parts:
- '`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.' - '`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.'
- '`ods.event_errors_dist` — распределенная таблица чтения и записи.' - '`ods.event_errors_dist` — распределенная поверхность чтения и записи.'
- '`ods.event_errors_mv` — материализованное представление для отбора сообщений STG с ошибками.' - '`ods.event_errors_mv` — матвью, которое отбирает негодные строки STG.'
fill: fill:
- '`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.' - '`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.'
- Ошибки хранятся один месяц. Остальные события продолжают загружаться. - Ошибки живут один месяц и не останавливают прием годных событий.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
- label: DDL таблиц - label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
- label: DDL материализованного представления - label: DDL матвью
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
- id: ods_order - id: ods_order
@@ -176,17 +177,17 @@ nodes:
layer: ods layer: ods
order: 2 order: 2
streams: [orders] streams: [orders]
what: "Последняя загруженная версия заказа с именами полей из источника. Состав заказа пока хранится как фрагмент JSON." what: Текущая версия заказа на языке источника. Позиции пока остаются сырым фрагментом JSON.
read: 'Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.' read: 'Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.'
parts: parts:
- '`ods.order_rep` — реплицируемая таблица версий заказа на шарде.' - '`ods.order_rep` — реплицируемая таблица версий заказа на шарде.'
- '`ods.order_dist` — распределенная таблица записи и диагностики.' - '`ods.order_dist` — распределенная поверхность записи и диагностики.'
- '`ods.order_v` — представление текущего состояния с `FINAL`.' - '`ods.order_v` — представление текущего состояния с `FINAL`.'
keys: keys:
- '`order_id` — тот же заказ в `dds.order_v` и `dm.purchase_vs_orders_v`.' - '`order_id` — тот же заказ в `dds.order_v` и `dm.purchase_vs_orders_v`.'
- '`user_id` — пользователь магазина, который переходит в заказ DDS.' - '`user_id` — пользователь магазина, который переходит в заказ DDS.'
fill: fill:
- Задача `parse_batch` дага `orders_ingest` разбирает загруженный слепок раз в модельные сутки. - Задача `parse_batch` дага `orders_ingest` разбирает пачку раз в модельные сутки.
- '`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка.' - '`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка.'
links: links:
- label: Справочник - label: Справочник
@@ -201,14 +202,14 @@ nodes:
layer: ods layer: ods
order: 3 order: 3
streams: [orders] streams: [orders]
what: "Строки выгрузки заказов, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и `_load_id`." what: Сырые строки слепка, которые не прошли строгий прием, с классом ошибки и `_load_id`.
read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.' read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.'
parts: parts:
- '`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.' - '`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.'
- '`ods.order_errors_dist` — распределенная таблица чтения и записи.' - '`ods.order_errors_dist` — распределенная поверхность чтения и записи.'
fill: fill:
- Задача `parse_batch` дага `orders_ingest` сохраняет строки с ошибками при разборе той же выгрузки, из которой загружаются заказы. - Задача `parse_batch` дага `orders_ingest` пишет ошибки из той же пачки, что и годные заказы.
- Ошибки хранятся один месяц. Остальные заказы продолжают загружаться. - Ошибки живут один месяц и не останавливают прием годных заказов.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
@@ -220,8 +221,8 @@ nodes:
layer: dds layer: dds
order: 0 order: 0
streams: [events] streams: [events]
what: "События с названиями полей, принятыми в DDS. Например, `WatchID` переименован в `event_id`, а числовой код устройства заменен названием категории." what: Публичное представление события на языке бизнеса. Его запрос переименовывает поля и расшифровывает категорию устройства.
read: "Читайте `dds.event_v`: представление возвращает явно перечисленные колонки DDS." read: 'Читайте `dds.event_v`: запрос представления возвращает явный список колонок DDS.'
parts: parts:
- '`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет.' - '`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет.'
keys: keys:
@@ -230,7 +231,7 @@ nodes:
- '`event_date` — тот же день, что `EventDate` в ODS.' - '`event_date` — тот же день, что `EventDate` в ODS.'
- '`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM.' - '`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM.'
fill: fill:
- "При запросе к `dds.event_v` данные читаются из актуальных событий ODS. Отдельная загрузка в DDS не нужна." - Данные для `dds.event_v` читаются из актуальных событий ODS без копии и отдельного расписания.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма
@@ -242,20 +243,20 @@ nodes:
layer: dds layer: dds
order: 1 order: 1
streams: [events] streams: [events]
what: "Сессии, рассчитанные дагом `dds_transform` по событиям посетителя. Новая сессия начинается после паузы дольше 30 минут или при смене модельной даты." what: Сессия, которую запрос дага `dds_transform` собирает из последовательных событий одной куки. В источнике готовой сессии нет.
read: "Читайте `dds.session_v`: здесь есть начало, конец, длительность сессии, показатели событий и служебные поля загрузки." read: 'Читайте `dds.session_v`: запрос представления возвращает деловые и служебные колонки.'
parts: parts:
- '`dds.session_rep` — реплицируемая таблица сессий на шарде.' - '`dds.session_rep` — реплицируемая таблица сессий на шарде.'
- '`dds.session_dist` — распределенная таблица загрузки и диагностики.' - '`dds.session_dist` — распределенная поверхность загрузки и диагностики.'
- '`dds.session_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.' - '`dds.session_stage_rep` — донор дневных партиций на шарде.'
- '`dds.session_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.' - '`dds.session_stage_dist` — распределенная поверхность донора.'
- '`dds.session_v` — представление для чтения готовых данных.' - '`dds.session_v` — публичное представление.'
keys: keys:
- '`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.' - '`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.'
- '`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`.' - '`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`.'
fill: fill:
- "При каждом запуске `dds_transform` выбирает дни от первого пропущенного в DDS до последнего дня с завершенной генерацией." - Даг `dds_transform` пересобирает данные прожитых дней при каждом прогоне конвейера.
- "Задача `session.rebuild` рассчитывает данные в промежуточной таблице (доноре). `session.replace` заменяет ими дневные партиции основной таблицы." - Задачи `session.rebuild` и `session.replace` собирают день в доноре и заменяют партицию.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение
@@ -269,22 +270,22 @@ nodes:
layer: dds layer: dds
order: 2 order: 2
streams: [orders] streams: [orders]
what: "Последнее известное состояние заказа, подготовленное для анализа. Артикулы, количества и цены товаров хранятся в массивах; элементы с одинаковым индексом относятся к одной позиции заказа." what: Заказ в текущем состоянии на языке бизнеса. Артикулы, количества и цены позиций хранятся в согласованных массивах.
read: "Читайте `dds.order_v`, чтобы получить готовые заказы из основной таблицы." read: 'Читайте `dds.order_v`: запрос представления возвращает данные без таблиц и донора партиций.'
parts: parts:
- '`dds.order_rep` — реплицируемая таблица заказов на шарде.' - '`dds.order_rep` — реплицируемая таблица заказов на шарде.'
- '`dds.order_dist` — распределенная таблица загрузки и диагностики.' - '`dds.order_dist` — распределенная поверхность загрузки и диагностики.'
- '`dds.order_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.' - '`dds.order_stage_rep` — донор дневных партиций на шарде.'
- '`dds.order_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.' - '`dds.order_stage_dist` — распределенная поверхность донора.'
- '`dds.order_v` — представление для чтения готовых данных.' - '`dds.order_v` — публичное представление.'
keys: keys:
- '`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.' - '`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.'
- '`user_id` — пользователь магазина, который входит в карту идентичностей.' - '`user_id` — пользователь магазина, который входит в карту идентичностей.'
- '`item_sku` — тот же артикул, что `sku` в `dic.products`.' - '`item_sku` — тот же артикул, что `sku` в `dic.products`.'
- '`order_date` — день, который в витринах называется `report_date` или `order_day`.' - '`order_date` — день, который в витринах называется `report_date` или `order_day`.'
fill: fill:
- "При каждом запуске `dds_transform` пересчитывает дни заказов из последнего слепка и заполняет пропуски в DDS." - Даг `dds_transform` пересобирает изменяемое окно при каждом прогоне конвейера.
- "Задача `order.rebuild` рассчитывает данные в промежуточной таблице (доноре). `order.replace` заменяет ими дневные партиции основной таблицы." - Задачи `order.rebuild` и `order.replace` собирают дни в доноре и заменяют партиции.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение
@@ -298,18 +299,18 @@ nodes:
layer: dds layer: dds
order: 3 order: 3
streams: [events, orders] streams: [events, orders]
what: "Связи между посетителем сайта и пользователем магазина. Они появляются, когда событие покупки сопоставлено с заказом по его номеру." what: Известные пары куки трекера и пользователя магазина. Связь появляется после сопоставленной покупки.
read: "Читайте `dds.identity_map_v`: представление с `FINAL` возвращает последнюю версию каждой пары посетителя и пользователя." read: 'Читайте `dds.identity_map_v`: запрос представления с `FINAL` возвращает актуальные строки полного пересчета.'
parts: parts:
- '`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.' - '`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.'
- '`dds.identity_map_dist` — распределенная таблица записи.' - '`dds.identity_map_dist` — распределенная поверхность записи.'
- '`dds.identity_map_v` — представление последних версий строк с `FINAL`.' - '`dds.identity_map_v` — точное публичное представление с `FINAL`.'
keys: keys:
- "`client_id` — идентификатор посетителя из cookie браузера, как в событии и сессии DDS." - '`client_id` — кука из события и сессии DDS.'
- "`user_id` — пользователь магазина из заказа DDS." - '`user_id` — пользователь магазина из заказа DDS.'
fill: fill:
- "Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов." - Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов.
- "При каждом запуске записывается новая версия набора связей." - Новая версия полного набора пишется при каждом прогоне; `FINAL` скрывает физические повторы.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка
@@ -323,16 +324,17 @@ nodes:
layer: dm layer: dm
order: 0 order: 0
streams: [events] streams: [events]
what: "Число посетителей и известных пользователей за каждый день. Известными считаются пользователи магазина, связанные с посетителями сайта через карту идентичностей." what: Дневные счетчики посетителей и известных пользователей после склейки идентичностей.
read: "Читайте `dm.daily_traffic_v`: представление с `FINAL` возвращает последний результат расчета за каждый день." read: 'Читайте `dm.daily_traffic_v`: запрос представления с `FINAL` возвращает актуальные строки полного пересчета.'
parts: parts:
- '`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.' - '`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.'
- '`dm.daily_traffic_dist` — распределенная таблица записи.' - '`dm.daily_traffic_dist` — распределенная поверхность записи.'
- '`dm.daily_traffic_v` — представление последних версий строк с `FINAL`.' - '`dm.daily_traffic_v` — точное публичное представление с `FINAL`.'
keys: keys:
- '`report_date` — тот же день, что `session_date` в сессии DDS.' - '`report_date` — тот же день, что `session_date` в сессии DDS.'
fill: fill:
- "Задача `daily_traffic` дага `dm_transform` при каждом запуске пересчитывает всю историю и записывает новую версию." - Задача `daily_traffic` дага `dm_transform` при каждом прогоне пересчитывает всю историю и записывает новую версию.
- '`FINAL` оставляет текущую строку каждого дня.'
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v
@@ -346,20 +348,20 @@ nodes:
layer: dm layer: dm
order: 1 order: 1
streams: [orders] streams: [orders]
what: "Выручка, число заказов и единиц товара за день по категориям. В расчет входят оплаченные заказы." what: Дневная выручка, число заказов и единиц товара по категориям.
read: "Читайте `dm.revenue_daily_v`, чтобы получить готовые показатели по дням и категориям товаров." read: 'Читайте `dm.revenue_daily_v`: это публичное представление готовой дневной витрины.'
parts: parts:
- '`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.' - '`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.'
- '`dm.revenue_daily_dist` — распределенная таблица загрузки.' - '`dm.revenue_daily_dist` — распределенная поверхность загрузки.'
- '`dm.revenue_daily_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.' - '`dm.revenue_daily_stage_rep` — донор дневных партиций на шарде.'
- '`dm.revenue_daily_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.' - '`dm.revenue_daily_stage_dist` — распределенная поверхность донора.'
- '`dm.revenue_daily_v` — представление для чтения готовых данных.' - '`dm.revenue_daily_v` — публичное представление.'
keys: keys:
- '`report_date` — тот же день, что `order_date` в заказе DDS.' - '`report_date` — тот же день, что `order_date` в заказе DDS.'
- '`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря.' - '`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря.'
fill: fill:
- "При каждом запуске `dm_transform` пересчитывает дни заказов из последнего слепка и заполняет пропущенные дни витрины." - Даг `dm_transform` пересобирает изменяемое окно заказов при каждом прогоне конвейера.
- "Задача `revenue_daily.rebuild` рассчитывает данные в промежуточной таблице (доноре). `revenue_daily.replace` заменяет ими дневные партиции витрины." - Задачи `revenue_daily.rebuild` и `revenue_daily.replace` собирают дни в доноре и заменяют партиции.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v
@@ -373,20 +375,20 @@ nodes:
layer: dm layer: dm
order: 2 order: 2
streams: [events, orders] streams: [events, orders]
what: "Результат сверки покупок из кликстрима с заказами магазина. Для каждого номера заказа указано, совпали ли данные двух источников и какое расхождение найдено." what: Сверка события покупки с заказом магазина. Одна строка показывает совпадение или класс расхождения.
read: "Читайте `dm.purchase_vs_orders_v`: если покупка или заказ отсутствует, поля этой стороны содержат `NULL`." read: 'Читайте `dm.purchase_vs_orders_v`: в результате запроса отсутствующей стороне соответствует `NULL`.'
parts: parts:
- '`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.' - '`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.'
- '`dm.purchase_vs_orders_dist` — распределенная таблица загрузки.' - '`dm.purchase_vs_orders_dist` — распределенная поверхность загрузки.'
- '`dm.purchase_vs_orders_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.' - '`dm.purchase_vs_orders_stage_rep` — донор дневных партиций на шарде.'
- '`dm.purchase_vs_orders_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.' - '`dm.purchase_vs_orders_stage_dist` — распределенная поверхность донора.'
- '`dm.purchase_vs_orders_v` — представление для чтения готовых данных.' - '`dm.purchase_vs_orders_v` — публичное представление.'
keys: keys:
- "`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS." - '`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS.'
- "`order_day` — дата заказа; если заказа еще нет, берется дата события покупки." - '`order_day` — общий день события покупки и заказа.'
fill: fill:
- "При каждом запуске `dm_transform` пересчитывает дни последнего слепка, добавляет новые дни покупок, заполняет пропуски и повторяет сверку дней с покупками без заказа." - Даг `dm_transform` пересобирает изменяемый хвост обоих источников при каждом прогоне конвейера.
- "Задача `purchase_vs_orders.rebuild` рассчитывает данные в промежуточной таблице (доноре). `purchase_vs_orders.replace` заменяет ими дневные партиции витрины." - Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` собирают дни в доноре и заменяют партиции.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v
@@ -400,18 +402,18 @@ nodes:
layer: dm layer: dm
order: 3 order: 3
streams: [events, orders] streams: [events, orders]
what: "Последние результаты пяти проверок качества данных за каждый день. Историю запусков можно посмотреть в Airflow." what: Текущее состояние пяти атомарных проверок качества по дням. История запусков остается в Airflow.
read: "Читайте `dm.dq_summary_v`, чтобы увидеть результат каждой проверки за день." read: 'Читайте `dm.dq_summary_v`: запрос к публичному представлению возвращает последнюю опубликованную сводку дня.'
parts: parts:
- '`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.' - '`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.'
- '`dm.dq_summary_dist` — распределенная таблица чтения и записи.' - '`dm.dq_summary_dist` — распределенная поверхность чтения и записи.'
- '`dm.dq_summary_stage_rep` — промежуточная таблица для результатов всех проверок за день.' - '`dm.dq_summary_stage_rep` — общий донор дневных партиций.'
- '`dm.dq_summary_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.' - '`dm.dq_summary_stage_dist` — распределенная поверхность донора.'
- '`dm.dq_summary_v` — представление для чтения готовых данных.' - '`dm.dq_summary_v` — публичное представление.'
fill: fill:
- Даг `dq_check` сначала рассчитывает результаты всех пяти проверок в промежуточной таблице, затем заменяет ими дневные партиции сводки. - Даг `dq_check` при каждом прогоне сначала выполняет все пять проверок, затем заменяет готовые дневные партиции.
- '`sessions_vs_reference` — `sql/dq/sessions_vs_reference.sql`.' - '`sessions_vs_reference` — `sql/dq/sessions_vs_reference.sql`.'
- '`classes_vs_inventory` — `sql/dq/classes_vs_inventory.sql` и эталонные числа из паспорта мира `data/world-inventory.json`.' - '`classes_vs_inventory` — `sql/dq/classes_vs_inventory.sql` и паспорт мира.'
- '`revenue_daily_vs_dds` — `sql/dq/revenue_daily_vs_dds.sql`.' - '`revenue_daily_vs_dds` — `sql/dq/revenue_daily_vs_dds.sql`.'
- '`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.' - '`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.'
- '`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`.' - '`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`.'
@@ -428,15 +430,15 @@ nodes:
layer: dic layer: dic
order: 0 order: 0
streams: [orders] streams: [orders]
what: "Словарь товаров. По артикулу можно получить название, бренд, категорию и цену. Эти данные доступны для расчетов в разных слоях." what: Словарь товаров вне цепочки слоев. По артикулу доступны название, бренд, категория и цена.
read: "Читайте `dic.products`: к словарю обращаются напрямую, суффикса `_v` у него нет." read: 'Читайте `dic.products`: у словаря одна поверхность без суффикса.'
parts: parts:
- "`dic.products_file` — таблица, которая читает каталог товаров из CSV-файла репозитория." - '`dic.products_file` — чтец CSV-каталога из файла репозитория.'
- "`dic.products` — словарь в памяти, загруженный из этой таблицы." - '`dic.products` — словарь в памяти поверх подложки.'
keys: keys:
- '`sku` — тот же артикул, что `item_sku` в позиции заказа DDS.' - '`sku` — тот же артикул, что `item_sku` в позиции заказа DDS.'
fill: fill:
- "ClickHouse обновляет словарь из `dic.products_file` каждые 60–90 секунд согласно настройке `LIFETIME`." - ClickHouse перечитывает подложку каждые 60–90 секунд по настройке `LIFETIME`.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-справочники url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-справочники
@@ -450,8 +452,8 @@ edges:
mechanism: materialized_view mechanism: materialized_view
streams: [events] streams: [events]
rhythm: непрерывно, по мере прихода сообщений rhythm: непрерывно, по мере прихода сообщений
actor: "Таблица `stg.hits_raw_kafka` и материализованное представление `stg.hits_raw_mv`." actor: Чтец `stg.hits_raw_kafka` и матвью `stg.hits_raw_mv`.
join: "Каждое сообщение Kafka записывается целой строкой, без разбора на поля." join: 'Соединения нет: сообщение кладется строкой целиком.'
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql
@@ -464,8 +466,8 @@ edges:
mechanism: materialized_view mechanism: materialized_view
streams: [events] streams: [events]
rhythm: непрерывно, по мере прихода сообщений rhythm: непрерывно, по мере прихода сообщений
actor: Материализованное представление `ods.event_mv` разбирает сообщения без ошибок. actor: Матвью `ods.event_mv` разбирает годные строки.
join: "Материализованное представление разбирает сообщение из STG на колонки с нужными типами данных." join: 'Соединения нет: матвью разбирает строку в колонки.'
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
@@ -478,8 +480,8 @@ edges:
mechanism: materialized_view mechanism: materialized_view
streams: [events] streams: [events]
rhythm: непрерывно, по мере прихода сообщений rhythm: непрерывно, по мере прихода сообщений
actor: "Материализованное представление `ods.event_errors_mv` отбирает сообщения с ошибками." actor: Матвью `ods.event_errors_mv` отбирает строки, которые не прошли строгий прием.
join: "Сообщение, которое не удалось разобрать по правилам ODS, сохраняется целиком вместе с классом ошибки." join: 'Соединения нет: негодная строка уходит целиком вместе с классом ошибки.'
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
@@ -491,9 +493,9 @@ edges:
to: stg_orders to: stg_orders
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: одной выгрузкой, раз в модельные сутки rhythm: пачкой, раз в модельные сутки
actor: Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`. actor: Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`.
join: "Строки выгрузки сохраняются целиком. Общий `_load_id` позволяет найти все строки одной загрузки." join: 'Соединения нет: сообщение слепка кладется целиком, пачка помечена `_load_id`.'
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/stg/orders_raw_load.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/stg/orders_raw_load.sql
@@ -505,9 +507,9 @@ edges:
to: ods_order to: ods_order
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: одной выгрузкой, раз в модельные сутки rhythm: пачкой, раз в модельные сутки
actor: Задача `parse_batch` дага `orders_ingest` записывает версии заказов без ошибок разбора. actor: Задача `parse_batch` дага `orders_ingest` пишет годные версии заказа.
join: "Строка разбирается на колонки заказа. Версия определяется датой слепка `snapshot_date`." join: 'Соединения нет: строка разбирается в колонки, версия задается `snapshot_date`.'
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql
@@ -519,9 +521,9 @@ edges:
to: ods_order_errors to: ods_order_errors
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: одной выгрузкой, раз в модельные сутки rhythm: пачкой, раз в модельные сутки
actor: Задача `parse_batch` дага `orders_ingest` записывает строки с ошибками из загруженной выгрузки. actor: Задача `parse_batch` дага `orders_ingest` пишет негодные строки той же пачки.
join: "Строка с ошибкой сохраняется целиком вместе с классом ошибки и идентификатором загрузки." join: 'Соединения нет: негодная строка пачки уходит целиком.'
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql
@@ -533,9 +535,9 @@ edges:
to: dds_event to: dds_event
mechanism: view mechanism: view
streams: [events] streams: [events]
rhythm: при запросе к представлению, без копирования данных rhythm: без копии, данные читаются на месте
actor: "Представление `dds.event_v` читает данные из `ods.event_v`." actor: Запрос `dds.event_v` читает данные из `ods.event_v` и переименовывает поля на языке бизнеса.
join: "Колонки события переименовываются по правилам DDS. Данные не копируются в отдельную таблицу." join: 'Соединения нет: представление переименовывает колонки той же строки.'
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
@@ -547,9 +549,9 @@ edges:
to: dds_session to: dds_session
mechanism: airflow_dag mechanism: airflow_dag
streams: [events] streams: [events]
rhythm: при каждом запуске `dds_transform` rhythm: пачкой, при каждом прогоне `dds_transform`
actor: Задачи `session.rebuild` и `session.replace` дага `dds_transform`. actor: Задачи `session.rebuild` и `session.replace` дага `dds_transform`.
join: "События одного `client_id` за день `event_date` упорядочиваются по времени. Пауза дольше 30 минут начинает новую сессию. Ее `session_id` рассчитывается как `cityHash64(client_id, min(event_time))`." join: 'События одной `client_id` за `event_date` режутся по паузам; `session_id` - `cityHash64(client_id, min(event_time))`.'
links: links:
- label: SQL сборки - label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/session_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/session_rebuild.sql
@@ -563,9 +565,9 @@ edges:
to: dds_order to: dds_order
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: при каждом запуске `dds_transform` rhythm: пачкой, при каждом прогоне `dds_transform`
actor: Задачи `order.rebuild` и `order.replace` дага `dds_transform`. actor: Задачи `order.rebuild` и `order.replace` дага `dds_transform`.
join: "Поля заказа приводятся к типам DDS, а позиции из JSON разбираются в массивы. Дата `order_date` рассчитывается из `created_at`." join: 'Соединения нет: из заказа берутся поля и позиции, `order_date` выводится из `created_at`.'
links: links:
- label: SQL сборки - label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_rebuild.sql
@@ -579,9 +581,9 @@ edges:
to: dds_identity to: dds_identity
mechanism: airflow_dag mechanism: airflow_dag
streams: [events] streams: [events]
rhythm: при каждом запуске `dds_transform` rhythm: пачкой, при каждом прогоне `dds_transform`
actor: Задача `identity_map` дага `dds_transform` соединяет покупки с заказами. actor: Задача `identity_map` дага `dds_transform` соединяет покупки с заказами.
join: "Событие покупки соединяется с заказом по `purchase_id[1] = order_id`. В результате связываются посетитель сайта и пользователь магазина." join: 'Строки покупки и заказа соединяются по `purchase_id[1] = order_id`.'
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql
@@ -593,9 +595,9 @@ edges:
to: dds_identity to: dds_identity
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: при каждом запуске `dds_transform` rhythm: пачкой, при каждом прогоне `dds_transform`
actor: Задача `identity_map` дага `dds_transform` соединяет заказы с покупками. actor: Задача `identity_map` дага `dds_transform` соединяет заказы с покупками.
join: "Заказ соединяется с событием покупки по `order_id = purchase_id[1]`. Из заказа берется `user_id` пользователя магазина." join: 'Строки заказа и покупки соединяются по `order_id = purchase_id[1]`; берется `user_id`.'
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql
@@ -607,9 +609,9 @@ edges:
to: dm_daily_traffic to: dm_daily_traffic
mechanism: airflow_dag mechanism: airflow_dag
streams: [events] streams: [events]
rhythm: при каждом запуске `dm_transform` rhythm: пачкой, при каждом прогоне `dm_transform`
actor: Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям. actor: Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям.
join: "К сессиям присоединяется карта идентичностей по `client_id`, затем данные группируются по `session_date`. Эта дата записывается в `report_date`." join: 'Соединения нет: сессии группируются по `session_date`, который становится `report_date`.'
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql
@@ -621,9 +623,9 @@ edges:
to: dm_daily_traffic to: dm_daily_traffic
mechanism: airflow_dag mechanism: airflow_dag
streams: [events] streams: [events]
rhythm: при каждом запуске `dm_transform` rhythm: пачкой, при каждом прогоне `dm_transform`
actor: Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`. actor: Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`.
join: "`GLOBAL LEFT JOIN` присоединяет карту идентичностей к сессиям по `client_id`. Сессии без известного пользователя тоже участвуют в расчете посетителей." join: 'GLOBAL LEFT JOIN по `client_id`; сессии без пары остаются в витрине.'
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql
@@ -635,9 +637,9 @@ edges:
to: dm_revenue to: dm_revenue
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: при каждом запуске `dm_transform` rhythm: пачкой, при каждом прогоне `dm_transform`
actor: Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`. actor: Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`.
join: "`ARRAY JOIN` разворачивает массивы `item_sku`, `item_quantity`, `item_price` в строки позиций заказа. Выручка считается по оплаченным заказам; `order_date` записывается в `report_date`." join: 'Соединения таблиц нет: ARRAY JOIN раскрывает `item_sku`, `item_quantity`, `item_price`; `order_date` становится `report_date`.'
links: links:
- label: SQL сборки - label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql
@@ -651,9 +653,9 @@ edges:
to: dm_revenue to: dm_revenue
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: при каждом запуске `dm_transform` rhythm: пачкой, при каждом прогоне `dm_transform`
actor: "Задача `revenue_daily.rebuild` дага `dm_transform`." actor: Даг `dm_transform` получает категорию через `dictGet` по `item_sku`; ключ словаря — `sku`.
join: "Функция `dictGet` получает категорию товара по артикулу `item_sku` из заказа. В словаре этому артикулу соответствует ключ `sku`." join: 'Категория читается через `dictGet` по `item_sku`; ключ справочника - `sku`.'
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql
@@ -665,9 +667,9 @@ edges:
to: dm_purchase to: dm_purchase
mechanism: airflow_dag mechanism: airflow_dag
streams: [events] streams: [events]
rhythm: при каждом запуске `dm_transform` rhythm: пачкой, при каждом прогоне `dm_transform`
actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки. actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки.
join: "События покупки группируются по номеру `purchase_id[1]`, который получает имя `purchase_order_id`. Дата `event_date` записывается в `purchase_day`. Затем `GLOBAL FULL OUTER JOIN` сопоставляет покупки с заказами по `purchase_order_id = backend_order_id`." join: 'Покупки группируются по `purchase_id[1]` как `purchase_order_id`; `event_date` становится `purchase_day`, затем GLOBAL FULL OUTER JOIN `purchase_order_id = backend_order_id`.'
links: links:
- label: SQL сборки - label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql
@@ -681,9 +683,9 @@ edges:
to: dm_purchase to: dm_purchase
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: при каждом запуске `dm_transform` rhythm: пачкой, при каждом прогоне `dm_transform`
actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают заказы. actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают заказы.
join: "`order_id` заказа получает имя `backend_order_id`, а `purchase_id[1]` покупки — `purchase_order_id`. `GLOBAL FULL OUTER JOIN` соединяет их по `purchase_order_id = backend_order_id`. Покупки без заказа и заказы без покупки сохраняются; поля отсутствующей стороны содержат `NULL`." join: 'GLOBAL FULL OUTER JOIN: `purchase_order_id = backend_order_id` (`purchase_id[1]` и `order_id`); отсутствующая сторона получает NULL.'
links: links:
- label: SQL сборки - label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql
+7 -7
View File
@@ -243,9 +243,9 @@
details.append(heading, meta); details.append(heading, meta);
addSection("Что это", node.what); addSection("Что это", node.what);
addSection("Как читать", node.read); addSection("Как читать", node.read);
addSection("Связанные таблицы и представления", node.parts); addSection("Из чего состоит", node.parts);
addSection("Как связать данные", node.keys); addSection("Ключи", node.keys);
addSection("Как обновляются данные", node.fill); addSection("Кто наполняет и как часто", node.fill);
addLinks(node.links); addLinks(node.links);
} }
function renderEdgeCard(edge) { function renderEdgeCard(edge) {
@@ -253,10 +253,10 @@
const heading = document.createElement("h2"); const heading = document.createElement("h2");
heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`; heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`;
details.append(heading); details.append(heading);
addSection("Способ обработки", mechanismById.get(edge.mechanism).label); addSection("Механизм", mechanismById.get(edge.mechanism).label);
addSection("Когда выполняется", edge.rhythm); addSection("Ритм", edge.rhythm);
addSection("Что выполняет обработку", edge.actor); addSection("Кто именно", edge.actor);
addSection("Что происходит с данными", edge.join); addSection("Как соединяет", edge.join);
addLinks(edge.links); addLinks(edge.links);
} }
function layoutMap() { function layoutMap() {
+26 -34
View File
@@ -1,49 +1,42 @@
# Генератор: откуда берутся данные # Генератор: откуда берутся данные
Генератор имитирует работу двух систем интернет-магазина. Счетчик Генератор изображает две системы интернет-магазина. Первая — счетчик на сайте,
на сайте отправляет записи о действиях посетителей в топик `hits`. Серверная часть он шлет события в топик `hits`. Вторая — бэкенд магазина, он раз в модельные
магазина раз в модельные сутки выгружает состояние заказов в топик `orders`. сутки выгружает слепок заказов в топик `orders`.
Эта дневная выгрузка называется слепком.
## Что он производит ## Что он производит
Событие содержит сведения о действии посетителя: например, время, Событие — широкая строка по образцу выгрузки Яндекс Метрики. Генератор создает
адрес страницы и идентификатор посетителя. Формат соответствует образцу три вида событий: просмотр страницы, корзину и покупку. Все колонки перечислены
выгрузки Яндекс Метрики. Генератор создает три вида событий: просмотр
страницы, добавление в корзину и покупку. Все колонки перечислены
в [описании выгрузки](../formats/clickstream-event.md). в [описании выгрузки](../formats/clickstream-event.md).
Заказ содержит сведения о покупке со стороны магазина: товары, суммы Заказ — та же покупка, но записанная бэкендом магазина, с суммами в деньгах.
и состояние заказа. После оформления он еще может измениться, например Семь модельных дней после оформления заказ еще может измениться, поэтому каждый
при отмене. Каждый дневной слепок содержит текущее состояние заказов, дневной слепок несет заказы за последние семь дней в их текущем состоянии.
оформленных за последние семь модельных дней. Состав и формат слепка Устройство и форма слепка разобраны в [отдельном
разобраны в [отдельном
справочнике](../architecture/orders/snapshot.md). справочнике](../architecture/orders/snapshot.md).
## Почему данные всегда одни и те же ## Почему данные всегда одни и те же
Случайные решения генератора зависят от заданного числа — Генератор не берет случайность извне. Все его случайные решения выводятся из
[зерна генерации](../architecture/generator/seeds.md) — и номера дня. [зерна](../architecture/generator/seeds.md) — одного числа в настройках — и из
При неизменных настройках и версии генератора одинаковые зерно и день номера дня. То же зерно и тот же номер дня дают ровно те же события, поэтому
дают одинаковые события. Поэтому при создании стенда вы каждый раз стартовый мир одинаков при каждом `make up`.
получаете те же начальные данные. Повторный `make up` на работающем стенде
сохраняет уже загруженные данные.
В файле `data/world-inventory.json` записаны зерно, версия генератора, Рядом лежит паспорт мира, `data/world-inventory.json`: зерно, версия генератора
число событий и контрольная сумма за каждый из восьми начальных дней. и по строке на каждый день с числом событий и контрольной суммой. По нему
В документации этот файл называется паспортом мира. С ним можно сверить проверяют, что генератор собрал тот же мир, что и раньше, и что в ODS все
результат генерации и проверить, все ли события загрузились в ODS. доехало без потерь. Паспорт описывает только восемь дней стартового мира.
## Кто его запускает ## Кто его запускает
На стенде генератором управляют [даги модельного На стенде генератором управляют [даги модельного
времени](airflow-dags.md#даги-мира). Его можно запустить и напрямую. Команды времени](airflow-dags.md#даги-мира). Его можно запустить и напрямую. Команды
собраны в [справочнике генератора и модельного собраны в [справочнике мира](../architecture/world.md#как-запустить-генератор).
времени](../architecture/world.md#как-запустить-генератор).
У генератора два режима. Пакетный отправляет все события дня без пауз. У генератора два режима. Пакетный отправляет весь день разом. Живой шлет
В живом режиме события поступают постепенно, как в работающем магазине, события по ходу модельных суток; сутки идут ускоренно и занимают около двадцати
но время идет ускоренно: модельные сутки занимают около 24 минут. четырех минут.
## Посмотрите сами ## Посмотрите сами
@@ -57,14 +50,13 @@ GROUP BY EventDate
ORDER BY EventDate; ORDER BY EventDate;
``` ```
Сравните число событий за каждый из первых восьми дней со значением Сравните первые восемь дней с восемью строками в [паспорте
`events` за ту же дату в [паспорте мира](../../data/world-inventory.json). мира](../../data/world-inventory.json): числа совпадут. Дни, сыгранные после
Числа должны совпасть. Если вы уже генерировали следующие дни, стартового мира, в паспорте не записаны. Так и задумано.
в результате запроса будут дополнительные даты; в паспорте их нет.
## Куда дальше ## Куда дальше
Подробнее генератор разобран в [README папки Подробнее генератор разобран в [README папки
`generator/`](../../generator/README.md) и в [справочнике его `generator/`](../../generator/README.md) и в [справочнике его
устройства](../architecture/generator/README.md). Теперь можно переходить устройства](../architecture/generator/README.md). На этом путеводитель
к [учебным заданиям](../labs/README.md). заканчивается: дальше справочники и [учебные задания](../labs/README.md).
+31 -43
View File
@@ -1,61 +1,52 @@
# Мониторинг: куда смотреть # Мониторинг: куда смотреть
В Grafana настроены три дашборда. «Данные» показывает загрузку и обработку В Grafana три дашборда, и у каждого своя тема. «Данные» показывает, все ли в
событий и заказов, «Кластер» — состояние узлов и самого ClickHouse, порядке с потоком данных. «Кластер» — состояние машин и самого ClickHouse.
а «Запросы» — выполненные запросы и затраченные на них ресурсы. «Запросы» — какие запросы выполнялись и сколько ресурсов они заняли.
Prometheus собирает метрики с двух узлов ClickHouse, ClickHouse Keeper Prometheus собирает метрики с двух узлов ClickHouse, keeper и экспортера Kafka.
и экспортера Kafka — службы, которая предоставляет метрики Kafka. Grafana читает и Prometheus, и ClickHouse напрямую. На панелях нет порогов и
Grafana получает данные из Prometheus и напрямую из ClickHouse. цветовой подсветки: оценивать картинку придется самому. Автоматические
На панелях не заданы пороги для цветовых предупреждений: оценивать показания проверки, которые выносят вердикт, собраны в контуре DQ.
вы будете самостоятельно. Качество данных автоматически проверяет
даг `dq_check`.
## Дашборд «Данные» ## Дашборд «Данные»
«Свежесть» показывает, сколько времени прошло с последнего события и с «Свежесть» показывает, сколько времени прошло с последнего события и с
последнего заказа в ODS. Показатель для заказов растет весь модельный день последнего заказа в ODS. Свежесть заказов растет весь модельный день и
и снижается, когда поступает очередная дневная выгрузка — слепок. сбрасывается, когда на границе суток приходит слепок. Это нормальное поведение
Это ожидаемо: заказы загружаются раз в день. источника, а не поломка.
«Поток» показывает число сырых сообщений в STG за выбранный интервал. «Брак» «Поток» показывает число сырых сообщений в STG за выбранный интервал. «Брак»
показывает долю строк, которые не удалось разобрать. рядом — доля строк, которые не удалось разобрать.
«Сходимость» сравнивает число событий в Kafka и в ClickHouse. Тревожный «Сходимость» сравнивает число событий в Kafka и в ClickHouse. Тревожный
признак — устойчивый разрыв между рядами. Разовое расхождение еще не говорит признак — устойчивый разрыв между линиями. Расхождение в одном столбце ничего
о потере данных: метрики Kafka и строки ClickHouse могут попадать в соседние не значит.
интервалы на графике.
«Отставание» показывает, сколько сообщений в Kafka еще не прочитано «Отставание» показывает, сколько сообщений в Kafka еще не прочитано
потребителями. «Дневной слепок» перечисляет последние десять слепков заказов в потребителями. «Дневной слепок» перечисляет последние десять слепков заказов в
STG: по нему видно, поступила ли очередная выгрузка. STG: по нему видно, пришла ли выгрузка вовремя.
Источники и правила чтения всех панелей разобраны в [справочнике дашборда Источники и правила чтения всех панелей разобраны в [справочнике дашборда
«Данные»](../architecture/monitoring/data.md). «Данные»](../architecture/monitoring/data.md).
## Почему у панелей разные источники ## Почему у панелей разные источники
Свежесть считается по ODS: если новые сообщения перестали разбираться, Свежесть считается по ODS: если разбор остановился, это видно именно там. Поток
показатель будет расти. Число поступивших сообщений берется из STG, и брак считаются по STG, где сохраняется каждое доставленное сообщение. В ODS
где сохраняются все сообщения, включая повторы. Для доли брака это число повторы события и старые версии заказа схлопываются, поэтому счетчик там был бы
сравнивается с числом строк в таблицах ошибок ODS. занижен. Подробности — в [разделе о слоях
В основных таблицах ODS повторы событий и старые версии заказов удаляются
при слияниях. Поэтому число строк в них не подходит для подсчета
всех доставленных сообщений. Подробности — в [разделе о слоях
панелей](../architecture/monitoring/data.md#почему-слои-у-панелей-разные). панелей](../architecture/monitoring/data.md#почему-слои-у-панелей-разные).
## Два других дашборда ## Два других дашборда
Открывайте «Кластер», чтобы проверить нагрузку на процессор, использование Открывайте «Кластер», когда нужно понять, что происходит с процессором, памятью
памяти и дисков, число частей таблиц и ход их слияний в ClickHouse. Здесь же и дисками, с кусками данных и их слияниями в ClickHouse, с keeper или очередью
показаны состояние ClickHouse Keeper и очередь распределенных DDL-команд. DDL. Устройство панелей описано в [справочнике дашборда
Устройство панелей описано в [справочнике дашборда
«Кластер»](../architecture/monitoring/cluster.md). «Кластер»](../architecture/monitoring/cluster.md).
«Запросы» поможет найти медленные запросы, ошибки и пользователей, «Запросы» пригодится, когда вы ищете тяжелые запросы, ошибки или расход
чьи запросы потребляют много ресурсов. Как читать панели, объясняет ресурсов по пользователям. Как читать списки, объясняет [справочник дашборда
[справочник дашборда
«Запросы»](../architecture/monitoring/queries.md). «Запросы»](../architecture/monitoring/queries.md).
## Посмотрите сами ## Посмотрите сами
@@ -66,18 +57,15 @@ Airflow [`world_live_day`](airflow-dags.md#даги-мира): модельны
около двадцати четырех минут. около двадцати четырех минут.
Смотрите свежесть событий и свежесть заказов по отдельности. Пока день идет, Смотрите свежесть событий и свежесть заказов по отдельности. Пока день идет,
со времени последнего события проходят секунды, а время с последней свежесть событий держится на секундах, а свежесть заказов растет. На границе дня
загрузки заказов растет. После завершения генерации событий отправляется слепок отправляется сразу: свежесть заказов сбрасывается, а свежесть событий
слепок заказов. Когда он загружен, показатель свежести заказов снижается. начинает расти, потому что новых событий до следующего дня не будет. Сам даг
Показатель для событий начинает расти: новые события поступят только остается в работе и после конца дня, потому что ждет дневной конвейер.
при генерации следующего дня. Даг пока продолжает работу, ожидая завершения
обработки данных в дневном конвейере.
## Куда дальше ## Куда дальше
Устройство мониторинга описано в [техническом Состав мониторинга собран в [справочнике
справочнике](../architecture/monitoring/README.md), панели дашборда «Данные» — зоны](../architecture/monitoring/README.md), детали главного экрана — в
в [отдельном разделе](../architecture/monitoring/data.md). [справочнике дашборда «Данные»](../architecture/monitoring/data.md), а границы
Почему выбраны именно эти показатели, объясняет решения — в [ADR 0002](../adr/0002-monitoring-scope.md). Следующая глава —
[ADR 0002](../adr/0002-monitoring-scope.md). Следующая глава —
[«Немного Superset»](superset.md). [«Немного Superset»](superset.md).
+45 -70
View File
@@ -2,8 +2,7 @@
В хранилище приходят два потока из Kafka: в топике `hits` — события В хранилище приходят два потока из Kafka: в топике `hits` — события
кликстрима, в `orders` — заказы магазина. Оба проходят один путь: STG → ODS → кликстрима, в `orders` — заказы магазина. Оба проходят один путь: STG → ODS →
DDS → DM. В STG сообщения сохраняются как есть, в ODS разбираются по полям, DDS → DM. Каждый слой отвечает на свой вопрос.
в DDS из них собираются события, заказы и сессии, а в DM — витрины для отчетов.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -12,54 +11,46 @@ flowchart LR
## Как читать имена ## Как читать имена
Для обычного чтения выбирайте представления с суффиксом `_v`. Они позволяют Для обычного чтения выбирайте объекты с суффиксом `_v`. Это представления:
менять устройство таблиц, сохраняя привычные запросы к данным. их устройство может поменяться, а ваш запрос останется прежним. Суффиксы
Суффиксы `_rep`, `_dist`, `_kafka` и `_mv` относятся к внутреннему устройству `_rep`, `_dist`, `_kafka` и `_mv` относятся к внутреннему
хранилища. Эти объекты нужны для загрузки, обслуживания и диагностики. устройству хранилища. Они нужны для загрузки, обслуживания и диагностики.
У словаря товаров `dic.products` суффикса нет. У словарей есть исключение: у `dic.products` нет суффикса, потому что
различать нечего — объект один.
Подробный разбор имен есть в [справочнике Подробный разбор имен есть в [справочнике
хранилища](../architecture/storage.md#имена-объектов). хранилища](../architecture/storage.md#имена-объектов).
## STG: как поступило ## STG: как поступило
STG — сырой слой. Он сохраняет сообщение целой строкой, без разбора. Вместе STG — сырой слой. Он сохраняет сообщение целой строкой, без разбора. Рядом
с ним записываются сведения о доставке: топик, партиция, смещение и время лежат метаданные доставки: топик, партиция, смещение и время приема. Поэтому в
приема. Содержимое сообщения сохраняется в STG, даже если в нем есть ошибки. STG можно увидеть, что именно приехало из Kafka, даже если сообщение
испорчено.
Сырые сообщения хранятся трое суток реального времени. В течение этого срока Срок хранения — трое суток реального времени. Этого хватает для расследования
их можно использовать для поиска ошибок и повторной обработки. Затем они и повторного разбора, но старые строки не занимают диск вечно. Точное правило
удаляются по правилу из [справочника — в [справочнике
хранилища](../architecture/storage.md#срок-жизни-сырых-данных). хранилища](../architecture/storage.md#срок-жизни-сырых-данных).
## ODS: язык источника ## ODS: язык источника
В ODS содержимое сообщения разбирается на колонки с нужными типами данных: ODS — слой разобранных данных. Строка получает типы: дата становится датой,
датами, числами, строками. Колонки сохраняют имена из источника. Например, число — числом. Имена полей остаются на языке источника. Например, событие
в таблице событий есть `WatchID`, `ClientID` и `EventDate`. сохраняет колонки `WatchID`, `ClientID` и `EventDate`.
Испорченное сообщение не останавливает загрузку. Оно попадает в таблицу Испорченное сообщение не останавливает загрузку. Оно попадает в таблицу
`*_errors` вместе с исходной строкой и классом ошибки. Правильно записанные `*_errors` вместе с сырой строкой и классом ошибки. Так брак остается виден, а
сообщения обрабатываются дальше. годные строки продолжают движение.
## DDS: язык бизнеса ## DDS: язык бизнеса
DDS — детальный слой: в нем данные подготовлены для анализа событий, DDS — детальный слой на языке бизнеса. Здесь есть событие, заказ, сессия и
заказов и сессий. Здесь же хранится карта идентичностей — таблица связей карта идентичностей. Карта связывает посетителя, то есть куку `ClientID`, с
между идентификатором посетителя `ClientID` и пользователем магазина пользователем магазина из заказа.
из заказа. `ClientID` хранится в cookie браузера: по нему можно узнать
браузер посетителя, но не всегда самого человека.
В стенде визитом, или сессией, считают подряд идущие события одного Сессия не приходит готовой из Kafka. Стенд сам выделяет ее из последовательных
`ClientID` без пауз дольше 30 минут. Визит не пересекает границу модельных суток. событий одного посетителя. Здесь собраны сущности, с которыми дальше работают
аналитики.
Источник передает готовый идентификатор визита `VisitID` по образцу
Яндекс Метрики. В учебном хранилище сессии рассчитываются заново по времени
событий; каждой присваивается `session_id`. Поле `VisitID` остается
эталоном для проверки. Так в заданиях вы сможете самостоятельно определить
границы сессий и сравнить их с результатом источника.
Готовые сессии читайте через `dds.session_v`. События для самостоятельного
расчета доступны в `dds.event_v`: в этом представлении `VisitID` скрыт.
## DM: данные для дашбордов ## DM: данные для дашбордов
@@ -71,31 +62,32 @@ DM — слой витрин. Витрина — готовый набор по
путь расчета. Состав готовых витрин смотрите в [карте путь расчета. Состав готовых витрин смотрите в [карте
таблиц](../architecture/storage.md#карта-таблиц). таблиц](../architecture/storage.md#карта-таблиц).
## Справочные данные: зона `dic` ## Сбоку цепочки: зона `dic`
В базе `dic` хранятся готовые справочные данные. Они загружаются отдельно Зона `dic` стоит вне пути STG → ODS → DDS → DM. В ней живут справочные данные,
от событий и заказов и могут использоваться в расчетах любого слоя. которые хранилище получает готовыми. Ни один слой их не производит, а читать
может любой.
Каталог товаров `dic.products` — один из таких справочников. Его ключ `sku` — Первый справочник стенда — каталог товаров `dic.products`. Его ключ `sku` —
артикул товара. По нему можно узнать название, бренд и категорию товара. Как артикул товара. По нему можно узнать название, бренд и категорию товара. Как
устроена зона, смотрите в [разделе о устроена зона, смотрите в [разделе о
справочниках](../architecture/storage.md#справочники). справочниках](../architecture/storage.md#справочники).
## Сквозные ключи ## Сквозные ключи
Идентификаторы позволяют связать данные из разных слоев. При переходе в DDS Один и тот же смысл проходит через слои под разными именами: имя меняется
часть колонок переименовывается: например, `WatchID` становится `event_id`. вместе с языком слоя. Таблица показывает исходное имя и то, где искать тот же
В таблице указано, как найти соответствующие колонки и соединить по ним данные. смысл дальше.
| Колонка | Что означает | Что соединяет | | Колонка | Что означает | Что соединяет |
|---|---|---| |---|---|---|
| `WatchID` | Идентификатор события | Событие ODS с событием DDS, где колонка называется `event_id` | | `WatchID` | Идентификатор события | Событие ODS с событием DDS, где колонка называется `event_id` |
| `ClientID` | Идентификатор посетителя из cookie браузера | События ODS с событиями, сессиями и картой идентичностей DDS; в DDS колонка называется `client_id` | | `ClientID` | Посетитель, то есть кука | События ODS с событиями, сессиями и картой идентичностей DDS; в DDS колонка называется `client_id` |
| `order_id` | Идентификатор заказа | Заказ ODS с заказом DDS и витриной сверки `dm.purchase_vs_orders_v` | | `order_id` | Идентификатор заказа | Заказ ODS с заказом DDS и витриной сверки `dm.purchase_vs_orders_v` |
| `sku` | Артикул товара в каталоге | Справочник `dic.products` с позициями заказа DDS, где артикулы лежат в `item_sku`; через категорию товара — с витриной выручки DM | | `sku` | Артикул товара в каталоге | Справочник `dic.products` с позициями заказа DDS, где артикулы лежат в `item_sku`; через категорию товара — с витриной выручки DM |
| `EventDate` | Дата события в [модельном времени](../../CONTEXT.md), указанная счетчиком сайта в [его часовом поясе](../architecture/storage.md#часовые-пояса) | Событие ODS с событием DDS, где колонка называется `event_date`, и дневными расчетами DM | | `EventDate` | День на [оси модельного времени](../../CONTEXT.md), который счетчик сайта проставляет в [своем часовом поясе](../architecture/storage.md#часовые-пояса) | Событие ODS с событием DDS, где колонка называется `event_date`, и дневными расчетами DM |
Почему идентификатор посетителя не равнозначен человеку, объясняет Что здесь значит «посетитель» и чем кука отличается от человека, объясняет
[словарь понятий](../../CONTEXT.md#язык). Точные имена объектов и полей сверяйте с [словарь понятий](../../CONTEXT.md#язык). Точные имена объектов и полей сверяйте с
[картой таблиц](../architecture/storage.md#карта-таблиц). [картой таблиц](../architecture/storage.md#карта-таблиц).
@@ -103,11 +95,11 @@ DM — слой витрин. Витрина — готовый набор по
Подключитесь к ClickHouse по инструкции из разделов [«Адреса и Подключитесь к ClickHouse по инструкции из разделов [«Адреса и
пароли»](../../README.md#адреса-и-пароли) и [«Подключиться из пароли»](../../README.md#адреса-и-пароли) и [«Подключиться из
DBeaver»](../../README.md#подключиться-из-dbeaver). Во всех трех примерах DBeaver»](../../README.md#подключиться-из-dbeaver). В обоих примерах
`2026-06-01` — первый день работы учебного магазина. В документации он `2026-06-01` — это D0, первый день [стартового
обозначается D0; подробнее — в [словаре понятий](../../CONTEXT.md). мира](../../CONTEXT.md). Чтобы посмотреть другой день, замените
Чтобы посмотреть другой день, замените дату в условии. эту дату в условии. Первый запрос покажет
Первый запрос покажет 20 событий за выбранный день: события одного модельного дня:
```sql ```sql
SELECT SELECT
@@ -122,25 +114,7 @@ ORDER BY UTCEventTime
LIMIT 20; LIMIT 20;
``` ```
Второй запрос покажет 20 сессий того же дня с наибольшим числом событий. Второй запрос покажет готовую выручку по категориям за тот же день:
Сессии рассчитаны по всем событиям дня:
```sql
SELECT
client_id,
started_at,
finished_at,
events_total,
pageviews,
purchases,
entry_url
FROM dds.session_v
WHERE session_date = '2026-06-01'
ORDER BY events_total DESC
LIMIT 20;
```
Третий запрос покажет готовую выручку по категориям за тот же день:
```sql ```sql
SELECT SELECT
@@ -153,13 +127,14 @@ WHERE report_date = '2026-06-01'
ORDER BY revenue DESC; ORDER BY revenue DESC;
``` ```
Все три запроса читают представления `_v`. Оба запроса читают поверхности `_v`. Первый показывает данные на языке
источника, второй — готовый ответ витрины.
## Куда дальше ## Куда дальше
Откройте [интерактивную карту](architecture-map.html) из своей копии Откройте [интерактивную карту](architecture-map.html) из своей копии
репозитория и проследите путь данных. репозитория и проследите путь данных.
Список объектов, сроки хранения и устройство таблиц — в [справочнике Список объектов, сроки хранения и устройство таблиц — в [справочнике
хранилища](../architecture/storage.md). Как данные обрабатываются между слоями — в хранилища](../architecture/storage.md). Как данные переносят между слоями — в
следующей главе: [потоки Airflow и даги модельного следующей главе: [потоки Airflow и даги модельного
времени](airflow-dags.md). времени](airflow-dags.md).
+24 -31
View File
@@ -1,49 +1,42 @@
# Немного Superset # Немного Superset
Superset показывает витрины слоя DM в виде графиков. К ClickHouse он Superset показывает витрины слоя DM в виде графиков. К ClickHouse он
подключается пользователем `bi`. Дашборд стенда «Бизнес-сюжеты» собран из трех подключается пользователем `bi`. На дашборде стенда «Бизнес-сюжеты» три
витрин: графика:
| Витрина | Что показывает | | График | Витрина |
|---|---| |---|---|
| `dm.revenue_daily_v` | Выручку, число заказов и средний чек; выручку по дням и категориям товаров, долю каждой категории | | «Выручка по дням и категориям» | `dm.revenue_daily_v` |
| `dm.purchase_vs_orders_v` | Результат сверки покупок из кликстрима с заказами: сколько заказов за день относится к каждому виду расхождения | | «Сверка purchase и заказов» | `dm.purchase_vs_orders_v` |
| `dm.daily_traffic_v` | Число посетителей и известных пользователей по дням, долю известных пользователей | | «Посетители и известные пользователи» | `dm.daily_traffic_v` |
Известные пользователи — те, кого удалось связать с посетителями сайта
по заказам. Эти связи хранятся в карте идентичностей DDS.
Слева от панелей стоит фильтр «Период». Он задает даты сразу для всех панелей.
## Как витрина становится графиком ## Как витрина становится графиком
В Superset сначала создается датасет — описание таблицы или представления, От витрины до экрана три объекта Superset: датасет → график → дашборд. Датасет
из которого будут браться данные. В нем задаются подписи колонок, формат описывает витрину: задает русские имена колонок и формат денег. График
чисел и показатели, например средний чек. ссылается на эти имена и задает способ показа. Дашборд собирает готовые графики
на одном экране.
Затем на основе датасета создается график: выбираются показатели и способ
их отображения. Несколько графиков можно разместить на одном дашборде.
## Стендовые объекты и ваши ## Стендовые объекты и ваши
Готовые датасеты, графики и дашборд сохранены в файлах репозитория Датасеты, графики и дашборд из поставки стенда лежат файлами в репозитории и
и импортируются при каждом `make up`. Если изменить их через интерфейс, заново импортируются при каждом `make up`. Если поправить такой объект в
следующий `make up` заменит изменения версией из файлов без предупреждения. интерфейсе, правка проживет до следующего `make up`, а потом без предупреждения
заменится версией из файла.
Созданные вами датасеты, графики и дашборды хранятся в базе метаданных Ваши собственные объекты хранятся в той же базе метаданных Superset, но импорт
Superset. Импорт их не меняет: они сохраняются после `make up` их не трогает: они переживают и `make up`, и `make rebuild-storage`. Стирает их
и `make rebuild-storage`. Команда `make clean` удалит их вместе со всеми только `make clean` вместе с остальным постоянным состоянием стенда. Поэтому
данными стенда. Для учебных отчетов создавайте собственные объекты, стендовые объекты оставляйте как есть, а свои отчеты стройте рядом. Устройство
чтобы повторный запуск не перезаписал вашу работу. Устройство
импорта описано в [справочнике импорта описано в [справочнике
Superset](../architecture/superset.md#импорт-при-каждой-инициализации). Superset](../architecture/superset.md#импорт-при-каждой-инициализации).
## Почему выручка отстает на день ## Почему края графиков не совпадают
Последняя дата с выручкой на день раньше последней даты с событиями Правый край графика выручки всегда на день позади графиков кликстрима. Слепок
кликстрима. Выгрузка заказов за день D поступает при генерации следующего заказов за день приходит только со следующим модельным днем. Так устроен
дня D+1, а затем обрабатывается конвейером. Вы уже видели эту разницу источник, а не витрина. Вы уже видели этот
в [главе об Airflow](airflow-dags.md#посмотрите-сами). Начальное состояние сдвиг в [главе об Airflow](airflow-dags.md#посмотрите-сами). Начальное состояние
показано в разделе [«Стартовый мир»](../architecture/world.md#стартовый-мир). показано в разделе [«Стартовый мир»](../architecture/world.md#стартовый-мир).
## Посмотрите сами ## Посмотрите сами
@@ -63,7 +56,7 @@ LIMIT 7;
## Куда дальше ## Куда дальше
Подключение и настройка Superset описаны в [справочнике Устройство BI-зоны описано в [справочнике
Superset](../architecture/superset.md), а состав витрин — в [справочнике Superset](../architecture/superset.md), а состав витрин — в [справочнике
DM](../architecture/dm.md). Следующая глава — [«Генератор: откуда берутся DM](../architecture/dm.md). Следующая глава — [«Генератор: откуда берутся
данные»](generator.md). данные»](generator.md).
-97
View File
@@ -1,97 +0,0 @@
# Задание 1: `dm.events_enriched_v`
**Чему учит:** подготовить удобный для анализа набор полей события
и обойтись представлением, сохранив одну строку на событие.
## Сначала прочитать
- [«Хранилище и его слои»](../handbook/storage-layers.md).
- [Как применить свой DDL](README.md#как-применить-свой-ddl): файл для решения
и подключение с правами на создание витрины.
## Для чего нужна витрина
В `dds.event_v` 44 колонки, включая технические поля источника и массивы
товаров. Для анализа событий в Superset нужны деловые поля и три готовых
значения: час события, путь страницы и домен перехода. Тогда вам не придется
повторять эти выражения в каждом запросе.
Витрины следующих заданий читают DDS напрямую. Использовать
`dm.events_enriched_v` как источник для них не нужно: задания независимы, их
можно выполнять в любом порядке.
## Что сделать
Создайте обычное представление `CREATE VIEW dm.events_enriched_v`.
Единственный источник — `dds.event_v`. Зерно — **событие**: каждой строке
источника соответствует одна строка витрины.
Физическая таблица, таблица-донор и задача в `dm_transform` не нужны.
Представление не хранит строки, поэтому ключ шардирования и пересборка ему
не требуются.
Укажите колонки явно. Иначе новая колонка источника попадет в витрину
автоматически, и договор с потребителем изменится без вашего участия.
| Группа | Колонки |
|---|---|
| Время и посетитель | `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-метки | `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` |
Три колонки вычислите в представлении. Остальные возьмите из источника без
изменений, сохранив массивы `product_*`.
| Колонка | Как считается | Для чего нужна |
|---|---|---|
| `event_hour` | `toHour(event_time)` | Анализ событий по часам суток |
| `page_path` | путь `url` без домена и параметров | Группировка по странице |
| `referer_host` | домен `referer` | Анализ источников перехода |
Не добавляйте `_load_id` и `_load_ts`. Их нет и у `dds.event_v`: события в
DDS читают из ODS без отдельной загрузки. Эти служебные поля указывают на
запуск, который записал строку на диск. Представление ничего не загружает.
## Типичная ошибка
Не разворачивайте массивы товаров через `ARRAY JOIN`. У события с тремя
позициями получится три строки. Зерно изменится. Витрину с новым зерном на
стенде хранят готовым агрегатом — причины описаны в разделе [«Как принято на
ступени 1»](README.md#как-принято-на-ступени-1).
Главная опасность — потеря событий с пустым массивом товаров: `ARRAY JOIN`
исключает их из результата. Товары есть только в событиях корзины и покупки.
Поэтому исчезнет большинство событий — девятнадцать строк из двадцати.
Запрос выполнится без ошибки, а витрина по имени и составу колонок
по-прежнему будет выглядеть таблицей событий.
Сохраните массивы как в источнике. Для анализа отдельных товарных позиций
нужна другая витрина с другим зерном и именем.
## Как проверить себя
После применения DDL откройте Airflow по [адресу из README](../../README.md#адреса-и-пароли),
найдите даг `homework_check` и запустите его вручную. В новом запуске откройте
задачу `events_enriched`: она должна завершиться успешно. Если задача
красная, откройте ее журнал (`Logs`) — там будет причина ошибки, а при
расхождениях с источником — дни и примеры несовпадений. Задачи еще не
созданных витрин будут красными; это ожидаемо и не мешает проверке первой.
Деловой ключ — `event_id` внутри дня. Получите результат прямо из
`dds.event_v` за тот же день. Соедините его с витриной полным внешним
соединением по `event_id`. Посчитайте строки, где ключ отсутствует с одной
из сторон или различаются значения деловых колонок. Расхождений должно быть
ноль.
Отдельно сверьте `event_hour`, `page_path` и `referer_host` с теми же
выражениями, рассчитанными прямо по `dds.event_v`.
## Что должно получиться
- [ ] у вас есть `dm.events_enriched_v` с явно перечисленными колонками;
- [ ] вы сверили число строк и набор `event_id` с `dds.event_v` за каждый
прожитый день; расхождений нет;
- [ ] проверка `events_enriched` в `homework_check` прошла успешно;
- [ ] витрина работает без физической таблицы и задачи в `dm_transform`.
-110
View File
@@ -1,110 +0,0 @@
# Задание 2: `dm.top_pages_daily`
**Чему учит:** собрать дневные показатели страниц и настроить их
обновление в `dm_transform` через замену партиций.
## Сначала прочитать
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
## Для чего нужна витрина
По дневным показателям страниц аналитик сравнивает просмотры, посетителей и
действия с товарами. Добавление в корзину происходит на карточке товара,
покупка — на странице подтверждения заказа. Отношение добавлений к
просмотрам карточки показывает ее конверсию.
Для воронки «просмотр → корзина → покупка» этих данных недостаточно:
действия происходят на разных страницах, а связи между ними в агрегате нет.
Путь человека исследуют по событиям или сессиям.
## Что сделать
Источник — `dds.event_v`. Зерно витрины — **день события × страница**.
Читайте DDS напрямую: так выполнение задания не зависит от витрины
задания 1.
На стенде витрину с новым зерном хранят как готовый агрегат, чтобы не
рассчитывать ее заново для каждого запроса. Причины описаны в разделе
[«Как принято на ступени 1»](README.md#как-принято-на-ступени-1). Создайте
пять объектов:
`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
публичное представление `dm.top_pages_daily_v`. Ключ партиции — день, он же
единица пересборки.
### Состав колонок
| Колонка | Тип | Значение |
|---|---|---|
| `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')` | Время сборки |
Вычислите `page_path` из `url`, убрав домен и параметры запроса. Полный
адрес разделил бы показатели одной страницы на сотни строк. Учитывайте все
три типа событий, а их счетчики вычисляйте через `countIf`.
### Ритм пересборки
События прожитого дня больше не меняются. Определяйте объем прогона по
состоянию витрины: от первого несобранного прожитого дня до последнего
прожитого. Отсчет от последнего собранного дня оставит пропуски после
частичного сбоя.
Как у `revenue_daily`, заполните донор за весь диапазон одной вставкой,
затем замените дневные партиции по одной.
### Ключ шардирования
Выберите ключ самостоятельно. У `revenue_daily` кроме дня одно измерение с
шестью близкими по объему значениями. Здесь страниц много, и число событий
на них сильно различается.
У цели и донора используйте одинаковый ключ: иначе при замене партиции
строка потеряется на одном шарде и удвоится на другом. О том, [как устроен
донор](../architecture/storage.md), читайте в разделе «Раскладка по шардам».
## Типичная ошибка
Из-за имени витрины хочется отобрать только просмотры. Но фильтр источника
по `event_type = 'pageview'` обнулит `cart_adds` и `purchases`, а запрос не
завершится ошибкой. Конверсия карточек станет нулевой. Проверка одной суммы
`pageviews` не обнаружит этот фильтр: все просмотры остались на месте.
Ошибку покажет сверка `purchases` или `cart_adds` с источником, если за день
были покупки или добавления в корзину.
Выбирайте проверку под возможную ошибку. Этот подход описан в
[справочнике проверок](../architecture/testing.md).
## Как проверить себя
Деловой ключ — `page_path` внутри дня. Получите прямой агрегат из
`dds.event_v` за день и соедините его с `dm.top_pages_daily_v` полным
внешним соединением по ключу. Посчитайте строки, где ключ отсутствует с
одной из сторон или различается любой из четырех счетчиков. Расхождений
должно быть ноль.
Перед полным сравнением проверьте дневные суммы:
- сумма `pageviews` равна числу событий `pageview` в `dds.event_v`;
- сумма `purchases` равна числу событий `purchase` в том же источнике.
Сумма `visitors` по страницам повторно учитывает посетителя, открывшего
несколько страниц. Число посетителей за день берите из `dm.daily_traffic_v`.
## Что должно получиться
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
использует тот же ключ шардирования, что и целевая таблица;
- [ ] в `dm.top_pages_daily_v` явно перечислены колонки, включая
`_load_id` и `_load_ts`;
- [ ] ваш запрос сам определяет дни пересборки по состоянию витрины;
- [ ] проверка `top_pages_daily` в `homework_check` прошла успешно;
- [ ] вы включили витрину в `dm_transform` и убедились, что повторный
прогон конвейера не меняет результат.
-115
View File
@@ -1,115 +0,0 @@
# Задание 3: `dm.session_overview`
**Чему учит:** хранить слагаемые для расчета средних и долей и понимать,
когда сложение числа посетителей по устройствам завышает аудиторию.
## Сначала прочитать
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
## Для чего нужна витрина
Витрина нужна, чтобы по дням сравнивать визиты с телефона и компьютера: их
число, длительность, просмотры страниц и частоту покупок.
Сессии уже рассчитаны в DDS. Из них вы соберете дневные показатели по
устройствам. Почему такие итоги хранят в отдельной таблице, объясняет
[справочник DM](../architecture/dm.md).
## Что сделать
Источник — `dds.session_v`. Зерно — **день сессии × категория устройства**.
Зерно новое, поэтому витрина хранится готовым агрегатом — см. [«Как принято
на ступени 1»](README.md#как-принято-на-ступени-1). Создайте пять объектов:
`dm.session_overview_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
публичное представление `dm.session_overview_v`. Ключ партиции — день.
### Состав колонок
| Колонка | Тип | Значение |
|---|---|---|
| `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')` | Время сборки |
Возьмите `device_category` из сессии: DDS уже заменил код словом. Из четырех
категорий встречаются `desktop`, `mobile` и `tablet`. Строку для `tv`
создавайте только при появлении таких визитов.
Вместо `avg_duration_seconds` храните слагаемые. Среднее из средних по трем
устройствам даст неверный ответ за день: число сессий в категориях
различается, поэтому средние нужно взвешивать. Потребитель делит сумму
`duration_seconds_total` на сумму `sessions`. Долю отказов считают так же:
сумму `bounce_sessions` делят на сумму `sessions`, а не усредняют готовые
доли.
### Ритм пересборки
Сессии прожитого дня больше не меняются. Как в задании 2, определяйте
диапазон от первого несобранного прожитого дня до последнего прожитого.
Соберите его в донор и замените дневные партиции. Добавьте задачу в
`dm_transform`.
### Ключ шардирования
Выберите ключ самостоятельно. Визитов с планшета мало, с телефонов и
компьютеров — значительно больше. Если хотите взять хеш только от
`device_category`, сначала посчитайте строки на каждом шарде. Затем
объясните, почему такое распределение вас устраивает.
Ключ донора — тот же, что у цели (см. [«Как принято на ступени
1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
[справочника хранилища](../architecture/storage.md)).
## Почему сумма `visitors` сходится с `dm.daily_traffic_v`
Сравните сумму `visitors` по устройствам за день с `dm.daily_traffic_v`. На
стенде значения совпадут: устройство приписано куке на всю жизнь, поэтому за
день она учитывается только в одной строке. Это свойство мира стенда; см.
раздел «Паспорт куки» в [словаре понятий](../../CONTEXT.md).
Если появятся визиты одной куки с двух устройств, сумма начнет завышать
аудиторию. Проверки счетчиков по категориям этого не обнаружат. Число
посетителей дня берите из `dm.daily_traffic_v`, где оно рассчитано для дня
целиком.
## Типичная ошибка
Повторное выделение визитов из событий может дать другие границы сессий, чем
в DDS. Используйте готовые сессии из `dds.session_v`. Если общее число
событий совпало с источником, а число сессий завышено, вероятная причина —
сборка витрины из событий вместо сессий.
## Как проверить себя
Деловой ключ — `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`.
-111
View File
@@ -1,111 +0,0 @@
# Задание 4: `dm.dq_errors_daily`
**Чему учит:** считать брак по дню загрузки и сохранять сводку дольше
исходных строк.
## Сначала прочитать
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
## Для чего нужна витрина
Сводку открывают при росте брака в потоке: смотрят, каких ошибок стало
больше и сколько строк забраковано. Причину конкретной ошибки исследуют
в `ods.event_errors_dist` и `ods.order_errors_dist`, где есть сырой текст и
координаты доставки.
## Что сделать
Зерно — **день загрузки × источник × класс брака**. В источнике зерно —
строка брака. Зерно новое, поэтому витрина хранится готовым агрегатом — см.
[«Как принято на ступени 1»](README.md#как-принято-на-ступени-1). Создайте
пять объектов: `dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` /
`_stage_dist` и публичное представление `dm.dq_errors_daily_v`. Ключ
партиции — день загрузки.
### Состав колонок
| Колонка | Тип | Значение |
|---|---|---|
| `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` как `toDate(_load_ts)` из таблицы ошибок — это
реальный день приема строки. Модельный день записан внутри исходной строки.
Ее не удалось разобрать, поэтому надежно получить из нее день не получится.
Служебная `_load_ts` витрины описывает сборку сводки; ее дата может
отличаться от `load_date`.
У событий и заказов свои классы брака. `source` входит в зерно, чтобы
одинаковые имена классов с разным смыслом не объединялись в одну строку.
### Ритм пересборки
Брак хранится месяц по `TTL`, сырые данные — трое суток. Сводка хранится
бессрочно: итоги нужны и после удаления исходных строк.
Получите список дней загрузки, еще имеющихся в источниках. Соберите их в
донор и замените только эти партиции, сохранив остальные.
В течение текущих суток ошибки еще поступают: пересчитайте этот день на
следующем прогоне.
### Ключ шардирования
Выберите ключ самостоятельно. Классов брака у событий и заказов вместе
меньше десятка. Объясните, насколько существенен перекос при таком объеме.
Ключ донора — тот же, что у цели (см. [«Как принято на ступени
1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
[справочника хранилища](../architecture/storage.md)).
## Источники: таблицы `_dist`
Только это задание читает данные не через `_v`: брак нужен для диагностики
приема и не входит в бизнес-модель. Публичных представлений у таблиц ошибок
нет, создавать их для задания не нужно. Источники:
- `ods.event_errors_dist` — ошибки разбора событий;
- `ods.order_errors_dist` — ошибки разбора слепка заказов.
Чтение локальной `_rep` даст строки одного шарда из двух: часть брака не
попадет в сводку, хотя запрос выполнится без ошибки.
## Типичная ошибка
Если заменить всю таблицу данными из нового донора, исчезнут итоги дней,
истекших по `TTL` источника. Восстановить их неоткуда.
## Как проверить себя
Деловой ключ — `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`.
-137
View File
@@ -1,137 +0,0 @@
# Задание 5: `dm.utm_effectiveness`
**Чему учит:** различать сумму покупки со слов клиента (`declared_`) и
серверную выручку, находить причины их расхождения.
## Сначала прочитать
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
## Для чего нужна витрина
Аналитик сравнивает рекламные каналы по дням: трафик, добавления в корзину,
покупки и заявленные клиентом суммы.
На стенде поведение и атрибуцию считают по трекеру, деньги — по бэкенду.
Здесь сделано исключение: UTM-метки есть только в кликстриме, поэтому для
оценки покупок по меткам используют клиентскую сумму.
## Что сделать
Читайте `dds.event_v` напрямую, без соседних витрин DM. Зерно — **день
события × `utm_source` × `utm_medium` × `utm_campaign`**. Зерно новое,
поэтому витрина хранится готовым агрегатом — см. [«Как принято на ступени
1»](README.md#как-принято-на-ступени-1). Создайте пять объектов:
`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
публичное представление `dm.utm_effectiveness_v`. Ключ партиции — день.
### Состав колонок
| Колонка | Тип | Значение |
|---|---|---|
| `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` выбран тип `String`: имен кампаний со временем становится
много, и затраты на словарь `LowCardinality` перестают окупаться.
### Ритм пересборки
События прожитого дня больше не меняются. Как в задании 2, соберите в донор
дни от первого несобранного до последнего прожитого и замените дневные
партиции. Добавьте задачу в `dm_transform`.
### Ключ шардирования
Выберите ключ самостоятельно. Если берете хеш одной из трех UTM-колонок,
посчитайте долю строк с ее самым частым значением и обоснуйте выбор.
Ключ донора — тот же, что у цели (см. [«Как принято на ступени
1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
[справочника хранилища](../architecture/storage.md)).
## Атрибуция по событию, а не по визиту
Группируйте события по их собственным меткам. Модели «первое касание» и
«последнее касание» здесь не рассчитываются.
В `dds.session_v` записаны UTM первого события визита. Распределение по
каналам может отличаться от этой витрины, и это не означает ошибку в
расчете. В отчете различайте атрибуцию визитов и событий.
## Прямой трафик остается в витрине
Сохраните события без меток: прямой трафик обычно крупнейший канал, и без
него доля рекламы будет завышена. Если отфильтровать такие строки, дневные
итоги перестанут сходиться с источником — а по этому совпадению вы
проверяете сборку. Разница составит вклад исключенных событий.
Оставьте пустые метки пустыми строками: новое обозначение прямого трафика
потребовало бы объяснения.
## Расхождение с `dm.revenue_daily_v`
Сравните за один день `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` по строкам повторно учитывает одного посетителя,
приходящего по разным меткам. Для числа посетителей дня она не подходит.
## Что должно получиться
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
использует ключ шардирования целевой таблицы;
- [ ] строки без UTM-меток сохранены, а `declared_revenue` хранится
в `Decimal(18, 2)`;
- [ ] вы записали, почему сумма отличается от `dm.revenue_daily_v`;
- [ ] проверка `utm_effectiveness` в `homework_check` прошла успешно;
- [ ] витрина обновляется при запуске `dm_transform`.
-92
View File
@@ -1,92 +0,0 @@
# Нарежьте сессии сами
**Чему учит:** собрать сессии из событий, сравнить их число с `VisitID`
источника и найти события, из-за которых расчеты расходятся.
## Сначала прочитать
- [Хранилище и его слои, раздел DDS](../handbook/storage-layers.md#dds-язык-бизнеса);
- [формат события: Идентификаторы и время](../formats/clickstream-event.md#идентификаторы-и-время).
## Для чего нужны сессии
Визит, или сессия, объединяет действия посетителя за один заход на сайт.
По визитам аналитик считает длительность и глубину просмотра.
В хранилище сессии собираются из отдельных событий.
Сверьте самостоятельный расчет с `VisitID`, чтобы увидеть, как из событий
получается сессия.
## Что сделать
### Нарезка
В SQL Lab или DBeaver под `analyst` напишите запрос к `ods.event_v`.
Для своей нарезки и эталона возьмите одни и те же строки за все доступные
дни: для расследования понадобятся события соседних дней.
Выберите `ClientID`, `UTCEventTime`, `EventDate`, `EventType` и `VisitID`.
В `dds.event_v` эталонного `VisitID` нет.
Для начала считайте визитом последовательные события одной куки (`ClientID`);
пауза дольше 30 минут открывает новый визит.
Учитывайте события всех типов. Упорядочите их по `UTCEventTime`.
Присвойте каждому событию номер своей сессии внутри куки.
Подсказка: оконные функции.
Днем сессии считайте `EventDate` ее первого события: дата из `UTCEventTime`
отличается от `EventDate` для части событий, поэтому берите `EventDate`.
При сверке останутся расхождения из-за еще одной договоренности стенда.
### Сверка с эталоном
В эталоне посчитайте число визитов каждой куки за модельный день:
```sql
SELECT ClientID, EventDate, uniqExact(VisitID) AS reference_sessions
FROM ods.event_v
GROUP BY ClientID, EventDate
ORDER BY EventDate, ClientID;
```
Посчитайте свои сессии по куке и дню сессии.
Соедините два готовых агрегата через `FULL OUTER JOIN` по куке и дню.
В результате полного соединения останутся ключи обеих сторон: при расхождении
на вашей стороне ключа может не быть совсем. Учтите такие строки при сравнении.
Выведите сводку по дням с числом разошедшихся кук и список этих кук
с днем и обоими значениями счета. Общая сумма за день могла бы скрыть
взаимную компенсацию ошибок разных кук. По списку видно, у кого искать причину.
Расхождений будет всего несколько. Если их нет, мир прожил мало дней.
Прогоните несколько дней через [даги модельного времени](../handbook/airflow-dags.md#даги-мира)
и повторите сверку.
### Расследование
Выберите одну разошедшуюся куку. Выведите ее события за два соседних дня
с `UTCEventTime`, `EventDate`, `VisitID` и номером своей сессии.
Сопоставьте сессии через события: общего ключа у двух нарезок нет,
зато у каждого события есть и `VisitID`, и присвоенный вами номер.
Найдите два соседних события, между которыми границы визитов разошлись.
Запишите куку, день, время обоих событий и причину одним предложением.
Уточните определение визита и добейтесь нуля расхождений во всех днях.
## Как проверить себя
Сохраните исходный и уточненный расчеты, результаты сверки и найденные строки.
Таблицы и представления создавать не нужно. Задачи в `homework_check`
для этого задания нет: вы проверяете расчет своим запросом.
В `sql/dds/session_rebuild.sql` и
`sql/dq/sessions_vs_reference.sql` лежат готовые запросы нарезки и сверки.
Откройте их после собственного решения и расследования. Сравните запросы
со своими.
Сопоставление со стендовой нарезкой по `session_id` остается на ступень 4.
## Что должно получиться
- [ ] ваш запрос собирает сессии из всех событий `ods.event_v` за все
доступные дни;
- [ ] у вас есть сводка расхождений по дням и список кук, полученные через
`FULL OUTER JOIN`;
- [ ] вы объяснили причину одного расхождения и записали куку, день и два
события, на которых оно видно;
- [ ] уточненный расчет дает ноль расхождений во всех днях.
-191
View File
@@ -1,191 +0,0 @@
# Перепишите нарезку стенда
**Чему учит:** переписать расчет сессий в готовом конвейере и проверить
результат по исходным событиям.
## Сначала прочитать
- [задание «Нарежьте сессии сами»](3-1-sessions-by-hand.md);
- [модель сессии DDS](../architecture/dds/session.md);
- [дневной конвейер трансформаций](../architecture/etl.md#объём-прогона);
- [раскладка по шардам](../architecture/storage.md#раскладка-по-шардам).
## Что предстоит сделать
Даг, таблицы сессий и проверка их числа уже созданы.
Перепишите только три файла:
- `sql/dds/session_scope.sql`;
- `sql/dds/session_rebuild.sql`;
- `sql/dds/session_replace.sql`.
Удалите из них готовый SQL и соберите свою реализацию. Параметры и порядок
запросов заданы в `dds_transform`. DDL задает целевые таблицы и донор. Донор —
служебная таблица, где день собирается перед заменой. В
`sessions_vs_reference.sql` число сессий сравнивается с `VisitID` из ODS.
## Как даг запускает ваши запросы
Объем прогона — дни, которые соберет один запуск. Его вычисляет первый файл.
| Файл | Вход | Результат |
|---|---|---|
| `session_scope.sql` | `{position:UInt16}` — номер первого несыгранного дня | `first_day` и `last_day` |
| `session_rebuild.sql` | `{first_day:Date}`, `{last_day:Date}`, `{load_id:String}` | Сессии в `dds.session_stage_dist` |
| `session_replace.sql` | День в `{{ params.day }}` | Партиция дня в `dds.session_rep` |
Первый прожитый день, которого еще нет в DDS, становится `first_day`.
`last_day` — последний прожитый день. Начало шкалы нужно взять из событий ODS:
`position` считает дни от него. Живой модельный день еще не закончен, поэтому
сессии за него не собираются.
Если в ODS нет ни одного события, запрос не возвращает строк. Если DDS уже
догнал мир, запрос возвращает строку с `first_day = NULL`. В этом случае группа
сессий в Airflow получает состояние `skipped`.
`session_rebuild.sql` сначала очищает `dds.session_stage_rep` на всем кластере.
Затем он собирает в `dds.session_stage_dist` все дни отрезка. Добавьте к
вставке `SETTINGS distributed_foreground_insert = 1`. Так строки успеют попасть
на шарды до замены партиций.
Цель `session_replace.sql` — локальная таблица `dds.session_rep`, источник —
локальный донор `dds.session_stage_rep`. Операция выполняется на всем кластере.
Не ставьте точку с запятой в конце `session_scope.sql`. Драйвер добавляет формат
ответа, и точка с запятой превратит строку в мультизапрос, который ClickHouse
отклонит. День для замены подставляет шаблон Jinja в Airflow. Параметр
ClickHouse в `ALTER ... PARTITION` здесь не работает.
## Нарежьте сессии
Источник сборки — `dds.event_v`. Одна строка результата представляет один
визит. На третьей ступени вы уже нарезали сессии:
- сессия не пересекает модельные сутки;
- пауза больше 30 минут открывает новую сессию;
- в нарезке участвуют события всех типов.
Пересборка добавляет требования к повторяемости. При одинаковом времени
порядок событий задает `event_id`, поэтому полный порядок окон —
`(event_time, event_id)`. Один визит должен получать одинаковый `session_id`
при каждом запуске. Тогда его можно сравнить с сохраненным результатом.
Вычислите идентификатор как `cityHash64(client_id, min(event_time))`.
Состав и типы строки заданы в `dds.session_rep`. Заполните начало, конец и
длительность. Посчитайте все события, просмотры, добавления в корзину и
покупки. Возьмите входную и выходную страницы, UTM первого события, устройство
и город. Заполните также служебные колонки из схемы таблицы.
Пишите через `dds.session_stage_dist`: место строки определит ключ
`client_id`. При записи прямо в локальную таблицу оно зависело бы от ноды,
которая выполнила запрос.
## Запустите свою реализацию
Готовые дни повторно не собираются. Чтобы новый SQL точно выполнился, выберите
последний собранный день:
```sql
SELECT max(session_date)
FROM dds.session_v;
```
Берите последний день: более ранняя дыра расширит объем до всех последующих
прожитых дней.
Сохраните десять строк с идентификатором, границами, длительностью и счетчиками:
```sql
SELECT
session_id,
client_id,
session_date,
started_at,
finished_at,
duration_seconds,
events_total,
pageviews,
cart_adds,
purchases
FROM dds.session_v
WHERE session_date = 'YYYY-MM-DD'
ORDER BY client_id, started_at, session_id
LIMIT 10;
```
Создайте в DBeaver подключение к первой ноде по [инструкции из
README](../../README.md#подключиться-из-dbeaver). Укажите пользователя `etl` и
пароль `CLICKHOUSE_ETL_PASSWORD` из `.env`. У этой учетной записи есть право
менять таблицы DDS на кластере. Подставьте найденную дату и удалите только ее
партицию:
```sql
ALTER TABLE dds.session_rep ON CLUSTER clickstream_cluster
DROP PARTITION 'YYYY-MM-DD';
```
Откройте Airflow и запустите `etl_pipeline`. В даге `dds_transform` раскройте
группу `session`: в ней должны выполниться задачи `scope` и `rebuild`, а также
`replace` для выбранного дня. Если после третьей ступени мир ушел вперед,
в группе появятся задачи `replace` и для последующих прожитых дней. Следом
выполнятся витрины и `dq_check`.
Если прогон дошел до замены, но упал на проверке, ошибочная партиция уже
находится в DDS. После правки снова удалите тот же день перед следующим
запуском. Иначе день останется заполненным, а новая сборка для него не
выполнится.
## Как проверить себя
Сначала повторите запрос с десятью строками. `session_id`, границы,
длительность и счетчики должны совпасть с сохраненным результатом. Так вы
завершите отложенное с третьей ступени сопоставление со стендовой нарезкой по
`session_id`.
Задача `dq_check.sessions_vs_reference` должна стать зеленой. Та же проверка
видна в хранилище:
```sql
SELECT data_date, reference_rows, actual_rows, failed_rows, status
FROM dm.dq_summary_v
WHERE check_name = 'sessions_vs_reference'
ORDER BY data_date;
```
Эта сверка сравнивает число сессий каждой куки за день с `VisitID`. Нулевой
`failed_rows` не подтверждает границы, страницы и UTM: две ошибки могут
оставить тот же счет. Возьмите поштучную сверку из третьей ступени. Для одной
куки выбранного дня сопоставьте границы `VisitID` по событиям с границами
строк `dds.session_v`. По исходным событиям проверьте страницы, UTM, устройство
и город. Страницы и UTM не входят в сохраненные десять строк: готовый SQL
стенда выбирал их через `argMin` и `argMax` только по `event_time`. При равном
времени ожидаемое значение определяйте по исходным событиям и полному порядку
`(event_time, event_id)`.
Осталась физическая раскладка. Напишите отдельный запрос к
`dds.session_dist`. Для каждой строки он должен:
1. получить фактическую ноду через `hostName()`;
2. пересчитать ожидаемую ноду по `cityHash64(client_id)`;
3. посчитать строки, у которых ноды не совпали.
У кластера два равных шарда: остаток 0 соответствует `clickhouse-01`, остаток
1 — `clickhouse-02`. Сгруппируйте ответ по фактической ноде. На каждой ноде
число строк на чужом шарде должно быть нулевым. Проверка числа сессий читает
Distributed-таблицу и такую ошибку не заметит.
После зеленого результата запустите `etl_pipeline` еще раз, не удаляя
партицию. Расчет объема должен пропустить группу сессий: новых прожитых дней и
дыр в слое нет.
## Что должно получиться
- [ ] вы переписали три файла `sql/dds/session_*.sql`, сохранив даг,
таблицы и проверку стенда;
- [ ] ваш расчет восстановил удаленный день, `etl_pipeline` завершился успешно;
- [ ] вы сверили десять сохраненных строк: `session_id`, границы,
длительность и счетчики совпали после пересборки;
- [ ] вы получили ноль расхождений в `sessions_vs_reference` за все
прожитые дни и вручную сверили границы и атрибуты сессии одной куки;
- [ ] ваш запрос с `hostName()` не нашел строк на чужом шарде;
- [ ] повторный прогон без удаления данных пропустил группу сессий.
-148
View File
@@ -1,148 +0,0 @@
# Научите проверки замечать ошибки в заказах
**Чему учит:** написать проверки заказов до изменения их обработки
и убедиться на намеренной ошибке, что проверки ее замечают.
## Сначала прочитать
- [модель заказа DDS](../architecture/dds/order.md);
- [дневной конвейер](../architecture/etl.md#объём-прогона);
- [правила проверок](../architecture/testing.md#проверка-которая-не-умеет-краснеть-бесполезна).
Посмотрите также на устройство одной проверки в [`sql/dq/`](../../sql/dq/)
и на ее включение в [`dq_check`](../../dags/dq_check.py). Готового ответа для
двух проверок из этого задания в репозитории нет.
## Что предстоит сделать
Добавьте две проверки и включите их в даг:
- `sql/dq/order_items_total.sql` проверяет сумму позиций заказа;
- `sql/dq/orders_vs_ods.sql` сверяет DDS с его источником;
- `dags/dq_check.py` запускает обе проверки, публикует их строки в
`dm.dq_summary` и создает для них отдельные задачи.
Разбор ODS и сборку DDS вы перепишете в следующих заданиях. Здесь сохраните
их, даги загрузки, DDL, таблицы-доноры, Kafka и службы как есть.
Перед опытами остановите автоматическое движение мира: поставьте `world_live`
на паузу и дождитесь завершения активных прогонов. До завершения опытов не
запускайте руками `world_next_day`, `world_live_day`, `world_initialize` и
`world_recreate`. Это условие стабильного модельного времени: одной паузы
`world_live` для него мало.
## Что будут проверять ваши запросы
Чтобы `dq_check` мог запустить проверку, ее запрос возвращает по одной строке
на день с колонками `data_date`, `reference_rows`, `actual_rows`,
`failed_rows`, `diagnostics`. Даг сохраняет в `dm.dq_summary` только счетчики,
статус и координаты запуска. Примеры из `diagnostics` ищите в
журнале упавшей задачи Airflow. Ограничьте их тем же малым числом, что и
существующие запросы в `sql/dq/`.
### Сумма позиций
Имя проверки - `order_items_total`. Для каждого заказа из `dds.order_v`
посчитайте сумму `item_price * item_quantity` и сравните ее с `items_total`.
`reference_rows` считает ключи со стороны `items_total`, `actual_rows` - ключи
со стороны вычисленной суммы, а `failed_rows` - несовпавшие значения. В
исправном наборе первые два счетчика равны; несовпадения сумм считает
`failed_rows`. Для диагностики достаточно `order_id`, ожидаемой суммы и
суммы позиций.
Проверка относится к типизированному разбору массива `items` на границе
ODS -> DDS. Скидку, доставку и равенство `total` здесь проверять не нужно.
### DDS против ODS
Имя проверки - `orders_vs_ods`. Источник модели DDS здесь - текущее состояние
`ods.order_v`. С ним вы и сравниваете DDS; обращаться к внутреннему состоянию
генератора для этой проверки не нужно.
Одним запросом соберите обе стороны на зерне дня заказа и сравните:
- число заказов;
- максимальный `updated_at`.
День ODS вычисляется в `Europe/Samara`, как `order_date` в DDS. Область
проверки - объединение дней обеих сторон, включая день, который полностью
пропал из одного слоя. Перед сравнением приведите времена к одному часовому
поясу. `reference_rows` - число заказов ODS, `actual_rows` - число заказов DDS,
а `failed_rows` равен единице для дня, где расходится хотя бы одно значение.
Обе стороны распределены по `order_id`. Дневные агрегаты соедините через
`GLOBAL FULL OUTER JOIN` с `join_use_nulls = 1`, как в существующих сверках.
Если разбор ODS и сборка DDS одинаково потеряют строку, слои останутся равны
по этой проверке. Общая потеря при разборе проявится в отдельном балансе по
`_load_id` из [следующего задания](5-2-order-parsing.md).
## Получите красный и зеленый результаты
Сначала запустите `dq_check` на нетронутых данных. Обе новые задачи должны
завершиться успешно, а в `dm.dq_summary_v` у обоих имен не должно быть
нарушенных дней.
Выберите оплаченный заказ из последнего слепка с непустым массивом позиций и
сохраните его `order_id` и `order_date`. Подключитесь к первой ноде из DBeaver
по [инструкции из README](../../README.md#подключиться-из-dbeaver) под
пользователем `etl`. Прибавьте одну копейку к `items_total` этого заказа в
локальной таблице `dds.order_rep` на всем кластере:
```sql
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
UPDATE items_total = items_total + toDecimal64(0.01, 2)
WHERE order_id = '<order_id>'
SETTINGS mutations_sync = 2;
```
Затем запустите только `dq_check`. Задача `order_items_total` должна
завершиться ошибкой, а ее журнал - назвать выбранный заказ.
`purchase_vs_orders_vs_dds` тоже даст красный результат: сохраненная витрина
содержит прежний `items_total`.
Запустите `etl_pipeline`. Текущее окно заказов будет заново собрано из ODS, и
исходная строка вернется в DDS. Повторный `dq_check` должен дать зеленый
результат для нового сторожа.
Теперь удалите тот же заказ из `dds.order_rep` на всем кластере:
```sql
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
DELETE WHERE order_id = '<order_id>'
SETTINGS mutations_sync = 2;
```
До восстановления снова запустите только `dq_check`. Красный результат нужен
для `orders_vs_ods`, `purchase_vs_orders_vs_dds` и `revenue_daily_vs_dds` на
дне оплаченного заказа. Затем запустите `etl_pipeline` и повторите проверку.
Если `orders_vs_ods` не обнаружил удаление, исправьте запрос до следующего
задания.
## Как проверить себя
В `dm.dq_summary_v` должны быть зелены обе новые и пять прежних проверок:
```sql
SELECT check_name, sum(failed_rows) AS failed_rows, groupUniqArray(status)
FROM dm.dq_summary_v
GROUP BY check_name
ORDER BY check_name;
```
Проверка суммы должна охватывать каждый день `dds.order_v`. Сверка слоев
должна охватывать объединение дней ODS и DDS, включая дни только одной стороны.
## Завершите опыт
После восстановления заказа и успешного `dq_check` можно закончить работу или
перейти к разбору заказов, оставив модельное время на паузе.
## Что должно получиться
- [ ] две новые проверки запускаются в `dq_check`, а их дневные результаты
появляются в `dm.dq_summary`;
- [ ] вы увидели ошибку каждой проверки после соответствующего изменения
заказа, а в журнале нашли примеры заказов с расхождениями;
- [ ] вы восстановили заказ через `etl_pipeline` после обоих опытов;
- [ ] `order_items_total`, `orders_vs_ods` и пять прежних проверок успешны;
- [ ] разбор ODS и сборка DDS остались прежними.
-180
View File
@@ -1,180 +0,0 @@
# Перепишите разбор заказов в ODS
**Чему учит:** переписать разбор заказов и на пробных сообщениях проверить,
что годные строки и брак попадают в свои таблицы без потерь.
## Сначала прочитать
- [проверки заказов](5-1-order-checks.md);
- [прием заказов в ODS](../architecture/orders/ingestion.md);
- [раскладку по шардам](../architecture/storage.md#раскладка-по-шардам);
- [`orders_ingest`](../../dags/orders_ingest.py);
- [`order_parse.sql`](../../sql/ods/order_parse.sql).
Запрос для проверки пробных строк вы напишете сами.
## Что предстоит сделать
Сначала проведите опыт на готовом `order_parse.sql`. Затем удалите его SQL,
соберите разбор заново и повторите опыт с другим маркером.
Не меняйте устройство `orders_ingest`, DDL, Kafka, таблиц и преобразований
DDS. Оба опыта должны закончиться адресной очисткой только своих строк.
Перед началом убедитесь, что после [предыдущего задания](5-1-order-checks.md)
все семь DQ остались зелеными. Поставьте `world_live` на паузу, дождитесь
активных прогонов и до завершения опыта не запускайте `world_next_day`,
`world_live_day`, `world_initialize` и `world_recreate`. Не запускайте
`etl_pipeline` между
отправкой пробной пачки и ее очисткой.
## Четыре строки для двух ветвей
Сделайте четыре сообщения с общим уникальным маркером:
1. годную строку с маркером в `order_id`;
2. JSON-строку вместо объекта - класс `not_an_object`;
3. объект с лишним ключом - класс `keyset_mismatch`;
4. объект с точным набором ключей, но строкой в `user_id` - класс
`field_invalid`.
`not_an_object` означает, что корень JSON не объект. `keyset_mismatch` означает,
что у объекта отличается точный набор верхнеуровневых ключей. `field_invalid`
означает, что ключи на месте, но значение не прошло проверку типа, формата или
вместимости.
Соберите строки в `/tmp/<маркер>.jsonl`. Заготовка дает одну годную строку и
по одному примеру каждого класса брака:
```bash
LAB_MARKER="lab5-$(date +%s)"
LAB_BATCH="/tmp/${LAB_MARKER}.jsonl"
LAB_DAY="$(
docker compose exec -T clickhouse-01 sh -lc \
'clickhouse-client --user etl --password "$CLICKHOUSE_ETL_PASSWORD" \
--query "SELECT toString(max(snapshot_date)) FROM ods.order_dist"'
)"
jq -cn --arg marker "$LAB_MARKER" --arg day "$LAB_DAY" '
def good($id): {
order_id: $id, user_id: 1, status: "paid",
created_at: ($day + "T00:00:00.000Z"),
updated_at: ($day + "T01:00:00.000Z"),
items_total: "10.00", discount: "0.00",
delivery: "0.00", total: "10.00",
items: [{sku: "lab5", qty: 1, price: "10.00"}],
snapshot_date: $day
};
good($marker),
($marker + "-not-an-object"),
(good($marker + "-keys") + {lab_marker: true}),
(good($marker + "-field") | .user_id = "broken")
' > "$LAB_BATCH"
```
Отправьте файл в обычный топик заказов утилитой из контейнера Kafka:
```bash
docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server kafka:9092 --topic orders < "$LAB_BATCH"
```
Запустите `orders_ingest` и найдите `_load_id` по маркеру в
`stg.orders_raw_dist`. У четырех строк должен быть один идентификатор прогона.
## Зафиксируйте исходный результат
До любого запуска `etl_pipeline` проверьте срез по найденному `_load_id`:
- в STG четыре строки;
- в физической `ods.order_dist` одна годная строка;
- в `ods.order_errors_dist` три строки, по одной каждого класса;
- `STG = годные + брак`;
- текущая строка `ods.order_v` совпадает по деловым полям с результатом
`argMax` по `snapshot_date` из годного сырья среза.
Счет физической таблицы нужен именно по `_load_id`: представление
`ods.order_v` оставляет текущую версию сущности и не показывает, сколько строк
разобрал запуск. Запрос сверки с `argMax` напишите сами. Эталонные деловые поля
стройте только из `raw`; отдельно сравните различающиеся `created_at` и
`updated_at`.
Проверьте раскладку годной строки через `hostName()` на `ods.order_dist`.
Фактическая нода должна совпасть с остатком `cityHash64(order_id) % 2`:
0 - `clickhouse-01`, 1 - `clickhouse-02`.
## Уберите пробную пачку
Удалите только строки маркера из локальных таблиц:
```sql
ALTER TABLE stg.orders_raw_rep ON CLUSTER clickstream_cluster
DELETE WHERE position(raw, '<маркер>') > 0
SETTINGS mutations_sync = 2;
ALTER TABLE ods.order_rep ON CLUSTER clickstream_cluster
DELETE WHERE startsWith(order_id, '<маркер>')
SETTINGS mutations_sync = 2;
ALTER TABLE ods.order_errors_rep ON CLUSTER clickstream_cluster
DELETE WHERE position(raw, '<маркер>') > 0
SETTINGS mutations_sync = 2;
```
В ClickHouse 26.3 мутации выполняются в фоне. С `mutations_sync = 2` команда
ждет их завершения на всех репликах. Запросами к трем Distributed-таблицам
убедитесь, что строк маркера не осталось, затем удалите временный файл.
Группа `clickstream_orders` уже подтвердила эти сообщения, поэтому обычный
повтор `orders_ingest` их не прочитает. В Kafka они остаются до удаления по
политике хранения. `world_recreate` удаляет группу и сам топик, поэтому в
пересозданный мир пробная пачка не переходит.
Очистка должна закончиться до запуска `etl_pipeline`. Если конвейер все же
успел собрать временный заказ в DDS, сначала очистите три таблицы выше, затем
запустите `etl_pipeline`: пересборка текущего окна удалит заказ из DDS и
обновит витрины.
## Перепишите разбор
Удалите готовый SQL из `sql/ods/order_parse.sql` и напишите свой. Чтобы
`orders_ingest` мог его использовать, сохраните следующие условия:
- вход - строки `stg.orders_raw_dist` одного `{load_id:String}`;
- каждая строка среза попадает ровно в один результат: годный заказ или брак;
- произвольный `raw` не останавливает весь разбор исключением;
- годные строки записываются в `ods.order_dist`, остальные - в
`ods.order_errors_dist`;
- классы брака и их порядок - `not_an_object`, `keyset_mismatch`,
`field_invalid`;
- метки доставки и `_load_id` переходят из STG без пересчета;
- задача завершается после доставки обоих результатов через Distributed, а
строки распределяются по ключу целевой таблицы.
Точный набор полей, типы и граница строгого приема описаны в
[документе приема](../architecture/orders/ingestion.md#граница-строгого-приёма),
а целевые колонки - в [`20-ods-tables.sql`](../../sql/ddl/20-ods-tables.sql).
Внутренность `items` остается сырым JSON: ее разбирает DDS.
Повторите опыт с новым маркером. Получите тот же баланс 4 = 1 + 3, те же три
класса, верную текущую строку по `snapshot_date`, разные времена и правильный
шард. Затем очистите второй срез теми же адресными командами и запустите
`dq_check`.
## Завершите опыт
Убедитесь, что оба маркера отсутствуют в STG, ODS, таблице брака и DDS,
временные файлы удалены, а семь проверок успешны. Теперь можно перейти к
сборке DDS, не снимая паузу с модельного времени. Если вы прерываетесь и до
опыта `world_live` работал, снимите его с паузы.
## Что должно получиться
- [ ] у вас сохранены результаты до переписывания: четыре строки дали
один заказ и три класса брака; вы сверили поля текущей версии и шард;
- [ ] вы переписали `sql/ods/order_parse.sql`, сохранив остальные части стенда;
- [ ] ваш разбор дал тот же результат на новой пробной пачке;
- [ ] вы проверили разные значения `created_at` и `updated_at` у годной
строки в обоих опытах и подтвердили правильный шард через `hostName()`;
- [ ] строки обоих опытов удалены по их маркерам;
- [ ] после очистки конвейер и все семь проверок успешны.
-123
View File
@@ -1,123 +0,0 @@
# Перепишите сборку заказов в DDS
**Чему учит:** настроить пересборку заказов по состоянию ODS и DDS
и проверить, что она восстанавливает пропущенный день за пределами текущего окна.
## Сначала прочитать
- [проверки заказов](5-1-order-checks.md);
- [разбор заказов в ODS](5-2-order-parsing.md);
- [модель заказа DDS](../architecture/dds/order.md);
- [дневной конвейер](../architecture/etl.md#объём-прогона);
- [раскладку по шардам](../architecture/storage.md#раскладка-по-шардам).
## Что предстоит сделать
Удалите готовый SQL и перепишите только три файла:
- `sql/dds/order_scope.sql`;
- `sql/dds/order_rebuild.sql`;
- `sql/dds/order_replace.sql`.
Даг `dds_transform`, DDL, донор, ODS и обе проверки из первого задания
оставьте как есть. Перед началом убедитесь, что пробные строки предыдущего
задания удалены, а семь DQ дают зеленый результат.
Поставьте `world_live` на паузу и дождитесь завершения активных прогонов. До
завершения опыта не запускайте `world_live_day`, `world_initialize` и
`world_recreate`. `world_next_day` понадобится только в явно указанном ниже
случае.
## Как даг запускает ваши запросы
Сохраните интерфейс `dds_transform`:
| Файл | Вход | Результат |
|---|---|---|
| `order_scope.sql` | без параметров | одна строка `first_day`, `last_day` |
| `order_rebuild.sql` | `{first_day:Date}`, `{last_day:Date}`, `{load_id:String}` | выбранные дни в `dds.order_stage_dist` |
| `order_replace.sql` | день в `{{ params.day }}` | партиция дня в `dds.order_rep` |
Объем состоит из текущего окна источника и хвоста от первого дня, который есть
в ODS, но отсутствует в DDS. Текущее окно дают строки последнего
`snapshot_date`: их минимальный и максимальный день заказа. Если источник
пуст, `order_scope.sql` не возвращает строку. Если дыр нет, `first_day` равен
началу окна. Если дыра старше, непрерывный отрезок начинается с нее.
`order_rebuild.sql` сначала очищает все партиции `dds.order_stage_rep` на
кластере, затем записывает в `dds.order_stage_dist` весь выбранный отрезок. Он
читает текущее состояние `ods.order_v`, вычисляет `order_date`, переводит
времена в `Europe/Samara`, разбирает `items` в три параллельных массива и
ставит координаты запуска.
Запись через Distributed должна завершиться до замены и использовать ключ
целевой таблицы для раскладки строк. Способ собрать весь отрезок внутри файла
выберите сами.
`order_replace.sql` работает с локальными `dds.order_rep` и
`dds.order_stage_rep` на всем кластере. `REPLACE PARTITION` переносит готовую
дневную партицию из донора и оставляет донор на месте. Структура, ключи и
политика хранения совпадают: DDL объявляет донор через `AS dds.order_rep`.
## Восстановите ближайший закрытый день
Найдите последний по дате день DDS, который лежит левее начала текущего окна
ODS. Это ближайший к окну закрытый день: его удаление показывает расширение
объема на дыру без пересборки всей истории.
Если такого дня еще нет, один раз продвиньте мир через `world_next_day`,
дождитесь `orders_ingest`, затем запустите `etl_pipeline`. После этого снова
зафиксируйте модельное время до конца опыта и выберите ближайший закрытый день.
Сохраните для него число заказов и контрольную сумму деловых полей без
`_load_id` и `_load_ts`. Удалите только его партицию:
```sql
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
DROP PARTITION 'YYYY-MM-DD';
```
Сразу запустите `dq_check`, до восстановления партиции. Красный результат
должны дать `orders_vs_ods` и `purchase_vs_orders_vs_dds`.
`revenue_daily_vs_dds` тоже даст красный результат, если в партиции были
оплаченные заказы. `classes_vs_inventory` останется зеленым: до запуска
`dm_transform` эта проверка читает прежнюю витрину сверки.
Запустите `etl_pipeline`. В группе `dds_transform.order` значение `first_day`
должно совпасть с удаленным днем, а задачи `replace` - покрыть непрерывный
отрезок от него до конца текущего окна. `first_day` ищите в строке про объем
пересборки в журнале задачи `order.scope` дага `dds_transform`. После успешного
прогона число заказов и контрольная сумма выбранного дня должны совпасть с
сохраненными.
## Проверьте раскладку и повтор
Напишите отдельный запрос к `dds.order_dist`, который получает фактическую
ноду через `hostName()`, вычисляет ожидаемую по `cityHash64(order_id) % 2` и
считает несовпадения по каждой ноде. Остаток 0 соответствует
`clickhouse-01`, остаток 1 - `clickhouse-02`. На обеих нодах число строк на
чужом шарде должно быть нулевым. Счетные DQ читают Distributed-таблицы и такую
ошибку раскладки не обнаруживают.
Запустите `etl_pipeline` еще раз при неизменном модельном времени. Группа
заказов снова должна пересобрать текущее изменяемое окно. Контрольные суммы
деловых полей остаются прежними, а все семь проверок дают зеленый результат.
## Завершите опыт
После восстановления партиции и успешных проверок снимите `world_live`
с паузы, если он работал до упражнения.
## Что должно получиться
- [ ] вы переписали три файла `sql/dds/order_*.sql`, сохранив остальные
части стенда;
- [ ] вы удалили ближайший к окну закрытый день и увидели ошибку DQ до
восстановления;
- [ ] ваш расчет выбрал `first_day` с удаленного дня и заменил партиции
всего отрезка до конца окна;
- [ ] вы сверили число заказов и контрольную сумму восстановленного дня
с сохраненными значениями; они совпали;
- [ ] `order_items_total`, `orders_vs_ods` и пять прежних проверок успешны;
- [ ] ваш запрос с `hostName()` не нашел заказов на чужом шарде;
- [ ] повторный `etl_pipeline` сохранил деловые данные, проверки прошли.
-219
View File
@@ -1,219 +0,0 @@
# Разберите сырые события рядом со стендом
**Чему учит:** пакетно разобрать сообщения отдельно от живого приема и
построчно проверить свой результат по нетронутому ODS.
## Сначала прочитать
- [формат события](../formats/clickstream-event.md);
- [справочник хранилища](../architecture/storage.md), разделы про имена
объектов, актуальные события, прием потока и срок жизни сырых данных;
- [ADR о приеме событий](../adr/0005-event-ingestion.md), раздел с принятым
решением;
- схему [`ods.event_rep`](../../sql/ddl/20-ods-tables.sql).
Готовый стендовый разбор находится в `sql/ddl/30-ods-views.sql`. Откройте его
после собственной сверки: в нем лежит ответ на основную часть задания.
## Что предстоит сделать
Создайте в базе `default` одну свою таблицу годных событий и наполните ее одним
пакетным `INSERT SELECT` из `stg.hits_raw_dist`. Работайте с первой нодой под
пользователем `etl`.
Матвью `ods.event_mv` и `ods.event_errors_mv` продолжают принимать живой
поток. Оставьте их, STG, ODS, Kafka и даги без изменений. Пересоздание матвью
на работающем стенде оставило бы зазор в приеме, а для пакетного опыта оно не
нужно.
Дайте своей таблице имя, которое не пересечется с чужой работой, и запишите
его вместе с запросами. В конце вы удалите только эту таблицу.
## Выберите сырой срез
STG хранит строки примерно три дня по `_load_ts`. Эта колонка отмечает время
поступления в хранилище. Модельный день события лежит в `EventDate`. Сначала
найдите среди недавних данных завершившийся интервал со строками. Посмотрите
счет по минутам:
```sql
SELECT
toStartOfMinute(_load_ts) AS minute,
count() AS rows
FROM stg.hits_raw_dist
WHERE _load_ts >= now() - INTERVAL 30 MINUTE
AND _load_ts < toStartOfMinute(now())
GROUP BY minute
ORDER BY minute DESC;
```
Выберите короткий интервал `[slice_from, slice_to)`, который уже закончился и
дает удобное для построчной проверки число строк. Если одна минута велика,
сузьте ее точными границами `_load_ts`. Если недавних строк нет, откройте
Airflow. При включенном `world_live` посмотрите его текущий запуск и дочерний
`world_live_day`: дождитесь свежих строк от текущего или следующего планового
запуска, а при ошибке сначала разберите ее причину. Запустите `world_live_day`
вручную, только если `world_live` стоит на паузе и активного `world_live_day`
нет. Успешный прогон распределяет события модельного дня по реальным минутам.
Зафиксируйте до разбора:
- обе границы с миллисекундами и число строк;
- минимальный и максимальный `kafka_offset` по каждой паре
`kafka_topic`, `kafka_partition`.
Во всех дальнейших запросах читайте `stg.hits_raw_dist` только с этими
границами. Новые строки с более поздним `_load_ts` в срез не попадут. Повторите
опыт до очистки по TTL; после нее прежний срез уже не восстановить из ODS.
## Соберите собственную таблицу
Таблица содержит все 47 полей источника с именами и типами
`ods.event_rep`, а также `_load_ts`. Тип `purchaseRevenue` возьмите из ODS:
`Array(Decimal(18, 2))`.
Обработайте повторные доставки так же, как ODS. Возьмите
`ReplacingMergeTree(_load_ts)`, партицию `EventDate` и полный ключ замены:
`(CounterID, EventDate, intHash32(ClientID), WatchID)`. Достаточно одной
локальной таблицы: загрузка и сверка в этом опыте выполняются на одной ноде.
`WatchID` — логический идентификатор события, уникальный по
[договору генератора](../specs/2026-08-01-generator.md). Физический ключ ODS
шире: в `ReplacingMergeTree` версиями считаются строки с одинаковым полным
ключом сортировки. Используйте его при загрузке и сверке.
## Разберите годные строки
Для каждой строки среза проверьте те же условия приема, что и на стенде:
1. корень `raw` — JSON-объект;
2. набор верхнеуровневых ключей в точности равен 47 ключам формата;
3. `WatchID`, `VisitID`, `ClientID`, `EventDate` и `UTCEventTime` разбираются
в ожидаемые типы.
Напишите разбор, который не прерывает пакет из-за одной грязной строки. Сами
выберите способ безопасного извлечения пяти опорных полей, форму общих выражений
и место фильтра. Остальные поля извлеките по описанию формата.
Перенесите `_load_ts` из STG без пересчета: это время исходной доставки и
колонка версии. Время запуска вашего запроса сюда не входит. Вставьте в свою
таблицу только годные строки выбранного среза.
Считайте отвергнутые строки прямо в исходном срезе, без отдельной таблицы
брака. Одним запросом посчитайте:
- все доставки;
- строки, прошедшие ваше условие отбора;
- строки, для которых это условие ложно.
Проверьте равенство общего числа строк сумме прошедших и отвергнутых. Так каждая
исходная строка учитывается ровно в одной группе. Если отвергнутые строки есть,
сохраните их `raw`, `_load_ts` и координаты Kafka. Найдите те же
координаты в `ods.event_errors_dist` и запишите стендовый `error_class`. Если
брака нет, так и зафиксируйте: опыт проверил успешную ветвь и выполнение
условия отбора на этом срезе, но не классификатор ошибок. Подкладывать грязные
сообщения ради задания не требуется.
## Приведите стороны к одному зерну
### Учтите повторные доставки
В STG одна строка представляет доставку. Повтор сообщения остается второй
строкой. В `ods.event_v` одна строка представляет актуальное событие, потому
что представление читает физическую таблицу с `FINAL`.
Свою сторону тоже читайте с `FINAL`. Ожидаемый счет равен числу различных
полных ключей среди годных доставок. Разница между числом доставок и этим
счетом показывает повторы в исходном срезе.
### Выберите события ODS для сравнения
Теперь нужно найти в ODS события из выбранного среза. Список стройте по
исходным доставкам: если взять его из своей таблицы, ошибочно отвергнутое
событие исчезнет с обеих сторон сверки и потеря останется незамеченной.
Снова возьмите весь исходный STG-срез. По координатам Kafka исключите
доставки, которые есть в `ods.event_errors_dist`. У оставшихся извлеките
`WatchID` без риска прервать запрос и выберите эти события из свежего
`ods.event_v`. Свое условие отбора здесь не применяйте: решение о годности
доставки вы берете из результата стендового разбора.
`WatchID` связывает доставку с событием в ODS. Для удаления повторов и
сравнения полей дальше используйте полный физический ключ из схемы таблицы.
## Сверьте 47 полей
Соедините свою таблицу и выбранные строки `ods.event_v` через `FULL OUTER
JOIN` по полному ключу замены. Полное соединение сохраняет строки,
которых нет на одной из сторон. Разделите результат на три исхода:
- строка есть только в ODS — вы ее потеряли;
- строка есть только у вас — вы приняли лишнее или иначе разобрали часть
ключа;
- ключ есть с обеих сторон, но различается хотя бы одно из 47 полей.
ClickHouse по умолчанию заполняет отсутствующую сторону значениями типов.
Включите для сверки `join_use_nulls = 1` или добавьте явный признак присутствия
в каждую сторону. Не определяйте отсутствие по значениям полей события.
Сравните все поля явно, включая массивы, `ecommerce` и точное десятичное
`purchaseRevenue`. Удобный вид диагностики выберите сами: важно, чтобы по
результату можно было назвать отличающиеся колонки одной строки. Выведите число
строк каждого из трех исходов. В верном разборе все три числа равны нулю.
`_load_ts` не входит в 47 полей источника и не проверяет JSON-разбор.
Проверьте его отдельно. Свяжите каждую строку своей таблицы с меткой одной из
доставок того же полного ключа в исходном срезе. После `FINAL` остается самая
поздняя метка этого ключа внутри среза. С ODS ее не сравнивайте: там может
храниться более поздняя доставка того же события из-за границ среза.
## Расследуйте расхождение
Если сверка не пуста, возьмите одну строку. Вернитесь к независимому списку
ключей и исходному `raw`, выведите рядом координаты Kafka и `_load_ts`.
Для измененной строки назовите поля, которые расходятся, и сравните каждое с
сырой строкой. Для пропавшей или лишней строки проверьте пять опорных полей и
результат условия отбора. Если координаты доставки есть в
`ods.event_errors_dist`, посмотрите `error_class`.
Исправьте разбор, удалите и заново создайте только собственную таблицу, затем
повторите вставку того же среза. Остановитесь, когда счет исходных доставок по
прежним границам не изменился, число годных и отвергнутых строк в сумме дает
число исходных доставок, а построчная сверка пуста.
## Чего не подтверждает сравнение с ODS
`ods.event_v` — образец для сравнения. Оба разбора могли повторить одну
ошибку. Для пяти опорных полей у вас есть проверка преобразования в нужный тип
и исходный JSON. Остальные 42 поля можно вручную сопоставить с `raw`, однако
второго независимого источника для всех значений на стенде нет.
## Уберите свой объект
Сохраните DDL таблицы, запрос разбора, границы среза, число исходных, годных
и отвергнутых строк и итоговую сверку без расхождений. Запишите также, чего
не подтверждает сравнение с ODS и как TTL ограничивает время для повторения
опыта. Затем удалите свою таблицу командой `DROP TABLE` с ее точным именем.
Убедитесь через `system.tables`, что объекта больше нет.
Прикладные строки стенда вы не меняли, поэтому удалять данные из STG, ODS или
Kafka после опыта не нужно.
## Что должно получиться
- [ ] у вас сохранены границы выбранного среза, число строк и координаты
доставки по партициям Kafka;
- [ ] ваша таблица содержит все 47 полей и `_load_ts` и обрабатывает
повторы по тем же правилам, что и ODS;
- [ ] ваш разбор принимает только JSON-объекты с точным набором ключей и
пятью опорными полями, которые преобразуются в нужные типы;
- [ ] вы сверили число исходных строк с суммой годных и отвергнутых,
а найденный брак разобрали по `raw`, координатам и классу ошибки стенда;
- [ ] вы выбрали события ODS по исходному срезу и таблице ошибок,
независимо от своей таблицы и условия отбора;
- [ ] вы сравнили обе стороны по полному физическому ключу через
`FULL OUTER JOIN`: во всех 47 полях нет расхождений,
пропавших и лишних строк тоже нет;
- [ ] вы отдельно проверили перенос `_load_ts` и учли повторные доставки;
- [ ] вы записали ограничения сравнения с ODS и срок повторения опыта;
- [ ] после опыта вы удалили свою таблицу, сохранив объекты и данные стенда.
-118
View File
@@ -1,118 +0,0 @@
# Мониторинг: куда смотреть
**Чему учит:** начать расследование с подходящего экрана, найти источник
нагрузки и объяснить свежесть данных через путь доставки.
## Перед началом
Откройте Grafana и Airflow по адресам из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли). Для первого знакомства в Grafana
есть три дашборда:
- «Данные» — путь сообщений от источника до хранилища;
- «Кластер» — процессор, память, диски и внутренняя работа ClickHouse;
- «Запросы» — выполненные запросы, их стоимость и ошибки.
Откройте все три дашборда и запишите по одному вопросу, на который каждый из
них помогает ответить. «Данные» оставьте открытым в соседней вкладке.
Проведите два расследования на работающем стенде. Службы останавливать и данные
портить не понадобится.
## Расследование 1. Кто читает больше всех
Начните в спокойный момент: не запускайте одновременно свои тяжелые запросы.
Откройте дашборд «Запросы». На панели «Тяжёлые запросы: что прочитало больше
всех» возьмите первую строку.
Сохраните:
- границы окна и время запроса;
- место запроса в списке, пользователя и ноду;
- текст запроса;
- длительность, память, число прочитанных и отданных строк.
По пользователю и тексту определите, кто отправил запрос и для чего. Затем
найдите запрос одного из трех открытых дашбордов, который прочитал больше всего
строк. Если это другая строка, сохраните для нее те же поля.
По тексту найдите панель, которая отправила запрос. Ищите ее среди трех
дашбордов по названиям колонок, таблиц и показателей. Запишите, зачем запрос
читает столько строк и согласуется ли это с вопросом, на который отвечает
панель.
В меню панели «Кто спрашивает: сколько запросов и какой у них аппетит»
откройте Inspect, затем Query. Ответьте на два вопроса:
1. Почему журнал читают сразу с обеих нод?
2. Почему из счета исключены строки начала запроса и работа шардов?
Проверьте второе объяснение по панели ошибок: ее фильтр не исключает записи
шардов. Объясните, почему различаются вопросы панелей, а не только их SQL.
## Расследование 2. Как меняется свежесть заказов
Откройте дашборд «Данные». Панель «Свежесть: сколько прошло с последней
доехавшей строки» показывает текущее число, а не историю. До наблюдения
нарисуйте ожидаемую форму свежести событий и заказов в течение одного живого
дня. Отдельно отметьте момент доставки слепка заказов.
Если `world_live` снят с паузы, наблюдайте его текущий или следующий
`world_live_day`: новый ручной запуск встанет в очередь. Если выключатель на
паузе и активного живого дня нет, запустите один `world_live_day` вручную.
Модельные сутки занимают около 24 реальных минут.
Запишите несколько наблюдений до конца дня и сразу после него:
| Реальное время | Свежесть событий, с | Свежесть заказов, с | Что происходило в Airflow |
|---|---:|---:|---|
| | | | |
Дашборд обновляется раз в минуту, поэтому точного совпадения момента
доставки со строкой таблицы ждать не нужно. Рост и сброс свежести заказов
перенесите на свой набросок. Объясните форму линии: какую метку времени
вычитает панель из текущего времени, почему число растет и какое событие его
сбрасывает. Модельную дату сыгранного дня подпишите отдельно от реального
времени наблюдения.
Подтвердите сброс двумя следами:
- в Airflow завершился прием слепка `orders_ingest`;
- на панели «Дневной слепок заказов: какой доехал, когда и после какой паузы»
появилась новая верхняя строка.
Если вы начали после доставки, дождитесь следующего дня либо честно запишите,
какую часть формы увидели. Одно текущее значение не доказывает ни рост, ни
сброс.
## Объясните путь данных
Не открывая справочник, объясните своими словами:
- почему свежесть считают в ODS, а поток — в STG;
- почему знаменатель доли брака берут из STG;
- почему у числителя и знаменателя доли брака одно окно.
Свяжите ответы с тем, что вы видели на панелях. Затем сверьте объяснение с
[главой о мониторинге](../handbook/monitoring.md) и справочниками дашбордов
[«Запросы»](../architecture/monitoring/queries.md) и
[«Данные»](../architecture/monitoring/data.md). Устройство живого дня и
доставки слепка описано в [главе об Airflow](../handbook/airflow-dags.md).
## Что должно получиться
- [ ] для каждого из трех дашбордов записан вопрос, на который он отвечает;
- [ ] у вас сохранены окно, текст и показатели верхнего запроса;
вы выяснили, кто его отправил и зачем;
- [ ] вы нашли запрос открытых дашбордов, который прочитал больше всего
строк, и панель, которая его отправила;
- [ ] вы можете объяснить, зачем журнал собирают с двух нод и какие записи
учитывают при подсчете запросов;
- [ ] у вас есть прогноз свежести, сделанный до наблюдения, и несколько
замеров; рост и сброс на рисунке объяснены через время последней доставки;
- [ ] вы подтвердили доставку заказов в Airflow и на панели слепков,
подписали модельную дату отдельно от реального времени наблюдений;
- [ ] вы можете объяснить выбор слоев для свежести, потока и доли брака
и почему числитель и знаменатель берут за одно окно;
- [ ] после наблюдений службы продолжают работать, данные и стендовые
дашборды остались без изменений.
+81 -90
View File
@@ -9,112 +9,107 @@
| Ступень | Задание | Чему учит | | Ступень | Задание | Чему учит |
|---|---|---| |---|---|---|
| 1 | Пять витрин слоя DM | построить результат для потребителя хранилища; уроки каждой витрины — в таблице ниже | | 1 | Пять витрин слоя DM | построить результат для потребителя хранилища; уроки каждой витрины — в таблице ниже |
| 2 | [Свой график в Superset](2-1-superset-chart.md) | превратить свой результат из хранилища в отчет в BI | | 2 | [Свой график в Superset](superset-chart.md) | превратить свой результат из хранилища в отчет в BI |
| 3 | [Нарежьте сессии сами](3-1-sessions-by-hand.md) | сверить число сессий в своей нарезке с эталоном и найти события, из-за которых оно расходится | | 3 | Нарежь сессии сам | счет показывает проблему, но причину приходится искать в отдельных строках |
| 4 | [Перепишите нарезку стенда](4-1-session-pipeline.md) | переписать расчет сессий в готовом конвейере и проверить по исходным событиям | | 4 | Перепиши нарезку стенда | изменить расчетную сущность внутри готового конвейера и проверить ее внешним эталоном |
| 5 | [Научите проверки замечать ошибки в заказах](5-1-order-checks.md)<br>[Перепишите разбор заказов в ODS](5-2-order-parsing.md)<br>[Перепишите сборку заказов в DDS](5-3-order-pipeline.md) | написать проверки качества до рискованного изменения, затем переписать разбор и сборку заказов | | 5 | Сначала сторож, потом ломай: заказы | построить сторожей до рискованного изменения, а не после первой поломки |
| 6 | [Разберите сырые события рядом со стендом](6-1-event-parsing.md) | изучить разбор потока отдельно от живого приема и сверить его со стендовым результатом | | 6 | Разбери сырые события и сверь результат со стендом | изучить разбор потока отдельно от живого приема и сверить его со стендовым результатом |
| 7 | [Мониторинг: куда смотреть](7-1-monitoring.md) | выбрать, куда и на что смотреть в живой системе | | 7 | Мониторинг: куда смотреть | выбрать, куда и на что смотреть в живой системе |
ТЗ для ступеней 3–7 появятся по мере продвижения программы.
## Ступень 1: пять витрин слоя DM ## Ступень 1: пять витрин слоя DM
Слой DM в стенде построен наполовину: витрины трех сюжетов дашборда и контроль Слой DM в стенде построен наполовину: витрины трех сюжетов дашборда и контроль
качества уже построены, остальные пять витрин строите вы. Каждая из них — качества уже построены, остальные пять витрин строите вы. Каждая из них —
самостоятельное задание: в нем есть состав витрины, зерно, источники и способ самостоятельное ТЗ: в нем есть состав витрины, зерно, источники и способ
проверки. проверки.
| № | Результат | Чему учит | | № | Результат | Чему учит |
|---|---|---| |---|---|---|
| 1 | [`events_enriched_v`](1-1-events-enriched.md) | подготовить удобный для анализа набор полей, сохранив одну строку на событие | | 1 | [`events_enriched_v`](events-enriched.md) | слой — это контракт, а не копия данных |
| 2 | [`top_pages_daily`](1-2-top-pages-daily.md) | создавать материализованную витрину с дневной пересборкой в конвейере | | 2 | [`top_pages_daily`](top-pages-daily.md) | зерно сменилось — витрина материализуется и встает в конвейер |
| 3 | [`session_overview`](1-3-session-overview.md) | хранить слагаемые для расчета средних и долей | | 3 | [`session_overview`](session-overview.md) | зерно решает, на какие вопросы витрина уже не ответит |
| 4 | [`dq_errors_daily`](1-4-dq-errors-daily.md) | считать брак по дню загрузки и хранить итоги дольше источника | | 4 | [`dq_errors_daily`](dq-errors-daily.md) | у брака свой день и свой срок жизни |
| 5 | [`utm_effectiveness`](1-5-utm-effectiveness.md) | читать `declared_` как предупреждение о сумме со слов клиента | | 5 | [`utm_effectiveness`](utm-effectiveness.md) | `declared_` в имени — предупреждение, а не украшение |
Общее устройство слоя — [справочник DM](../architecture/dm.md). Он отвечает на Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на
вопросы «почему так», задание отвечает на вопрос «что сделать». вопросы «почему так», задание отвечает на вопрос «что сделать».
## Как принято на ступени 1 ## Общие правила ступени 1
**Читать — через `_v`.** Физические `_rep` и `_dist` служат загрузке и Они одни для пяти заданий по витринам ступени 1, поэтому записаны здесь, а не
обслуживанию, а публичный договор DDS — `dds.event_v` или `dds.session_v` — повторяются в каждом ТЗ.
остается прежним даже при изменении физических объектов. Чтение DDS
напрямую делает задания независимыми: соседняя витрина может быть еще не
построена.
Исключение — задание 4: у таблиц брака публичного договора нет.
**Писать — в `_dist`.** Так строки распределяются по ключу шардирования. При **Читать — через `_v`.** Публичный договор сущности DDS — представление с
записи в локальную таблицу распределение зависит от узла, выполнившего суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только
запрос, и при следующем запуске может измениться. Подробности — раздел его; физические `_rep` и `_dist` служат загрузке и обслуживанию. Соседнюю
«Раскладка по шардам» [справочника хранилища](../architecture/storage.md). домашку витрина источником тоже не берет: витрина слоя DM читает договор
DDS, и задания остаются независимыми. Исключение одно и оно оговорено в
задании 4: у таблиц брака публичного договора нет.
**Форма — по зерну.** На стенде агрегат с новым зерном принято хранить **Писать — только в `_dist`.** При записи в локальную таблицу раскладку
готовым: потребитель читает его много раз без повторного расчета по определяет то, какая нода случайно выполняла код, а не свойство данных.
источнику. Создайте `dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` / Правило и его следствия — [дока хранилища](../architecture/storage.md),
`_stage_dist` и публичное `dm.<имя>_v` с явным списком колонок. При прежнем «Раскладка по шардам».
зерне достаточно представления: таблица дублировала бы данные. Доводы —
раздел «Таблица или представление» [справочника
DM](../architecture/dm.md).
**Ключ шардирования — ваше решение.** Объясните, почему выбранный ключ не **Форма — по зерну.** Витрина, меняющая зерно источника, материализуется:
дает существенного перекоса и почему более сложный ничего не добавит. `dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` / `_stage_dist` и
Объявите донор через `AS <цель>_rep` с ключом цели: иначе замена партиции публичное `dm.<имя>_v` с явным списком колонок. Витрина, сохраняющая зерно,
потеряет строку на одном шарде и удвоит на другом. Детали — раздел остается обычным представлением. Довод — [дока DM](../architecture/dm.md),
«Раскладка по шардам» [справочника хранилища](../architecture/storage.md). «Форма: по зерну, не по цене запроса».
Пример обоснования — `dm.revenue_daily_dist` в
[`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
**Где лежит код.** DDL заданий — в `sql/ddl/70-labs.sql`. **Ключ шардирования — ваше решение.** Ни одно ТЗ ключа не называет.
При создании мира он применяется после стендовых файлов. Правило выбора и требование к донору — [дока
В уже работающем стенде сохранение файла и повторный `make up` новый DDL хранилища](../architecture/storage.md), «Раскладка по шардам»: донор
не применят — выполните его сами, как описано ниже. В объявляется через `AS <цель>_rep` и шардируется тем же ключом, что цель,
`sql/dm/<витрина>_scope.sql` определяются дни прогона, в `_rebuild.sql` они иначе замена партиции потеряет строку на одном шарде и задвоит на другом.
собираются в донор, в `_replace.sql` заменяется партиция. Задачу сборки Названный ключ надо уметь защитить: почему он не дает перекоса и почему
добавьте в [`dags/dm_transform.py`](../../dags/dm_transform.py): без нее более сложный тут ничего не добавляет. Образец такой защиты —
конвейер не обновит витрину. Представлению достаточно `CREATE VIEW`. `dm.revenue_daily_dist` в [`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
**Образец — витрина `revenue_daily`.** У нее есть пять объектов, донор, **Где лежит код.** DDL домашек — свой файл `sql/ddl/70-labs.sql`: он
сборка, замена дневных партиций и задача в конвейере. Используйте ее как применяется после стендовых по порядку имени, и ваши объекты не мешаются
образец всего порядка пересборки. Состав колонок берите из своего ТЗ, иначе со стендовыми. У агрегатов рядом со стендовыми ложатся еще три запроса —
получите показатели другой витрины. `sql/dm/<витрина>_scope.sql` спрашивает у хранилища, какие дни пересобирает
запуск, `_rebuild.sql` собирает их в донор, `_replace.sql` заменяет
партицию, — и задача в
[`dags/dm_transform.py`](../../dags/dm_transform.py). У разминочного
представления нет ничего из этого: весь его код — одно `CREATE VIEW`.
## Как применить свой DDL **Образец на все случаи** — витрина `revenue_daily`: у нее есть все пять
объектов, донор, сборка, замена дневных партиций и задача в конвейере.
В DBeaver создайте подключение по [инструкции в README](../../README.md#подключиться-из-dbeaver), Списывать с нее устройство можно и нужно; списывать состав колонок — нет,
но укажите пользователя `etl` и пароль из `CLICKHOUSE_ETL_PASSWORD` в `.env`. он у каждой витрины свой.
Пользователь `analyst` из той инструкции предназначен для чтения;
у `etl` есть права создавать таблицы и представления в `dm`.
Создайте файл `sql/ddl/70-labs.sql` и сохраняйте в нем DDL своих витрин.
Откройте его в SQL-редакторе этого подключения и выполните DDL текущего
задания. Создавайте объекты с
`ON CLUSTER clickstream_cluster`, чтобы они появились на обеих нодах;
образец для представления — [`sql/ddl/60-dm-views.sql`](../../sql/ddl/60-dm-views.sql).
Сохраненный файл позволит воссоздать ваши объекты при пересборке мира.
## Самопроверка ступени 1 ## Самопроверка ступени 1
Даг `homework_check` запускается руками, расписания у него нет. Задача Даг `homework_check` запускается рукой, расписания у него нет. Задача
витрины красная, пока витрины нет или счет не сходится, и зеленая, когда витрины красная, пока витрины нет или счет не сходится, и зеленая, когда
собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет: это собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет:
инструмент лабы, а не история качества стенда. это инструмент лабы, а не история качества стенда.
Для каждой витрины проверка независимо рассчитывает ожидаемый результат Проверка пяти заданий по витринам устроена одинаково и золотых констант не
прямо по ее источнику. У четырех заданий источник содержит: рядом с витриной независимым запросом собирается тот же ответ —
эталона тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания прямо из источника витрины, минуя ее саму. У четырех заданий источник эталона
4 это таблицы брака ODS, потому что в DDS нет сущности брака. Обе стороны тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания 4 это
соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число таблицы брака ODS, потому что в DDS у брака представительства нет. Обе стороны
строк, где стороны разошлись или одной стороны нет. Ноль — зеленый. Полное соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число строк,
соединение сохраняет ключи обеих сторон: при внутреннем пропавший день или где стороны разошлись или одной стороны нет. Ноль — зеленый.
ключ не попал бы в результат, и потеря осталась бы незамеченной. Полное соединение здесь обязательно: при обычном пропавший день или
пропавший ключ просто не попал бы в результат и остался бы незамечен.
Раздел «Как проверить себя» каждого задания по витрине называет деловой ключ и Раздел «Как проверить себя» каждого ТЗ по витрине называет деловой ключ и
сравниваемые величины. Тот же прием в стендовом исполнении — [даг сравниваемые величины. Тот же прием в стендовом исполнении — [даг
`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq). `dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
Запросы проверок лежат в `sql/dq/`. Откройте их после собственного решения: **Предупреждение.** Запросы самой проверки лежат в `sql/dq/` открыто, и
они заново рассчитывают показатели витрины, поэтому содержат ответ на имена называют витрину и роль сверки. Проверка на инвариантах неизбежно
существенную часть задания. Сравните свой способ расчета с готовым. близка к ответу: чтобы сверить витрину, надо ее пересчитать. Прятать эти
файлы никто не станет — но открыв их до того, как задание сделано, вы
обменяете урок на готовый SQL.
Смотреть туда стоит после своей сборки: сравнить два решения полезно,
списать одно — нет.
## Факультативы ## Факультативы
@@ -134,12 +129,8 @@ DM](../architecture/dm.md).
### Superset и BI ### Superset и BI
Открывается после ступени 2. Два самостоятельных задания: Открывается после ступени 2. [Свой дашборд в Superset](superset-dashboard.md):
свои объекты BI живут отдельно от стендовых.
- [Свой дашборд в Superset](optional/superset-dashboard.md): собрать отчет
из своих витрин и подтвердить выводы запросами.
- [В DBeaver работает, в Superset нет](optional/superset-missing-object.md):
выяснить, почему клиенты по-разному видят один объект хранилища.
### Инженерия стенда ### Инженерия стенда
@@ -148,12 +139,12 @@ DM](../architecture/dm.md).
### Что делать, когда стенд сломался ### Что делать, когда стенд сломался
Открывается после ступени 7. Поломки стенда и инструкции по восстановлению Открывается после ступени 7. Поломки стенда и runbook по итогам
по итогам расследований; весь раздел — повышенная сложность. расследований; весь раздел — повышенная сложность.
## Уборка ## Уборка
После опытов удаляйте свои временные объекты. Если удалили учебную витрину, Черновики и опыты за собой сносить: правило проверок стенда —
ее задача в `homework_check` должна снова завершиться ошибкой: отсутствие [дока тестирования](../architecture/testing.md). Снесенная витрина обязана
витрины тоже проверяется. Подробнее — в снова покраснить свою задачу в `homework_check` — если не покраснила,
[справочнике проверок](../architecture/testing.md). проверка ничего и не проверяла.
+118
View File
@@ -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`.
+114
View File
@@ -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` **нет** — и не должно быть.
@@ -1,70 +0,0 @@
# В DBeaver работает, в Superset нет
**Чему учит:** найти причину, по которой один клиент читает объект
хранилища, а другой его не видит.
Факультатив доступен после [ступени 2](../2-1-superset-chart.md).
Свой дашборд для опыта не нужен. Используйте готовую витрину стенда
`dm.daily_traffic_v`.
## Подготовьте опыт
В DBeaver подключитесь к первой ноде пользователем `etl`, как описано
в разделе [«Как применить свой DDL»](../README.md#как-применить-свой-ddl).
Для опыта создайте отдельное представление:
```sql
CREATE VIEW dm.bi_node_probe_v AS
SELECT * FROM dm.daily_traffic_v;
```
Запрос выполнится успешно. Теперь проверьте, откуда можно прочитать
созданное представление. Начните с того же подключения:
```sql
SELECT count() FROM dm.bi_node_probe_v;
```
Повторите запрос в SQL Lab Superset, выбрав подключение `ClickHouse`.
Сохраните текст ошибки.
## Найдите причину
Выясните, к каким серверам обращаются DBeaver и Superset. Адреса подключения
показывают настройки клиентов, а имя ответившего сервера можно спросить
запросом `SELECT hostName()`.
Проверьте наличие `dm.bi_node_probe_v` на обеих нодах. Для второй ноды
заведите подключение DBeaver с портом из
[README](../../../README.md#подключиться-из-dbeaver). Запишите, где объект
есть, и объясните, почему успешного создания в первом подключении оказалось
недостаточно.
Исправьте DDL так, чтобы представление появилось на обеих нодах. Подключение
Superset должно остаться прежним. Сохраните исправленный DDL и повторите
исходный запрос в обоих клиентах.
Если застряли, прочитайте раздел «Устройство зоны» в
[справочнике Superset](../../architecture/superset.md#устройство-зоны) и
[описание распределенного DDL ClickHouse](https://clickhouse.com/docs/reference/statements/distributed-ddl).
## Как проверить себя
В обоих клиентах запрос к вашему представлению должен вернуть столько же
строк, сколько `SELECT count() FROM dm.daily_traffic_v`. Сравнивайте результаты
при завершенном прогоне конвейера, пока мир не продвигается.
Объясните причину ошибки через три факта: куда пришел DDL, куда пришел
запрос Superset и на каких нодах существовало представление.
После опыта удалите только `dm.bi_node_probe_v` на обеих нодах. Не включайте
его в постоянный DDL домашних витрин: это временный объект расследования.
## Что должно получиться
- [ ] у вас сохранены успешный запрос из DBeaver и ошибка из SQL Lab;
- [ ] вы можете объяснить, к каким серверам подключены клиенты, где было
создано представление и почему Superset его не видел;
- [ ] у вас есть исправленный DDL; оба клиента возвращают столько же строк,
сколько исходная витрина;
- [ ] после опыта вы удалили учебное представление с обеих нод.
+121
View File
@@ -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`.
@@ -6,9 +6,9 @@
## Сначала прочитать ## Сначала прочитать
- [«Немного Superset»](../handbook/superset.md); - [«Немного Superset»](../handbook/superset.md);
- [задание 3: `dm.session_overview`](1-3-session-overview.md). - [задание 3: `dm.session_overview`](session-overview.md).
## На какой вопрос ответит график ## Роль графика
В задании 3 вы собрали витрину `dm.session_overview_v`. Теперь покажите, с В задании 3 вы собрали витрину `dm.session_overview_v`. Теперь покажите, с
каких устройств чаще уходят после первой страницы. Постройте столбиковый каких устройств чаще уходят после первой страницы. Постройте столбиковый
@@ -45,10 +45,10 @@
укажите «Категория устройства», а «Dimensions» не заполняйте. Дашборд укажите «Категория устройства», а «Dimensions» не заполняйте. Дашборд
создавать не нужно: сохраненного графика достаточно. создавать не нужно: сохраненного графика достаточно.
Для этого задания оставьте период «No filter»: график и проверочный запрос По умолчанию в графике выбран период «No filter». Оставьте его таким или
ниже считают долю отказов за весь период витрины. Относительный период, задайте явные даты. Относительный период, например «Last week», даст пустой
например «Last week», может дать пустой график: модельный календарь стенда график: модельный календарь стенда отстает от настоящего. Последнюю дату
отстает от настоящего. Последнюю дату витрины посмотрите в SQL Lab: витрины посмотрите в SQL Lab:
```sql ```sql
SELECT max(report_date) SELECT max(report_date)
@@ -60,8 +60,8 @@ FROM dm.session_overview_v;
## Как проверить себя ## Как проверить себя
Автоматической проверки этого задания нет. Сверьте числа на графике График проверяется глазами, поэтому задачи для него в `homework_check` нет.
с результатом запроса в SQL Lab: В SQL Lab выполните запрос:
```sql ```sql
SELECT device_category, SELECT device_category,
@@ -76,16 +76,15 @@ ORDER BY device_category;
проценты с одним знаком, как формат `.1%`, поэтому числа должны совпасть проценты с одним знаком, как формат `.1%`, поэтому числа должны совпасть
один в один. один в один.
## Что должно получиться ## Приемка
- [ ] у вас есть физический датасет `dm.session_overview_v` в подключении - [ ] создан физический датасет `dm.session_overview_v` в подключении
`ClickHouse`; `ClickHouse`;
- [ ] в датасете есть метрика «Доля отказов» с выражением - [ ] в датасете есть метрика «Доля отказов» с выражением
`SUM(bounce_sessions) / SUM(sessions)` и форматом `.1%`; `SUM(bounce_sessions) / SUM(sessions)` и форматом `.1%`;
- [ ] колонки `device_category` и `report_date` подписаны «Категория - [ ] для `device_category` и `report_date` заданы русские имена «Категория
устройства» и «Дата отчета»; устройства» и «Дата отчета»;
- [ ] сохраненный столбиковый график показывает долю отказов по категориям - [ ] сохранен столбиковый график доли отказов по категориям устройств без
устройств за весь период витрины; относительного фильтра по времени;
- [ ] числа на графике совпадают с результатом запроса из SQL Lab; - [ ] график сверен с запросом из SQL Lab;
- [ ] вы сохранили график отдельно, без создания дашборда и изменения - [ ] стендовые объекты не изменены, дашборд не создан.
стендовых объектов.
@@ -1,12 +1,13 @@
# Свой дашборд в Superset # Свой дашборд в Superset
**Чему учит:** собрать из своих витрин отчет о поведении посетителей, **Чему учит:** довести свои витрины до потребителя — завести в Superset
подобрать графики под вопросы и разместить их на одном понятном экране. датасеты поверх публичных представлений, подобрать графики под вопросы и
собрать из них один связный экран.
Это факультатив раздела «Superset и BI». Его можно пройти после ступени 2, Это факультатив раздела «Superset и BI». Его можно пройти после ступени 2,
когда готовы витрины ступени 1. когда готовы витрины ступени 1.
## На какие вопросы ответит дашборд ## Роль дашборда
Соберите обзор трафика и поведения посетителей. Он должен отвечать на три Соберите обзор трафика и поведения посетителей. Он должен отвечать на три
вопроса: что происходило в магазине, какие страницы, устройства и рекламные вопроса: что происходило в магазине, какие страницы, устройства и рекламные
@@ -22,20 +23,17 @@
Не добавляйте графики в стендовый дашборд и не правьте его датасеты. При Не добавляйте графики в стендовый дашборд и не правьте его датасеты. При
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
таких объектов описан в [доке Superset](../../architecture/superset.md), «Импорт таких объектов описан в [доке Superset](../architecture/superset.md), «Импорт
при каждой инициализации». при каждой инициализации».
Используйте свои датасеты и графики, соберите из них свой дашборд. Заведите отдельные датасеты и графики, соберите из них свой дашборд.
Назовите его, например, «Мои витрины DM». Это то же правило, по которому DDL Назовите его, например, «Мои витрины DM». Это то же правило, по которому DDL
домашек лежит в `sql/ddl/70-labs.sql`: стендовые объекты уже есть, свои домашек лежит в `sql/ddl/70-labs.sql`: стендовые объекты уже есть, свои
создаете вы. создаете вы.
## Датасеты ## Датасеты
Датасет `session_overview_v`, его метрика доли отказов и график уже готовы Заведите в подключении `ClickHouse` пять физических датасетов схемы `dm`:
после [ступени 2](../2-1-superset-chart.md). Используйте их здесь. Добавьте
недостающие датасеты, чтобы в подключении `ClickHouse` были представлены
пять публичных представлений схемы `dm`:
- `events_enriched_v`; - `events_enriched_v`;
- `top_pages_daily_v`; - `top_pages_daily_v`;
@@ -58,25 +56,14 @@
Для долей используйте слагаемые из витрин. Например, доля отказов считается Для долей используйте слагаемые из витрин. Например, доля отказов считается
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
долям для такого графика не подходит. долям для такого графика не подходит — урок задания 3 остается в силе.
Названия графиков, осей и показателей должны объяснять экран без чтения SQL. Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
## Период отчета
Выберите явный период из модельного календаря и задайте его в графиках
поведения посетителей. Последнюю доступную дату посмотрите в своих витринах.
Относительный период вроде `Last week` отсчитывается от настоящего времени
и может дать пустой экран.
Ошибки приема считаются по `load_date`, дню загрузки, а поведение посетителей
по модельному времени. Для панели ошибок выберите период загрузки отдельно
и укажите его в подписи. Общий фильтр времени для всего дашборда не требуется.
## Как проверить себя ## Как проверить себя
Автоматической проверки этого задания нет. Числа вы сверите запросами, Эта лаба проверяется глазами. У расположения панелей и понятных подписей нет
а понятность графиков проверите по подписям. счетного инварианта, поэтому задачи для нее в `homework_check` нет.
Откройте список дашбордов и проверьте: Откройте список дашбордов и проверьте:
@@ -88,26 +75,14 @@
серверную; серверную;
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк. - пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
По готовому экрану запишите три коротких вывода за выбранный период: Последний проход сделайте как читатель: найдите на экране самый заметный
канал, страницу и устройство, затем проверьте, понятно ли из подписей, что
именно сравнивается. Если ответ приходится угадывать, поправьте график или
его название.
- какой рекламный канал дал больше покупок; ## Приемка
- какая страница получила больше просмотров;
- у какой категории устройств выше доля отказов.
Для каждого вывода укажите период и число, на которое опираетесь. Проверьте - [ ] пять датасетов читают публичные представления витрин заданий 1–5;
его запросом к своей витрине в SQL Lab с тем же периодом и способом расчета. - [ ] создан отдельный дашборд, стендовые объекты не изменены;
Сохраните запросы рядом с выводами. При равенстве значений назовите всех - [ ] каждая витрина представлена хотя бы одним графиком;
лидеров. Если данных нет, это тоже результат: подтвердите отсутствие строк - [ ] дашборд проверен глазами по разделу выше.
запросом.
Проверьте, можно ли найти эти ответы по одним подписям графиков. Если есть
возможность, попросите другого человека прочитать экран без ваших пояснений.
## Что должно получиться
- [ ] у вас есть пять датасетов поверх публичных представлений витрин заданий 1–5;
- [ ] вы собрали свой дашборд, сохранив стендовые объекты без изменений;
- [ ] на дашборде есть хотя бы один график от каждой витрины;
- [ ] вы проверили, что графики и подписи понятны по вопросам из раздела выше;
- [ ] вы записали три вывода и сверили числа с SQL; в отчете указаны периоды
поведения посетителей и ошибок приема.
+114
View File
@@ -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`, повторный прогон конвейера
результат не меняет.
+150
View File
@@ -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`.
+1 -1
View File
@@ -116,7 +116,7 @@
**17. Свой дашборд в Superset (готовая необязательная лаба).** **17. Свой дашборд в Superset (готовая необязательная лаба).**
Урок: пользовательские объекты BI хранятся отдельно от стендовых и переживают перезапуск. Стендовые объекты при импорте перезаписываются. Урок: пользовательские объекты BI хранятся отдельно от стендовых и переживают перезапуск. Стендовые объекты при импорте перезаписываются.
Материал: `docs/labs/optional/superset-dashboard.md`, `docs/architecture/superset.md`.\ Материал: `docs/labs/superset-dashboard.md`, `docs/architecture/superset.md`.
Форма: построить свое. Форма: построить свое.
Проверка: глазами — счетного инварианта у раскладки панелей нет. Проверка: глазами — счетного инварианта у раскладки панелей нет.
+8 -34
View File
@@ -40,20 +40,6 @@
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а - *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а
менти ищет причину и объясняет ее. менти ищет причину и объясняет ее.
### Как писать задания
Пишите для учащегося: объясняйте, чему он научится, зачем нужны действия
и как понять результат. Историю разработки оставляйте за пределами задания.
«Как проверить себя» описывает действия для проверки и поиска ошибок.
«Что должно получиться» — результат работы и то, что учащийся теперь может
объяснить. Если проверка уже встроена в шаги упражнения, повторять ее
отдельным разделом не нужно.
Остальные заголовки и структуру выбирайте по содержанию. Образцы — задания
про [дашборд Superset](../labs/optional/superset-dashboard.md) и
[расследование подключения](../labs/optional/superset-missing-object.md).
## 3. Основная программа — семь ступеней ## 3. Основная программа — семь ступеней
### Ступень 1. Построй пять витрин слоя DM ### Ступень 1. Построй пять витрин слоя DM
@@ -78,7 +64,7 @@
ступени предстоит написать при реализации программы. Полный собственный дашборд ступени предстоит написать при реализации программы. Полный собственный дашборд
остается факультативом в разделе «Superset и BI». остается факультативом в разделе «Superset и BI».
### Ступень 3. Нарежьте сессии сами ### Ступень 3. Нарежь сессии сам
До того как открыть стендовый SQL нарезки сессий в DDS, менти самостоятельно До того как открыть стендовый SQL нарезки сессий в DDS, менти самостоятельно
делит события на визиты. Сначала он сравнивает число своих сессий с эталоном делит события на визиты. Сначала он сравнивает число своих сессий с эталоном
@@ -89,7 +75,7 @@
Форма — построить свое по ТЗ и провести расследование. Обратная связь состоит Форма — построить свое по ТЗ и провести расследование. Обратная связь состоит
из счетной сверки с `VisitID` и ручного сопоставления границ. из счетной сверки с `VisitID` и ручного сопоставления границ.
### Ступень 4. Перепишите нарезку стенда ### Ступень 4. Перепиши нарезку стенда
Менти удаляет и заново пишет SQL нарезки сессий DDS: расчет объема, пересборку Менти удаляет и заново пишет SQL нарезки сессий DDS: расчет объема, пересборку
и замену. Урок — изменить расчетную сущность внутри готового конвейера и и замену. Урок — изменить расчетную сущность внутри готового конвейера и
@@ -100,7 +86,7 @@
Форма — вычитание. Обратную связь дают `dq_check` и поштучная сверка, Форма — вычитание. Обратную связь дают `dq_check` и поштучная сверка,
построенная на третьей ступени. построенная на третьей ступени.
### Ступень 5. Научите проверки замечать ошибки в заказах ### Ступень 5. Сначала сторож, потом ломай: заказы
Сначала менти строит две недостающие DQ-проверки. Первая утверждает равенство Сначала менти строит две недостающие DQ-проверки. Первая утверждает равенство
«цена × количество = items_total». Вторая сверяет слой с источником по дням. Их «цена × количество = items_total». Вторая сверяет слой с источником по дням. Их
@@ -185,23 +171,11 @@
Открывается после ступени 2 основной программы. Открывается после ступени 2 основной программы.
- **[Свой дашборд](../labs/optional/superset-dashboard.md).** Менти использует - **Свой дашборд.** Готовая лаба
результат ступени 2, собирает отчет из пяти своих витрин и подтверждает [`docs/labs/superset-dashboard.md`](../labs/superset-dashboard.md) учит
выводы запросами. Пользовательские объекты остаются отдельно от стендовых. хранить пользовательские объекты отдельно от стендовых.
- **[В DBeaver работает, в Superset нет](../labs/optional/superset-missing-object.md).** - **Почему дашборд не видит объект на второй ноде.** Забытый `ON CLUSTER` может
Менти создает отдельное представление на первой ноде и расследует ошибку остаться незаметным до тех пор, пока дашборд не обратится ко второй ноде.
чтения из Superset, подключенного ко второй. Опыт заканчивается объяснением,
исправлением DDL и удалением учебного объекта.
Задания самостоятельны: расследование использует готовую витрину стенда.
Отсутствие объекта проверяется в SQL Lab, поскольку создание физического
датасета может остановиться раньше, чем появится график. Чтение только одного
шарда остается материалом инженерного раздела: это отдельная причина
неполного результата, которую добавление `ON CLUSTER` само по себе не устраняет.
Технический опыт проверен 2026-09-13 на ClickHouse 26.3.17.56: локальное
представление недоступно пользователю `bi` на второй ноде; после создания
на кластере обе ноды возвращают тот же счет, что исходная витрина.
### Инженерия стенда ### Инженерия стенда
@@ -1,19 +0,0 @@
slice_name: Средний чек, ₽
description: null
certified_by: null
certification_details: null
viz_type: big_number_total
params:
viz_type: big_number_total
metric: avg_check
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
header_font_size: 0.3
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000106
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000001
@@ -1,19 +0,0 @@
slice_name: Заказы
description: null
certified_by: null
certification_details: null
viz_type: big_number_total
params:
viz_type: big_number_total
metric: orders
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
header_font_size: 0.3
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000105
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000001
@@ -1,19 +0,0 @@
slice_name: Выручка, ₽
description: null
certified_by: null
certification_details: null
viz_type: big_number_total
params:
viz_type: big_number_total
metric: revenue
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
header_font_size: 0.3
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000104
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000001
@@ -1,19 +0,0 @@
slice_name: Посетители
description: null
certified_by: null
certification_details: null
viz_type: big_number_total
params:
viz_type: big_number_total
metric: visitors
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
header_font_size: 0.3
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000107
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000003
@@ -1,31 +0,0 @@
slice_name: Доля известных пользователей
description: null
certified_by: null
certification_details: null
viz_type: echarts_timeseries_line
params:
viz_type: echarts_timeseries_line
x_axis: report_date
metrics:
- known_share
groupby: []
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
row_limit: 10000
seriesType: line
show_legend: false
rich_tooltip: true
show_value: false
markerEnabled: true
markerSize: 6
y_axis_format: .1%
x_axis_time_format: '%d.%m'
tooltipTimeFormat: '%d.%m.%Y'
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000109
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000003
@@ -6,9 +6,9 @@ viz_type: pivot_table_v2
params: params:
viz_type: pivot_table_v2 viz_type: pivot_table_v2
groupbyRows: groupbyRows:
- mismatch_class
groupbyColumns:
- order_day - order_day
groupbyColumns:
- mismatch_class
metrics: metrics:
- order_count - order_count
adhoc_filters: adhoc_filters:
@@ -23,7 +23,8 @@ params:
colOrder: key_a_to_z colOrder: key_a_to_z
order_desc: true order_desc: true
row_limit: 10000 row_limit: 10000
date_format: '%d.%m.%Y' valueFormat: SMART_NUMBER
date_format: smart_date
cache_timeout: null cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000102 uuid: 19600000-0000-4000-8000-000000000102
version: 1.0.0 version: 1.0.0
@@ -1,4 +1,4 @@
slice_name: Выручка по дням и категориям, ₽ slice_name: Выручка по дням и категориям
description: null description: null
certified_by: null certified_by: null
certification_details: null certification_details: null
@@ -20,13 +20,10 @@ params:
stack: Stack stack: Stack
orientation: vertical orientation: vertical
show_legend: true show_legend: true
legendType: plain
legendOrientation: bottom
rich_tooltip: true rich_tooltip: true
show_value: false show_value: false
y_axis_format: ',d' y_axis_format: ',.2f'
x_axis_time_format: '%d.%m' x_axis_time_format: smart_date
tooltipTimeFormat: '%d.%m.%Y'
cache_timeout: null cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000101 uuid: 19600000-0000-4000-8000-000000000101
version: 1.0.0 version: 1.0.0
@@ -1,29 +0,0 @@
slice_name: Доля категорий в выручке
description: null
certified_by: null
certification_details: null
viz_type: pie
params:
viz_type: pie
groupby:
- product_category
metric: revenue
adhoc_filters:
- expressionType: SIMPLE
subject: report_date
operator: TEMPORAL_RANGE
comparator: No filter
clause: WHERE
row_limit: 10000
sort_by_metric: true
donut: true
innerRadius: 30
outerRadius: 70
show_labels: true
labels_outside: false
label_type: percent
show_legend: false
cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000108
version: 1.0.0
dataset_uuid: 19600000-0000-4000-8000-000000000001
@@ -17,16 +17,12 @@ params:
comparator: No filter comparator: No filter
clause: WHERE clause: WHERE
row_limit: 10000 row_limit: 10000
seriesType: line seriesType: echarts_timeseries_line
show_legend: true show_legend: true
legendOrientation: bottom
rich_tooltip: true rich_tooltip: true
show_value: false show_value: false
markerEnabled: true
markerSize: 6
y_axis_format: ',d' y_axis_format: ',d'
x_axis_time_format: '%d.%m' x_axis_time_format: smart_date
tooltipTimeFormat: '%d.%m.%Y'
cache_timeout: null cache_timeout: null
uuid: 19600000-0000-4000-8000-000000000103 uuid: 19600000-0000-4000-8000-000000000103
version: 1.0.0 version: 1.0.0
@@ -19,79 +19,9 @@ position:
parents: parents:
- ROOT_ID - ROOT_ID
children: children:
- ROW-kpi
- ROW-revenue - ROW-revenue
- ROW-pvo - ROW-pvo
- ROW-traffic - ROW-traffic
ROW-kpi:
id: ROW-kpi
type: ROW
parents:
- ROOT_ID
- GRID_ID
children:
- CHART-kpi-revenue
- CHART-kpi-orders
- CHART-kpi-avg-check
- CHART-kpi-visitors
meta:
background: BACKGROUND_TRANSPARENT
CHART-kpi-revenue:
id: CHART-kpi-revenue
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-kpi
children: []
meta:
chartId: 4
height: 20
width: 3
sliceName: Выручка, ₽
uuid: 19600000-0000-4000-8000-000000000104
CHART-kpi-orders:
id: CHART-kpi-orders
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-kpi
children: []
meta:
chartId: 5
height: 20
width: 3
sliceName: Заказы
uuid: 19600000-0000-4000-8000-000000000105
CHART-kpi-avg-check:
id: CHART-kpi-avg-check
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-kpi
children: []
meta:
chartId: 6
height: 20
width: 3
sliceName: Средний чек, ₽
uuid: 19600000-0000-4000-8000-000000000106
CHART-kpi-visitors:
id: CHART-kpi-visitors
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-kpi
children: []
meta:
chartId: 7
height: 20
width: 3
sliceName: Посетители
uuid: 19600000-0000-4000-8000-000000000107
ROW-revenue: ROW-revenue:
id: ROW-revenue id: ROW-revenue
type: ROW type: ROW
@@ -100,7 +30,6 @@ position:
- GRID_ID - GRID_ID
children: children:
- CHART-revenue - CHART-revenue
- CHART-revenue-share
meta: meta:
background: BACKGROUND_TRANSPARENT background: BACKGROUND_TRANSPARENT
CHART-revenue: CHART-revenue:
@@ -114,23 +43,9 @@ position:
meta: meta:
chartId: 1 chartId: 1
height: 50 height: 50
width: 8 width: 12
sliceName: Выручка по дням и категориям, ₽ sliceName: Выручка по дням и категориям
uuid: 19600000-0000-4000-8000-000000000101 uuid: 19600000-0000-4000-8000-000000000101
CHART-revenue-share:
id: CHART-revenue-share
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-revenue
children: []
meta:
chartId: 8
height: 50
width: 4
sliceName: Доля категорий в выручке
uuid: 19600000-0000-4000-8000-000000000108
ROW-pvo: ROW-pvo:
id: ROW-pvo id: ROW-pvo
type: ROW type: ROW
@@ -151,7 +66,7 @@ position:
children: [] children: []
meta: meta:
chartId: 2 chartId: 2
height: 44 height: 50
width: 12 width: 12
sliceName: Сверка purchase и заказов sliceName: Сверка purchase и заказов
uuid: 19600000-0000-4000-8000-000000000102 uuid: 19600000-0000-4000-8000-000000000102
@@ -163,7 +78,6 @@ position:
- GRID_ID - GRID_ID
children: children:
- CHART-traffic - CHART-traffic
- CHART-known-share
meta: meta:
background: BACKGROUND_TRANSPARENT background: BACKGROUND_TRANSPARENT
CHART-traffic: CHART-traffic:
@@ -177,57 +91,15 @@ position:
meta: meta:
chartId: 3 chartId: 3
height: 50 height: 50
width: 6 width: 12
sliceName: Посетители и известные пользователи sliceName: Посетители и известные пользователи
uuid: 19600000-0000-4000-8000-000000000103 uuid: 19600000-0000-4000-8000-000000000103
CHART-known-share:
id: CHART-known-share
type: CHART
parents:
- ROOT_ID
- GRID_ID
- ROW-traffic
children: []
meta:
chartId: 9
height: 50
width: 6
sliceName: Доля известных пользователей
uuid: 19600000-0000-4000-8000-000000000109
metadata: metadata:
color_scheme: supersetColors color_scheme: supersetColors
label_colors:
Товары для дома: '#1FA8C9'
Текстиль: '#454E7C'
Посуда: '#5AC189'
Бытовая техника: '#FF7F44'
Детские товары: '#E04355'
Одежда и обувь: '#A868B7'
visitors: '#1FA8C9'
known_users: '#454E7C'
known_share: '#454E7C'
cross_filters_enabled: false cross_filters_enabled: false
chart_configuration: {} chart_configuration: {}
expanded_slices: {} expanded_slices: {}
default_filters: '{}' default_filters: '{}'
native_filter_configuration: native_filter_configuration: []
- id: NATIVE_FILTER-period
name: Период
filterType: filter_time
type: NATIVE_FILTER
targets:
- {}
controlValues:
enableEmptyFilter: false
defaultDataMask:
extraFormData: {}
filterState: {}
ownState: {}
cascadeParentIds: []
scope:
rootPath:
- ROOT_ID
excluded: []
description: ''
theme_uuid: null theme_uuid: null
version: 1.0.0 version: 1.0.0
@@ -36,15 +36,6 @@ metrics:
currency: null currency: null
extra: null extra: null
warning_text: null warning_text: null
- metric_name: known_share
verbose_name: Доля известных
metric_type: null
expression: SUM(known_users) / nullIf(SUM(visitors), 0)
description: null
d3format: .1%
currency: null
extra: null
warning_text: null
columns: columns:
- column_name: _load_ts - column_name: _load_ts
verbose_name: Время загрузки verbose_name: Время загрузки
@@ -23,26 +23,10 @@ metrics:
metric_type: null metric_type: null
expression: SUM(revenue) expression: SUM(revenue)
description: null description: null
d3format: ',d' d3format: ',.2f'
currency: null currency:
extra: null symbol: ₽
warning_text: null symbolPosition: suffix
- metric_name: orders
verbose_name: Заказы
metric_type: null
expression: SUM(orders)
description: null
d3format: ',d'
currency: null
extra: null
warning_text: null
- metric_name: avg_check
verbose_name: Средний чек
metric_type: null
expression: SUM(revenue) / nullIf(SUM(orders), 0)
description: null
d3format: ',d'
currency: null
extra: null extra: null
warning_text: null warning_text: null
columns: columns:
@@ -60,7 +44,7 @@ columns:
datetime_format: null datetime_format: null
extra: null extra: null
- column_name: aov - column_name: aov
verbose_name: Средний чек за день и категорию verbose_name: Средний чек
is_dttm: false is_dttm: false
is_active: true is_active: true
type: DECIMAL(18, 2) type: DECIMAL(18, 2)
+42 -24
View File
@@ -1,20 +1,35 @@
-- DDS: расчет сессий за дни от first_day до last_day включительно. -- DDS: нарезка событий на визиты и сборка дней в таблицу-двойник.
-- Сначала очищаем промежуточную таблицу session_stage_rep, затем записываем
-- в нее рассчитанные строки через session_stage_dist. Основная session_rep
-- остается прежней до выполнения session_replace.sql.
-- --
-- Первое окно помечает событие единицей, если с предыдущего события того же -- Двойник очищается целиком в начале, дни собираются одной вставкой на весь
-- посетителя за тот же день прошло больше 1800 секунд. Второе окно суммирует -- отрезок — по правилу заказа (sql/dds/order_rebuild.sql).
-- эти единицы: получается номер визита для каждого посетителя за день.
-- PARTITION BY (client_id, event_date) считает каждый модельный день отдельно:
-- события разных дней не попадут в одну сессию даже при короткой паузе.
-- Учитываются все типы событий.
-- --
-- У первого события lagInFrame возвращает его собственное время: разница -- Нарезка — центральный расчёт слоя, и стоит она на двух окнах подряд.
-- равна нулю, opens_visit = 0, и первый visit_no тоже равен нулю. -- Первое спрашивает у каждого события, сколько прошло с предыдущего события
-- Оба окна используют (event_time, event_id), чтобы при равном времени -- той же куки в тот же день, и ставит единицу там, где пауза длиннее
-- отметка начала и номер визита относились к одним событиям. -- таймаута: это шов между визитами. Второе считает нарастающий итог этих
-- Порог 1800 секунд — правило визита; он не передается параметром. -- единиц — номер визита внутри дня куки. Дальше визит становится обычной
-- группировкой.
--
-- Границу суток окна не проверяют: она уже в разбиении PARTITION BY
-- (client_id, event_date), поэтому визит через полночь не склеится и при
-- короткой паузе. Так правило визита ложится в запрос целиком: одна кука,
-- один день, паузы не длиннее таймаута (CONTEXT.md, «Визит»).
--
-- Первое событие куки за день сравнивается само с собой — третий аргумент
-- lagInFrame. Паузы у него нет, шов ему не нужен, и номер первого визита
-- получается нулём: номера различают визиты внутри дня куки, а не считают их
-- от единицы.
--
-- Порядок в окнах полный — метка времени и event_id. Окна считаются двумя
-- разными сортировками (видно в EXPLAIN PIPELINE), и при равных метках они
-- вправе разойтись: шов достался бы одной строке, а нарастающий итог другой,
-- и сессия разъехалась бы надвое.
--
-- Тридцать минут написаны числом, а не приехали параметром: это правило
-- аналитики, а не константа мира. Генератор режет визиты тем же порогом;
-- разойдись эти два числа — увидит счётная сверка с VisitID
-- (docs/architecture/dds/session.md, «Сверка с эталоном»).
ALTER TABLE dds.session_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL; ALTER TABLE dds.session_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL;
INSERT INTO dds.session_stage_dist INSERT INTO dds.session_stage_dist
@@ -78,8 +93,8 @@ WITH
FROM seams FROM seams
) )
SELECT SELECT
-- Идентификатор зависит от посетителя и начала визита. -- Чекан визита: кука и его начало. Время входит в хеш секундой эпохи,
-- Повторная сборка тех же событий дает тот же session_id. -- поэтому id держится за содержание сессии, а не за порядок сборки.
cityHash64(client_id, min(event_time)), cityHash64(client_id, min(event_time)),
client_id, client_id,
event_date, event_date,
@@ -92,10 +107,8 @@ SELECT
countIf(event_type = 'purchase'), countIf(event_type = 'purchase'),
argMin(url, event_time), argMin(url, event_time),
argMax(url, event_time), argMax(url, event_time),
-- UTM, устройство и город берутся по минимальному event_time. -- Атрибуция и паспорт куки берутся с первого события визита. У паспорта
-- Как и у страниц выше, при равном времени выбор между событиями -- любое событие дало бы то же: устройство и город у куки постоянны.
-- не определен: event_id в эти argMin/argMax не входит.
-- Устройство и город на стенде постоянны для одной куки.
argMin(utm_source, event_time), argMin(utm_source, event_time),
argMin(utm_medium, event_time), argMin(utm_medium, event_time),
argMin(utm_campaign, event_time), argMin(utm_campaign, event_time),
@@ -107,7 +120,12 @@ SELECT
now64(3, 'UTC') now64(3, 'UTC')
FROM numbered FROM numbered
GROUP BY client_id, event_date, visit_no GROUP BY client_id, event_date, visit_no
-- Источник и цель распределены по cityHash64(client_id): локальная вставка -- Капкана parallel_distributed_insert_select здесь нет, и это выигрыш
-- сохраняет нужную раскладку, parallel_distributed_insert_select менять -- ко-локации: ключ шардирования у источника и цели один — cityHash64 от
-- не требуется. Перед заменой партиций ждем доставки строк на шарды. -- куки, — поэтому визит ляжет туда же, где лежали его события, исполнись
-- вставка хоть на инициаторе, хоть локально на шардах. Заказу приходится
-- называть ноль вслух: совпадение ключей у него случайное
-- (sql/dds/order_rebuild.sql). Здесь выбор сделан обратный и он тоже
-- осознанный: настройку не называть, чтобы не притворяться, будто её значение
-- что-то решает.
SETTINGS distributed_foreground_insert = 1; SETTINGS distributed_foreground_insert = 1;
+8 -8
View File
@@ -1,11 +1,11 @@
-- DDS: заменяем раздел session_rep за выбранный день готовым разделом -- DDS: подмена одного дня сессий собранной партицией двойника.
-- промежуточной session_stage_rep. Раздел таблицы называется партицией.
-- ON CLUSTER выполняет замену на локальных таблицах каждого шарда.
-- На одном шарде раздел меняется целиком; общей транзакции для кластера нет.
-- Повтор с теми же строками session_stage_rep не добавляет копий в session_rep.
-- --
-- Дату из session_scope.sql подставляет Jinja: параметр ClickHouse в -- Механика и доводы целиком — sql/dds/order_replace.sql: замена идёт по
-- ALTER ... PARTITION здесь не поддерживается. Поиск дней следующего -- локальным таблицам, шард за шардом, атомарность пошардовая, день
-- запуска не обнаружит неполный день, если часть его строк уже есть в DDS. -- подставляет Jinja, потому что параметров ALTER … PARTITION не берёт.
--
-- Разница одна, и она про отказ. Несобранный день заказа вернуло бы в работу
-- следующее окно; сессиям возвращать нечем, и упавшая замена оставляет дыру
-- до тех пор, пока её не найдёт счёт хвоста (sql/dds/session_scope.sql).
ALTER TABLE dds.session_rep ON CLUSTER clickstream_cluster ALTER TABLE dds.session_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dds.session_stage_rep; REPLACE PARTITION '{{ params.day }}' FROM dds.session_stage_rep;
+35 -16
View File
@@ -1,31 +1,50 @@
-- DDS: дни для сборки сессий, от first_day до last_day включительно. -- DDS: объём пересборки сессий — какие дни собирает этот запуск.
-- Без событий ODS строк в ответе нет; NULL в first_day — дней для расчета нет.
-- --
-- position — номер следующего дня для генерации. Последний прожитый день -- Ответ — две даты, границы непрерывного отрезка; список дней между ними
-- равен min(EventDate) + position - 1. В отличие от max(EventDate), эта -- разворачивает даг. Пустой ответ означает пустой источник. Пустой первый
-- граница исключает еще идущий день. Ранние события ODS не удаляются по TTL, -- день — что собирать нечего: все прожитые дни уже в слое, и прогон честно
-- поэтому min(EventDate) сохраняет начало модельного календаря. -- пропускает сборку.
-- --
-- До этого запроса даг ждет чтения hits и опустошения очереди ods.event_dist. -- Файл кончается без точки с запятой: почему — sql/dds/order_scope.sql.
-- Позиция мира сама по себе не подтверждает доставку событий.
-- --
-- Точки с запятой в конце нет: драйвер добавляет FORMAT к этому запросу. -- Правый край — последний прожитый день, и знает его только позиция мира.
-- События живой день от прожитого не отличают: и посреди дня D, и сразу после
-- его конца max(EventDate) = D. А разница несущая — визит, начатый в 23:50,
-- посреди дня ещё идёт, и собранная сессия оказалась бы обрезанной навсегда:
-- пересобирать замёрзший день слой не станет
-- (docs/architecture/dds/session.md, «Движок и наполнение»).
--
-- Позиция — номер первого несыгранного дня, счётчик прожитого. Своей копии
-- начала оси стенд из-за неё не заводит: начало он спрашивает у данных, и
-- дата дня N в них — константа, начало оси плюс N. Держится якорь на том, что
-- ранние дни из ods.event не исчезают: срок жизни назначен сырым данным и таблицам
-- ошибок, самому событию — нет (sql/ddl/20-ods-tables.sql).
--
-- За «доехал» позиция не отвечает — только за «прожит». Перед расчётом объёма
-- первый шаг дага дожидается хвоста потока и очереди доставки: иначе
-- недожеванный хвост заморозил бы недособранный день
-- (docs/architecture/etl.md, «Готовность источника»).
WITH WITH
(SELECT min(EventDate) FROM ods.event_dist) AS first_source_day, (SELECT min(EventDate) FROM ods.event_dist) AS first_source_day,
first_source_day + ({position:UInt16} - 1) AS last_lived_day, first_source_day + ({position:UInt16} - 1) AS last_lived_day,
-- Ищем первую дату событий ODS, за которую еще нет сессий DDS. -- Хвост считается от первой дыры, а не от последнего собранного дня:
-- Поиск после max(session_date) пропустил бы более раннюю потерянную дату. -- замены партиций падают порознь, и счёт по максимуму собранного дня
-- Если за день есть хотя бы одна сессия, он уже не считается пропущенным. -- дыру позади себя не заметил бы (доводы целиком — sql/dds/order_scope.sql).
-- GLOBAL берет даты сессий со всех шардов. -- У сессий это единственный способ дыру найти: дышащего окна, которое
-- minOrNull возвращает NULL, если пропусков нет. -- пересобрало бы день заодно, здесь нет.
--
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
-- бы дни источника с локальной частью слоя на каждом шарде.
-- minOrNull отличает «дыр нет» от даты по умолчанию.
( (
SELECT minOrNull(day) SELECT minOrNull(day)
FROM (SELECT DISTINCT EventDate AS day FROM ods.event_dist) FROM (SELECT DISTINCT EventDate AS day FROM ods.event_dist)
WHERE day GLOBAL NOT IN (SELECT session_date FROM dds.session_dist) WHERE day GLOBAL NOT IN (SELECT session_date FROM dds.session_dist)
) AS first_missing_day ) AS first_missing_day
SELECT SELECT
-- Отсутствующий день попадет в сборку только после его завершения. -- Живой день в слое отсутствует всегда, и работой это не считается:
-- дырой он станет, только когда мир его проживёт.
if(first_missing_day <= last_lived_day, first_missing_day, NULL) AS first_day, if(first_missing_day <= last_lived_day, first_missing_day, NULL) AS first_day,
last_lived_day AS last_day last_lived_day AS last_day
-- FINAL не нужен: повтор доставки не меняет начало календаря и набор дат. -- FINAL не нужен: повтор доставки не двигает ни край оси, ни набор дней.
WHERE (SELECT count() FROM ods.event_dist) > 0 WHERE (SELECT count() FROM ods.event_dist) > 0
+8 -12
View File
@@ -1,10 +1,7 @@
-- DM: расчет выручки за дни от first_day до last_day включительно. -- DM: сборка выручки в донор перед заменой дневных партиций.
-- DROP PARTITION ALL очищает промежуточную revenue_daily_stage_rep. -- DROP PARTITION ALL очищает локальные таблицы на каждом узле; причина не
-- Затем INSERT записывает по одной строке итогов на день и категорию товара. -- применять TRUNCATE разобрана в соседнем sql/dds/order_rebuild.sql.
-- Основная revenue_daily_rep остается прежней до revenue_daily_replace.sql.
-- В расчет входят только оплаченные заказы. ARRAY JOIN превращает артикул,
-- количество и цену с одним индексом в массивах в одну строку позиции заказа.
-- Причина выбора DROP вместо TRUNCATE — в sql/dds/order_rebuild.sql.
ALTER TABLE dm.revenue_daily_stage_rep ON CLUSTER clickstream_cluster ALTER TABLE dm.revenue_daily_stage_rep ON CLUSTER clickstream_cluster
DROP PARTITION ALL; DROP PARTITION ALL;
@@ -25,8 +22,8 @@ SELECT
orders, orders,
units, units,
CAST(revenue, 'Decimal(18, 2)'), CAST(revenue, 'Decimal(18, 2)'),
-- Делим с шестью знаками после запятой, затем округляем до копеек. -- Обычное деление Decimal усекает доли копейки вместо округления;
-- Так дробная часть сохраняется до округления среднего чека. -- запас масштаба сохраняет их до денежного результата.
CAST( CAST(
round(divideDecimal(revenue, toDecimal128(orders, 0), 6), 2), round(divideDecimal(revenue, toDecimal128(orders, 0), 6), 2),
'Decimal(18, 2)' 'Decimal(18, 2)'
@@ -52,8 +49,7 @@ FROM
report_date, report_date,
product_category product_category
) )
-- Источник распределен по заказу, цель — по категории. Значение 0 оставляет -- Источник лежит по заказу, цель — по категории. Ноль заставляет инициатор
-- итоговую агрегацию и распределение по ключу цели на ноде, принявшей запрос. -- слить частичные агрегаты и разложить готовые строки по ключу цели.
-- distributed_foreground_insert = 1 ждет доставки строк перед заменой.
SETTINGS distributed_foreground_insert = 1, SETTINGS distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0; parallel_distributed_insert_select = 0;
+6 -7
View File
@@ -1,9 +1,8 @@
-- DM: заменяем раздел revenue_daily_rep за выбранный день готовым разделом -- DM: подмена одного дня выручки готовой партицией донора.
-- промежуточной revenue_daily_stage_rep. Раздел таблицы называется партицией. --
-- Дату из revenue_daily_scope.sql подставляет Jinja: параметр ClickHouse -- День приходит из результата revenue_daily_scope.sql, а не от пользователя.
-- в ALTER ... PARTITION здесь не поддерживается. -- Jinja нужна потому, что ClickHouse не подставляет параметр в PARTITION.
-- ON CLUSTER выполняет замену на локальных таблицах каждого шарда. -- Замена идет на локальной таблице ON CLUSTER: граница атомарности и повтор
-- На одном шарде раздел меняется целиком; общей транзакции для кластера нет. -- разобраны на таком же шаге в sql/dds/order_replace.sql.
-- Повтор с теми же строками промежуточной таблицы не добавляет копий в основной.
ALTER TABLE dm.revenue_daily_rep ON CLUSTER clickstream_cluster ALTER TABLE dm.revenue_daily_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dm.revenue_daily_stage_rep; REPLACE PARTITION '{{ params.day }}' FROM dm.revenue_daily_stage_rep;
+14 -10
View File
@@ -1,11 +1,14 @@
-- DM: диапазон пересборки выручки, first_day и last_day включительно. -- DM: объем пересборки выручки.
-- Последний слепок ODS задает даты оформления заказов, которые еще могут
-- измениться. Саму выручку считаем по DDS; ODS нужен только для выбора дат.
-- Левая граница расширяется до первого дня с оплаченными заказами DDS,
-- которого нет в витрине. Так первый запуск охватывает историю выручки.
-- По наличию даты нельзя обнаружить частично заполненный день.
-- --
-- Точки с запятой в конце нет: драйвер добавляет FORMAT к этому запросу. -- Последний слепок ODS называет дни, состояние которых источник еще может
-- изменить. Сам агрегат читает только DDS; ODS здесь нужен лишь для границ.
-- Первый запуск и повтор после частичного отказа расширяют левую границу до
-- первого дня оплаченных заказов, которого еще нет в витрине.
-- Почему хвост начинается с первого пропуска и чем за это платит ODS,
-- разобрано в sql/dds/order_scope.sql.
--
-- Файл кончается без точки с запятой: даг читает его одним запросом и ждет
-- одну строку с границами.
WITH WITH
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot, (SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
( (
@@ -21,7 +24,8 @@ WITH
( (
SELECT minOrNull(report_date) SELECT minOrNull(report_date)
FROM (SELECT DISTINCT order_date AS report_date FROM dds.order_v WHERE status = 'paid') FROM (SELECT DISTINCT order_date AS report_date FROM dds.order_v WHERE status = 'paid')
-- GLOBAL собирает список дней витрины со всех шардов. -- Правая сторона тоже распределенная: GLOBAL собирает множество дней
-- на инициаторе и не превращает запрос в запрещенное двойное чтение.
WHERE report_date GLOBAL NOT IN ( WHERE report_date GLOBAL NOT IN (
SELECT report_date FROM dm.revenue_daily_dist SELECT report_date FROM dm.revenue_daily_dist
) )
@@ -29,7 +33,7 @@ WITH
SELECT SELECT
least(window_start, coalesce(first_missing_day, window_start)) AS first_day, least(window_start, coalesce(first_missing_day, window_start)) AS first_day,
window_end AS last_day window_end AS last_day
-- Для выбора диапазона нужны заказы в ODS, для расчета — в DDS. -- Без ODS неизвестно окно. Без готового DDS донор останется пустым, а замена
-- Эта проверка наличия строк не подтверждает полноту источников. -- сотрет уже собранные дни выручки. Поэтому для прогона нужны оба источника.
WHERE (SELECT count() FROM dds.order_v) > 0 WHERE (SELECT count() FROM dds.order_v) > 0
AND (SELECT count() FROM ods.order_dist) > 0 AND (SELECT count() FROM ods.order_dist) > 0