Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d03086bce |
No files matched your search
@@ -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,21 +157,14 @@ 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`
|
|
||||||
рядом с главами.
|
|
||||||
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
|
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
|
||||||
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
|
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
|
||||||
в имени не нужна.
|
в имени не нужна.
|
||||||
|
|||||||
@@ -1,7 +1 @@
|
|||||||
@AGENTS.md
|
@AGENTS.md
|
||||||
|
|
||||||
# Читабельность языка
|
|
||||||
|
|
||||||
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
|
|
||||||
|
|
||||||
Please remove all mannered prose.
|
|
||||||
+206
-252
@@ -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-проверка — та работает на наблюдаемых данных магазина.
|
||||||
@@ -51,11 +51,9 @@ config-test:
|
|||||||
# того своя дверь и свой конфиг. Версия закреплена, потому что лока в корне
|
# того своя дверь и свой конфиг. Версия закреплена, потому что лока в корне
|
||||||
# нет, а форматтер между версиями меняет вывод — иначе проверка однажды
|
# нет, а форматтер между версиями меняет вывод — иначе проверка однажды
|
||||||
# покраснела бы сама, без единой правки в репозитории.
|
# покраснела бы сама, без единой правки в репозитории.
|
||||||
# Проверка рендера быстрая, не требует стенда и ловит устаревший HTML.
|
|
||||||
lint:
|
lint:
|
||||||
uvx ruff@0.16.1 check dags infra/superset docs/handbook/architecture-map
|
uvx ruff@0.16.1 check dags infra/superset
|
||||||
uvx ruff@0.16.1 format --check dags infra/superset docs/handbook/architecture-map
|
uvx ruff@0.16.1 format --check dags infra/superset
|
||||||
uv run docs/handbook/architecture-map/render.py --check
|
|
||||||
|
|
||||||
# --- Наполнение миром ---
|
# --- Наполнение миром ---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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]:
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`. Старые версии
|
|
||||||
могут физически оставаться до фонового слияния; повторяемость относится
|
|
||||||
к результату чтения, а не к числу строк на диске.
|
|
||||||
|
|
||||||
## Чего конвейер о себе не знает
|
## Чего конвейер о себе не знает
|
||||||
|
|
||||||
|
|||||||
@@ -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)).
|
|
||||||
|
|
||||||
## Служебные колонки
|
## Служебные колонки
|
||||||
|
|
||||||
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
|
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
|
||||||
|
|||||||
@@ -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: на дашборд смотрят
|
|
||||||
глазами, красное и зеленое живет в проверках.
|
|
||||||
|
|
||||||
Поводы вернуться к разговору: плагин карты начинает отдавать кросс-фильтр; у
|
|
||||||
заказа появляется адрес доставки; витрины получают общее измерение по своей
|
|
||||||
причине, не ради дашборда.
|
|
||||||
@@ -36,13 +36,13 @@
|
|||||||
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
|
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
|
||||||
`make smoke` и `make check-services` перемерены 28 августа 2026 года,
|
`make smoke` и `make check-services` перемерены 28 августа 2026 года,
|
||||||
`make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
|
`make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
|
||||||
корневой `make lint` — 3 сентября, `make test` — 30 августа; остальные стоят с
|
корневой `make lint` — 9 августа, `make test` — 30 августа; остальные стоят с
|
||||||
замера 6 августа.
|
замера 6 августа.
|
||||||
|
|
||||||
| Цель | Откуда | Что утверждает | Стенд | Цена |
|
| Цель | Откуда | Что утверждает | Стенд | Цена |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
||||||
| `make lint` | корень | Код стенда и сборщик карты отформатированы и проходят ruff; готовый HTML карты соответствует исходникам | не нужен | 0,8 с |
|
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||||
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
|
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
|
||||||
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против паспорта | нужен | 24 с |
|
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против паспорта | нужен | 24 с |
|
||||||
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
|
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
|
||||||
@@ -128,7 +128,7 @@ ClickHouse отвечает сразу.
|
|||||||
|
|
||||||
Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test`
|
Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test`
|
||||||
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
|
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
|
||||||
дорогая. Правки дагов, Superset и карты — корневой `make lint`.
|
дорогая. Правки дагов и Superset — корневой `make lint`.
|
||||||
|
|
||||||
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
|
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
|
||||||
для сверки с паспортом мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
|
для сверки с паспортом мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
|
||||||
@@ -286,10 +286,6 @@ Kafka по той же причине спрашивают снаружи. Её
|
|||||||
|
|
||||||
## Что проверено
|
## Что проверено
|
||||||
|
|
||||||
**Перезамер 3 сентября 2026 года при исполнении #222.** `make lint` прошел за
|
|
||||||
0,768 секунды; в таблице 0,8 с. `ruff format --check` проверил 13 файлов стенда,
|
|
||||||
включая исходники карты, а затем проверка рендера подтвердила свежесть HTML.
|
|
||||||
|
|
||||||
**Калибровка заказной стороны 30 августа 2026 года при исполнении #194.**
|
**Калибровка заказной стороны 30 августа 2026 года при исполнении #194.**
|
||||||
Пересборки эталонного мира не будет (#192), поэтому доли заказной стороны
|
Пересборки эталонного мира не будет (#192), поэтому доли заказной стороны
|
||||||
сверены замером внутренностей генератора — окно 28 модельных дней,
|
сверены замером внутренностей генератора — окно 28 модельных дней,
|
||||||
@@ -478,11 +474,11 @@ shell-версия узкой пересборки за 2 м 25 с пересо
|
|||||||
24 секунды. Это цена всей цели, а не одной новой строки.
|
24 секунды. Это цена всей цели, а не одной новой строки.
|
||||||
|
|
||||||
**Замер 9 августа 2026 года при появлении корневого `lint` (#65).** Три прогона
|
**Замер 9 августа 2026 года при появлении корневого `lint` (#65).** Три прогона
|
||||||
подряд — 0,39, 0,36 и 0,39 секунды на трех файлах стенда, которые тогда входили в проверку; в таблице 0,4 с. На
|
подряд — 0,39, 0,36 и 0,39 секунды на трёх файлах стенда; в таблице 0,4 с. На
|
||||||
машине с непрогретым кешем первый прогон дороже: `uvx` сначала скачивает ruff.
|
машине с непрогретым кешем первый прогон дороже: `uvx` сначала скачивает ruff.
|
||||||
|
|
||||||
Тогда же снято, что двери не перекрываются: `ruff check --show-files` из корня
|
Тогда же снято, что двери не перекрываются: `ruff check --show-files` из корня
|
||||||
перечислял ровно три файла стенда, из `generator/` — только файлы генератора.
|
перечисляет ровно три файла стенда, из `generator/` — только файлы генератора.
|
||||||
Корневой конфиг правила генератору не подменяет: конфиг ищется вверх по дереву
|
Корневой конфиг правила генератору не подменяет: конфиг ищется вверх по дереву
|
||||||
от файла, и ближний выигрывает.
|
от файла, и ближний выигрывает.
|
||||||
|
|
||||||
|
|||||||
+29
-28
@@ -1,47 +1,48 @@
|
|||||||
# Путеводитель по стенду
|
# Путеводитель по стенду
|
||||||
|
|
||||||
Путеводитель объясняет, как данные поступают в хранилище, обрабатываются
|
Если вы впервые видите стенд, начните отсюда. Путеводитель подскажет, на что
|
||||||
и попадают в отчеты. В главах есть короткие примеры, которые можно выполнить
|
смотреть и в каком порядке, а за подробностями отправит в справочники. Так вы
|
||||||
на запущенном стенде перед переходом к учебным заданиям.
|
разберетесь в устройстве, прежде чем браться за уроки.
|
||||||
|
|
||||||
## Как читать
|
## Как читать
|
||||||
|
|
||||||
Читайте главы в порядке ниже. Незнакомые термины можно найти в
|
Тропа такая: [словарь понятий](../../CONTEXT.md) → главы путеводителя →
|
||||||
[словаре понятий](../../CONTEXT.md), а подробности устройства —
|
[справочники `docs/architecture/`](../architecture/) →
|
||||||
в [технических справочниках](../architecture/). К ним достаточно обращаться
|
[уроки](../labs/README.md).
|
||||||
по мере необходимости. После путеводителя переходите к
|
|
||||||
[учебным заданиям](../labs/README.md).
|
|
||||||
|
|
||||||
## Карта хранилища
|
Словарь не надо читать подряд. Возвращайтесь к нему за незнакомым словом.
|
||||||
|
Первая статья объясняет хранилище. Следом идет несколько статей об устройстве
|
||||||
[Интерактивная карта](architecture-map.html) показывает путь данных через слои.
|
генератора; они понадобятся нескоро.
|
||||||
Gitea показывает HTML как текст. Откройте файл из своей копии репозитория
|
|
||||||
или скачайте его кнопкой «Raw» и откройте в браузере.
|
|
||||||
Сверяйтесь с картой при чтении главы «Хранилище и его слои».
|
|
||||||
|
|
||||||
## Главы
|
## Главы
|
||||||
|
|
||||||
### [Хранилище и его слои](storage-layers.md)
|
### [Хранилище и его слои](storage-layers.md)
|
||||||
|
|
||||||
Как меняются данные при переходе из Kafka в слои STG, ODS, DDS и DM.
|
От события в Kafka до витрины: глава разбирает путь через STG, ODS, DDS и DM
|
||||||
В конце главы вы посмотрите события, сессии и выручку с помощью SQL.
|
и показывает, за что отвечает каждый слой.
|
||||||
|
|
||||||
### [Потоки Airflow и даги модельного времени](airflow-dags.md)
|
### Потоки Airflow и даги модельного времени
|
||||||
|
|
||||||
Какие даги создают новые данные, а какие обрабатывают их в хранилище.
|
Шаговый даг играет модельный день, а дневной конвейер раскладывает данные по
|
||||||
Вы запустите генерацию и обработку за день и посмотрите результат в витринах.
|
слоям. Глава поможет увидеть их связь и роль каждого дага. Пока главы нет,
|
||||||
|
смотрите [справочник конвейера](../architecture/etl.md) и [справочник мира и
|
||||||
|
дагов времени](../architecture/world.md).
|
||||||
|
|
||||||
### [Мониторинг: куда смотреть](monitoring.md)
|
### Мониторинг: куда смотреть
|
||||||
|
|
||||||
Что показывают панели Grafana и как меняются их показания, пока в стенд
|
Свежесть и поток данных, брак и отставание собраны на дашборде «Данные».
|
||||||
поступают события и заказы.
|
Глава научит находить нужную панель и разбирать ее показания. Пока главы
|
||||||
|
нет, смотрите [устройство мониторинга](../architecture/monitoring/README.md).
|
||||||
|
|
||||||
### [Немного Superset](superset.md)
|
### Немного Superset
|
||||||
|
|
||||||
Как Superset строит графики по витринам и что происходит с вашими отчетами
|
Глава покажет, как витрина становится графиком и где проходит граница между
|
||||||
при перезапуске стенда.
|
своими объектами и теми, которые поднимает стенд. Пока главы нет, смотрите
|
||||||
|
[справочник Superset](../architecture/superset.md).
|
||||||
|
|
||||||
### [Генератор: откуда берутся данные](generator.md)
|
### Генератор: откуда берутся данные
|
||||||
|
|
||||||
Откуда берутся события и заказы, почему начальные данные повторяются
|
Здесь генератор — источник данных, а не предмет изучения. Глава в общих
|
||||||
и как сверить число загруженных событий с ожидаемым.
|
чертах объяснит, откуда берутся события и заказы и в каком виде их получает
|
||||||
|
хранилище. Пока главы нет, смотрите [`generator/`](../../generator/) и
|
||||||
|
[описание события кликстрима](../formats/clickstream-event.md).
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
# Потоки Airflow и даги модельного времени
|
|
||||||
|
|
||||||
Airflow управляет генерацией данных и их обработкой в хранилище. Это разные
|
|
||||||
этапы: сначала в Kafka поступают события и заказы за день работы магазина,
|
|
||||||
затем по ним рассчитываются данные DDS и витрины DM.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
world_initialize --> etl_pipeline
|
|
||||||
world_live_day --> etl_pipeline
|
|
||||||
manual[запуск рукой] --> etl_pipeline
|
|
||||||
etl_pipeline --> dds_transform --> dm_transform --> dq_check
|
|
||||||
world_next_day --> sources[источники без ETL]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Два потока входят по-разному
|
|
||||||
|
|
||||||
События поступают в топик `hits` и обрабатываются по мере поступления:
|
|
||||||
материализованные представления переносят строки из STG в ODS без участия
|
|
||||||
Airflow.
|
|
||||||
|
|
||||||
Заказы поступают в топик `orders` раз в модельные сутки одной выгрузкой.
|
|
||||||
Она содержит состояние заказов на конец дня и называется слепком.
|
|
||||||
Даг `orders_ingest` загружает ее целиком. Такой способ приема называется
|
|
||||||
[пакетным](../../CONTEXT.md).
|
|
||||||
|
|
||||||
## Даги мира
|
|
||||||
|
|
||||||
В документации миром называется учебный магазин с его посетителями,
|
|
||||||
товарами и заказами. Время в нем идет отдельно от календарного.
|
|
||||||
Два дага с тегом «жизненный цикл мира» создают начальные данные:
|
|
||||||
|
|
||||||
| Даг | Что делает |
|
|
||||||
|---|---|
|
|
||||||
| `world_initialize` | Создает начальные данные, если их еще нет |
|
|
||||||
| `world_recreate` | Удаляет прикладные данные и создает их заново |
|
|
||||||
|
|
||||||
Для добавления следующих дней есть три дага с тегом «модельное время»:
|
|
||||||
|
|
||||||
| Даг | Что делает |
|
|
||||||
|---|---|
|
|
||||||
| `world_next_day` | Генерирует данные за следующие дни без пауз между событиями |
|
|
||||||
| `world_live_day` | Генерирует данные за следующий день постепенно, примерно за 24 минуты |
|
|
||||||
| `world_live` | Запускает `world_live_day` день за днем, пока снят с паузы |
|
|
||||||
|
|
||||||
В переменной Airflow `world_position` хранится номер следующего дня для
|
|
||||||
генерации. Например, при значении `8` генератор уже отправил события
|
|
||||||
за дни с 0 по 7.
|
|
||||||
|
|
||||||
`world_next_day` и `world_live_day` сначала отправляют события дня D,
|
|
||||||
затем — слепок заказов за предыдущий день D−1. Они ждут, пока `orders_ingest`
|
|
||||||
загрузит слепок, и только после успешного завершения дня обновляют
|
|
||||||
`world_position`. Если генерация или прием данных завершились с ошибкой,
|
|
||||||
номер дня остается прежним. Подробности — в [справочнике генератора
|
|
||||||
и модельного времени](../architecture/world.md#даги-модельного-времени).
|
|
||||||
|
|
||||||
## Дневной конвейер
|
|
||||||
|
|
||||||
Даг `etl_pipeline` по очереди запускает `dds_transform`, `dm_transform`
|
|
||||||
и `dq_check`, дожидаясь завершения каждого. Они рассчитывают данные DDS,
|
|
||||||
обновляют витрины DM и проверяют качество результата.
|
|
||||||
|
|
||||||
При запуске `etl_pipeline` не нужно указывать даты. Дни для обработки
|
|
||||||
определяются по `world_position` и по уже загруженным данным в слоях.
|
|
||||||
Детали есть в
|
|
||||||
[справочнике конвейера](../architecture/etl.md#объём-прогона).
|
|
||||||
|
|
||||||
После `world_next_day` новые события и заказы уже загружены, но данные DDS
|
|
||||||
и DM еще не пересчитаны: запустите `etl_pipeline` вручную.
|
|
||||||
`world_live_day` запускает его автоматически и ждет завершения. Поэтому
|
|
||||||
`world_live_day` может продолжать работу после окончания генерации за день.
|
|
||||||
|
|
||||||
## Если даг завершился с ошибкой
|
|
||||||
|
|
||||||
Ошибка в `etl_pipeline` не меняет `world_position`: генерация и прием
|
|
||||||
данных за день уже завершились. После устранения причины ошибки
|
|
||||||
перезапустите конвейер вручную. Даты для повторной обработки он определит сам.
|
|
||||||
Кто запускает конвейер, описано в
|
|
||||||
[разделе о трех путях запуска](../architecture/etl.md#три-пути-запуска).
|
|
||||||
|
|
||||||
За качество данных отвечают проверки DQ (Data Quality) в даге `dq_check`.
|
|
||||||
Если проверка находит расхождение, задача завершается с ошибкой и записывает
|
|
||||||
в журнал ключи строк, которые ее не прошли. Состав
|
|
||||||
проверок описан в [разделе «Звено DQ
|
|
||||||
конвейера»](../architecture/dm.md#звено-dq-конвейера).
|
|
||||||
|
|
||||||
## Как найти в интерфейсе
|
|
||||||
|
|
||||||
Даги мира ищите по тегам «модельное время» и «жизненный цикл мира». Остальные
|
|
||||||
даги ищите по тегам «заказы», `etl`, `dds`, `dm` и `dq`. `homework_check` с
|
|
||||||
тегами `dm` и `labs` проверяет домашние работы; о нем рассказано в учебных
|
|
||||||
заданиях.
|
|
||||||
|
|
||||||
## Посмотрите сами
|
|
||||||
|
|
||||||
Откройте Airflow по адресу из раздела [«Адреса и
|
|
||||||
пароли»](../../README.md#адреса-и-пароли). В Admin → Variables найдите
|
|
||||||
`world_position`. Затем запустите `world_next_day` на один день по [инструкции
|
|
||||||
из README](../../README.md#добавить-данные-за-следующие-дни). Убедитесь, что
|
|
||||||
`orders_ingest` завершился успешно, а в витринах нового дня еще нет.
|
|
||||||
Запустите `etl_pipeline` вручную и дождитесь его завершения.
|
|
||||||
|
|
||||||
В DBeaver, подключенном по
|
|
||||||
[инструкции](../../README.md#подключиться-из-dbeaver), выполните два запроса:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT report_date, visitors, known_users
|
|
||||||
FROM dm.daily_traffic_v
|
|
||||||
ORDER BY report_date DESC
|
|
||||||
LIMIT 3;
|
|
||||||
|
|
||||||
SELECT report_date, sum(revenue) AS revenue
|
|
||||||
FROM dm.revenue_daily_v
|
|
||||||
GROUP BY report_date
|
|
||||||
ORDER BY report_date DESC
|
|
||||||
LIMIT 3;
|
|
||||||
```
|
|
||||||
|
|
||||||
В витрине трафика уже есть новый день, а в выручке последняя дата —
|
|
||||||
предыдущий день. Причина в выгрузке заказов: вместе с событиями дня D
|
|
||||||
приходит слепок за день D−1. Выручка дня D появится после генерации
|
|
||||||
дня D+1 и запуска конвейера.
|
|
||||||
|
|
||||||
## Куда дальше
|
|
||||||
|
|
||||||
Детали — в справочниках [конвейера](../architecture/etl.md) и
|
|
||||||
[мира](../architecture/world.md). Следующая глава — [«Мониторинг: куда
|
|
||||||
смотреть»](monitoring.md).
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,9 +0,0 @@
|
|||||||
# Интерактивная карта хранилища
|
|
||||||
|
|
||||||
`map.yaml` хранит слои, узлы, стрелки и тексты карточек.
|
|
||||||
`template.html` задает внешний вид и поведение карты.
|
|
||||||
`render.py` вкладывает данные в шаблон и пишет файл рядом с главами.
|
|
||||||
Правьте исходники, а не `architecture-map.html`.
|
|
||||||
Пересоберите карту командой:
|
|
||||||
`uv run docs/handbook/architecture-map/render.py`.
|
|
||||||
Проверьте свежесть готового файла с флагом `--check`.
|
|
||||||
@@ -1,693 +0,0 @@
|
|||||||
title: Карта слоев хранилища
|
|
||||||
lead: Проследите путь событий и заказов от Kafka до отчетов.
|
|
||||||
chapter_url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои
|
|
||||||
intro:
|
|
||||||
- Выберите объект, чтобы узнать, что он хранит, как его читать и откуда в него поступают данные.
|
|
||||||
- Выберите стрелку, чтобы посмотреть способ обработки и открыть SQL-запрос.
|
|
||||||
- Для выбранного объекта подсвечиваются все пути, по которым данные поступают в него и используются дальше.
|
|
||||||
- Кнопки «События» и «Заказы» выделяют нужный поток. Остальные объекты остаются на месте, но становятся бледнее.
|
|
||||||
- Если вы впервые знакомитесь со слоями хранилища, начните с главы по ссылке ниже.
|
|
||||||
|
|
||||||
streams:
|
|
||||||
all: Все
|
|
||||||
events: События
|
|
||||||
orders: Заказы
|
|
||||||
|
|
||||||
dictionary:
|
|
||||||
title: Словарь
|
|
||||||
caption: справочные данные
|
|
||||||
|
|
||||||
layers:
|
|
||||||
- id: kafka
|
|
||||||
title: Kafka
|
|
||||||
caption: сообщения источников
|
|
||||||
- id: stg
|
|
||||||
title: STG
|
|
||||||
caption: исходные сообщения
|
|
||||||
- id: ods
|
|
||||||
title: ODS
|
|
||||||
caption: данные разобраны по полям
|
|
||||||
- id: dds
|
|
||||||
title: DDS
|
|
||||||
caption: события, заказы и сессии
|
|
||||||
- id: dm
|
|
||||||
title: DM
|
|
||||||
caption: витрины для отчетов
|
|
||||||
|
|
||||||
mechanisms:
|
|
||||||
- id: materialized_view
|
|
||||||
label: материализованное представление
|
|
||||||
line: solid
|
|
||||||
rhythm: непрерывно, по мере прихода сообщений
|
|
||||||
- id: airflow_dag
|
|
||||||
label: даг Airflow
|
|
||||||
line: dashed
|
|
||||||
rhythm: 'пакетно: раз в модельные сутки (`orders_ingest`) или при каждом запуске конвейера'
|
|
||||||
- id: view
|
|
||||||
label: представление
|
|
||||||
line: dotted
|
|
||||||
rhythm: при запросе к представлению, без копирования данных
|
|
||||||
|
|
||||||
nodes:
|
|
||||||
- id: kafka_hits
|
|
||||||
name: hits
|
|
||||||
layer: kafka
|
|
||||||
order: 0
|
|
||||||
streams: [events]
|
|
||||||
what: Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие.
|
|
||||||
read: "Для просмотра загруженных сообщений используйте `stg.hits_raw_dist`. Таблица `stg.hits_raw_kafka` служит для приема из топика."
|
|
||||||
fill:
|
|
||||||
- "Генератор отправляет события без пауз в пакетном режиме или постепенно в живом режиме."
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
|
|
||||||
- label: Формат
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/formats/clickstream-event.md#user-content-описание-выгрузки-событие-кликстрима
|
|
||||||
|
|
||||||
- id: kafka_orders
|
|
||||||
name: orders
|
|
||||||
layer: kafka
|
|
||||||
order: 1
|
|
||||||
streams: [orders]
|
|
||||||
what: "Топик Kafka с дневными выгрузками заказов — слепками. Каждый слепок содержит состояние заказов за последние семь модельных дней."
|
|
||||||
read: "Для просмотра загруженных сообщений используйте `stg.orders_raw_dist`. Таблица `stg.orders_raw_kafka` служит для приема из топика."
|
|
||||||
fill:
|
|
||||||
- "После генерации событий дня D источник отправляет слепок заказов за предыдущий день D−1."
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных
|
|
||||||
- label: Устройство слепка
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/snapshot.md#user-content-слепок-и-его-доставка
|
|
||||||
|
|
||||||
- id: stg_hits
|
|
||||||
name: stg.hits_raw_dist
|
|
||||||
layer: stg
|
|
||||||
order: 0
|
|
||||||
streams: [events]
|
|
||||||
what: "Исходное сообщение события и сведения о его доставке из Kafka. Содержимое сохраняется целой строкой, без разбора на поля."
|
|
||||||
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
|
|
||||||
parts:
|
|
||||||
- "`stg.hits_raw_kafka` — таблица для чтения сообщений топика `hits`."
|
|
||||||
- "`stg.hits_raw_rep` — реплицируемая таблица на шарде."
|
|
||||||
- "`stg.hits_raw_dist` — распределенная таблица для записи и чтения."
|
|
||||||
- "`stg.hits_raw_mv` — материализованное представление, которое записывает сообщения из Kafka в STG."
|
|
||||||
fill:
|
|
||||||
- '`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.'
|
|
||||||
- Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL.
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
|
|
||||||
- label: DDL таблиц
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql
|
|
||||||
- label: DDL материализованного представления
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql
|
|
||||||
|
|
||||||
- id: stg_orders
|
|
||||||
name: stg.orders_raw_dist
|
|
||||||
layer: stg
|
|
||||||
order: 1
|
|
||||||
streams: [orders]
|
|
||||||
what: "Исходные строки выгрузки заказов, сведения о доставке и идентификатор загруженной партии `_load_id`."
|
|
||||||
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
|
|
||||||
parts:
|
|
||||||
- "`stg.orders_raw_kafka` — таблица для чтения топика `orders` на первом узле."
|
|
||||||
- "`stg.orders_raw_rep` — реплицируемая таблица на шарде."
|
|
||||||
- "`stg.orders_raw_dist` — распределенная таблица для записи и чтения."
|
|
||||||
fill:
|
|
||||||
- Задача `pull_batch` дага `orders_ingest` загружает слепок раз в модельные сутки.
|
|
||||||
- Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL.
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных
|
|
||||||
- label: DDL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql
|
|
||||||
|
|
||||||
- id: ods_event
|
|
||||||
name: ods.event_v
|
|
||||||
layer: ods
|
|
||||||
order: 0
|
|
||||||
streams: [events]
|
|
||||||
what: "События, разобранные на колонки с типами данных. Имена полей сохранены как в источнике."
|
|
||||||
read: "Читайте `ods.event_v`: представление использует `FINAL` и возвращает последнюю версию каждого события."
|
|
||||||
parts:
|
|
||||||
- '`ods.event_rep` — реплицируемая таблица версий события на шарде.'
|
|
||||||
- '`ods.event_dist` — распределенная таблица записи и диагностики.'
|
|
||||||
- '`ods.event_v` — представление актуального состояния с `FINAL`.'
|
|
||||||
- '`ods.event_mv` — материализованное представление для разбора сообщений STG без ошибок.'
|
|
||||||
keys:
|
|
||||||
- '`WatchID` — тот же идентификатор, что `event_id` в DDS.'
|
|
||||||
- '`ClientID` — тот же посетитель, что `client_id` в DDS.'
|
|
||||||
- '`EventDate` — тот же день, что `event_date` в DDS.'
|
|
||||||
fill:
|
|
||||||
- '`ods.event_mv` разбирает строки непрерывно, по мере записи в STG.'
|
|
||||||
- '`ReplacingMergeTree(_load_ts)` хранит версии, а `FINAL` выбирает актуальную.'
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-актуальные-события
|
|
||||||
- label: DDL таблиц
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
|
|
||||||
- label: DDL представлений
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
|
|
||||||
|
|
||||||
- id: ods_event_errors
|
|
||||||
name: ods.event_errors_dist
|
|
||||||
layer: ods
|
|
||||||
order: 1
|
|
||||||
streams: [events]
|
|
||||||
what: "Сообщения событий, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и сведения о доставке."
|
|
||||||
read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.'
|
|
||||||
parts:
|
|
||||||
- '`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.'
|
|
||||||
- '`ods.event_errors_dist` — распределенная таблица чтения и записи.'
|
|
||||||
- '`ods.event_errors_mv` — материализованное представление для отбора сообщений STG с ошибками.'
|
|
||||||
fill:
|
|
||||||
- '`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.'
|
|
||||||
- Ошибки хранятся один месяц. Остальные события продолжают загружаться.
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
|
|
||||||
- label: DDL таблиц
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
|
|
||||||
- label: DDL материализованного представления
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
|
|
||||||
|
|
||||||
- id: ods_order
|
|
||||||
name: ods.order_v
|
|
||||||
layer: ods
|
|
||||||
order: 2
|
|
||||||
streams: [orders]
|
|
||||||
what: "Последняя загруженная версия заказа с именами полей из источника. Состав заказа пока хранится как фрагмент JSON."
|
|
||||||
read: 'Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.'
|
|
||||||
parts:
|
|
||||||
- '`ods.order_rep` — реплицируемая таблица версий заказа на шарде.'
|
|
||||||
- '`ods.order_dist` — распределенная таблица записи и диагностики.'
|
|
||||||
- '`ods.order_v` — представление текущего состояния с `FINAL`.'
|
|
||||||
keys:
|
|
||||||
- '`order_id` — тот же заказ в `dds.order_v` и `dm.purchase_vs_orders_v`.'
|
|
||||||
- '`user_id` — пользователь магазина, который переходит в заказ DDS.'
|
|
||||||
fill:
|
|
||||||
- Задача `parse_batch` дага `orders_ingest` разбирает загруженный слепок раз в модельные сутки.
|
|
||||||
- '`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка.'
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-версии-заказов
|
|
||||||
- label: DDL таблиц
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
|
|
||||||
- label: DDL представления
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
|
|
||||||
|
|
||||||
- id: ods_order_errors
|
|
||||||
name: ods.order_errors_dist
|
|
||||||
layer: ods
|
|
||||||
order: 3
|
|
||||||
streams: [orders]
|
|
||||||
what: "Строки выгрузки заказов, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и `_load_id`."
|
|
||||||
read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.'
|
|
||||||
parts:
|
|
||||||
- '`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.'
|
|
||||||
- '`ods.order_errors_dist` — распределенная таблица чтения и записи.'
|
|
||||||
fill:
|
|
||||||
- Задача `parse_batch` дага `orders_ingest` сохраняет строки с ошибками при разборе той же выгрузки, из которой загружаются заказы.
|
|
||||||
- Ошибки хранятся один месяц. Остальные заказы продолжают загружаться.
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
|
|
||||||
- label: DDL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
|
|
||||||
|
|
||||||
- id: dds_event
|
|
||||||
name: dds.event_v
|
|
||||||
layer: dds
|
|
||||||
order: 0
|
|
||||||
streams: [events]
|
|
||||||
what: "События с названиями полей, принятыми в DDS. Например, `WatchID` переименован в `event_id`, а числовой код устройства заменен названием категории."
|
|
||||||
read: "Читайте `dds.event_v`: представление возвращает явно перечисленные колонки DDS."
|
|
||||||
parts:
|
|
||||||
- '`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет.'
|
|
||||||
keys:
|
|
||||||
- '`event_id` — тот же идентификатор, что `WatchID` в ODS.'
|
|
||||||
- '`client_id` — тот же посетитель, что `ClientID` в ODS; соединяет событие с сессией и картой идентичностей.'
|
|
||||||
- '`event_date` — тот же день, что `EventDate` в ODS.'
|
|
||||||
- '`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM.'
|
|
||||||
fill:
|
|
||||||
- "При запросе к `dds.event_v` данные читаются из актуальных событий ODS. Отдельная загрузка в DDS не нужна."
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма
|
|
||||||
- label: DDL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
|
|
||||||
|
|
||||||
- id: dds_session
|
|
||||||
name: dds.session_v
|
|
||||||
layer: dds
|
|
||||||
order: 1
|
|
||||||
streams: [events]
|
|
||||||
what: "Сессии, рассчитанные дагом `dds_transform` по событиям посетителя. Новая сессия начинается после паузы дольше 30 минут или при смене модельной даты."
|
|
||||||
read: "Читайте `dds.session_v`: здесь есть начало, конец, длительность сессии, показатели событий и служебные поля загрузки."
|
|
||||||
parts:
|
|
||||||
- '`dds.session_rep` — реплицируемая таблица сессий на шарде.'
|
|
||||||
- '`dds.session_dist` — распределенная таблица загрузки и диагностики.'
|
|
||||||
- '`dds.session_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
|
|
||||||
- '`dds.session_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
|
|
||||||
- '`dds.session_v` — представление для чтения готовых данных.'
|
|
||||||
keys:
|
|
||||||
- '`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.'
|
|
||||||
- '`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`.'
|
|
||||||
fill:
|
|
||||||
- "При каждом запуске `dds_transform` выбирает дни от первого пропущенного в DDS до последнего дня с завершенной генерацией."
|
|
||||||
- "Задача `session.rebuild` рассчитывает данные в промежуточной таблице (доноре). `session.replace` заменяет ими дневные партиции основной таблицы."
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение
|
|
||||||
- label: DDL таблиц
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/45-dds-tables.sql
|
|
||||||
- label: DDL представления
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
|
|
||||||
|
|
||||||
- id: dds_order
|
|
||||||
name: dds.order_v
|
|
||||||
layer: dds
|
|
||||||
order: 2
|
|
||||||
streams: [orders]
|
|
||||||
what: "Последнее известное состояние заказа, подготовленное для анализа. Артикулы, количества и цены товаров хранятся в массивах; элементы с одинаковым индексом относятся к одной позиции заказа."
|
|
||||||
read: "Читайте `dds.order_v`, чтобы получить готовые заказы из основной таблицы."
|
|
||||||
parts:
|
|
||||||
- '`dds.order_rep` — реплицируемая таблица заказов на шарде.'
|
|
||||||
- '`dds.order_dist` — распределенная таблица загрузки и диагностики.'
|
|
||||||
- '`dds.order_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
|
|
||||||
- '`dds.order_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
|
|
||||||
- '`dds.order_v` — представление для чтения готовых данных.'
|
|
||||||
keys:
|
|
||||||
- '`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.'
|
|
||||||
- '`user_id` — пользователь магазина, который входит в карту идентичностей.'
|
|
||||||
- '`item_sku` — тот же артикул, что `sku` в `dic.products`.'
|
|
||||||
- '`order_date` — день, который в витринах называется `report_date` или `order_day`.'
|
|
||||||
fill:
|
|
||||||
- "При каждом запуске `dds_transform` пересчитывает дни заказов из последнего слепка и заполняет пропуски в DDS."
|
|
||||||
- "Задача `order.rebuild` рассчитывает данные в промежуточной таблице (доноре). `order.replace` заменяет ими дневные партиции основной таблицы."
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение
|
|
||||||
- label: DDL таблиц
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/45-dds-tables.sql
|
|
||||||
- label: DDL представления
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
|
|
||||||
|
|
||||||
- id: dds_identity
|
|
||||||
name: dds.identity_map_v
|
|
||||||
layer: dds
|
|
||||||
order: 3
|
|
||||||
streams: [events, orders]
|
|
||||||
what: "Связи между посетителем сайта и пользователем магазина. Они появляются, когда событие покупки сопоставлено с заказом по его номеру."
|
|
||||||
read: "Читайте `dds.identity_map_v`: представление с `FINAL` возвращает последнюю версию каждой пары посетителя и пользователя."
|
|
||||||
parts:
|
|
||||||
- '`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.'
|
|
||||||
- '`dds.identity_map_dist` — распределенная таблица записи.'
|
|
||||||
- '`dds.identity_map_v` — представление последних версий строк с `FINAL`.'
|
|
||||||
keys:
|
|
||||||
- "`client_id` — идентификатор посетителя из cookie браузера, как в событии и сессии DDS."
|
|
||||||
- "`user_id` — пользователь магазина из заказа DDS."
|
|
||||||
fill:
|
|
||||||
- "Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов."
|
|
||||||
- "При каждом запуске записывается новая версия набора связей."
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка
|
|
||||||
- label: DDL таблиц
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/45-dds-tables.sql
|
|
||||||
- label: DDL представления
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
|
|
||||||
|
|
||||||
- id: dm_daily_traffic
|
|
||||||
name: dm.daily_traffic_v
|
|
||||||
layer: dm
|
|
||||||
order: 0
|
|
||||||
streams: [events]
|
|
||||||
what: "Число посетителей и известных пользователей за каждый день. Известными считаются пользователи магазина, связанные с посетителями сайта через карту идентичностей."
|
|
||||||
read: "Читайте `dm.daily_traffic_v`: представление с `FINAL` возвращает последний результат расчета за каждый день."
|
|
||||||
parts:
|
|
||||||
- '`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.'
|
|
||||||
- '`dm.daily_traffic_dist` — распределенная таблица записи.'
|
|
||||||
- '`dm.daily_traffic_v` — представление последних версий строк с `FINAL`.'
|
|
||||||
keys:
|
|
||||||
- '`report_date` — тот же день, что `session_date` в сессии DDS.'
|
|
||||||
fill:
|
|
||||||
- "Задача `daily_traffic` дага `dm_transform` при каждом запуске пересчитывает всю историю и записывает новую версию."
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v
|
|
||||||
- label: DDL таблиц
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql
|
|
||||||
- label: DDL представления
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql
|
|
||||||
|
|
||||||
- id: dm_revenue
|
|
||||||
name: dm.revenue_daily_v
|
|
||||||
layer: dm
|
|
||||||
order: 1
|
|
||||||
streams: [orders]
|
|
||||||
what: "Выручка, число заказов и единиц товара за день по категориям. В расчет входят оплаченные заказы."
|
|
||||||
read: "Читайте `dm.revenue_daily_v`, чтобы получить готовые показатели по дням и категориям товаров."
|
|
||||||
parts:
|
|
||||||
- '`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.'
|
|
||||||
- '`dm.revenue_daily_dist` — распределенная таблица загрузки.'
|
|
||||||
- '`dm.revenue_daily_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
|
|
||||||
- '`dm.revenue_daily_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
|
|
||||||
- '`dm.revenue_daily_v` — представление для чтения готовых данных.'
|
|
||||||
keys:
|
|
||||||
- '`report_date` — тот же день, что `order_date` в заказе DDS.'
|
|
||||||
- '`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря.'
|
|
||||||
fill:
|
|
||||||
- "При каждом запуске `dm_transform` пересчитывает дни заказов из последнего слепка и заполняет пропущенные дни витрины."
|
|
||||||
- "Задача `revenue_daily.rebuild` рассчитывает данные в промежуточной таблице (доноре). `revenue_daily.replace` заменяет ими дневные партиции витрины."
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v
|
|
||||||
- label: DDL таблиц
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql
|
|
||||||
- label: DDL представления
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql
|
|
||||||
|
|
||||||
- id: dm_purchase
|
|
||||||
name: dm.purchase_vs_orders_v
|
|
||||||
layer: dm
|
|
||||||
order: 2
|
|
||||||
streams: [events, orders]
|
|
||||||
what: "Результат сверки покупок из кликстрима с заказами магазина. Для каждого номера заказа указано, совпали ли данные двух источников и какое расхождение найдено."
|
|
||||||
read: "Читайте `dm.purchase_vs_orders_v`: если покупка или заказ отсутствует, поля этой стороны содержат `NULL`."
|
|
||||||
parts:
|
|
||||||
- '`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.'
|
|
||||||
- '`dm.purchase_vs_orders_dist` — распределенная таблица загрузки.'
|
|
||||||
- '`dm.purchase_vs_orders_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
|
|
||||||
- '`dm.purchase_vs_orders_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
|
|
||||||
- '`dm.purchase_vs_orders_v` — представление для чтения готовых данных.'
|
|
||||||
keys:
|
|
||||||
- "`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS."
|
|
||||||
- "`order_day` — дата заказа; если заказа еще нет, берется дата события покупки."
|
|
||||||
fill:
|
|
||||||
- "При каждом запуске `dm_transform` пересчитывает дни последнего слепка, добавляет новые дни покупок, заполняет пропуски и повторяет сверку дней с покупками без заказа."
|
|
||||||
- "Задача `purchase_vs_orders.rebuild` рассчитывает данные в промежуточной таблице (доноре). `purchase_vs_orders.replace` заменяет ими дневные партиции витрины."
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v
|
|
||||||
- label: DDL таблиц
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql
|
|
||||||
- label: DDL представления
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql
|
|
||||||
|
|
||||||
- id: dm_dq
|
|
||||||
name: dm.dq_summary_v
|
|
||||||
layer: dm
|
|
||||||
order: 3
|
|
||||||
streams: [events, orders]
|
|
||||||
what: "Последние результаты пяти проверок качества данных за каждый день. Историю запусков можно посмотреть в Airflow."
|
|
||||||
read: "Читайте `dm.dq_summary_v`, чтобы увидеть результат каждой проверки за день."
|
|
||||||
parts:
|
|
||||||
- '`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.'
|
|
||||||
- '`dm.dq_summary_dist` — распределенная таблица чтения и записи.'
|
|
||||||
- '`dm.dq_summary_stage_rep` — промежуточная таблица для результатов всех проверок за день.'
|
|
||||||
- '`dm.dq_summary_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
|
|
||||||
- '`dm.dq_summary_v` — представление для чтения готовых данных.'
|
|
||||||
fill:
|
|
||||||
- Даг `dq_check` сначала рассчитывает результаты всех пяти проверок в промежуточной таблице, затем заменяет ими дневные партиции сводки.
|
|
||||||
- '`sessions_vs_reference` — `sql/dq/sessions_vs_reference.sql`.'
|
|
||||||
- '`classes_vs_inventory` — `sql/dq/classes_vs_inventory.sql` и эталонные числа из паспорта мира `data/world-inventory.json`.'
|
|
||||||
- '`revenue_daily_vs_dds` — `sql/dq/revenue_daily_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`.'
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdq_summary
|
|
||||||
- label: DDL таблиц
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql
|
|
||||||
- label: DDL представления
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql
|
|
||||||
|
|
||||||
- id: dic_products
|
|
||||||
name: dic.products
|
|
||||||
layer: dic
|
|
||||||
order: 0
|
|
||||||
streams: [orders]
|
|
||||||
what: "Словарь товаров. По артикулу можно получить название, бренд, категорию и цену. Эти данные доступны для расчетов в разных слоях."
|
|
||||||
read: "Читайте `dic.products`: к словарю обращаются напрямую, суффикса `_v` у него нет."
|
|
||||||
parts:
|
|
||||||
- "`dic.products_file` — таблица, которая читает каталог товаров из CSV-файла репозитория."
|
|
||||||
- "`dic.products` — словарь в памяти, загруженный из этой таблицы."
|
|
||||||
keys:
|
|
||||||
- '`sku` — тот же артикул, что `item_sku` в позиции заказа DDS.'
|
|
||||||
fill:
|
|
||||||
- "ClickHouse обновляет словарь из `dic.products_file` каждые 60–90 секунд согласно настройке `LIFETIME`."
|
|
||||||
links:
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-справочники
|
|
||||||
- label: DDL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/05-dic-catalog.sql
|
|
||||||
|
|
||||||
edges:
|
|
||||||
- id: hits_to_stg
|
|
||||||
from: kafka_hits
|
|
||||||
to: stg_hits
|
|
||||||
mechanism: materialized_view
|
|
||||||
streams: [events]
|
|
||||||
rhythm: непрерывно, по мере прихода сообщений
|
|
||||||
actor: "Таблица `stg.hits_raw_kafka` и материализованное представление `stg.hits_raw_mv`."
|
|
||||||
join: "Каждое сообщение Kafka записывается целой строкой, без разбора на поля."
|
|
||||||
links:
|
|
||||||
- label: SQL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
|
|
||||||
|
|
||||||
- id: stg_to_event
|
|
||||||
from: stg_hits
|
|
||||||
to: ods_event
|
|
||||||
mechanism: materialized_view
|
|
||||||
streams: [events]
|
|
||||||
rhythm: непрерывно, по мере прихода сообщений
|
|
||||||
actor: Материализованное представление `ods.event_mv` разбирает сообщения без ошибок.
|
|
||||||
join: "Материализованное представление разбирает сообщение из STG на колонки с нужными типами данных."
|
|
||||||
links:
|
|
||||||
- label: SQL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
|
|
||||||
|
|
||||||
- id: stg_to_event_errors
|
|
||||||
from: stg_hits
|
|
||||||
to: ods_event_errors
|
|
||||||
mechanism: materialized_view
|
|
||||||
streams: [events]
|
|
||||||
rhythm: непрерывно, по мере прихода сообщений
|
|
||||||
actor: "Материализованное представление `ods.event_errors_mv` отбирает сообщения с ошибками."
|
|
||||||
join: "Сообщение, которое не удалось разобрать по правилам ODS, сохраняется целиком вместе с классом ошибки."
|
|
||||||
links:
|
|
||||||
- label: SQL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
|
|
||||||
|
|
||||||
- id: orders_to_stg
|
|
||||||
from: kafka_orders
|
|
||||||
to: stg_orders
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [orders]
|
|
||||||
rhythm: одной выгрузкой, раз в модельные сутки
|
|
||||||
actor: Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`.
|
|
||||||
join: "Строки выгрузки сохраняются целиком. Общий `_load_id` позволяет найти все строки одной загрузки."
|
|
||||||
links:
|
|
||||||
- label: SQL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/stg/orders_raw_load.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-одно-чтение-kafka
|
|
||||||
|
|
||||||
- id: stg_to_order
|
|
||||||
from: stg_orders
|
|
||||||
to: ods_order
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [orders]
|
|
||||||
rhythm: одной выгрузкой, раз в модельные сутки
|
|
||||||
actor: Задача `parse_batch` дага `orders_ingest` записывает версии заказов без ошибок разбора.
|
|
||||||
join: "Строка разбирается на колонки заказа. Версия определяется датой слепка `snapshot_date`."
|
|
||||||
links:
|
|
||||||
- label: SQL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-граница-строгого-при%D1%91ма
|
|
||||||
|
|
||||||
- id: stg_to_order_errors
|
|
||||||
from: stg_orders
|
|
||||||
to: ods_order_errors
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [orders]
|
|
||||||
rhythm: одной выгрузкой, раз в модельные сутки
|
|
||||||
actor: Задача `parse_batch` дага `orders_ingest` записывает строки с ошибками из загруженной выгрузки.
|
|
||||||
join: "Строка с ошибкой сохраняется целиком вместе с классом ошибки и идентификатором загрузки."
|
|
||||||
links:
|
|
||||||
- label: SQL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-граница-строгого-при%D1%91ма
|
|
||||||
|
|
||||||
- id: ods_to_dds_event
|
|
||||||
from: ods_event
|
|
||||||
to: dds_event
|
|
||||||
mechanism: view
|
|
||||||
streams: [events]
|
|
||||||
rhythm: при запросе к представлению, без копирования данных
|
|
||||||
actor: "Представление `dds.event_v` читает данные из `ods.event_v`."
|
|
||||||
join: "Колонки события переименовываются по правилам DDS. Данные не копируются в отдельную таблицу."
|
|
||||||
links:
|
|
||||||
- label: SQL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма
|
|
||||||
|
|
||||||
- id: event_to_session
|
|
||||||
from: dds_event
|
|
||||||
to: dds_session
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [events]
|
|
||||||
rhythm: при каждом запуске `dds_transform`
|
|
||||||
actor: Задачи `session.rebuild` и `session.replace` дага `dds_transform`.
|
|
||||||
join: "События одного `client_id` за день `event_date` упорядочиваются по времени. Пауза дольше 30 минут начинает новую сессию. Ее `session_id` рассчитывается как `cityHash64(client_id, min(event_time))`."
|
|
||||||
links:
|
|
||||||
- label: SQL сборки
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/session_rebuild.sql
|
|
||||||
- label: SQL замены
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/session_replace.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение
|
|
||||||
|
|
||||||
- id: order_to_dds
|
|
||||||
from: ods_order
|
|
||||||
to: dds_order
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [orders]
|
|
||||||
rhythm: при каждом запуске `dds_transform`
|
|
||||||
actor: Задачи `order.rebuild` и `order.replace` дага `dds_transform`.
|
|
||||||
join: "Поля заказа приводятся к типам DDS, а позиции из JSON разбираются в массивы. Дата `order_date` рассчитывается из `created_at`."
|
|
||||||
links:
|
|
||||||
- label: SQL сборки
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_rebuild.sql
|
|
||||||
- label: SQL замены
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_replace.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение
|
|
||||||
|
|
||||||
- id: event_to_identity
|
|
||||||
from: dds_event
|
|
||||||
to: dds_identity
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [events]
|
|
||||||
rhythm: при каждом запуске `dds_transform`
|
|
||||||
actor: Задача `identity_map` дага `dds_transform` соединяет покупки с заказами.
|
|
||||||
join: "Событие покупки соединяется с заказом по `purchase_id[1] = order_id`. В результате связываются посетитель сайта и пользователь магазина."
|
|
||||||
links:
|
|
||||||
- label: SQL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка
|
|
||||||
|
|
||||||
- id: order_to_identity
|
|
||||||
from: dds_order
|
|
||||||
to: dds_identity
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [orders]
|
|
||||||
rhythm: при каждом запуске `dds_transform`
|
|
||||||
actor: Задача `identity_map` дага `dds_transform` соединяет заказы с покупками.
|
|
||||||
join: "Заказ соединяется с событием покупки по `order_id = purchase_id[1]`. Из заказа берется `user_id` пользователя магазина."
|
|
||||||
links:
|
|
||||||
- label: SQL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка
|
|
||||||
|
|
||||||
- id: session_to_traffic
|
|
||||||
from: dds_session
|
|
||||||
to: dm_daily_traffic
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [events]
|
|
||||||
rhythm: при каждом запуске `dm_transform`
|
|
||||||
actor: Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям.
|
|
||||||
join: "К сессиям присоединяется карта идентичностей по `client_id`, затем данные группируются по `session_date`. Эта дата записывается в `report_date`."
|
|
||||||
links:
|
|
||||||
- label: SQL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v
|
|
||||||
|
|
||||||
- id: identity_to_traffic
|
|
||||||
from: dds_identity
|
|
||||||
to: dm_daily_traffic
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [events]
|
|
||||||
rhythm: при каждом запуске `dm_transform`
|
|
||||||
actor: Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`.
|
|
||||||
join: "`GLOBAL LEFT JOIN` присоединяет карту идентичностей к сессиям по `client_id`. Сессии без известного пользователя тоже участвуют в расчете посетителей."
|
|
||||||
links:
|
|
||||||
- label: SQL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v
|
|
||||||
|
|
||||||
- id: order_to_revenue
|
|
||||||
from: dds_order
|
|
||||||
to: dm_revenue
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [orders]
|
|
||||||
rhythm: при каждом запуске `dm_transform`
|
|
||||||
actor: Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`.
|
|
||||||
join: "`ARRAY JOIN` разворачивает массивы `item_sku`, `item_quantity`, `item_price` в строки позиций заказа. Выручка считается по оплаченным заказам; `order_date` записывается в `report_date`."
|
|
||||||
links:
|
|
||||||
- label: SQL сборки
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql
|
|
||||||
- label: SQL замены
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_replace.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v
|
|
||||||
|
|
||||||
- id: products_to_revenue
|
|
||||||
from: dic_products
|
|
||||||
to: dm_revenue
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [orders]
|
|
||||||
rhythm: при каждом запуске `dm_transform`
|
|
||||||
actor: "Задача `revenue_daily.rebuild` дага `dm_transform`."
|
|
||||||
join: "Функция `dictGet` получает категорию товара по артикулу `item_sku` из заказа. В словаре этому артикулу соответствует ключ `sku`."
|
|
||||||
links:
|
|
||||||
- label: SQL
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v
|
|
||||||
|
|
||||||
- id: event_to_purchase
|
|
||||||
from: dds_event
|
|
||||||
to: dm_purchase
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [events]
|
|
||||||
rhythm: при каждом запуске `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`."
|
|
||||||
links:
|
|
||||||
- label: SQL сборки
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql
|
|
||||||
- label: SQL замены
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_replace.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v
|
|
||||||
|
|
||||||
- id: order_to_purchase
|
|
||||||
from: dds_order
|
|
||||||
to: dm_purchase
|
|
||||||
mechanism: airflow_dag
|
|
||||||
streams: [orders]
|
|
||||||
rhythm: при каждом запуске `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`."
|
|
||||||
links:
|
|
||||||
- label: SQL сборки
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql
|
|
||||||
- label: SQL замены
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_replace.sql
|
|
||||||
- label: Справочник
|
|
||||||
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# /// script
|
|
||||||
# requires-python = ">=3.13"
|
|
||||||
# dependencies = ["pyyaml"]
|
|
||||||
# ///
|
|
||||||
|
|
||||||
"""Собрать интерактивную карту хранилища из YAML и HTML-шаблона."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
ROOT = Path(__file__).parent
|
|
||||||
SOURCE = ROOT / "map.yaml"
|
|
||||||
TEMPLATE = ROOT / "template.html"
|
|
||||||
OUTPUT = ROOT.parent / "architecture-map.html"
|
|
||||||
MARKER = "/*__MAP_DATA__*/"
|
|
||||||
NOTICE = (
|
|
||||||
"<!-- Файл собран автоматически. Правьте architecture-map/map.yaml и "
|
|
||||||
"template.html, затем выполните uv run "
|
|
||||||
"docs/handbook/architecture-map/render.py. -->\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def render() -> str:
|
|
||||||
"""Подставить данные карты в шаблон без изменения порядка ключей."""
|
|
||||||
data = yaml.safe_load(SOURCE.read_text(encoding="utf-8"))
|
|
||||||
payload = json.dumps(data, ensure_ascii=False, indent=2)
|
|
||||||
template = TEMPLATE.read_text(encoding="utf-8")
|
|
||||||
return NOTICE + template.replace(MARKER, payload)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
"""Записать карту или проверить, что готовый файл не устарел."""
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
|
||||||
parser.add_argument("--check", action="store_true", help="проверить готовый файл")
|
|
||||||
args = parser.parse_args()
|
|
||||||
rendered = render()
|
|
||||||
if args.check:
|
|
||||||
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != rendered:
|
|
||||||
print(f"{OUTPUT} устарел; пересоберите карту")
|
|
||||||
return 1
|
|
||||||
print(f"{OUTPUT} соответствует исходникам")
|
|
||||||
return 0
|
|
||||||
OUTPUT.write_text(rendered, encoding="utf-8")
|
|
||||||
print(f"собрана карта: {OUTPUT}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,454 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Карта слоев хранилища</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--paper: #f7f7f5;
|
|
||||||
--panel: #ffffff;
|
|
||||||
--ink: #272a2d;
|
|
||||||
--muted: #6b7177;
|
|
||||||
--line: #8c9298;
|
|
||||||
--soft: #e6e8e9;
|
|
||||||
--accent: #b54a2a;
|
|
||||||
--accent-soft: #f5ded7;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: var(--paper);
|
|
||||||
color: var(--ink);
|
|
||||||
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
}
|
|
||||||
header { padding: 22px 26px 12px; }
|
|
||||||
h1 { margin: 0 0 5px; font-size: 25px; }
|
|
||||||
.lead { margin: 0; color: var(--muted); }
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 26px 16px;
|
|
||||||
}
|
|
||||||
.switcher { display: flex; gap: 6px; }
|
|
||||||
button {
|
|
||||||
border: 1px solid #aeb3b7;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--ink);
|
|
||||||
padding: 6px 13px;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button[aria-pressed="true"] {
|
|
||||||
border-color: var(--accent);
|
|
||||||
background: var(--accent-soft);
|
|
||||||
color: #71301e;
|
|
||||||
}
|
|
||||||
button:focus-visible, [tabindex="0"]:focus-visible {
|
|
||||||
outline: 3px solid #d79078;
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
.legend { display: flex; flex-wrap: wrap; gap: 15px; color: var(--muted); }
|
|
||||||
.legend-item { display: inline-flex; align-items: center; gap: 7px; }
|
|
||||||
.legend-line { width: 34px; border-top: 2px solid var(--line); }
|
|
||||||
.legend-line.dashed { border-top-style: dashed; }
|
|
||||||
.legend-line.dotted { border-top-style: dotted; }
|
|
||||||
main {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 350px;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 0 18px 24px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.map-scroll {
|
|
||||||
overflow-x: auto;
|
|
||||||
border: 1px solid #d7d9da;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
svg { display: block; width: 100%; min-width: 980px; height: auto; }
|
|
||||||
.layer-title { font-size: 15px; font-weight: 700; text-anchor: middle; }
|
|
||||||
.layer-caption { font-size: 11px; fill: var(--muted); text-anchor: middle; }
|
|
||||||
.zone-caption { font-size: 12px; fill: var(--muted); }
|
|
||||||
.column-guide { stroke: #eef0f1; stroke-width: 1; }
|
|
||||||
.edge-line {
|
|
||||||
fill: none;
|
|
||||||
stroke: var(--line);
|
|
||||||
stroke-width: 1.8;
|
|
||||||
marker-end: url(#arrowhead);
|
|
||||||
transition: opacity .15s, stroke .15s, stroke-width .15s;
|
|
||||||
}
|
|
||||||
.edge-line.dashed { stroke-dasharray: 8 6; }
|
|
||||||
.edge-line.dotted { stroke-dasharray: 2 6; stroke-linecap: round; }
|
|
||||||
.edge-hit { fill: none; stroke: transparent; stroke-width: 14; cursor: pointer; }
|
|
||||||
.node { cursor: pointer; transition: opacity .15s; }
|
|
||||||
.node rect {
|
|
||||||
fill: #f1f2f2;
|
|
||||||
stroke: #858b90;
|
|
||||||
stroke-width: 1.2;
|
|
||||||
rx: 7;
|
|
||||||
transition: fill .15s, stroke .15s, stroke-width .15s;
|
|
||||||
}
|
|
||||||
.node text {
|
|
||||||
fill: var(--ink);
|
|
||||||
font: 12px ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
||||||
text-anchor: middle;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.node .stream-mark { fill: var(--muted); font: 10px system-ui, sans-serif; }
|
|
||||||
.is-filtered, .is-context-dim { opacity: .16; }
|
|
||||||
.is-path:not(.is-filtered), .is-hover:not(.is-filtered) { opacity: 1; }
|
|
||||||
.is-path .edge-line, .edge.is-path .edge-line {
|
|
||||||
stroke: var(--accent); stroke-width: 2.8; marker-end: url(#arrowhead-active);
|
|
||||||
}
|
|
||||||
.node.is-path rect { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
|
|
||||||
.node.is-hover rect { fill: #faefeb; stroke: #d79078; }
|
|
||||||
.edge.is-hover .edge-line { stroke: #d79078; stroke-width: 2.2; }
|
|
||||||
.node.is-selected rect { stroke-width: 3; }
|
|
||||||
.edge.is-selected .edge-line { stroke-width: 4; }
|
|
||||||
aside {
|
|
||||||
min-height: 470px;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid #d7d9da;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
@media (max-width: 1530px) { main { grid-template-columns: minmax(0, 1fr); } aside { min-height: 0; } }
|
|
||||||
aside h2 { margin: 0 0 3px; font-size: 20px; overflow-wrap: anywhere; }
|
|
||||||
.meta { margin: 0 0 18px; color: var(--muted); }
|
|
||||||
.card-section { margin-top: 17px; }
|
|
||||||
.card-section h3 { margin: 0 0 5px; font-size: 13px; }
|
|
||||||
.card-section p { margin: 0; }
|
|
||||||
.card-section ul { margin: 5px 0 0; padding-left: 20px; }
|
|
||||||
.card-section li + li { margin-top: 5px; }
|
|
||||||
code { font: .92em ui-monospace, SFMono-Regular, Consolas, monospace; }
|
|
||||||
a { color: #8a351e; text-underline-offset: 2px; }
|
|
||||||
.hint { color: var(--muted); font-size: 13px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<h1 id="page-title"></h1>
|
|
||||||
<p class="lead" id="lead"></p>
|
|
||||||
</header>
|
|
||||||
<div class="controls">
|
|
||||||
<div class="switcher" id="switcher" aria-label="Выбор потока"></div>
|
|
||||||
<div class="legend" id="legend" aria-label="Механизмы переноса"></div>
|
|
||||||
</div>
|
|
||||||
<main>
|
|
||||||
<div class="map-scroll">
|
|
||||||
<svg id="map" viewBox="0 0 1230 470" role="group" aria-label="Путь данных через слои хранилища"></svg>
|
|
||||||
</div>
|
|
||||||
<aside id="details" aria-live="polite"></aside>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
const MAP = /*__MAP_DATA__*/;
|
|
||||||
const NS = "http://www.w3.org/2000/svg";
|
|
||||||
const state = { stream: "all", selected: null, hovered: null };
|
|
||||||
const nodeById = new Map(MAP.nodes.map((node) => [node.id, node]));
|
|
||||||
const mechanismById = new Map(MAP.mechanisms.map((item) => [item.id, item]));
|
|
||||||
const positions = new Map();
|
|
||||||
const svg = document.getElementById("map");
|
|
||||||
const details = document.getElementById("details");
|
|
||||||
function svgElement(name, attributes = {}) {
|
|
||||||
const element = document.createElementNS(NS, name);
|
|
||||||
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value));
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
function layerTitle(layerId) { return MAP.layers.find((layer) => layer.id === layerId)?.title ?? MAP.dictionary.title; }
|
|
||||||
|
|
||||||
function appendRichText(parent, value) {
|
|
||||||
value.split("`").forEach((part, index) => {
|
|
||||||
const element = index % 2 ? document.createElement("code") : document.createTextNode(part);
|
|
||||||
if (index % 2) element.textContent = part;
|
|
||||||
parent.append(element);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function addSection(title, value) {
|
|
||||||
if (!value || (Array.isArray(value) && !value.length)) return;
|
|
||||||
const section = document.createElement("section");
|
|
||||||
section.className = "card-section";
|
|
||||||
const heading = document.createElement("h3");
|
|
||||||
heading.textContent = title;
|
|
||||||
section.append(heading);
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
const list = document.createElement("ul");
|
|
||||||
value.forEach((item) => {
|
|
||||||
const row = document.createElement("li");
|
|
||||||
appendRichText(row, item);
|
|
||||||
list.append(row);
|
|
||||||
});
|
|
||||||
section.append(list);
|
|
||||||
} else {
|
|
||||||
const paragraph = document.createElement("p");
|
|
||||||
appendRichText(paragraph, value);
|
|
||||||
section.append(paragraph);
|
|
||||||
}
|
|
||||||
details.append(section);
|
|
||||||
}
|
|
||||||
function addLinks(links) {
|
|
||||||
if (!links?.length) return;
|
|
||||||
const section = document.createElement("section");
|
|
||||||
section.className = "card-section";
|
|
||||||
const heading = document.createElement("h3");
|
|
||||||
heading.textContent = "Ссылки";
|
|
||||||
const list = document.createElement("ul");
|
|
||||||
links.forEach((item) => {
|
|
||||||
const row = document.createElement("li");
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = item.url;
|
|
||||||
link.target = "_blank";
|
|
||||||
link.rel = "noopener noreferrer";
|
|
||||||
link.textContent = item.label;
|
|
||||||
row.append(link);
|
|
||||||
list.append(row);
|
|
||||||
});
|
|
||||||
section.append(heading, list);
|
|
||||||
details.append(section);
|
|
||||||
}
|
|
||||||
function renderIntro() {
|
|
||||||
details.replaceChildren();
|
|
||||||
const heading = document.createElement("h2");
|
|
||||||
heading.textContent = "Как пользоваться картой";
|
|
||||||
details.append(heading);
|
|
||||||
MAP.intro.forEach((line) => {
|
|
||||||
const paragraph = document.createElement("p");
|
|
||||||
paragraph.textContent = line;
|
|
||||||
details.append(paragraph);
|
|
||||||
});
|
|
||||||
const paragraph = document.createElement("p");
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = MAP.chapter_url;
|
|
||||||
link.target = "_blank";
|
|
||||||
link.rel = "noopener noreferrer";
|
|
||||||
link.textContent = "Открыть главу «Хранилище и его слои»";
|
|
||||||
paragraph.append(link);
|
|
||||||
details.append(paragraph);
|
|
||||||
const hint = document.createElement("p");
|
|
||||||
hint.className = "hint";
|
|
||||||
hint.textContent = "Повторный щелчок или Esc снимает выделение.";
|
|
||||||
details.append(hint);
|
|
||||||
}
|
|
||||||
function renderNodeCard(node) {
|
|
||||||
details.replaceChildren();
|
|
||||||
const heading = document.createElement("h2");
|
|
||||||
heading.textContent = node.name;
|
|
||||||
const meta = document.createElement("p");
|
|
||||||
meta.className = "meta";
|
|
||||||
meta.textContent = `${layerTitle(node.layer)} — ${node.streams.map((id) => MAP.streams[id]).join(", ")}`;
|
|
||||||
details.append(heading, meta);
|
|
||||||
addSection("Что это", node.what);
|
|
||||||
addSection("Как читать", node.read);
|
|
||||||
addSection("Связанные таблицы и представления", node.parts);
|
|
||||||
addSection("Как связать данные", node.keys);
|
|
||||||
addSection("Как обновляются данные", node.fill);
|
|
||||||
addLinks(node.links);
|
|
||||||
}
|
|
||||||
function renderEdgeCard(edge) {
|
|
||||||
details.replaceChildren();
|
|
||||||
const heading = document.createElement("h2");
|
|
||||||
heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`;
|
|
||||||
details.append(heading);
|
|
||||||
addSection("Способ обработки", mechanismById.get(edge.mechanism).label);
|
|
||||||
addSection("Когда выполняется", edge.rhythm);
|
|
||||||
addSection("Что выполняет обработку", edge.actor);
|
|
||||||
addSection("Что происходит с данными", edge.join);
|
|
||||||
addLinks(edge.links);
|
|
||||||
}
|
|
||||||
function layoutMap() {
|
|
||||||
const nodeWidth = 180;
|
|
||||||
const nodeHeight = 48;
|
|
||||||
const columnStep = 250;
|
|
||||||
MAP.layers.forEach((layer, index) => {
|
|
||||||
const x = 20 + index * columnStep;
|
|
||||||
MAP.nodes.filter((node) => node.layer === layer.id).forEach((node) => {
|
|
||||||
positions.set(node.id, { x, y: 88 + node.order * 70, width: nodeWidth, height: nodeHeight });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const dicX = 20 + ((MAP.layers.findIndex((layer) => layer.id === "dds") + MAP.layers.findIndex((layer) => layer.id === "dm")) * columnStep) / 2 + nodeWidth / 2 - 130;
|
|
||||||
MAP.nodes.filter((node) => node.layer === "dic").forEach((node) => positions.set(node.id, { x: dicX, y: 402 + node.order * 70, width: 260, height: nodeHeight }));
|
|
||||||
}
|
|
||||||
function edgePath(edge, sameColumnIndex) {
|
|
||||||
const source = positions.get(edge.from);
|
|
||||||
const target = positions.get(edge.to);
|
|
||||||
if (nodeById.get(edge.from).layer === "dic") {
|
|
||||||
const x1 = source.x + source.width / 2;
|
|
||||||
const y1 = source.y;
|
|
||||||
const x2 = target.x;
|
|
||||||
const y2 = target.y + target.height / 2;
|
|
||||||
return `M ${x1} ${y1} C ${x1} ${y1 - 38}, ${x1} ${y2 + 48}, ${x2} ${y2}`;
|
|
||||||
}
|
|
||||||
const x1 = source.x + source.width;
|
|
||||||
const y1 = source.y + source.height / 2;
|
|
||||||
if (source.x === target.x) {
|
|
||||||
const route = source.x + source.width + 16 + sameColumnIndex * 18;
|
|
||||||
const y2 = target.y + target.height / 2;
|
|
||||||
return `M ${x1} ${y1} C ${route} ${y1}, ${route} ${y2}, ${x1} ${y2}`;
|
|
||||||
}
|
|
||||||
const x2 = target.x;
|
|
||||||
const y2 = target.y + target.height / 2;
|
|
||||||
const bend = Math.max(18, (x2 - x1) / 2);
|
|
||||||
return `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawMap() {
|
|
||||||
const defs = svgElement("defs");
|
|
||||||
[["arrowhead", "#8c9298"], ["arrowhead-active", "#b54a2a"]].forEach(([id, fill]) => {
|
|
||||||
const marker = svgElement("marker", { id, viewBox: "0 0 8 8", refX: "7", refY: "4", markerWidth: "6", markerHeight: "6", orient: "auto" });
|
|
||||||
marker.append(svgElement("path", { d: "M 0 0 L 8 4 L 0 8 z", fill }));
|
|
||||||
defs.append(marker);
|
|
||||||
});
|
|
||||||
svg.append(defs);
|
|
||||||
MAP.layers.forEach((layer, index) => {
|
|
||||||
const center = 110 + index * 250;
|
|
||||||
svg.append(svgElement("line", { x1: center, y1: 65, x2: center, y2: 377, class: "column-guide" }));
|
|
||||||
const title = svgElement("text", { x: center, y: 28, class: "layer-title" });
|
|
||||||
title.textContent = layer.title;
|
|
||||||
const caption = svgElement("text", { x: center, y: 47, class: "layer-caption" });
|
|
||||||
caption.textContent = layer.caption;
|
|
||||||
svg.append(title, caption);
|
|
||||||
});
|
|
||||||
const dictionaryPosition = positions.get(MAP.nodes.find((node) => node.layer === "dic").id);
|
|
||||||
const zone = svgElement("text", { x: dictionaryPosition.x, y: dictionaryPosition.y - 13, class: "zone-caption" });
|
|
||||||
zone.textContent = MAP.dictionary.caption;
|
|
||||||
svg.append(zone);
|
|
||||||
let sameIndex = 0;
|
|
||||||
MAP.edges.forEach((edge) => {
|
|
||||||
const same = positions.get(edge.from).x === positions.get(edge.to).x;
|
|
||||||
const index = same ? sameIndex++ : 0;
|
|
||||||
const group = svgElement("g", { class: "edge", "data-id": edge.id, tabindex: "0", role: "button", "aria-label": `${nodeById.get(edge.from).name} — ${nodeById.get(edge.to).name}` });
|
|
||||||
const path = edgePath(edge, index);
|
|
||||||
const lineClass = `edge-line ${mechanismById.get(edge.mechanism).line}`;
|
|
||||||
group.append(svgElement("path", { d: path, class: lineClass }), svgElement("path", { d: path, class: "edge-hit" }));
|
|
||||||
bindSelection(group, "edge", edge.id);
|
|
||||||
svg.append(group);
|
|
||||||
});
|
|
||||||
MAP.nodes.forEach((node) => {
|
|
||||||
const position = positions.get(node.id);
|
|
||||||
const group = svgElement("g", { class: "node", "data-id": node.id, tabindex: "0", role: "button", "aria-label": node.name });
|
|
||||||
group.append(svgElement("rect", position));
|
|
||||||
const name = svgElement("text", { x: position.x + position.width / 2, y: position.y + 21 });
|
|
||||||
name.textContent = node.name;
|
|
||||||
const mark = svgElement("text", { x: position.x + position.width / 2, y: position.y + 38, class: "stream-mark" });
|
|
||||||
mark.textContent = node.streams.map((id) => MAP.streams[id]).join(" + ");
|
|
||||||
group.append(name, mark);
|
|
||||||
group.addEventListener("mouseenter", () => { state.hovered = node.id; updateHighlights(); });
|
|
||||||
group.addEventListener("mouseleave", () => { state.hovered = null; updateHighlights(); });
|
|
||||||
bindSelection(group, "node", node.id);
|
|
||||||
svg.append(group);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function bindSelection(element, type, id) {
|
|
||||||
const choose = () => selectItem(type, id);
|
|
||||||
element.addEventListener("click", choose);
|
|
||||||
element.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); choose(); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function findReachability(nodeId) {
|
|
||||||
const nodes = new Set([nodeId]);
|
|
||||||
const edges = new Set();
|
|
||||||
const walk = (start, direction) => {
|
|
||||||
const queue = [start];
|
|
||||||
while (queue.length) {
|
|
||||||
const current = queue.shift();
|
|
||||||
MAP.edges.forEach((edge) => {
|
|
||||||
const matches = direction === "out" ? edge.from === current : edge.to === current;
|
|
||||||
if (!matches) return;
|
|
||||||
edges.add(edge.id);
|
|
||||||
const next = direction === "out" ? edge.to : edge.from;
|
|
||||||
if (!nodes.has(next)) { nodes.add(next); queue.push(next); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk(nodeId, "in");
|
|
||||||
walk(nodeId, "out");
|
|
||||||
return { nodes, edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectItem(type, id) {
|
|
||||||
const repeated = state.selected?.type === type && state.selected.id === id;
|
|
||||||
state.selected = repeated ? null : { type, id };
|
|
||||||
if (!state.selected) renderIntro();
|
|
||||||
else if (type === "node") renderNodeCard(nodeById.get(id));
|
|
||||||
else renderEdgeCard(MAP.edges.find((edge) => edge.id === id));
|
|
||||||
updateHighlights();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHighlights() {
|
|
||||||
let activeNodes = new Set();
|
|
||||||
let activeEdges = new Set();
|
|
||||||
if (state.selected?.type === "node") ({ nodes: activeNodes, edges: activeEdges } = findReachability(state.selected.id));
|
|
||||||
if (state.selected?.type === "edge") {
|
|
||||||
const edge = MAP.edges.find((item) => item.id === state.selected.id);
|
|
||||||
activeNodes = new Set([edge.from, edge.to]);
|
|
||||||
activeEdges = new Set([edge.id]);
|
|
||||||
}
|
|
||||||
const hoverNodes = new Set(state.hovered ? [state.hovered] : []);
|
|
||||||
const hoverEdges = new Set();
|
|
||||||
if (state.hovered) MAP.edges.forEach((edge) => {
|
|
||||||
if (edge.from === state.hovered || edge.to === state.hovered) {
|
|
||||||
hoverEdges.add(edge.id); hoverNodes.add(edge.from); hoverNodes.add(edge.to);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.querySelectorAll(".node").forEach((element) => {
|
|
||||||
const node = nodeById.get(element.dataset.id);
|
|
||||||
element.classList.toggle("is-filtered", state.stream !== "all" && !node.streams.includes(state.stream));
|
|
||||||
element.classList.toggle("is-context-dim", Boolean(state.selected) && !activeNodes.has(node.id));
|
|
||||||
element.classList.toggle("is-path", activeNodes.has(node.id));
|
|
||||||
element.classList.toggle("is-hover", hoverNodes.has(node.id));
|
|
||||||
element.classList.toggle("is-selected", state.selected?.type === "node" && state.selected.id === node.id);
|
|
||||||
});
|
|
||||||
document.querySelectorAll(".edge").forEach((element) => {
|
|
||||||
const edge = MAP.edges.find((item) => item.id === element.dataset.id);
|
|
||||||
element.classList.toggle("is-filtered", state.stream !== "all" && !edge.streams.includes(state.stream));
|
|
||||||
element.classList.toggle("is-context-dim", Boolean(state.selected) && !activeEdges.has(edge.id));
|
|
||||||
element.classList.toggle("is-path", activeEdges.has(edge.id));
|
|
||||||
element.classList.toggle("is-hover", hoverEdges.has(edge.id));
|
|
||||||
element.classList.toggle("is-selected", state.selected?.type === "edge" && state.selected.id === edge.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderControls() {
|
|
||||||
const switcher = document.getElementById("switcher");
|
|
||||||
Object.entries(MAP.streams).forEach(([id, label]) => {
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.type = "button";
|
|
||||||
button.textContent = label;
|
|
||||||
button.setAttribute("aria-pressed", String(id === state.stream));
|
|
||||||
button.addEventListener("click", () => {
|
|
||||||
state.stream = id;
|
|
||||||
switcher.querySelectorAll("button").forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
|
|
||||||
updateHighlights();
|
|
||||||
});
|
|
||||||
switcher.append(button);
|
|
||||||
});
|
|
||||||
const legend = document.getElementById("legend");
|
|
||||||
MAP.mechanisms.forEach((item) => {
|
|
||||||
const row = document.createElement("span");
|
|
||||||
row.className = "legend-item";
|
|
||||||
const line = document.createElement("span");
|
|
||||||
line.className = `legend-line ${item.line}`;
|
|
||||||
const label = document.createElement("span");
|
|
||||||
appendRichText(label, `${item.label} — ${item.rhythm}`);
|
|
||||||
row.append(line, label);
|
|
||||||
legend.append(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("page-title").textContent = MAP.title;
|
|
||||||
document.getElementById("lead").textContent = MAP.lead;
|
|
||||||
layoutMap();
|
|
||||||
drawMap();
|
|
||||||
renderControls();
|
|
||||||
renderIntro();
|
|
||||||
document.addEventListener("keydown", (event) => {
|
|
||||||
if (event.key === "Escape" && state.selected) selectItem(state.selected.type, state.selected.id);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# Генератор: откуда берутся данные
|
|
||||||
|
|
||||||
Генератор имитирует работу двух систем интернет-магазина. Счетчик
|
|
||||||
на сайте отправляет записи о действиях посетителей в топик `hits`. Серверная часть
|
|
||||||
магазина раз в модельные сутки выгружает состояние заказов в топик `orders`.
|
|
||||||
Эта дневная выгрузка называется слепком.
|
|
||||||
|
|
||||||
## Что он производит
|
|
||||||
|
|
||||||
Событие содержит сведения о действии посетителя: например, время,
|
|
||||||
адрес страницы и идентификатор посетителя. Формат соответствует образцу
|
|
||||||
выгрузки Яндекс Метрики. Генератор создает три вида событий: просмотр
|
|
||||||
страницы, добавление в корзину и покупку. Все колонки перечислены
|
|
||||||
в [описании выгрузки](../formats/clickstream-event.md).
|
|
||||||
|
|
||||||
Заказ содержит сведения о покупке со стороны магазина: товары, суммы
|
|
||||||
и состояние заказа. После оформления он еще может измениться, например
|
|
||||||
при отмене. Каждый дневной слепок содержит текущее состояние заказов,
|
|
||||||
оформленных за последние семь модельных дней. Состав и формат слепка
|
|
||||||
разобраны в [отдельном
|
|
||||||
справочнике](../architecture/orders/snapshot.md).
|
|
||||||
|
|
||||||
## Почему данные всегда одни и те же
|
|
||||||
|
|
||||||
Случайные решения генератора зависят от заданного числа —
|
|
||||||
[зерна генерации](../architecture/generator/seeds.md) — и номера дня.
|
|
||||||
При неизменных настройках и версии генератора одинаковые зерно и день
|
|
||||||
дают одинаковые события. Поэтому при создании стенда вы каждый раз
|
|
||||||
получаете те же начальные данные. Повторный `make up` на работающем стенде
|
|
||||||
сохраняет уже загруженные данные.
|
|
||||||
|
|
||||||
В файле `data/world-inventory.json` записаны зерно, версия генератора,
|
|
||||||
число событий и контрольная сумма за каждый из восьми начальных дней.
|
|
||||||
В документации этот файл называется паспортом мира. С ним можно сверить
|
|
||||||
результат генерации и проверить, все ли события загрузились в ODS.
|
|
||||||
|
|
||||||
## Кто его запускает
|
|
||||||
|
|
||||||
На стенде генератором управляют [даги модельного
|
|
||||||
времени](airflow-dags.md#даги-мира). Его можно запустить и напрямую. Команды
|
|
||||||
собраны в [справочнике генератора и модельного
|
|
||||||
времени](../architecture/world.md#как-запустить-генератор).
|
|
||||||
|
|
||||||
У генератора два режима. Пакетный отправляет все события дня без пауз.
|
|
||||||
В живом режиме события поступают постепенно, как в работающем магазине,
|
|
||||||
но время идет ускоренно: модельные сутки занимают около 24 минут.
|
|
||||||
|
|
||||||
## Посмотрите сами
|
|
||||||
|
|
||||||
Подключитесь из DBeaver по [инструкции](../../README.md#подключиться-из-dbeaver)
|
|
||||||
и выполните запрос:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT EventDate, count() AS events
|
|
||||||
FROM ods.event_v
|
|
||||||
GROUP BY EventDate
|
|
||||||
ORDER BY EventDate;
|
|
||||||
```
|
|
||||||
|
|
||||||
Сравните число событий за каждый из первых восьми дней со значением
|
|
||||||
`events` за ту же дату в [паспорте мира](../../data/world-inventory.json).
|
|
||||||
Числа должны совпасть. Если вы уже генерировали следующие дни,
|
|
||||||
в результате запроса будут дополнительные даты; в паспорте их нет.
|
|
||||||
|
|
||||||
## Куда дальше
|
|
||||||
|
|
||||||
Подробнее генератор разобран в [README папки
|
|
||||||
`generator/`](../../generator/README.md) и в [справочнике его
|
|
||||||
устройства](../architecture/generator/README.md). Теперь можно переходить
|
|
||||||
к [учебным заданиям](../labs/README.md).
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# Мониторинг: куда смотреть
|
|
||||||
|
|
||||||
В Grafana настроены три дашборда. «Данные» показывает загрузку и обработку
|
|
||||||
событий и заказов, «Кластер» — состояние узлов и самого ClickHouse,
|
|
||||||
а «Запросы» — выполненные запросы и затраченные на них ресурсы.
|
|
||||||
|
|
||||||
Prometheus собирает метрики с двух узлов ClickHouse, ClickHouse Keeper
|
|
||||||
и экспортера Kafka — службы, которая предоставляет метрики Kafka.
|
|
||||||
Grafana получает данные из Prometheus и напрямую из ClickHouse.
|
|
||||||
На панелях не заданы пороги для цветовых предупреждений: оценивать показания
|
|
||||||
вы будете самостоятельно. Качество данных автоматически проверяет
|
|
||||||
даг `dq_check`.
|
|
||||||
|
|
||||||
## Дашборд «Данные»
|
|
||||||
|
|
||||||
«Свежесть» показывает, сколько времени прошло с последнего события и с
|
|
||||||
последнего заказа в ODS. Показатель для заказов растет весь модельный день
|
|
||||||
и снижается, когда поступает очередная дневная выгрузка — слепок.
|
|
||||||
Это ожидаемо: заказы загружаются раз в день.
|
|
||||||
|
|
||||||
«Поток» показывает число сырых сообщений в STG за выбранный интервал. «Брак»
|
|
||||||
показывает долю строк, которые не удалось разобрать.
|
|
||||||
|
|
||||||
«Сходимость» сравнивает число событий в Kafka и в ClickHouse. Тревожный
|
|
||||||
признак — устойчивый разрыв между рядами. Разовое расхождение еще не говорит
|
|
||||||
о потере данных: метрики Kafka и строки ClickHouse могут попадать в соседние
|
|
||||||
интервалы на графике.
|
|
||||||
|
|
||||||
«Отставание» показывает, сколько сообщений в Kafka еще не прочитано
|
|
||||||
потребителями. «Дневной слепок» перечисляет последние десять слепков заказов в
|
|
||||||
STG: по нему видно, поступила ли очередная выгрузка.
|
|
||||||
|
|
||||||
Источники и правила чтения всех панелей разобраны в [справочнике дашборда
|
|
||||||
«Данные»](../architecture/monitoring/data.md).
|
|
||||||
|
|
||||||
## Почему у панелей разные источники
|
|
||||||
|
|
||||||
Свежесть считается по ODS: если новые сообщения перестали разбираться,
|
|
||||||
показатель будет расти. Число поступивших сообщений берется из STG,
|
|
||||||
где сохраняются все сообщения, включая повторы. Для доли брака это число
|
|
||||||
сравнивается с числом строк в таблицах ошибок ODS.
|
|
||||||
|
|
||||||
В основных таблицах ODS повторы событий и старые версии заказов удаляются
|
|
||||||
при слияниях. Поэтому число строк в них не подходит для подсчета
|
|
||||||
всех доставленных сообщений. Подробности — в [разделе о слоях
|
|
||||||
панелей](../architecture/monitoring/data.md#почему-слои-у-панелей-разные).
|
|
||||||
|
|
||||||
## Два других дашборда
|
|
||||||
|
|
||||||
Открывайте «Кластер», чтобы проверить нагрузку на процессор, использование
|
|
||||||
памяти и дисков, число частей таблиц и ход их слияний в ClickHouse. Здесь же
|
|
||||||
показаны состояние ClickHouse Keeper и очередь распределенных DDL-команд.
|
|
||||||
Устройство панелей описано в [справочнике дашборда
|
|
||||||
«Кластер»](../architecture/monitoring/cluster.md).
|
|
||||||
|
|
||||||
«Запросы» поможет найти медленные запросы, ошибки и пользователей,
|
|
||||||
чьи запросы потребляют много ресурсов. Как читать панели, объясняет
|
|
||||||
[справочник дашборда
|
|
||||||
«Запросы»](../architecture/monitoring/queries.md).
|
|
||||||
|
|
||||||
## Посмотрите сами
|
|
||||||
|
|
||||||
Откройте Grafana по адресу из раздела [«Адреса и
|
|
||||||
пароли»](../../README.md#адреса-и-пароли), затем дашборд «Данные». Запустите в
|
|
||||||
Airflow [`world_live_day`](airflow-dags.md#даги-мира): модельный день займет
|
|
||||||
около двадцати четырех минут.
|
|
||||||
|
|
||||||
Смотрите свежесть событий и свежесть заказов по отдельности. Пока день идет,
|
|
||||||
со времени последнего события проходят секунды, а время с последней
|
|
||||||
загрузки заказов растет. После завершения генерации событий отправляется
|
|
||||||
слепок заказов. Когда он загружен, показатель свежести заказов снижается.
|
|
||||||
Показатель для событий начинает расти: новые события поступят только
|
|
||||||
при генерации следующего дня. Даг пока продолжает работу, ожидая завершения
|
|
||||||
обработки данных в дневном конвейере.
|
|
||||||
|
|
||||||
## Куда дальше
|
|
||||||
|
|
||||||
Устройство мониторинга описано в [техническом
|
|
||||||
справочнике](../architecture/monitoring/README.md), панели дашборда «Данные» —
|
|
||||||
в [отдельном разделе](../architecture/monitoring/data.md).
|
|
||||||
Почему выбраны именно эти показатели, объясняет
|
|
||||||
[ADR 0002](../adr/0002-monitoring-scope.md). Следующая глава —
|
|
||||||
[«Немного Superset»](superset.md).
|
|
||||||
@@ -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,31 @@ 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 +94,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 +113,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 +126,12 @@ WHERE report_date = '2026-06-01'
|
|||||||
ORDER BY revenue DESC;
|
ORDER BY revenue DESC;
|
||||||
```
|
```
|
||||||
|
|
||||||
Все три запроса читают представления `_v`.
|
Оба запроса читают поверхности `_v`. Первый показывает данные на языке
|
||||||
|
источника, второй — готовый ответ витрины.
|
||||||
|
|
||||||
## Куда дальше
|
## Куда дальше
|
||||||
|
|
||||||
Откройте [интерактивную карту](architecture-map.html) из своей копии
|
|
||||||
репозитория и проследите путь данных.
|
|
||||||
Список объектов, сроки хранения и устройство таблиц — в [справочнике
|
Список объектов, сроки хранения и устройство таблиц — в [справочнике
|
||||||
хранилища](../architecture/storage.md). Как данные обрабатываются между слоями — в
|
хранилища](../architecture/storage.md). Как данные переносят между слоями — в
|
||||||
следующей главе: [потоки Airflow и даги модельного
|
следующей главе: [потоки Airflow и даги модельного
|
||||||
времени](airflow-dags.md).
|
времени](README.md#потоки-airflow-и-даги-модельного-времени).
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Немного Superset
|
|
||||||
|
|
||||||
Superset показывает витрины слоя DM в виде графиков. К ClickHouse он
|
|
||||||
подключается пользователем `bi`. Дашборд стенда «Бизнес-сюжеты» собран из трех
|
|
||||||
витрин:
|
|
||||||
|
|
||||||
| Витрина | Что показывает |
|
|
||||||
|---|---|
|
|
||||||
| `dm.revenue_daily_v` | Выручку, число заказов и средний чек; выручку по дням и категориям товаров, долю каждой категории |
|
|
||||||
| `dm.purchase_vs_orders_v` | Результат сверки покупок из кликстрима с заказами: сколько заказов за день относится к каждому виду расхождения |
|
|
||||||
| `dm.daily_traffic_v` | Число посетителей и известных пользователей по дням, долю известных пользователей |
|
|
||||||
|
|
||||||
Известные пользователи — те, кого удалось связать с посетителями сайта
|
|
||||||
по заказам. Эти связи хранятся в карте идентичностей DDS.
|
|
||||||
|
|
||||||
Слева от панелей стоит фильтр «Период». Он задает даты сразу для всех панелей.
|
|
||||||
|
|
||||||
## Как витрина становится графиком
|
|
||||||
|
|
||||||
В Superset сначала создается датасет — описание таблицы или представления,
|
|
||||||
из которого будут браться данные. В нем задаются подписи колонок, формат
|
|
||||||
чисел и показатели, например средний чек.
|
|
||||||
|
|
||||||
Затем на основе датасета создается график: выбираются показатели и способ
|
|
||||||
их отображения. Несколько графиков можно разместить на одном дашборде.
|
|
||||||
|
|
||||||
## Стендовые объекты и ваши
|
|
||||||
|
|
||||||
Готовые датасеты, графики и дашборд сохранены в файлах репозитория
|
|
||||||
и импортируются при каждом `make up`. Если изменить их через интерфейс,
|
|
||||||
следующий `make up` заменит изменения версией из файлов без предупреждения.
|
|
||||||
|
|
||||||
Созданные вами датасеты, графики и дашборды хранятся в базе метаданных
|
|
||||||
Superset. Импорт их не меняет: они сохраняются после `make up`
|
|
||||||
и `make rebuild-storage`. Команда `make clean` удалит их вместе со всеми
|
|
||||||
данными стенда. Для учебных отчетов создавайте собственные объекты,
|
|
||||||
чтобы повторный запуск не перезаписал вашу работу. Устройство
|
|
||||||
импорта описано в [справочнике
|
|
||||||
Superset](../architecture/superset.md#импорт-при-каждой-инициализации).
|
|
||||||
|
|
||||||
## Почему выручка отстает на день
|
|
||||||
|
|
||||||
Последняя дата с выручкой на день раньше последней даты с событиями
|
|
||||||
кликстрима. Выгрузка заказов за день D поступает при генерации следующего
|
|
||||||
дня D+1, а затем обрабатывается конвейером. Вы уже видели эту разницу
|
|
||||||
в [главе об Airflow](airflow-dags.md#посмотрите-сами). Начальное состояние
|
|
||||||
показано в разделе [«Стартовый мир»](../architecture/world.md#стартовый-мир).
|
|
||||||
|
|
||||||
## Посмотрите сами
|
|
||||||
|
|
||||||
Откройте Superset по адресу из раздела [«Адреса и
|
|
||||||
пароли»](../../README.md#адреса-и-пароли) и выберите дашборд «Бизнес-сюжеты».
|
|
||||||
Затем откройте SQL Lab и выполните запрос:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT report_date, visitors, known_users
|
|
||||||
FROM dm.daily_traffic_v
|
|
||||||
ORDER BY report_date DESC
|
|
||||||
LIMIT 7;
|
|
||||||
```
|
|
||||||
|
|
||||||
Как построить свой отчет, рассказывают [учебные задания](../labs/README.md).
|
|
||||||
|
|
||||||
## Куда дальше
|
|
||||||
|
|
||||||
Подключение и настройка Superset описаны в [справочнике
|
|
||||||
Superset](../architecture/superset.md), а состав витрин — в [справочнике
|
|
||||||
DM](../architecture/dm.md). Следующая глава — [«Генератор: откуда берутся
|
|
||||||
данные»](generator.md).
|
|
||||||
@@ -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`.
|
|
||||||
@@ -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` и убедились, что повторный
|
|
||||||
прогон конвейера не меняет результат.
|
|
||||||
@@ -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`.
|
|
||||||
@@ -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`.
|
|
||||||
@@ -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`.
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# Свой график в Superset
|
|
||||||
|
|
||||||
**Чему учит:** увидеть, как собственный результат из хранилища становится
|
|
||||||
отчетом в BI.
|
|
||||||
|
|
||||||
## Сначала прочитать
|
|
||||||
|
|
||||||
- [«Немного Superset»](../handbook/superset.md);
|
|
||||||
- [задание 3: `dm.session_overview`](1-3-session-overview.md).
|
|
||||||
|
|
||||||
## На какой вопрос ответит график
|
|
||||||
|
|
||||||
В задании 3 вы собрали витрину `dm.session_overview_v`. Теперь покажите, с
|
|
||||||
каких устройств чаще уходят после первой страницы. Постройте столбиковый
|
|
||||||
график доли отказов по `device_category` за весь период витрины. Измерение
|
|
||||||
одно — `device_category`, метрика тоже одна.
|
|
||||||
|
|
||||||
## Что сделать
|
|
||||||
|
|
||||||
Заведите физический датасет в подключении к `ClickHouse`: схема `dm`, объект
|
|
||||||
`session_overview_v`. Назовите по-русски хотя бы две колонки:
|
|
||||||
|
|
||||||
- `device_category` — «Категория устройства»;
|
|
||||||
- `report_date` — «Дата отчета».
|
|
||||||
|
|
||||||
В редакторе датасета, на вкладке «Metrics», создайте метрику:
|
|
||||||
|
|
||||||
| Поле | Значение |
|
|
||||||
|---|---|
|
|
||||||
| `Metric Key` | `bounce_share` — ключ метрики и ее SQL-псевдоним |
|
|
||||||
| `Label` | «Доля отказов» |
|
|
||||||
| `SQL expression` | `SUM(bounce_sessions) / SUM(sessions)` |
|
|
||||||
| `D3 format` | `.1%` |
|
|
||||||
|
|
||||||
Доля считается из сумм слагаемых: среднее готовых долей соврет. Поэтому
|
|
||||||
метрике нужно это выражение. Сохраните ее в датасете, а не в графике:
|
|
||||||
подписи и формат задаются в датасете, а график их использует. Так во всех
|
|
||||||
будущих графиках у метрики будут одинаковые подписи. Это правило описано в
|
|
||||||
[справочнике Superset](../architecture/superset.md#дерево-импорта).
|
|
||||||
|
|
||||||
Не используйте вкладку «Custom SQL» конструктора графика. Созданная там
|
|
||||||
метрика доступна только этому графику, а нужна метрика датасета.
|
|
||||||
|
|
||||||
Создайте столбиковый график с метрикой «Доля отказов». В поле «X-axis»
|
|
||||||
укажите «Категория устройства», а «Dimensions» не заполняйте. Дашборд
|
|
||||||
создавать не нужно: сохраненного графика достаточно.
|
|
||||||
|
|
||||||
Для этого задания оставьте период «No filter»: график и проверочный запрос
|
|
||||||
ниже считают долю отказов за весь период витрины. Относительный период,
|
|
||||||
например «Last week», может дать пустой график: модельный календарь стенда
|
|
||||||
отстает от настоящего. Последнюю дату витрины посмотрите в SQL Lab:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT max(report_date)
|
|
||||||
FROM dm.session_overview_v;
|
|
||||||
```
|
|
||||||
|
|
||||||
Стендовые объекты не меняйте. Что переживает `make up`, а что стирает
|
|
||||||
`make clean`, описано в главе [«Немного Superset»](../handbook/superset.md#стендовые-объекты-и-ваши).
|
|
||||||
|
|
||||||
## Как проверить себя
|
|
||||||
|
|
||||||
Автоматической проверки этого задания нет. Сверьте числа на графике
|
|
||||||
с результатом запроса в SQL Lab:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT device_category,
|
|
||||||
round(100 * sum(bounce_sessions) / sum(sessions), 1) AS bounce_pct
|
|
||||||
FROM dm.session_overview_v
|
|
||||||
GROUP BY device_category
|
|
||||||
ORDER BY device_category;
|
|
||||||
```
|
|
||||||
|
|
||||||
Сверьте с графиком значение для каждой категории. В разделе «Customize»
|
|
||||||
включите «Show Value» или наведите указатель на столбик. Запрос возвращает
|
|
||||||
проценты с одним знаком, как формат `.1%`, поэтому числа должны совпасть
|
|
||||||
один в один.
|
|
||||||
|
|
||||||
## Что должно получиться
|
|
||||||
|
|
||||||
- [ ] у вас есть физический датасет `dm.session_overview_v` в подключении
|
|
||||||
`ClickHouse`;
|
|
||||||
- [ ] в датасете есть метрика «Доля отказов» с выражением
|
|
||||||
`SUM(bounce_sessions) / SUM(sessions)` и форматом `.1%`;
|
|
||||||
- [ ] колонки `device_category` и `report_date` подписаны «Категория
|
|
||||||
устройства» и «Дата отчета»;
|
|
||||||
- [ ] сохраненный столбиковый график показывает долю отказов по категориям
|
|
||||||
устройств за весь период витрины;
|
|
||||||
- [ ] числа на графике совпадают с результатом запроса из SQL Lab;
|
|
||||||
- [ ] вы сохранили график отдельно, без создания дашборда и изменения
|
|
||||||
стендовых объектов.
|
|
||||||
@@ -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`;
|
|
||||||
- [ ] вы объяснили причину одного расхождения и записали куку, день и два
|
|
||||||
события, на которых оно видно;
|
|
||||||
- [ ] уточненный расчет дает ноль расхождений во всех днях.
|
|
||||||
@@ -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()` не нашел строк на чужом шарде;
|
|
||||||
- [ ] повторный прогон без удаления данных пропустил группу сессий.
|
|
||||||
@@ -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 остались прежними.
|
|
||||||
@@ -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()`;
|
|
||||||
- [ ] строки обоих опытов удалены по их маркерам;
|
|
||||||
- [ ] после очистки конвейер и все семь проверок успешны.
|
|
||||||
@@ -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` сохранил деловые данные, проверки прошли.
|
|
||||||
@@ -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 и срок повторения опыта;
|
|
||||||
- [ ] после опыта вы удалили свою таблицу, сохранив объекты и данные стенда.
|
|
||||||
@@ -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 и на панели слепков,
|
|
||||||
подписали модельную дату отдельно от реального времени наблюдений;
|
|
||||||
- [ ] вы можете объяснить выбор слоев для свежести, потока и доли брака
|
|
||||||
и почему числитель и знаменатель берут за одно окно;
|
|
||||||
- [ ] после наблюдений службы продолжают работать, данные и стендовые
|
|
||||||
дашборды остались без изменений.
|
|
||||||
+82
-132
@@ -1,159 +1,109 @@
|
|||||||
# Учебные задания
|
# Учебные задания: витрины слоя DM и дашборд в Superset
|
||||||
|
|
||||||
Программа состоит из двух частей: основная включает семь ступеней, которые вы
|
Пять домашних заданий — про витрины слоя DM. Стенд привозит слой
|
||||||
проходите по порядку, а факультативы выбираете по желанию. Причины такого
|
наполовину: витрины трех сюжетов дашборда и контроль качества построены,
|
||||||
порядка и формы заданий описаны в [программе учебных лаб](../specs/2026-09-01-labs-program.md#2-устройство-программы-основная-программа-и-факультативы).
|
остальные витрины строит менти. Каждое из них — самостоятельное ТЗ: что за
|
||||||
|
витрина, какое у нее зерно, из чего она собирается и как проверить, что
|
||||||
|
собралась верно.
|
||||||
|
|
||||||
## Основная программа
|
Шестое задание — необязательная лаба в Superset. В ней менти собирает свой
|
||||||
|
дашборд поверх пяти витрин.
|
||||||
|
|
||||||
| Ступень | Задание | Чему учит |
|
Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на
|
||||||
|---|---|---|
|
вопросы «почему так», задание отвечает на вопрос «что сделать».
|
||||||
| 1 | Пять витрин слоя DM | построить результат для потребителя хранилища; уроки каждой витрины — в таблице ниже |
|
|
||||||
| 2 | [Свой график в Superset](2-1-superset-chart.md) | превратить свой результат из хранилища в отчет в BI |
|
|
||||||
| 3 | [Нарежьте сессии сами](3-1-sessions-by-hand.md) | сверить число сессий в своей нарезке с эталоном и найти события, из-за которых оно расходится |
|
|
||||||
| 4 | [Перепишите нарезку стенда](4-1-session-pipeline.md) | переписать расчет сессий в готовом конвейере и проверить по исходным событиям |
|
|
||||||
| 5 | [Научите проверки замечать ошибки в заказах](5-1-order-checks.md)<br>[Перепишите разбор заказов в ODS](5-2-order-parsing.md)<br>[Перепишите сборку заказов в DDS](5-3-order-pipeline.md) | написать проверки качества до рискованного изменения, затем переписать разбор и сборку заказов |
|
|
||||||
| 6 | [Разберите сырые события рядом со стендом](6-1-event-parsing.md) | изучить разбор потока отдельно от живого приема и сверить его со стендовым результатом |
|
|
||||||
| 7 | [Мониторинг: куда смотреть](7-1-monitoring.md) | выбрать, куда и на что смотреть в живой системе |
|
|
||||||
|
|
||||||
## Ступень 1: пять витрин слоя 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_` в имени — предупреждение, а не украшение |
|
||||||
|
| 6 | [Свой дашборд в Superset](superset-dashboard.md) — необязательная лаба | свои объекты BI живут отдельно от стендовых |
|
||||||
|
|
||||||
Общее устройство слоя — [справочник DM](../architecture/dm.md). Он отвечает на
|
## Общие правила
|
||||||
вопросы «почему так», задание отвечает на вопрос «что сделать».
|
|
||||||
|
|
||||||
## Как принято на ступени 1
|
Они одни для пяти заданий по витринам, поэтому записаны здесь, а не
|
||||||
|
повторяются в каждом ТЗ.
|
||||||
|
|
||||||
**Читать — через `_v`.** Физические `_rep` и `_dist` служат загрузке и
|
**Читать — через `_v`.** Публичный договор сущности DDS — представление с
|
||||||
обслуживанию, а публичный договор DDS — `dds.event_v` или `dds.session_v` —
|
суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только
|
||||||
остается прежним даже при изменении физических объектов. Чтение DDS
|
его; физические `_rep` и `_dist` служат загрузке и обслуживанию. Соседнюю
|
||||||
напрямую делает задания независимыми: соседняя витрина может быть еще не
|
домашку витрина источником тоже не берет: витрина слоя DM читает договор
|
||||||
построена.
|
DDS, и задания остаются независимыми. Исключение одно и оно оговорено в
|
||||||
Исключение — задание 4: у таблиц брака публичного договора нет.
|
задании 4: у таблиц брака публичного договора нет.
|
||||||
|
|
||||||
**Писать — в `_dist`.** Так строки распределяются по ключу шардирования. При
|
**Писать — только в `_dist`.** При записи в локальную таблицу раскладку
|
||||||
записи в локальную таблицу распределение зависит от узла, выполнившего
|
определяет то, какая нода случайно выполняла код, а не свойство данных.
|
||||||
запрос, и при следующем запуске может измениться. Подробности — раздел
|
Правило и его следствия — [дока хранилища](../architecture/storage.md),
|
||||||
«Раскладка по шардам» [справочника хранилища](../architecture/storage.md).
|
«Раскладка по шардам».
|
||||||
|
|
||||||
**Форма — по зерну.** На стенде агрегат с новым зерном принято хранить
|
**Форма — по зерну.** Витрина, меняющая зерно источника, материализуется:
|
||||||
готовым: потребитель читает его много раз без повторного расчета по
|
`dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` / `_stage_dist` и
|
||||||
источнику. Создайте `dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` /
|
публичное `dm.<имя>_v` с явным списком колонок. Витрина, сохраняющая зерно,
|
||||||
`_stage_dist` и публичное `dm.<имя>_v` с явным списком колонок. При прежнем
|
остается обычным представлением. Довод — [дока DM](../architecture/dm.md),
|
||||||
зерне достаточно представления: таблица дублировала бы данные. Доводы —
|
«Форма: по зерну, не по цене запроса».
|
||||||
раздел «Таблица или представление» [справочника
|
|
||||||
DM](../architecture/dm.md).
|
|
||||||
|
|
||||||
**Ключ шардирования — ваше решение.** Объясните, почему выбранный ключ не
|
**Ключ шардирования — твое решение.** Ни одно ТЗ ключа не называет.
|
||||||
дает существенного перекоса и почему более сложный ничего не добавит.
|
Правило выбора и требование к донору — [дока
|
||||||
Объявите донор через `AS <цель>_rep` с ключом цели: иначе замена партиции
|
хранилища](../architecture/storage.md), «Раскладка по шардам»: донор
|
||||||
потеряет строку на одном шарде и удвоит на другом. Детали — раздел
|
объявляется через `AS <цель>_rep` и шардируется тем же ключом, что цель,
|
||||||
«Раскладка по шардам» [справочника хранилища](../architecture/storage.md).
|
иначе замена партиции потеряет строку на одном шарде и задвоит на другом.
|
||||||
Пример обоснования — `dm.revenue_daily_dist` в
|
Названный ключ надо уметь защитить: почему он не дает перекоса и почему
|
||||||
[`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
|
более сложный тут ничего не добавляет. Образец такой защиты —
|
||||||
|
`dm.revenue_daily_dist` в [`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
|
||||||
|
|
||||||
**Где лежит код.** DDL заданий — в `sql/ddl/70-labs.sql`.
|
**Где лежит код.** DDL домашек — свой файл `sql/ddl/70-labs.sql`: он
|
||||||
При создании мира он применяется после стендовых файлов.
|
применяется после стендовых по порядку имени, и твои объекты не мешаются
|
||||||
В уже работающем стенде сохранение файла и повторный `make up` новый DDL
|
со стендовыми. У агрегатов рядом со стендовыми ложатся еще три запроса —
|
||||||
не применят — выполните его сами, как описано ниже. В
|
`sql/dm/<витрина>_scope.sql` спрашивает у хранилища, какие дни пересобирает
|
||||||
`sql/dm/<витрина>_scope.sql` определяются дни прогона, в `_rebuild.sql` они
|
запуск, `_rebuild.sql` собирает их в донор, `_replace.sql` заменяет
|
||||||
собираются в донор, в `_replace.sql` заменяется партиция. Задачу сборки
|
партицию, — и задача в
|
||||||
добавьте в [`dags/dm_transform.py`](../../dags/dm_transform.py): без нее
|
[`dags/dm_transform.py`](../../dags/dm_transform.py). У разминочного
|
||||||
конвейер не обновит витрину. Представлению достаточно `CREATE VIEW`.
|
представления нет ничего из этого: весь его код — одно `CREATE VIEW`.
|
||||||
|
|
||||||
**Образец — витрина `revenue_daily`.** У нее есть пять объектов, донор,
|
**Образец на все случаи** — витрина `revenue_daily`: у нее есть все пять
|
||||||
сборка, замена дневных партиций и задача в конвейере. Используйте ее как
|
объектов, донор, сборка, замена дневных партиций и задача в конвейере.
|
||||||
образец всего порядка пересборки. Состав колонок берите из своего ТЗ, иначе
|
Списывать с нее устройство можно и нужно; списывать состав колонок — нет,
|
||||||
получите показатели другой витрины.
|
он у каждой витрины свой.
|
||||||
|
|
||||||
## Как применить свой DDL
|
## Самопроверка
|
||||||
|
|
||||||
В DBeaver создайте подключение по [инструкции в README](../../README.md#подключиться-из-dbeaver),
|
Даг `homework_check` запускается рукой, расписания у него нет. Задача
|
||||||
но укажите пользователя `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
|
|
||||||
|
|
||||||
Даг `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.
|
||||||
|
Смотреть туда стоит после своей сборки: сравнить два решения полезно,
|
||||||
Эти задания вы берете по желанию. Полный список сюжетов — в разделе
|
списать одно — нет.
|
||||||
[«Факультативы — пять разделов»](../specs/2026-09-01-labs-program.md#4-факультативы--пять-разделов)
|
|
||||||
программы.
|
|
||||||
|
|
||||||
### Почему выручка не сходится
|
|
||||||
|
|
||||||
Открывается после ступени 1. Расхождения между источниками, повторы
|
|
||||||
покупок и день, после которого отчет можно считать окончательным.
|
|
||||||
|
|
||||||
### Тонкий анализ данных
|
|
||||||
|
|
||||||
Открывается после ступени 1. Знание о пользователе на дату и обогащение
|
|
||||||
отчета своим словарем.
|
|
||||||
|
|
||||||
### Superset и BI
|
|
||||||
|
|
||||||
Открывается после ступени 2. Два самостоятельных задания:
|
|
||||||
|
|
||||||
- [Свой дашборд в Superset](optional/superset-dashboard.md): собрать отчет
|
|
||||||
из своих витрин и подтвердить выводы запросами.
|
|
||||||
- [В DBeaver работает, в Superset нет](optional/superset-missing-object.md):
|
|
||||||
выяснить, почему клиенты по-разному видят один объект хранилища.
|
|
||||||
|
|
||||||
### Инженерия стенда
|
|
||||||
|
|
||||||
Открывается после ступени 1. Прием и хранение данных, раскладка по шардам,
|
|
||||||
повторная обработка дня и обновление витрин.
|
|
||||||
|
|
||||||
### Что делать, когда стенд сломался
|
|
||||||
|
|
||||||
Открывается после ступени 7. Поломки стенда и инструкции по восстановлению
|
|
||||||
по итогам расследований; весь раздел — повышенная сложность.
|
|
||||||
|
|
||||||
## Уборка
|
## Уборка
|
||||||
|
|
||||||
После опытов удаляйте свои временные объекты. Если удалили учебную витрину,
|
Черновики и опыты за собой сносить: правило проверок стенда —
|
||||||
ее задача в `homework_check` должна снова завершиться ошибкой: отсутствие
|
[дока тестирования](../architecture/testing.md). Снесенная витрина обязана
|
||||||
витрины тоже проверяется. Подробнее — в
|
снова покраснить свою задачу в `homework_check` — если не покраснила,
|
||||||
[справочнике проверок](../architecture/testing.md).
|
проверка ничего и не проверяла.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Задание 4: `dm.dq_errors_daily`
|
||||||
|
|
||||||
|
**Чему учит:** у брака свой день и свой срок жизни. Витрина качества живет
|
||||||
|
по времени загрузки, а не по модельному дню, и переживает собственный
|
||||||
|
источник — поэтому «пересобрать все» здесь означает «стереть историю».
|
||||||
|
|
||||||
|
## Роль витрины
|
||||||
|
|
||||||
|
Сводка приемки: сколько строк за день не прошли разбор и по какой причине.
|
||||||
|
Читают ее, когда качество потока просело: сначала смотрят, вырос ли класс
|
||||||
|
брака, потом идут за сырым текстом в саму таблицу ошибок.
|
||||||
|
|
||||||
|
Витрина отвечает на вопрос «сколько и чего», а не «что именно сломалось».
|
||||||
|
Разбирательство с конкретной строкой начинается в `ods.event_errors_dist` и
|
||||||
|
`ods.order_errors_dist`, где рядом с классом лежат сырой текст и координаты
|
||||||
|
доставки.
|
||||||
|
|
||||||
|
## Источник: единственное исключение из правила `_v`
|
||||||
|
|
||||||
|
Общее правило домашек — читать через публичный договор `_v`. У таблиц брака
|
||||||
|
такого договора нет, и заводить его ради этой витрины не надо: брак — не
|
||||||
|
сущность бизнес-модели, а диагностика слоя приема. Читать нужно
|
||||||
|
распределенные таблицы напрямую:
|
||||||
|
|
||||||
|
- `ods.event_errors_dist` — брак разбора событий;
|
||||||
|
- `ods.order_errors_dist` — брак разбора слепка заказов.
|
||||||
|
|
||||||
|
Именно `_dist`, а не `_rep`: локальная таблица вернет строки одного шарда, и
|
||||||
|
витрина тихо покажет половину брака. Ошибки при этом не будет — ошибка
|
||||||
|
появится позже, когда по витрине примут решение.
|
||||||
|
|
||||||
|
## Зерно и форма
|
||||||
|
|
||||||
|
Зерно — **день загрузки × источник × класс брака**. Зерно источника —
|
||||||
|
строка брака, значит зерно сменилось и витрина материализуется:
|
||||||
|
`dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
|
||||||
|
публичное `dm.dq_errors_daily_v`. Ключ партиции — день загрузки.
|
||||||
|
|
||||||
|
День здесь — `toDate(_load_ts)`, то есть настоящее время, когда строка
|
||||||
|
приехала в хранилище, а не модельный день мира. Модельного дня у брака нет
|
||||||
|
и быть не может: он живет в содержимом, а содержимое как раз и не
|
||||||
|
разобралось.
|
||||||
|
|
||||||
|
## Состав колонок
|
||||||
|
|
||||||
|
| Колонка | Тип | Что это |
|
||||||
|
|---|---|---|
|
||||||
|
| `load_date` | `Date` | день загрузки, в который приехал брак |
|
||||||
|
| `source` | `LowCardinality(String)` | `event` или `order` |
|
||||||
|
| `error_class` | `LowCardinality(String)` | класс брака из таблицы ошибок |
|
||||||
|
| `error_rows` | `UInt64` | число забракованных строк |
|
||||||
|
| `_load_id` | `String` | запуск, собравший день |
|
||||||
|
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||||
|
|
||||||
|
Две похожие колонки рядом различаются намеренно: `load_date` — про день,
|
||||||
|
когда приехал брак, `_load_ts` — про запуск, который собрал витрину. Первая
|
||||||
|
деловая, вторая служебная, и совпадать они не обязаны.
|
||||||
|
|
||||||
|
Классы у двух источников свои и не пересекаются по смыслу, поэтому колонка
|
||||||
|
`source` в зерне обязательна: без нее одинаковое имя класса у событий и у
|
||||||
|
заказов слилось бы в одну строку.
|
||||||
|
|
||||||
|
## Ритм пересборки: витрина переживает источник
|
||||||
|
|
||||||
|
Таблицы брака живут месяц (`TTL`), сырые данные — трое суток. Витрина живет без
|
||||||
|
срока: сводка «сколько брака было в марте» нужна и после того, как сами строки
|
||||||
|
марта удалены.
|
||||||
|
|
||||||
|
Отсюда правило прогона: пересобираются **только те дни загрузки, которые
|
||||||
|
еще есть в источнике**. Спроси у источника список живых дней и замени
|
||||||
|
партиции ровно этих дней. Соблазн «собрать все заново в донор и подменить
|
||||||
|
таблицу целиком» здесь стоит дороже обычного: истекшие по `TTL` дни исчезли
|
||||||
|
бы из витрины навсегда, а вместе с ними — единственная оставшаяся память о
|
||||||
|
них.
|
||||||
|
|
||||||
|
Текущий день загрузки не замерзает до конца суток: поток событий идет, и
|
||||||
|
брак в него еще добавится. Это нормально, день просто пересоберется на
|
||||||
|
следующем прогоне.
|
||||||
|
|
||||||
|
## Ключ шардирования
|
||||||
|
|
||||||
|
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||||
|
шардам». Витрина крошечная: классов брака у событий и заказов вместе меньше
|
||||||
|
десятка. Учти это в защите ключа — иногда правильный ответ звучит как
|
||||||
|
«перекос здесь ничего не стоит, и вот почему».
|
||||||
|
|
||||||
|
## Как проверить себя
|
||||||
|
|
||||||
|
Деловой ключ — пара `source` и `error_class` внутри `load_date`. Область
|
||||||
|
проверки — дни загрузки, которые еще живы хотя бы в одной таблице ошибок:
|
||||||
|
день за границей `TTL` сверять не с чем, и его отсутствие в источнике не
|
||||||
|
ошибка.
|
||||||
|
|
||||||
|
Собери прямой счет по обеим таблицам ошибок, соедини с
|
||||||
|
`dm.dq_errors_daily_v` полным внешним соединением по ключу и посчитай
|
||||||
|
строки, где ключа нет с одной из сторон или где расходится `error_rows`.
|
||||||
|
Ноль — зеленый.
|
||||||
|
|
||||||
|
Отдельно: сумма `error_rows` по живым дням равна общему числу строк в
|
||||||
|
`ods.event_errors_dist` плюс `ods.order_errors_dist`. Если эта сумма вышла
|
||||||
|
примерно вдвое меньше — витрина почти наверняка собрана с `_rep` вместо
|
||||||
|
`_dist` и видит один шард из двух.
|
||||||
|
|
||||||
|
**Пустая витрина — верный ответ.** На чистом стенде брака нет, обе стороны
|
||||||
|
пусты, расхождений ноль, задача зеленая. Проверка, которая требует хотя бы
|
||||||
|
одной строки, на здоровом стенде краснела бы всегда и приучала бы себя
|
||||||
|
игнорировать.
|
||||||
|
|
||||||
|
## Приемка
|
||||||
|
|
||||||
|
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||||
|
ключом цели;
|
||||||
|
- [ ] источники — `_dist`-таблицы обоих видов брака;
|
||||||
|
- [ ] прогон заменяет партиции только живых дней источника и не трогает
|
||||||
|
истекшие;
|
||||||
|
- [ ] задача `dq_errors_daily` в `homework_check` зеленая — в том числе на
|
||||||
|
чистом стенде, где брака нет;
|
||||||
|
- [ ] задача витрины добавлена в `dm_transform`.
|
||||||
@@ -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,113 +0,0 @@
|
|||||||
# Свой дашборд в Superset
|
|
||||||
|
|
||||||
**Чему учит:** собрать из своих витрин отчет о поведении посетителей,
|
|
||||||
подобрать графики под вопросы и разместить их на одном понятном экране.
|
|
||||||
|
|
||||||
Это факультатив раздела «Superset и BI». Его можно пройти после ступени 2,
|
|
||||||
когда готовы витрины ступени 1.
|
|
||||||
|
|
||||||
## На какие вопросы ответит дашборд
|
|
||||||
|
|
||||||
Соберите обзор трафика и поведения посетителей. Он должен отвечать на три
|
|
||||||
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
|
|
||||||
каналы дали результат, были ли ошибки приема данных.
|
|
||||||
|
|
||||||
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
|
|
||||||
соберите из пяти витрин, которые вы построили в заданиях. Копировать раскладку
|
|
||||||
стендового дашборда не нужно: выберите форму, в которой ответы читаются без
|
|
||||||
пояснений рядом с экраном.
|
|
||||||
|
|
||||||
## Свои объекты — отдельно
|
|
||||||
|
|
||||||
Не добавляйте графики в стендовый дашборд и не правьте его датасеты. При
|
|
||||||
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
|
|
||||||
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
|
|
||||||
таких объектов описан в [доке Superset](../../architecture/superset.md), «Импорт
|
|
||||||
при каждой инициализации».
|
|
||||||
|
|
||||||
Используйте свои датасеты и графики, соберите из них свой дашборд.
|
|
||||||
Назовите его, например, «Мои витрины DM». Это то же правило, по которому DDL
|
|
||||||
домашек лежит в `sql/ddl/70-labs.sql`: стендовые объекты уже есть, свои
|
|
||||||
создаете вы.
|
|
||||||
|
|
||||||
## Датасеты
|
|
||||||
|
|
||||||
Датасет `session_overview_v`, его метрика доли отказов и график уже готовы
|
|
||||||
после [ступени 2](../2-1-superset-chart.md). Используйте их здесь. Добавьте
|
|
||||||
недостающие датасеты, чтобы в подключении `ClickHouse` были представлены
|
|
||||||
пять публичных представлений схемы `dm`:
|
|
||||||
|
|
||||||
- `events_enriched_v`;
|
|
||||||
- `top_pages_daily_v`;
|
|
||||||
- `session_overview_v`;
|
|
||||||
- `dq_errors_daily_v`;
|
|
||||||
- `utm_effectiveness_v`.
|
|
||||||
|
|
||||||
## Состав дашборда
|
|
||||||
|
|
||||||
Каждая из пяти витрин должна появиться хотя бы в одном графике. Точный тип
|
|
||||||
графика выберите сами. На дашборде покажите следующее:
|
|
||||||
|
|
||||||
| Витрина | Что показать |
|
|
||||||
|---|---|
|
|
||||||
| `events_enriched_v` | число событий по часу и типу события |
|
|
||||||
| `top_pages_daily_v` | страницы с просмотрами, посетителями и добавлениями в корзину |
|
|
||||||
| `session_overview_v` | сравнение устройств по визитам, отказам и визитам с покупкой |
|
|
||||||
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
|
|
||||||
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
|
|
||||||
|
|
||||||
Для долей используйте слагаемые из витрин. Например, доля отказов считается
|
|
||||||
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
|
|
||||||
долям для такого графика не подходит.
|
|
||||||
|
|
||||||
Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
|
|
||||||
|
|
||||||
## Период отчета
|
|
||||||
|
|
||||||
Выберите явный период из модельного календаря и задайте его в графиках
|
|
||||||
поведения посетителей. Последнюю доступную дату посмотрите в своих витринах.
|
|
||||||
Относительный период вроде `Last week` отсчитывается от настоящего времени
|
|
||||||
и может дать пустой экран.
|
|
||||||
|
|
||||||
Ошибки приема считаются по `load_date`, дню загрузки, а поведение посетителей
|
|
||||||
по модельному времени. Для панели ошибок выберите период загрузки отдельно
|
|
||||||
и укажите его в подписи. Общий фильтр времени для всего дашборда не требуется.
|
|
||||||
|
|
||||||
## Как проверить себя
|
|
||||||
|
|
||||||
Автоматической проверки этого задания нет. Числа вы сверите запросами,
|
|
||||||
а понятность графиков проверите по подписям.
|
|
||||||
|
|
||||||
Откройте список дашбордов и проверьте:
|
|
||||||
|
|
||||||
- «Бизнес-сюжеты» остался отдельным стендовым дашбордом;
|
|
||||||
- ваш дашборд открывается без ошибок и содержит график от каждого из пяти
|
|
||||||
датасетов;
|
|
||||||
- подписи, единицы измерения и легенды читаются без обращения к SQL;
|
|
||||||
- прямой трафик не исчез из графика UTM, а заявленная выручка не выдана за
|
|
||||||
серверную;
|
|
||||||
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
|
|
||||||
|
|
||||||
По готовому экрану запишите три коротких вывода за выбранный период:
|
|
||||||
|
|
||||||
- какой рекламный канал дал больше покупок;
|
|
||||||
- какая страница получила больше просмотров;
|
|
||||||
- у какой категории устройств выше доля отказов.
|
|
||||||
|
|
||||||
Для каждого вывода укажите период и число, на которое опираетесь. Проверьте
|
|
||||||
его запросом к своей витрине в SQL Lab с тем же периодом и способом расчета.
|
|
||||||
Сохраните запросы рядом с выводами. При равенстве значений назовите всех
|
|
||||||
лидеров. Если данных нет, это тоже результат: подтвердите отсутствие строк
|
|
||||||
запросом.
|
|
||||||
|
|
||||||
Проверьте, можно ли найти эти ответы по одним подписям графиков. Если есть
|
|
||||||
возможность, попросите другого человека прочитать экран без ваших пояснений.
|
|
||||||
|
|
||||||
## Что должно получиться
|
|
||||||
|
|
||||||
- [ ] у вас есть пять датасетов поверх публичных представлений витрин заданий 1–5;
|
|
||||||
- [ ] вы собрали свой дашборд, сохранив стендовые объекты без изменений;
|
|
||||||
- [ ] на дашборде есть хотя бы один график от каждой витрины;
|
|
||||||
- [ ] вы проверили, что графики и подписи понятны по вопросам из раздела выше;
|
|
||||||
- [ ] вы записали три вывода и сверили числа с SQL; в отчете указаны периоды
|
|
||||||
поведения посетителей и ошибок приема.
|
|
||||||
@@ -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; оба клиента возвращают столько же строк,
|
|
||||||
сколько исходная витрина;
|
|
||||||
- [ ] после опыта вы удалили учебное представление с обеих нод.
|
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Задание 6 (необязательное): свой дашборд в Superset
|
||||||
|
|
||||||
|
**Чему учит:** довести свои витрины до потребителя — завести в Superset
|
||||||
|
датасеты поверх публичных представлений, подобрать графики под вопросы и
|
||||||
|
собрать из них один связный экран.
|
||||||
|
|
||||||
|
Эта лаба идет после заданий 1–5 и опирается на их результат. Ее можно
|
||||||
|
пропустить: основной предмет стенда — ClickHouse, а здесь вся работа — в
|
||||||
|
интерфейсе BI.
|
||||||
|
|
||||||
|
## Роль дашборда
|
||||||
|
|
||||||
|
Собери обзор трафика и поведения посетителей. Он должен отвечать на три
|
||||||
|
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
|
||||||
|
каналы дали результат, были ли ошибки приема данных.
|
||||||
|
|
||||||
|
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
|
||||||
|
собери из пяти витрин, которые ты построил в заданиях. Копировать раскладку
|
||||||
|
стендового дашборда не нужно: выбери форму, в которой ответы читаются без
|
||||||
|
пояснений рядом с экраном.
|
||||||
|
|
||||||
|
## Свои объекты — отдельно
|
||||||
|
|
||||||
|
Не добавляй графики в стендовый дашборд и не правь его датасеты. При
|
||||||
|
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
|
||||||
|
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
|
||||||
|
таких объектов описан в [доке Superset](../architecture/superset.md), «Импорт
|
||||||
|
при каждой инициализации».
|
||||||
|
|
||||||
|
Заведи отдельные датасеты и графики, собери из них свой дашборд. Назови его,
|
||||||
|
например, «Мои витрины DM». Это то же правило, по которому DDL домашек лежит
|
||||||
|
в `sql/ddl/70-labs.sql`: стенд привозит свои объекты, менти создает свои.
|
||||||
|
|
||||||
|
## Датасеты
|
||||||
|
|
||||||
|
Заведи в подключении `ClickHouse` пять физических датасетов схемы `dm`:
|
||||||
|
|
||||||
|
- `events_enriched_v`;
|
||||||
|
- `top_pages_daily_v`;
|
||||||
|
- `session_overview_v`;
|
||||||
|
- `dq_errors_daily_v`;
|
||||||
|
- `utm_effectiveness_v`.
|
||||||
|
|
||||||
|
## Состав дашборда
|
||||||
|
|
||||||
|
Каждая из пяти витрин должна появиться хотя бы в одном графике. Точный тип
|
||||||
|
графика выбери сам. На дашборде покажи следующее:
|
||||||
|
|
||||||
|
| Витрина | Что показать |
|
||||||
|
|---|---|
|
||||||
|
| `events_enriched_v` | число событий по часу и типу события |
|
||||||
|
| `top_pages_daily_v` | страницы с просмотрами, посетителями и добавлениями в корзину |
|
||||||
|
| `session_overview_v` | сравнение устройств по визитам, отказам и визитам с покупкой |
|
||||||
|
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
|
||||||
|
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
|
||||||
|
|
||||||
|
Для долей используй слагаемые из витрин. Например, доля отказов считается
|
||||||
|
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
|
||||||
|
долям для такого графика не подходит — урок задания 3 остается в силе.
|
||||||
|
|
||||||
|
Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
|
||||||
|
|
||||||
|
## Как проверить себя
|
||||||
|
|
||||||
|
Эта лаба проверяется глазами. У расположения панелей и понятных подписей нет
|
||||||
|
счетного инварианта, поэтому задачи для нее в `homework_check` нет.
|
||||||
|
|
||||||
|
Открой список дашбордов и проверь:
|
||||||
|
|
||||||
|
- «Бизнес-сюжеты» остался отдельным стендовым дашбордом;
|
||||||
|
- твой дашборд открывается без ошибок и содержит график от каждого из пяти
|
||||||
|
датасетов;
|
||||||
|
- подписи, единицы измерения и легенды читаются без обращения к SQL;
|
||||||
|
- прямой трафик не исчез из графика UTM, а заявленная выручка не выдана за
|
||||||
|
серверную;
|
||||||
|
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
|
||||||
|
|
||||||
|
Последний проход сделай как читатель: найди на экране самый заметный канал,
|
||||||
|
страницу и устройство, затем проверь, понятно ли из подписей, что именно
|
||||||
|
сравнивается. Если ответ приходится угадывать, поправь график или его название.
|
||||||
|
|
||||||
|
## Приемка
|
||||||
|
|
||||||
|
- [ ] пять датасетов читают публичные представления витрин заданий 1–5;
|
||||||
|
- [ ] создан отдельный дашборд, стендовые объекты не изменены;
|
||||||
|
- [ ] каждая витрина представлена хотя бы одним графиком;
|
||||||
|
- [ ] дашборд проверен глазами по разделу выше.
|
||||||
@@ -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`, повторный прогон конвейера
|
||||||
|
результат не меняет.
|
||||||
@@ -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`.
|
||||||
@@ -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`.
|
||||||
Форма: построить свое.
|
Форма: построить свое.
|
||||||
Проверка: глазами — счетного инварианта у раскладки панелей нет.
|
Проверка: глазами — счетного инварианта у раскладки панелей нет.
|
||||||
|
|
||||||
@@ -276,4 +276,4 @@
|
|||||||
|
|
||||||
STG и прием событий нельзя отдавать в форме «удали и перепиши»: код там служит несущей частью стенда. Для этих участков подходят расследования и форма «собери рядом».
|
STG и прием событий нельзя отдавать в форме «удали и перепиши»: код там служит несущей частью стенда. Для этих участков подходят расследования и форма «собери рядом».
|
||||||
|
|
||||||
У всех счетных проверок остается общая дыра — раскладка по шардам. Поэтому пересчет ключа по `hostName()` должен входить в ТЗ каждого вычитания отдельной строкой.
|
У всех счетных проверок остается общая дыра — раскладка по шардам. Поэтому пересчет ключа по `hostName()` должен входить в ТЗ каждого вычитания отдельной строкой.
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user