24 Commits
Author SHA1 Message Date
ddadmin 453a1d6ddf docs(handbook): упрощен язык путеводителя
Зачем: начинающим ученикам трудно читать главы без пояснений терминов
и ожидаемого результата примеров.

Что: уточнены заголовки и объяснения в пяти главах и вводной, добавлена
сверка графика с SQL, обновлены ссылки из заданий и документации генератора.

Проверка: SQL и команды сохранены, локальные ссылки проверены;
git diff --check пройден. Ревью смысла Sol 6.1 high замечаний не выявило,
новые пояснения дополнительно сверены с кодом и настройками.
2026-10-18 19:59:21 +02:00
ddadmin 9394b789e5 docs(labs): исправлены две ошибки согласования
- Зачем: после замены терминов осталось неверное согласование слов.
- Что: исправлены обороты «узла, который выполнил» и «число ... не изменилось».
- Проверка: проверены diff, сохранность SQL и команд, таблиц состава колонок и локальных ссылок; git diff --check прошел.
2026-10-18 18:16:26 +02:00
ddadmin 4693bc5af9 docs(labs): упрощен язык учебных заданий
- Зачем: начинающим трудно читать задания из-за необъясненных терминов и неестественных формулировок.
- Что: переработаны вводная и 21 задание, названы темы разделов, объяснены термины и сокращены повторы; согласованы правила учебного текста и ссылки.
- Проверка: сохранены 25 блоков SQL и команд и таблицы состава колонок; проверены 153 локальные ссылки и 32 входящие ссылки на разделы; git diff --check прошел.
2026-10-18 18:09:43 +02:00
ddmitry 3c3eb825e1 Merge pull request 'docs(labs): история связей и инкрементальное обновление витрины' (#256) from labs/identity-incremental into main
Reviewed-on: #256
2026-09-22 15:30:25 +03:00
ddadmin d6b172e511 docs(labs): уточнены формулировки и шаги работы со связями
Зачем: сделать действия в двух лабораторных понятными студенту без
дополнительной расшифровки названий и временных обозначений.

Что: переименована первая работа, абстрактные заголовки заменены
конкретными действиями. Уточнены выбор копии списка связей и обновление
даты из журнала перед повтором загрузки. Выводы требуют сравнения данных.

Проверка: последовательное чтение глазами студента и независимая проверка
по исходникам; 81 локальная ссылка, неизменность SQL-блоков, diff --check.
2026-09-22 06:15:04 -04:00
ddadmin b839a671a1 docs(labs): добавлены история связей и инкрементальный пересчет
Зачем: дать практику учета новых связей пользователей при обновлении
прежних дней витрины.

Что: добавлены две связанные лабораторные с наблюдениями через Airflow,
собственными таблицами student и повтором частично записанной загрузки.
Обновлены программа, индекс и ссылки из справочников DDS и DM.

Проверка: DDL и SQL под student на ClickHouse 26.3.17.56, четыре модельных
дня через Airflow, полные сверки и повтор незавершенной загрузки;
config-test, lint, локальные ссылки и независимое ревью.
2026-09-22 05:57:11 -04:00
ddmitry c1d3a6db9b feat(labs): добавлены четыре расследования выручки (#255)
Зачем: дать учащемуся практику проверки полноты, повторов и изменений данных для витрин.

Что: добавлены четыре лабораторные и учебная учетная запись для сохранения наблюдений.

Проверка: SQL и права student на стенде, восемь модельных дней через Airflow, config-test, lint и ссылки.
2026-09-22 12:13:33 +03:00
ddadmin bb8605eed7 feat(labs): добавлено сохранение наблюдений под учебной учеткой
Зачем: студенту нужен понятный путь от сохраненного состояния к SQL-сравнению без доступа технической учетной записи к изменению хранилища.

Что: добавлен student с чтением источников и записью только в default; лабораторная сохраняет заказы и итоги в две локальные таблицы. Обновлены настройка доступа и программа, уточнено округление productPrice.

Проверка: DDL и INSERT SELECT под student, пустое наблюдение, восемь переходов сохраненной истории и запрет вставки в пять прикладных баз на обеих нодах. config-test, lint стенда и генератора, 54 ссылки, независимое ревью.
2026-09-22 04:56:45 -04:00
ddadmin 3e9249933b docs(labs): расследования выручки привязаны к задачам DE
Зачем: результатом лабораторных должны быть обоснованные решения о подготовке данных для витрин.

Что: добавлены проверяемые результаты сверки полноты, выбора ключа, точного разбора и правила пересчета; обновлены программа и индекс, убран подсчет долей потерь.

Проверка: SELECT-запросы под analyst, расчеты по сохраненной истории девяти наблюдений, 54 локальные ссылки, git diff --check и независимое техническое ревью.
2026-09-22 04:28:27 -04:00
ddadmin bb087f4477 docs(labs): уточнены вопросы для самостоятельного расследования
Зачем: учащемуся нужен понятный путь от наблюдений к выводу без готового ответа в задании.

Что: уточнены сравнения в четырех расследованиях; готовые объяснения заменены вопросами к строкам, расчетам и сохраненным состояниям.

Проверка: проверены 54 локальные ссылки и git diff --check; формулировки сверены с устройством источников и витрин.
2026-09-22 04:18:05 -04:00
ddadmin 515f386e5f docs(labs): добавлено наблюдение за выручкой до закрытия дня
Зачем: связать опоздания и отмены с изменением уже рассчитанного отчета.
Что: лабораторная с сохранением состояний заказов, ссылки в индексе и программе; убраны повторы в описании раздела.
Проверка: восемь однодневных world_next_day и etl_pipeline на Airflow 3.3.0; изменения сумм сошлись до копейки, закрытый день неизменен, DQ пройдены. Проверены 54 локальные ссылки, SQL-примеры и git diff --check.
2026-09-22 03:58:42 -04:00
ddadmin 2502277af1 docs(labs): добавлены три расследования расхождений выручки
Зачем: дать практику сверки событий покупки с заказами магазина.
Что: задания о потерях событий, повторах покупок и расхождении состава корзин.
Проверка: запросы под analyst на ClickHouse 26.3.17.56; счетчики, суммы и точный разбор JSON сошлись. make lint и независимая техническая проверка пройдены.
2026-09-22 03:46:45 -04:00
ddadmin ddb186f841 docs(labs): согласованы четыре расследования выручки
Зачем: превратить шесть идей факультативов в связный раздел из четырех работ.
Что: определены результаты работ и наблюдение за заказами через Airflow.
Проверка: git diff --check; порядок обновления данных сверен с кодом дагов.
2026-09-22 03:32:16 -04:00
ddadmin 090df8a715 docs(agents): убрано устаревшее распределение авторства
Зачем: оценка моделей изменилась, назначение Opus автором текстов устарело.
Что: автор задачи сам пишет и редактирует материалы, если владелец не назначил иное.
Проверка: git diff --check; проверены AGENTS.md, CLAUDE.md и docs/agents.
2026-09-22 03:32:12 -04:00
ddmitry 7a002f4d5a Merge pull request 'docs(ui): уточнены подсказки и сообщения учебного стенда' (#254) from 251-ui-texts into main 2026-09-14 09:08:40 +03:00
ddadmin d15f4fe4d8 docs(ui): уточнены подсказки и сообщения учебного стенда
Зачем: до запуска менти должен понимать действие параметров, а после
ошибки проверки — где искать причину.

Что: уточнены тексты Airflow, самопроверки, DQ и run-dag.sh, подписи
Superset и Grafana. Логика, запросы, метрики и идентификаторы сохранены.

Проверка: make lint, git diff --check, bash -n; разбор YAML/JSON,
сравнение структуры Python и конфигурации, искусственные примеры ошибок.
Airflow и Grafana проверены через локальные API, Superset — по YAML.
Визуальная проверка интерфейсов не выполнена: браузер недоступен.
2026-09-13 16:41:53 -04:00
ddadmin 431fed72ad docs(ingestion): влита редактура приема данных (#250)
Зачем: включить принятую редактуру материалов ступеней 5–6 в main.
Что: объединена ветка 250-ingestion-docs после просмотра владельцем.
Проверка: make lint, ссылки и неизменность исполняемого кода проверены
до приемки; git diff --check прошел перед коммитом.

Closes #250
2026-09-13 16:32:57 -04:00
ddadmin 75f28fe99f docs(ingestion): упрощены объяснения источников и приема данных
Зачем: помочь начинающему инженеру проследить событие и версию заказа
от генератора до хранилища, включая ошибки и повторные поступления.

Что: обновлены вводная генератора, справочники приема и модели заказа,
разделы хранилища и комментарии SQL и дага. Сохранены технические условия;
исполняемый код, схемы и поведение стенда не изменены.

Проверка: make lint, git diff --check, 142 локальные ссылки и справка CLI.
SQL без комментариев и Python AST без строк документации совпали
с исходными. Генерация данных и сброс состояния не выполнялись.

Refs #250
2026-09-13 16:32:45 -04:00
ddmitry 83434102d5 docs(warehouse): упрощены объяснения витрин, сессий и пересчета (#253) 2026-09-13 22:22:40 +03:00
ddadmin 086007fbd4 docs(warehouse): объяснены расчет строк и замена дневных разделов
Зачем: выражения «собираются в донор» и «публикуются дни» требовали
знать устройство конвейера до чтения объяснения.

Что: описаны назначение промежуточных таблиц и последовательность
расчета, записи и замены разделов. Перечитаны остальные объяснения:
выбор дней, разворачивание позиций заказа, проверка сессий и повтор запуска.

Проверка: make lint, git diff --check, локальные ссылки и якоря.
SQL без комментариев и AST обоих дагов без строк документации не изменились.

Refs: #249
2026-09-13 15:16:26 -04:00
ddadmin 5f317ba8d2 docs(warehouse): упрощены объяснения витрин, сессий и пересчета
Зачем: читателю ступеней 1–4 нужно проследить выбор дней, расчет строки
и публикацию результата без расшифровки метафор.

Что: согласованы справочники DM, сессий, ETL и два раздела хранилища
с комментариями шести SQL-примеров. Уточнены ограничения повторного
запуска и выбора атрибутов при равном времени событий.

Проверка: make lint, git diff --check, локальные Markdown-ссылки и якоря.
SQL без комментариев совпадает; AST Python без строк документации совпадает.
Исторические разделы и материалы вне границ задачи сохранены.

Refs: #249
2026-09-13 14:33:39 -04:00
ddmitry 84dc718f62 docs(context): влита редактура словаря (#252) 2026-09-13 21:00:12 +03:00
ddadmin 5a3e3f92d9 docs(context): упрощен словарь понятий для начинающего
Зачем: понимать термины стенда без изучения устройства генератора.
Что: понятия сгруппированы по учебным темам, определения упрощены;
уточнены идентификаторы, модельное время и повторный запуск стенда.
Проверка: сохранены все 43 прежних термина; проверены 11 входящих
и 6 внутренних ссылок, якорь «Язык» и git diff --check.
2026-09-13 13:59:37 -04:00
ddadmin f3373f1e8f docs(map): упрощен язык интерактивной карты хранилища
Зачем: помочь учащемуся понять назначение объектов и обработку данных по стрелкам карты.

Что: вычитаны карточки и подписи, пояснены термины, удалены повторы. Описания обработки уточнены по SQL, HTML пересобран.

Проверка: сохранены 17 объектов и 17 стрелок, проверены 85 ссылок. В Chromium проверены карточки, фильтры, подсветка, Escape и отображение при ширине 1920 и 1280 пикселей. make lint и git diff --cached --check прошли.
2026-09-13 13:20:58 -04:00
79 changed files with 3623 additions and 2832 deletions

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ CLICKHOUSE_ETL_PASSWORD=etl-26-writer
CLICKHOUSE_LIFECYCLE_PASSWORD=lifecycle-26-owner CLICKHOUSE_LIFECYCLE_PASSWORD=lifecycle-26-owner
CLICKHOUSE_BI_PASSWORD=bi-26-reader CLICKHOUSE_BI_PASSWORD=bi-26-reader
CLICKHOUSE_ANALYST_PASSWORD=analyst-26-reader CLICKHOUSE_ANALYST_PASSWORD=analyst-26-reader
CLICKHOUSE_STUDENT_PASSWORD=student-26-lab
CLICKHOUSE_GRAFANA_PASSWORD=grafana-26-reader CLICKHOUSE_GRAFANA_PASSWORD=grafana-26-reader
KAFKA_EXTERNAL_PORT=29092 KAFKA_EXTERNAL_PORT=29092
PROMETHEUS_PORT=29090 PROMETHEUS_PORT=29090
+3 -8
View File
@@ -46,15 +46,10 @@
## Кому что поручать ## Кому что поручать
Читатель здесь не побочный потребитель, а тот, ради кого стенд существует: Автор задачи сам пишет и редактирует код, комментарии и документы, если
код, комментарии и документы и есть продукт. Поэтому всё, что человек будет владелец не задал другое распределение работы.
читать и разбирать, пишет модель с чувством меры — сейчас это Opus. Codex
берёт техническую работу без вкусовых решений: миграции, разбор журналов и
объёмного вывода, обход репозитория, повторяющиеся правки. Там его запас
лимитов работает на нас, а экономить их на читаемом коде — экономия ложная:
переписывать выйдет дороже, чем сразу написать хорошо.
Делить работу между ними стоит не целыми задачами, а подзадачами, и решать Делить работу стоит не целыми задачами, а подзадачами, и решать
это при планировании. Вкусовая часть идёт первой: она задаёт форму, имена и это при планировании. Вкусовая часть идёт первой: она задаёт форму, имена и
границы, под которые механическая потом подстраивается. границы, под которые механическая потом подстраивается.
+247 -201
View File
@@ -1,229 +1,275 @@
# Кликстрим-платформа (стенд v2) # Кликстрим-платформа (стенд v2)
Словарь понятий проекта: одни и те же слова для одних и тех же вещей — Словарь понятий, которые встречаются в учебных заданиях, документации и коде
у владельца, кода, документов и агентов. Только язык, никаких решений. стенда.
## Язык ## Язык
**Хранилище**: - [Люди и события](#люди-и-события)
Аналитическая база стенда — кластер ClickHouse со слоями STG/ODS/DDS/DM. - [Хранилище и форматы данных](#хранилище-и-форматы-данных)
Принимающая сторона границы «трекер | хранилище»: нормализует имена и стили - [Заказы](#заказы)
источников, строится по их документации. - [Модельное время и запуск стенда](#модельное-время-и-запуск-стенда)
_Избегать_: склад, склад данных - [Устройство генератора](#устройство-генератора) — для чтения его кода
и технических справочников.
**Сырые данные**: ### Люди и события
Слой STG хранилища и его содержимое: сообщение топика, положенное строкой,
как пришло, без разбора. В прозе — сырые данные и сырой слой; про
конкретную таблицу или базу — STG.
_Избегать_: сырьё
**Состав мира**:
Постоянная часть мира генератора — популяция посетителей, их привычки,
двухкуковые пары. По дням его выдаёт план состава.
_Избегать_: состояние мира
**План состава**:
Способ спросить состав мира: функция зерна, выдающая его по дням —
когорту новых кук, их возвраты, назначенные заказы двухкуковых пар.
**Подпоток**:
Ветвь дерева случайности генератора: своё зерно у состава мира, у каждого
дня и у каждого компонента дня. Подпоток задан позицией в дереве, а не
порядком вычислений.
**Конфигурация мира**:
Модуль чистых данных со всеми числами мира: приток, профили возвратов, доли
покупателей, D0. Правка модуля — смена мира. Модуль-близнец контракта схемы:
там колонки, здесь числа.
**Когорта дня**:
Люди, впервые пришедшие в мир в один день, со всеми их куками и днями
активности. Единица плана состава: когорта — функция зерна и номера дня.
**Человек**: **Человек**:
Устойчивая личность в составе мира, которой принадлежат одна или две куки. Участник модели магазина, который может заходить с одного или двух
Не поле источника: кликстрим видит куку, а заказ — пользователя магазина. устройств. Генератор знает, какие идентификаторы посетителя ему принадлежат,
но в событиях сайта этой связи нет.
**Посетитель**: **Посетитель**:
Кука, наблюдаемая в кликстриме и обозначенная `ClientID`; единица Браузер на устройстве, который счетчик сайта узнает по куке — сохраненному
`uniq(ClientID)`. Один человек может быть представлен несколькими посетителями. в браузере идентификатору. В кликстриме он обозначается `ClientID`:
_Избегать_: «посетитель» про человека после склейки `uniq(ClientID)` считает посетителей, и у одного человека их может быть несколько.
_Избегать_: «посетитель» про человека после объединения его идентификаторов.
**Пользователь магазина**: **Пользователь магазина**:
Представление человека со стороны заказов; в записи заказа обозначается Человек, известный серверной части магазина под идентификатором `user_id`.
`user_id`. Через заказы связывает разные куки человека, в кликстрим напрямую Он указан в заказах и позволяет связать разные `ClientID` одного человека;
не попадает. в кликстриме поля `user_id` нет. Например, покупки с телефона и ноутбука
могут иметь разные `ClientID`, но один `user_id` в заказах.
**Приток**:
Появление новых кук на всём протяжении оси модельного времени; единица —
кука (`ClientID`). Из-за притока накопленная аудитория растёт с
горизонтом и не совпадает с дневной.
**Хвост возвратов**:
Окно активности, отсчитанное от первого дня человека и общее на обе
его куки; дольше окна кука не возвращается.
**Предыстория**:
Когорты плана с первым днём активности до D0; событий не порождают.
**День активности**:
День, в который кука хоть раз появилась в мире: день её рождения или день
возврата. Единица плана состава: за день он у куки один, а визитов внутри
него бывает несколько.
_Избегать_: визит (визит — про сессию)
**Визит**: **Визит**:
Подряд идущие события одной куки без пауз длиннее 30 минут, не пересекающие Последовательность событий одного посетителя без пауз длиннее 30 минут
границу суток. Поле `VisitID` — эталон для лабы сессий. «Сессия» — то же и без перехода через полночь по часовому поясу счетчика. Сессия означает
понятие словами аналитики: в данных и в коде оно зовётся визитом, но имена то же самое; поле `VisitID` служит эталоном в задании по нарезке сессий.
вроде «лаба сессий» и «сборка сессий» остаются.
**День активности**:
День, в который посетитель появился на сайте хотя бы раз: впервые или
вернувшись. За один день активности у него может быть несколько визитов.
_Избегать_: визит в значении целого дня активности.
**Паспорт куки**: **Паспорт куки**:
Устройство и город, приписанные куке на всю жизнь: кука — это браузер на Устройство и город, закрепленные за посетителем во все дни его активности.
устройстве. Держит их план состава, разворачивает в поля события Генератор записывает их в поля событий. У двух посетителей одного человека
день-функция; у двухкуковой пары город один на две куки, устройства разные. город совпадает, а устройства различаются.
**Покупатель**:
Человек, которого план состава пометил склонным покупать. Метка значима:
помеченный доходит до заказа заметно чаще прочих, но и непомеченный иногда
покупает. Из покупателей отбираются двухкуковые пары.
_Избегать_: «покупатель» про того, кто купил в конкретный день — это визит
с заказом.
**День-функция**:
Функция (зерно, номер дня), выдающая упорядоченный поток событий этих
модельных суток. Состояния между днями нет: день D не зависит от того,
прожиты ли дни до него.
**Торговое событие**: **Торговое событие**:
Событие корзины или покупки — отдельная строка потока, а не просмотр Запись о добавлении товара в корзину или о покупке: `add_to_cart` или
страницы. Садится на ту страницу, где случилось: корзина — на карточку `purchase`. Это отдельная строка кликстрима со страницей, где произошло
товара, покупка — на страницу подтверждения заказа. действие: карточкой товара для корзины или страницей подтверждения для покупки.
### Хранилище и форматы данных
**Хранилище**:
Аналитическая база стенда в ClickHouse. Данные проходят слои STG, ODS, DDS
и DM: от исходных сообщений до витрин для отчетов. Хранилище принимает
данные по документации источников и приводит их к своей модели и именам.
_Избегать_: склад, склад данных.
**Сырые данные**:
Исходные сообщения Kafka, сохраненные в слое STG без разбора: одно сообщение
занимает одну строку таблицы. Слой STG также называют сырым слоем.
_Избегать_: сырьё.
**Справочник**: **Справочник**:
Данные, которые хранилище не производит, а получает готовыми со стороны: Готовые сведения из внешнего источника, которыми хранилище дополняет
содержимым владеет источник вне хранилища, слои только читают. Живут в базе свои данные. На стенде они находятся в базе `dic`; пока это только каталог
`dic`. Первый и пока единственный — каталог товаров. товаров. Объект `DICTIONARY` ClickHouse — одна из форм доступа к справочнику.
_Избегать_: словарь — этим словом в проекте зовут и глоссарий понятий, и _Избегать_: словарь в значении справочных данных: это слово уже обозначает
объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник доступен. `DICTIONARY` и словарь понятий.
**Каталог товаров**: **Каталог товаров**:
`data/catalog/products.csv` — общий файл генератора и словаря Список товаров с артикулами, названиями, категориями, брендами, ценами
ClickHouse. Форма файла решена, длина — нет: строки дописываются. и уровнями спроса. Файл `data/catalog/products.csv` общий для генератора
и справочника ClickHouse; товары можно добавлять строками в этом формате.
**Уровень спроса**:
Свойство товара в каталоге: как часто открытую карточку кладут в корзину.
Три значения — магнит, обычный, залёживается. Постоянная часть мира, а не
поведение дня: он и делает конверсию «просмотр → корзина» по товарам и
брендам различимой.
**Ось модельного времени**:
Собственный календарь мира генератора. Дни считаются от фиксированного
первого дня D0; реальный календарь в модели не участвует. Между прогонами
живут только зерно и позиция на оси.
**Стартовый мир**:
Первые восемь дней оси (понедельник по понедельник), которыми стенд
наполняется при каждом `make up`. Он же эталонный мир, всегда один и тот же:
на нем принимаются следующие этапы.
_Избегать_: зерновой мир
**Паспорт мира**:
`data/world-inventory.json` — единственное, что о мире хранится в git: шапка
(зерно, версия генератора, хеш каталога), строка на каждый день событий
(число и хеш байтов) и строка на каждый отправленный слепок (хеш байтов).
Счётчик сверх этого заводится только с названным читателем. Сам мир не
хранится, а пересчитывается; паспорт отвечает на один вопрос: тот ли это мир.
_Избегать_: опись мира, манифест, мини-манифест
**Пошаговый режим**:
Базовый способ движения по оси модельного времени: «прожить следующий
день» — явное действие.
**Живой день**:
Проигрывание текущего модельного дня в реальном времени с ускорением;
включается по требованию — постоянным фоном идёт, только пока включён
выключатель `world_live`.
**Пакетный режим**:
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
стенда, пересборки и проверки.
**Даги модельного времени**:
Даги Airflow, которыми двигают ось модельного времени: шаговые даги играют
день — пачкой или в темпе, — а выключатель держит стенд живущим, пока включён.
Коротко — даги времени.
_Избегать_: пульт мира, управляющие даги
**Шаговый даг**:
Даг модельного времени, который играет день и ставит позицию на оси:
`world_next_day` — пачкой, `world_live_day` — в темпе. Запускается руками,
расписания и паузы не имеет; автоматику поверх него даёт выключатель.
_Избегать_: работник
**Граница суток**:
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
снимается на ней, день проживается только целиком.
**Контракт схемы**:
Python-модуль с описателями колонок события — собственность генератора.
Из него выводятся генератор, валидация и документация формата; хранилище
строится по документации, не по модулю.
**Нормализованное имя**: **Нормализованное имя**:
Имя колонки источника, приведённое к нашему стилю (snake_case). Живёт в Имя поля источника, записанное в стиле `snake_case`: например,
контракте схемы и в описании выгрузки. Не то же, что имя атрибута в модели `ClientID` становится `client_id`. Оно указано в описании выгрузки и
данных: слой DDS складывает модель и называет атрибуты по ней. контракте схемы. Слой DDS выбирает имена по своей модели данных, поэтому
они не обязаны повторять нормализованные имена источника.
**Описание выгрузки**: **Описание выгрузки**:
Публичная документация формата события: таблица колонок, собранная из Документ, который объясняет поля и формат событий источника:
контракта схемы. По ней пишется сторона хранилища — как в бою по документации [`docs/formats/clickstream-event.md`](docs/formats/clickstream-event.md).
источника. Правится только контракт, документ пересобирается. По нему строится прием данных в хранилище. Документ собирается из
_Избегать_: описание схемы, документация контракта описаний колонок генератора: правят их, затем пересобирают документ.
_Избегать_: описание схемы, документация контракта.
**Канонический сериализатор**:
Единственная граница, где запись генератора превращается в байты. Фиксированный
порядок ключей и строк — основа побайтовой воспроизводимости.
**Проигрыватель**:
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
(пачкой, без темпа) и живой день.
**Слепок**:
Полная выгрузка заказов окна изменяемости, снятая бэкендом на границе суток:
состояние заказов на этот момент, а не поток их изменений. Опоздавший заказ
может отсутствовать в ранних слепках; после первого появления ездит до конца
своего окна.
**Дата слепка**:
Дата завершившегося модельного дня, состояние которого снято на исходящей
границе суток. Одна для всех записей выгрузки; не дата заказа и не время
отправки сообщения.
**Время аудита источника**:
Время создания или последнего изменения строки заказа по часам базы источника.
В записи слепка это `created_at` и `updated_at`; бизнес-время покупки живёт в
событии `purchase` отдельно.
**Судьба заказа**:
Исход, моменты изменений, вычеркнутая позиция и опоздание заказа — всё
решается бросками при его рождении и укладывается в окно изменяемости целиком.
Слепок любого дня — чтение готовой судьбы, а не накопление состояния.
**Окно изменяемости**:
Сколько модельных дней заказ ещё может измениться и потому продолжает ездить
в слепках. Константа мира: семь дней. За окном заказ замёрз, выручка дня
перестала «дышать».
**Пакетный забор**:
Способ приёма топика, при котором данные тянет запрос по команде, а не
матвью непрерывно. Так стенд принимает заказы: у слепка есть начало и конец,
и забирать его уместно тогда, когда он собран целиком. Противоположность —
потоковый приём событий.
_Избегать_: путать с приёмником и пакетным режимом — те про сторону
генератора, про отправку; забор — про сторону хранилища, про чтение.
**Техническая проверка**: **Техническая проверка**:
Проверка, чей оракул — внутренний эталон стенда (паспорт мира), которого у Проверка по внутреннему эталону стенда, например сравнение числа событий
настоящего магазина нет. За учебный урок DQ не выдаётся. с паспортом мира. Проверка качества данных (DQ) опирается на данные
_Избегать_: 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` она определяется
общим зерном, днем и компонентом; для состава мира есть отдельные
последовательности. Результат не зависит от порядка расчета частей.
**День-функция**:
Расчет событий одних модельных суток по зерну и номеру дня, без генерации
предыдущих дней. Функция `day.stream(seed, day)` возвращает события
в порядке времени.
**Судьба заказа**:
Заранее рассчитанные изменения заказа: итоговый статус, время оплаты
или отмены, удаленная позиция и задержка первого появления в слепке.
Они укладываются в семидневное окно заказа. Слепок показывает состояние
на выбранный день без проигрывания предыдущих изменений.
**Контракт схемы**:
Описания колонок события в модуле генератора `schema.py`: имена,
типы и ограничения. По ним генератор создает и проверяет события,
а `schema_doc.py` собирает описание выгрузки для хранилища.
**Канонический сериализатор**:
Код в `serialize.py`, который превращает события и записи заказов в JSON
для отправки. Порядок ключей и формат значений фиксированы, поэтому
одинаковые записи дают одинаковые байты.
**Проигрыватель**:
Код в `player.py`, который доставляет готовые события дня в приемник,
например в Kafka или файл. В пакетном режиме он отправляет их без пауз,
в режиме живого дня — с ускорением по времени событий.
+4 -1
View File
@@ -121,8 +121,11 @@ Kafka, затем заново создаст схему хранилища и
Для подключения ко второму узлу укажите порт `28124`. Для подключения ко второму узлу укажите порт `28124`.
Пользователь `analyst` может читать данные во всех слоях и справочниках. Пользователь `analyst` может читать данные во всех слоях и справочниках.
Для заданий с собственными таблицами наблюдений используйте `student` и пароль
из `CLICKHOUSE_STUDENT_PASSWORD` в `.env`. Он читает те же источники, а создавать,
заполнять и удалять таблицы может только в базе `default`.
Для создания учебных витрин используйте подключение, описанное Для создания учебных витрин используйте подключение, описанное
в [инструкции к заданиям](docs/labs/README.md#как-применить-свой-ddl). в [инструкции к заданиям](docs/labs/README.md#выполнение-sql-в-dbeaver).
Нативные порты `29000` и `29001` нужны `clickhouse-client`, но не DBeaver. Нативные порты `29000` и `29001` нужны `clickhouse-client`, но не DBeaver.
Остальные пользователи и роли описаны в Остальные пользователи и роли описаны в
[справочнике стенда](docs/architecture/stand.md#пользователи-и-роли-clickhouse). [справочнике стенда](docs/architecture/stand.md#пользователи-и-роли-clickhouse).
+1
View File
@@ -20,6 +20,7 @@ x-clickhouse-common: &clickhouse-common
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_ANALYST_PASSWORD: ${CLICKHOUSE_ANALYST_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_ANALYST_PASSWORD: ${CLICKHOUSE_ANALYST_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_STUDENT_PASSWORD: ${CLICKHOUSE_STUDENT_PASSWORD:?Добавьте CLICKHOUSE_STUDENT_PASSWORD из .env.example в .env}
CLICKHOUSE_GRAFANA_PASSWORD: ${CLICKHOUSE_GRAFANA_PASSWORD:?Скопируйте .env.example в .env} CLICKHOUSE_GRAFANA_PASSWORD: ${CLICKHOUSE_GRAFANA_PASSWORD:?Скопируйте .env.example в .env}
depends_on: depends_on:
clickhouse-keeper: clickhouse-keeper:
+24 -32
View File
@@ -1,18 +1,17 @@
"""Трансформации слоя DDS: сборка моделей. """Трансформации DDS: заказы, сессии и карта идентичностей.
Заказ и сессия собираются заменой дневных партиций. Карта идентичностей Запросы рассчитывают заказы и сессии за выбранные дни и записывают результат
пересчитывается целиком после заказа, потому что читает его текущую модель. в промежуточные таблицы. Затем готовые дневные разделы этих таблиц заменяют
разделы основных таблиц. Пока идет расчет, читатели видят прежние данные.
Расписания у дага нет и параметров тоже: объём каждого прогона он выводит сам Карта идентичностей пересчитывается целиком после заказов. Новые строки
из состояния слоёв и позиции мира, поэтому один и тот же запуск одинаково добавляются с меткой времени сборки; представление с FINAL выбирает последнюю
верен из триггера дневного конвейера, с кнопки менти и после падения версию каждой строки.
(docs/architecture/etl.md, «Объём прогона»).
Заказ и сессия гасят повтор заменой партиции целиком: день собирается в Даг запускается без расписания и параметров. Даты он выбирает по состоянию
таблицу-двойник и подменяет собой партицию цели. Карта пишет полный набор пар таблиц и позиции мира. Новый запуск заново определяет диапазон: наличие даты
новой версией, а точное чтение даёт FINAL. Замена партиции разобрана в не подтверждает полноту дня и не гарантирует повтор его сборки после сбоя.
sql/dds/order_replace.sql; точное чтение карты описано в Подробности — docs/architecture/etl.md, «Объём прогона» и «Идемпотентность».
sql/ddl/50-dds-views.sql.
""" """
from __future__ import annotations from __future__ import annotations
@@ -257,16 +256,11 @@ 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 для каждого дня.
Шаг существует ради ограничения Airflow: размножать задачу можно expand_kwargs получает список словарей и создает по одному экземпляру
только по целому возврату другой задачи — ни по ключу словаря, ни по задачи на каждый словарь. Дата подставляется в 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"]]
@@ -276,8 +270,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"],
@@ -298,7 +292,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]:
@@ -306,8 +300,7 @@ 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 нет ни одного заказа: собирать нечего; "
@@ -342,9 +335,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,
@@ -370,9 +363,8 @@ 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} включительно, "
+12 -8
View File
@@ -1,9 +1,12 @@
"""Трансформации слоя DM: сборка стендовых витрин. """Трансформации слоя DM: сборка стендовых витрин.
Выручка наследует ритм заказов. Сверка соединяет окно заказов с прожитым Даты для расчета выручки берутся из последнего слепка заказов и списка
хвостом событий. Обе заменяют дневные партиции. Трафик наследует пропущенных дней. Сверка покупок с заказами учитывает также прожитые дни
ретроспективность карты идентичностей и пересчитывает всю историю новой событий и дни, где заказ еще не найден. Запросы записывают результаты
версией. Объем каждого прогона выводится из состояния слоев. в промежуточные таблицы, затем заменяют дневные разделы основных таблиц.
Трафик пересчитывается за всю историю: новая связь посетителя с пользователем
магазина меняет число известных пользователей и за прошлые дни.
""" """
from __future__ import annotations from __future__ import annotations
@@ -62,10 +65,11 @@ 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 получает список словарей и создает по одному экземпляру
прием разобран в ``dds_transform.py`` на замене партиций заказов. задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja:
параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается.
""" """
return [{"params": {"day": day}} for day in scope["days"]] return [{"params": {"day": day}} for day in scope["days"]]
@@ -141,7 +145,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]:
+23 -8
View File
@@ -74,7 +74,11 @@ def _publish_summary(
) -> list[str]: ) -> list[str]:
"""Записать дневную сводку и вернуть малый срез готовой диагностики.""" """Записать дневную сводку и вернуть малый срез готовой диагностики."""
if not summaries: if not summaries:
raise AirflowException(f"{check_name}: область проверки пуста") raise AirflowException(
f"{check_name}: запрос не вернул ни одного дня для проверки; "
"проверьте наличие данных в источниках сверки. Состав проверок "
"описан в docs/architecture/dm.md"
)
diagnostics: list[str] = [] diagnostics: list[str] = []
context = get_current_context() context = get_current_context()
@@ -148,7 +152,10 @@ def build_classes_vs_inventory() -> list[str]:
for mismatch_class, count in day["orders"].items() for mismatch_class, count in day["orders"].items()
} }
if not expected: if not expected:
raise AirflowException("classes_vs_inventory: в паспорте нет закрытых дней") raise AirflowException(
"classes_vs_inventory: в data/world-inventory.json нет счетчиков "
"заказов для закрытых дней; сообщите наставнику"
)
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8") query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
# GROUP BY закономерно не возвращает группу из нуля строк. # GROUP BY закономерно не возвращает группу из нуля строк.
@@ -179,9 +186,9 @@ def build_classes_vs_inventory() -> list[str]:
failed_rows += 1 failed_rows += 1
actual_text = actual_count if actual_count is not None else "нет ключа" actual_text = actual_count if actual_count is not None else "нет ключа"
details.append( details.append(
f"{day} key={mismatch_class}: " f"{day} mismatch_class={mismatch_class}: "
f"эталон={reference_count}, " f"заказов в паспорте={reference_count}, "
f"объект={actual_text}" f"в dm.purchase_vs_orders_v={actual_text}"
) )
summaries.append((data_date, reference_rows, actual_rows, failed_rows, details)) summaries.append((data_date, reference_rows, actual_rows, failed_rows, details))
return _publish_summary("classes_vs_inventory", summaries) return _publish_summary("classes_vs_inventory", summaries)
@@ -194,7 +201,10 @@ def publication_scope() -> list[dict[str, dict[str, str]]]:
"SELECT DISTINCT data_date FROM dm.dq_summary_stage_dist ORDER BY data_date" "SELECT DISTINCT data_date FROM dm.dq_summary_stage_dist ORDER BY data_date"
) )
if not rows: if not rows:
raise AirflowException("донор dm.dq_summary пуст: публиковать нечего") raise AirflowException(
"dm.dq_summary_stage_dist пуста: нет результатов для записи "
"в сводку DQ; проверьте журналы задач build_* этого запуска"
)
return [{"params": {"day": data_date.isoformat()}} for (data_date,) in rows] return [{"params": {"day": data_date.isoformat()}} for (data_date,) in rows]
@@ -239,10 +249,15 @@ def assert_check(check_name: str, diagnostics: list[str]) -> None:
logging.error("%s: %s", check_name, detail) logging.error("%s: %s", check_name, detail)
summary = "; ".join( summary = "; ".join(
f"{data_date}: эталонных ключей {reference_rows}, " f"{data_date}: эталонных ключей {reference_rows}, "
f"фактических {actual_rows}, нарушено {failed_rows}" f"ключей в проверяемом объекте {actual_rows}, "
f"отсутствуют или различаются {failed_rows}"
for data_date, reference_rows, actual_rows, failed_rows in failed_days for data_date, reference_rows, actual_rows, failed_rows in failed_days
) )
raise AirflowException(f"{check_name}: {summary}") raise AirflowException(
f"{check_name}: {summary}. Примеры расхождений — в журнале этой задачи; "
"итоги по дням — в dm.dq_summary_v. Источники каждой сверки описаны "
"в docs/architecture/dm.md"
)
@dag( @dag(
+8 -3
View File
@@ -45,7 +45,10 @@ def check_mart(
"""Сверить одну домашнюю витрину с независимым ответом из источника.""" """Сверить одну домашнюю витрину с независимым ответом из источника."""
hook = _clickhouse_hook() hook = _clickhouse_hook()
if not _mart_exists(hook, mart): if not _mart_exists(hook, mart):
raise AirflowException(f"витрина {mart} не найдена; выполните ТЗ {lab_path}") raise AirflowException(
f"витрина {mart} не найдена на узле подключения clickhouse_default; "
f"проверьте имя и место создания витрины по заданию {lab_path}"
)
try: try:
query = (SQL_ROOT / sql_name).read_text(encoding="utf-8") query = (SQL_ROOT / sql_name).read_text(encoding="utf-8")
@@ -72,7 +75,8 @@ def check_mart(
except Exception as error: except Exception as error:
raise AirflowException( raise AirflowException(
f"не удалось проверить витрину {mart}: {error}; " f"не удалось проверить витрину {mart}: {error}; "
f"сверьте договор колонок и проверку в ТЗ {lab_path}" f"сверьте имена и типы колонок с заданием {lab_path}. "
"Подробности ошибки — в журнале этой задачи"
) from error ) from error
failed_rows = sum(summary[3] for summary in summaries) failed_rows = sum(summary[3] for summary in summaries)
@@ -93,7 +97,8 @@ def check_mart(
compared_sides = diagnostics[0] compared_sides = diagnostics[0]
raise AirflowException( raise AirflowException(
f"витрина {mart} расходится с источником: {days}. " f"витрина {mart} расходится с источником: {days}. "
f"Пример сравнения: {compared_sides}. Сверьтесь с ТЗ {lab_path}" f"Пример сравнения: {compared_sides}. Другие примеры — в журнале этой "
f"задачи. Проверьте расчет для указанных ключей по заданию {lab_path}"
) )
+14 -21
View File
@@ -1,18 +1,13 @@
"""Приём заказов: пакетный забор слепка из Kafka в хранилище. """Пакетный прием заказов: Kafka → STG → ODS.
Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор Шаговый даг модельного времени отправляет слепок, запускает orders_ingest
порции в `stg`, затем разбор того же среза в типизированный ODS и в таблицу и ждет его завершения. Собственного расписания у orders_ingest нет.
брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, — Сначала pull_batch сохраняет порцию сообщений в STG, затем parse_batch
шаговый даг модельного времени после проигрыша дня, — и ждёт конца. разбирает тот же срез по _load_id в заказы и ошибки.
Контраст с приёмом событий и есть урок. События тянет матвью, навсегда События принимаются непрерывно через материализованные представления.
подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов Заказы читаются одним запросом; полнота слепка этим не гарантируется.
приезжает раз в модельный день целой выгрузкой, у которой есть начало и конец, Правила и ограничения — docs/architecture/orders/ingestion.md.
и забирает её запрос по команде. Push против pull — два режима на одном стенде,
каждый там, где ему место по природе источника.
Решения и доводы целиком — ADR 0008 (забор) и ADR 0010 (версии в ODS); форма
перехода — docs/architecture/orders/ingestion.md.
""" """
from __future__ import annotations from __future__ import annotations
@@ -24,8 +19,7 @@ from airflow.sdk import dag
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC) START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
# Запросы лежат файлами в sql/ репозитория, разложенные по слоям хранилища; # Compose монтирует sql/ по этому пути во все службы Airflow.
# сюда их монтирует compose — всем службам Airflow сразу.
SQL_ROOT = "/opt/airflow/sql" SQL_ROOT = "/opt/airflow/sql"
@@ -34,15 +28,14 @@ SQL_ROOT = "/opt/airflow/sql"
schedule=None, schedule=None,
start_date=START_DATE, start_date=START_DATE,
is_paused_upon_creation=False, is_paused_upon_creation=False,
# Чтец у топика один, и группа потребителей у него одна. Два прогона разом # Один запуск за раз: параллельные чтения разделили бы порцию
# дрались бы за неё, а слепок разъехался бы по двум _load_id; второй # между разными _load_id.
# прогон подождёт своей очереди.
max_active_runs=1, max_active_runs=1,
template_searchpath=SQL_ROOT, template_searchpath=SQL_ROOT,
tags=["заказы"], tags=["заказы"],
) )
def orders_ingest(): def orders_ingest():
"""Забрать приехавший слепок заказов и разложить его по слоям.""" """Сохранить порцию заказов и разобрать ее в ODS."""
pull_batch = SQLExecuteQueryOperator( pull_batch = SQLExecuteQueryOperator(
task_id="pull_batch", task_id="pull_batch",
conn_id="clickhouse_default", conn_id="clickhouse_default",
@@ -59,8 +52,8 @@ def orders_ingest():
do_xcom_push=False, do_xcom_push=False,
) )
# ClickHouse не даёт транзакции между двумя вставками parse_batch. Один # Две вставки parse_batch не образуют транзакцию. Повтор задачи
# task_id сохраняет их порядок и одну точку повтора для неизменного load_id. # выполняет обе над тем же срезом STG по _load_id.
pull_batch >> parse_batch pull_batch >> parse_batch
+29 -27
View File
@@ -146,25 +146,25 @@ def _ingest_orders() -> TriggerDagRunOperator:
type="integer", type="integer",
minimum=1, minimum=1,
maximum=7, maximum=7,
title="Сколько дней прожить", title="Число модельных дней для генерации",
# description_md рендерится подсказкой прямо в форме запуска # description_md рендерится подсказкой прямо в форме запуска
# (params.rst, Airflow 3.3), чтобы правило было видно сразу. # (params.rst, Airflow 3.3), чтобы правило было видно сразу.
description_md=( description_md=(
"Форма задает только число дней. Номер первого несыгранного " "Создать данные за следующие 1–7 модельных дней; по умолчанию "
"дня шаговый даг берёт из переменной `world_position` (Admin → " "за один день. События отправляются без пауз. Номер первого дня "
"Variables). Поля Logical date и Run ID нужны самому Airflow " "берется из `world_position` (Admin → Variables). Поля Logical "
"и на выбор дня не влияют." "date и Run ID не меняют выбор дня. После завершения запустите "
"`etl_pipeline`, чтобы обновить DDS и витрины DM."
), ),
) )
}, },
) )
def world_next_day(): def world_next_day():
"""Прожить следующие дни пачкой, без пауз. """Создать данные за следующие модельные дни без пауз между событиями.
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним Параметр `days` задает число дней: от 1 до 7, по умолчанию 1. Первый день
нажимом, а не десятью: сколько дней играть — параметр запуска. Первый берется из `world_position`. После загрузки событий и слепков заказов
день шаговый даг берёт из `world_position`. Логическая дата прогона на выбор запустите `etl_pipeline` вручную, чтобы обновить DDS и витрины DM.
дня не влияет.
""" """
@task @task
@@ -174,7 +174,10 @@ def world_next_day():
position = first_day + days position = first_day + days
Variable.set(WORLD_POSITION, str(position)) Variable.set(WORLD_POSITION, str(position))
logging.info( logging.info(
"дни %s–%s сыграны; новая позиция — %s", first_day, position - 1, position "данные за модельные дни %s–%s отправлены; world_position=%s",
first_day,
position - 1,
position,
) )
first_day = first_unplayed_day() first_day = first_unplayed_day()
@@ -210,20 +213,23 @@ def world_next_day():
tags=TAGS, tags=TAGS,
) )
def world_live_day(): def world_live_day():
"""Прожить следующий день в темпе модельного времени. """Постепенно отправить события за следующий модельный день.
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре При ускорении ×60 отправка занимает около 24 реальных минут. Номер дня
реальные минуты, и суточная волна разворачивается на глазах. Запускается берется из `world_position`; Logical date его не меняет. Затем даг
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`. отправляет слепок заказов и ждет его загрузки и завершения `etl_pipeline`.
Следующий день шаговый даг берёт из `world_position`. Логическая дата прогона Для автоматического запуска следующих дней снимите с паузы `world_live`.
на выбор дня не влияет.
""" """
@task @task
def remember_played(first_day: int) -> None: def remember_played(first_day: int) -> None:
"""Позиция ставится по сыгранному дню и только по успеху.""" """Позиция ставится по сыгранному дню и только по успеху."""
Variable.set(WORLD_POSITION, str(first_day + 1)) Variable.set(WORLD_POSITION, str(first_day + 1))
logging.info("сыгран день %s; новая позиция — %s", first_day, first_day + 1) logging.info(
"данные за модельный день %s отправлены; world_position=%s",
first_day,
first_day + 1,
)
first_day = first_unplayed_day() first_day = first_unplayed_day()
played = _generator("play_day", ["live", "--day", PLAYED_DAY]) played = _generator("play_day", ["live", "--day", PLAYED_DAY])
@@ -254,16 +260,12 @@ def world_live_day():
tags=TAGS, tags=TAGS,
) )
def world_live(): def world_live():
"""Выключатель: пока включён, мир живёт день за днём. """Автоматически запускать модельные дни, пока даг снят с паузы.
Своей работы у выключателя нет — он дёргает `world_live_day` и ждёт конца. Запускает `world_live_day` и ждет его завершения. Пауза останавливает
Ожидание тут несущая конструкция, а не вежливость: без него тик шёл бы запуск следующих дней; уже запущенный день завершится. Если он завершился
независимо от хода дня, лишние прогоны скопились бы очередью, и мир потом с ошибкой, этот даг тоже завершится с ошибкой: откройте дочерний запуск
промчался бы по ней без всякого темпа. и журнал неуспешной задачи.
Ждём триггером, а не сенсором: оператор опрашивает тот прогон, который сам
и создал, и ссылка на дочерний прогон видна прямо отсюда. Упал день —
краснеет и выключатель.
""" """
TriggerDagRunOperator( TriggerDagRunOperator(
task_id="trigger_live_day", task_id="trigger_live_day",
+11 -1
View File
@@ -3,10 +3,11 @@
Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader` Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader`
и пользователь `grafana` добавлены 21 августа 2026 года (#114), права и пользователь `grafana` добавлены 21 августа 2026 года (#114), права
`bi_reader` расширены до ODS, DDS и DM 25 августа 2026 года (#150). `bi_reader` расширены до ODS, DDS и DM 25 августа 2026 года (#150).
Пользователь `student` и роль `student_writer` добавлены 22 сентября 2026 года.
## Решение ## Решение
У кластера семь пользователей и шесть ролей. У кластера восемь пользователей и семь ролей.
- `default` — с паролем, только служебный: проверки здоровья нод и работа - `default` — с паролем, только служебный: проверки здоровья нод и работа
изнутри контейнеров. Приложения им не ходят. изнутри контейнеров. Приложения им не ходят.
@@ -20,6 +21,10 @@
шире. шире.
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им - `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
человек подключается снаружи, из своего клиента. человек подключается снаружи, из своего клиента.
- `student` с ролями `analyst_reader` и `student_writer` — то же чтение и
работа со своими таблицами в `default`: создание, вставка и удаление.
Сохранение учебных наблюдений не требует прав технической учётной записи
`etl` на изменение слоёв. Таблицы локальные; работа ведётся на одной ноде.
- `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и - `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и
системных таблиц, `CLUSTER` и `REMOTE`. Им ходит Grafana, и больше никто. системных таблиц, `CLUSTER` и `REMOTE`. Им ходит Grafana, и больше никто.
- `dict` с ролью `dict_reader` — чтение одной базы `dic`, пароля нет. Им - `dict` с ролью `dict_reader` — чтение одной базы `dic`, пароля нет. Им
@@ -148,6 +153,11 @@ DDL для матвью приёма выполняется от имени `lif
## Что проверено ## Что проверено
Права `student` сверены 22 сентября 2026 года через MCP Context7 и на стенде
26.3.17.56: создание локальных `MergeTree`, `INSERT SELECT` из DDS и DM,
чтение и удаление своих таблиц работают. На обеих нодах вставка в STG, ODS,
DDS, DM и `dic` завершается `ACCESS_DENIED`.
Сверено 9 августа 2026 года по документации ClickHouse через MCP Context7 и Сверено 9 августа 2026 года по документации ClickHouse через MCP Context7 и
на живом стенде 26.3.17.56. на живом стенде 26.3.17.56.
+1 -1
View File
@@ -30,7 +30,7 @@
`first_matched_at` нет. Запланированные витрины его не читают, а два возможных `first_matched_at` нет. Запланированные витрины его не читают, а два возможных
смысла — «первая покупка вообще» и «момент, с которого связь была известна смысла — «первая покупка вообще» и «момент, с которого связь была известна
хранилищу» — требуют разных исторических моделей. Этот вопрос оставлен хранилищу» — требуют разных исторических моделей. Этот вопрос оставлен
[самостоятельной работе](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/145), [самостоятельной работе](../../labs/optional/identity-over-time.md),
а текущий стенд не выдаёт один смысл за другой. а текущий стенд не выдаёт один смысл за другой.
Статус заказа связь не меняет: любой заказ, сопоставленный с событием покупки, Статус заказа связь не меняет: любой заказ, сопоставленный с событием покупки,
+86 -107
View File
@@ -1,134 +1,113 @@
# Модель заказа в DDS # Модель заказа в DDS
Учебный результат: менти проектирует бизнес-сущность поверх готового ODS — DDS готовит заказ для анализа: выбирает текущее состояние из ODS,
выбирает зерно, состав строки и способ наполнения — и видит идемпотентность разбирает позиции и переводит время в часовой пояс счетчика. Этот слой
трансформации, устроенную иначе, чем в приёме: заменой партиции, а не показывает, как собрать модель данных для витрин и повторить загрузку
версиями. без удвоения заказов.
## Зерно ## Зерно
Одна строка — заказ в текущем состоянии, ключ — `order_id`. Модель отвечает Одна строка — один заказ в последнем известном состоянии, ключ — `order_id`.
на вопрос «что сейчас известно о заказе»; историю версий не хранит: История изменений здесь не хранится. ODS тоже не служит ее архивом:
читателя у истории нет ни в одной витрине, а ODS её и не обещает — промежуточные версии удаляются при фоновых слияниях.
промежуточные версии съедают фоновые слияния. «Дыхание» выручки внутри окна
K живёт на текущем состоянии само: пересборка дня меняет статусы заказов, и
число дня меняется вместе с ними.
## Состав строки ## Состав строки
Деловые поля источника переезжают из `ods.order_v` без переименований — Поля заказа приходят из `ods.order_v` с именами источника: `order_id`,
бэкенд уже говорит в snake_case: `order_id`, `user_id`, `status`, деньги в `user_id`, `status`, `items_total`, `discount`, `delivery`, `total`.
`Decimal` (`items_total`, `discount`, `delivery`, `total`). Денежные колонки хранят рубли с копейками в `Decimal(18, 2)`.
Времена `created_at` и `updated_at` пересчитываются при загрузке в пояс `created_at` и `updated_at` переводятся из UTC в `Europe/Samara`, пояс
счётчика: DDS обслуживает человека с дашбордом ([дока счетчика сайта. Из `created_at` в этом поясе вычисляется `order_date` —
хранилища](../storage.md), «Часовые пояса»). Там же один раз считается день оформления заказа. Витрины группируют по готовой дате;
**день заказа** — колонка `order_date`, по образцу `EventDate` у события: правило времени общее для [хранилища](../storage.md#часовые-пояса).
суточные срезы группируются по готовой дате и пояса не упоминают.
Позиции разобраны из сырой строки `items` в три типизированных массива Строка `items` из ODS разбирается в три массива одной длины:
одной длины — артикул, количество, цена (`Decimal`). Это родная для
ClickHouse денормализация: позиция не живёт без заказа и собственного ключа | Колонка | Что хранит |
не имеет, а разворачивает массивы читатель — `ARRAY JOIN` в витрине |---|---|
выручки, с обогащением категорией через `dictGet` | `item_sku` | Артикулы товаров |
([мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 7). Разбор | `item_quantity` | Количество каждого товара, `UInt32` |
JSON происходит один раз, на границе ODS → DDS, куда его и положила спека | `item_price` | Цены за единицу в рублях, `Decimal(18, 2)` |
(раздел 2); записывать его параллельными колонками-массивами или `Nested` —
выбор DDL, не модели. Элементы с одинаковым номером описывают одну позицию заказа. Витрина
выручки разворачивает их через `ARRAY JOIN` и получает категорию товара
из справочника через `dictGet`. Разбор JSON выполняется один раз
при загрузке DDS.
ODS проверяет только то, что `items` — массив. Разбор в DDS не заменяет
проверку его содержимого: например, некорректное количество может стать
нулем. Проверку суммы позиций участник добавляет в
[первом задании ступени 5](../../labs/5-1-order-checks.md).
## Движок и наполнение ## Движок и наполнение
`dds.order_rep` — `ReplicatedMergeTree` без колонки версии: партиция — `dds.order_rep` — таблица на `ReplicatedMergeTree`, без колонки версии.
`order_date` (день заказа неизменен), ключ сортировки — `order_id`. Партиция хранит один `order_date`, ключ сортировки — `order_id`.
`dds.order_dist` шардирует по `cityHash64(order_id)`, тем же выражением, что Запись через `dds.order_dist` распределяет заказы по `cityHash64(order_id)`,
ODS: это единственный устойчивый ключ сущности. как в ODS. Для чтения служит `dds.order_v` с явным списком колонок
над `dds.order_dist`; `FINAL` в этом слое не нужен.
Идемпотентность держит замена партиции, а не версии. Сбор дня — батч после Даг `dds_transform` пересобирает выбранные дни из `ods.order_v`:
прихода слепка: трансформация собирает партицию заново из `ods.order_v` и
атомарно подменяет. Поэтому каждая строка физической таблицы — ровно один
заказ, а чтению не нужен `FINAL`. Пока окно K живо, день «дышит» — статусы
заказов меняются, и партиция дня пересобирается очередным запуском; на выходе
из окна день замерзает.
Публичная поверхность — `dds.order_v`: обычное представление с явным списком 1. [`order_scope.sql`](../../../sql/dds/order_scope.sql) определяет
деловых и собственных служебных колонок над `dds.order_dist`, без `FINAL`. непрерывный отрезок `first_day`…`last_day`. Он включает дни заказов
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена из последнего слепка и начинается раньше, если в DDS отсутствует
объектов». день, который есть в ODS.
2. [`order_rebuild.sql`](../../../sql/dds/order_rebuild.sql) очищает
промежуточную таблицу `dds.order_stage_rep` и наполняет ее через
`dds.order_stage_dist`. Это донор: в нем готовится результат,
пока основная таблица остается доступной для чтения.
3. [`order_replace.sql`](../../../sql/dds/order_replace.sql) заменяет
каждую дневную партицию основной таблицы готовой партицией донора.
Механика запуска — где собирается партиция и как подменяется на кластере — Повтор при неизменном ODS дает те же деловые данные без дублей —
сюда не входит: это устройство [дневного конвейера](../etl.md). это идемпотентность загрузки. Пока заказы дня входят в семидневное окно
источника, их состояние может меняться, и день пересобирается.
За пределами окна новые состояния не приходят, но пропущенный день
можно восстановить из ODS. Границы пересборки берутся из данных;
число семь в SQL хранилища не повторяется.
Замена атомарна внутри одного шарда: читатель видит прежнюю или новую
партицию целиком. Замены на двух шардах независимы, поэтому между ними
запрос через Distributed может увидеть смешанный результат.
Донор остается до следующей сборки и доступен для диагностики.
Порядок задач и повторы — в [описании дневного конвейера](../etl.md).
## Служебные колонки ## Служебные колонки
Строка получает `_load_id` и `_load_ts` запуска трансформации, собравшего `_load_id` и `_load_ts` показывают, какой запуск DDS собрал строку
её партицию, — по общей конвенции хранилища. При замене партиции они и когда он выполнил вставку. Они одинаковы у строк одной сборки.
одинаковы у всех строк дня и отвечают на вопрос «каким запуском и когда При следующей пересборке эти метки обновятся, даже если заказ не изменился.
собран этот день».
Ярлыки ODS (`snapshot_date`, `_load_id` приёма, `_load_ts` прибытия) в модель `snapshot_date` и метки приема ODS в DDS не переносятся: они описывают
не переезжают. Они описывают наблюдение и доставку строки в ODS. Модель DDS получение сообщения источника. Их можно найти в ODS по `order_id`.
хранит текущее деловое состояние заказа. В ODS `snapshot_date` задаёт версию и В каждом слое служебные колонки относятся к его собственной загрузке.
называет последний слепок, в котором источник показал заказ
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Дату
наблюдения и координаты доставки читатель спрашивает у ODS по
`order_id`. Правило слоя:
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
спрашиваются у их слоя по ключу сущности**.
Происшествие качества данных расследуется цепочкой, где каждый шаг спрашивает При поиске ошибки начните с `_load_id` в DDS: он указывает на запуск
свой слой на его языке: по `_load_id` строки DDS видно, какой запуск собрал сборки. По `order_id` найдите версии заказа в ODS, а по их `_load_id` —
день; по `order_id` ODS показывает физические версии и брак — там свой соответствующие сырые сообщения в STG и ошибки того же приема.
`_load_id` приёма связывает годные строки, ошибки и данные STG; дальше читают В таблице ошибок `order_id` может быть не разобран, поэтому нужное
STG, пока данные живы. Окно расследования задано сроками хранения: сырые данные сообщение ищут по `raw` и координатам Kafka.
— трое суток, брак — месяц ([дока хранилища](../storage.md)). Сырые сообщения хранятся около трех реальных суток, ошибки — месяц;
уточнения к срокам — в [справочнике хранилища](../storage.md#срок-жизни-сырых-данных).
## Читатели ## Читатели
- Витрина выручки `dm.revenue_daily_v`: заказы в `paid`, `ARRAY JOIN` - `dm.revenue_daily_v` считает выручку по заказам со статусом `paid`,
позиций, категория через `dictGet` (спека, раздел 7). разворачивает позиции и дополняет их категорией товара.
- Сверка `dm.purchase_vs_orders_v`: `order_id`, `order_date`, `items_total`, - `dm.purchase_vs_orders_v` сопоставляет заказ с покупкой на сайте
`status`; соединение — GLOBAL JOIN с обеими сторонами-подзапросами по `order_id`, используя `order_date`, `items_total` и `status`.
([исследование соединений](../../research/2026-08-22-distributed-join.md)). - `dds.identity_map` связывает идентификаторы посетителей с `user_id`
- Сборка карты идентичностей: пара `order_id` ↔ `user_id`, источник — через покупки и заказы.
`dds.order_v`.
## Не входит
- Механика запуска трансформации и замены партиций на кластере —
[дневной конвейер](../etl.md).
- Контур проверок качества (даг DQ, `dm.dq_summary`) — отдельный разговор
этапа 4.
- Инкрементальное наполнение вместо пересборки — техдолг #2.
## Отклонённые варианты
- **Одно динамическое представление вместо физической таблицы.** Рамка стенда —
показать модель данных, которую хранилище готовит потребителям; урок
«слой — контракт, а не копия» уже закреплён за `dds.event_v`, а разбор
позиций и пересчёт времён при каждом чтении собирали бы модель заново на
лету. Публичное `dds.order_v` поверх готовой физической модели этому доводу
не противоречит: оно ничего заново не вычисляет.
- **`ReplacingMergeTree` ещё раз.** Повторил бы механику ODS вторым разом:
физически хранил бы устаревшие состояния, вернул бы `FINAL`
и обёртку-представление — а урок версий менти уже отыграл.
- **Отдельная сущность `dds.order_item`.** Вторая таблица, второй вопрос
шардирования и `JOIN` там, где хватает `ARRAY JOIN`; нормализация из
реляционного мира, а не модель под колоночное хранилище. `JOIN` менти
увидит в сверке, работу с массивами — только здесь.
- **Зерно «версия заказа» (история).** Читателя нет; ODS историю не
обещает, а мир при нужде переигрывается генератором.
- **Перенос ярлыков ODS** — см. «Служебные колонки».
- **Без модели вовсе: витрины читают `ods.order_v` напрямую.** Разбор JSON
и пересчёт времён расползлись бы по витринам, а модель данных — то, ради
чего сущность и строится, — не была бы показана.
## Связанные решения ## Связанные решения
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) — версии заказов в - [ADR 0010](../../adr/0010-order-versions-in-ods.md) — версии заказов в ODS
ODS, устойчивая поверхность `ods.order_v`. и чтение через `ods.order_v`.
- [Приём заказов](../orders/ingestion.md) — граница строгого приёма и - [Прием заказов](../orders/ingestion.md) — проверки формата и выбор
четыре координаты строки ODS. актуальной версии.
- [Проталкивание условий сквозь - [Исследование соединений](../../research/2026-08-22-distributed-join.md) —
представления](../../research/2026-08-22-view-predicate-pushdown.md) — почему сверка распределенных источников использует `GLOBAL JOIN`.
резолюция #128 той же карты: цена `FINAL` и цепочек представлений, фон, на - [Проталкивание условий сквозь представления](../../research/2026-08-22-view-predicate-pushdown.md) —
котором здесь выбрана физическая таблица. как фильтр запроса влияет на объем чтения ODS.
+91 -84
View File
@@ -1,111 +1,118 @@
# Модель сессии в DDS # Модель сессии в DDS
Учебный результат: менти строит расчётную сущность, которой нет в потоке, — Сессия объединяет события одного визита. На стенде вы учитесь определять
режет события на сессии по правилу визита, чеканит идентификатор и сверяет ее границы по событиям, вычислять устойчивый идентификатор и сверять
свою нарезку с эталоном. Стенд здесь играет роль сессионизатора: в бою её результат с эталоном. Поэтому сборка читает `dds.event_v`, где нет готового
играет Метрика, которая режет визиты у себя и с октября 2025 отдаёт готовый `VisitID`. Этот идентификатор источника остается в ODS для проверки.
`VisitID` прямо в хитах. Поэтому стенд считает сессии сам, а `VisitID`
генератора использует только как эталон самопроверки — иначе сборка сессий
выродилась бы в `GROUP BY` по чужому полю и центральный урок
слоя пропал бы. Без этого абзаца действия стенда выглядят странно: «зачем
считать то, что уже дано колонкой».
## Зерно ## Зерно
Одна строка — один визит ([CONTEXT.md](../../../CONTEXT.md)): подряд идущие Зерно описывает, чему соответствует одна строка: здесь это один
события одной куки без пауз длиннее 30 минут, не пересекающие границу суток. [визит](../../../CONTEXT.md#люди-и-события). В него входят подряд идущие
В нарезку входят все три типа событий — торговые садятся на свои страницы события одного `client_id` без пауз длиннее 30 минут и без перехода через
и держат сессию наравне с просмотрами. События внутри сессии модельную полночь. Пауза ровно в 30 минут новую сессию не открывает.
упорядочиваются по метке времени события (`UTCEventTime` — опорная колонка Учитываются все три типа событий: `pageview`, `add_to_cart`, `purchase`.
приёма).
Граница суток проходит по модельному дню счётчика, и он у события уже есть: День сессии `session_date` берется из `event_date` событий DDS — это
`EventDate` — свойство содержимого. Раз сессия сутки не пересекает, у всех `EventDate` источника, дата по часовому поясу счетчика. У всех событий
её событий `EventDate` один; он и становится **днём сессии** — колонкой одной сессии она одинакова. Пересчитывать ее из времени UTC не нужно.
партиции, без пересчёта.
SQL рассчитывает границы визитов двумя оконными функциями: первая отмечает
начало нового визита, вторая по этим отметкам присваивает событиям номер
визита. Обе используют порядок `(event_time, event_id)`: при равном времени
события идут по идентификатору. Иначе два расчета могли бы по-разному
упорядочить события и отнести отметку начала к разным строкам.
## Идентификатор ## Идентификатор
Идентификатор сессии в природе не существует: сессия — расчётная сущность, `session_id` вычисляется как `cityHash64(client_id, min(event_time))`.
и id ей чеканит тот, кто сессионизирует. `VisitID` — такой же суррогат, При повторной сборке тех же событий посетитель и начало визита остаются
отчеканенный генератором в роли Метрики; наш — `session_id`, прежними, поэтому идентификатор не меняется. Счетчик или случайный UUID
детерминированный хеш от пары «кука + начало сессии» (`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` — идентификатор запуска и время сборки строки.
- **ключи и времена**: кука, `session_id`, начало и конец сессии, В готовом SQL страницы выбираются через `argMin` и `argMax`, а UTM,
длительность, день сессии. Времена — в поясе счётчика по правилу слоя; устройство и город — через `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` без колонки
заказа: партиция — день сессии, ключ сортировки — кука и начало сессии. версии. При чтении не нужно заново выполнять оконный расчет. Партиция —
`dds.session_dist` шардирует по `cityHash64(client_id)`, тем же выражением, раздел таблицы за один `session_date`; ключ сортировки — посетитель
что события, — это ко-локация с `dds.identity_map_rep` ([дока и начало сессии. Читайте результат через `dds.session_v`: представление
хранилища](../storage.md), «Раскладка по шардам»). над `dds.session_dist` явно перечисляет деловые и служебные колонки.
Совпадение ключа с событиями не случайно: все события куки лежат на одном
шарде, поэтому сессионизация локальна по природе — капкан смены ключа при
вставке, который дока хранилища обещает сборкам DDS, у сессий не стреляет.
Сборка — дневной батч: хранилище выше ODS по смыслу батчевое, раз второй Источник сборки — `dds.event_v`. События, сессии и карта идентичностей
источник приходит раз в сутки. Собирается день, который мир уже прожил, и распределены по одному ключу `cityHash64(client_id)`: данные посетителя
собирается один раз и целиком; какой день прожит, слой спрашивает у позиции находятся на одном шарде. Роли объектов и правило записи через `_dist`
мира ([дневной конвейер](../etl.md), «Объём прогона»). Идемпотентность держит описаны в [справочнике хранилища](../storage.md#раскладка-по-шардам).
замена партиции. В отличие от заказов, где батч пересобирает до K «дышащих»
дней, у сессии дышащего окна нет: прожитый день самодостаточен (правило
спеки, раздел 6). Второй раз он соберётся только
вынужденно — когда упавшая замена оставила позади дыру и хвост от неё накрыл
соседние дни. Дней в одном прогоне при этом бывает несколько — столько,
сколько мир успел прожить без слоя. Внутри живого дня
модель сессии про сегодня честно молчит — «сегодня» смотрят через
`dds.event_v`; сессия и по смыслу не финальна, пока день не прожит.
Публичная поверхность — `dds.session_v`: обычное представление с явным Три SQL-файла выполняются последовательно в группе `session` дага
списком деловых и собственных служебных колонок над `dds.session_dist`. `dds_transform`:
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
объектов».
Источник сборки — `dds.event_v` ([контракт события](event.md)): сессии 1. `session_scope.sql` находит первую дату событий, за которую еще нет
нужны от него ключи нарезки, тип события, страница, UTM и паспорт куки. сессий. Последний день для расчета — первый день событий ODS
Механика запуска — где собирается партиция и как подменяется на кластере — плюс `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#готовность-источника).
## Сверка с эталоном ## Сверка с эталоном
Счётная DQ-проверка с зерном «кука × день»: число сессий нашей нарезки Проверка `sessions_vs_reference` в `dq_check` сравнивает для каждого
равно `uniqExact(VisitID)` по тем же событиям. Счёт по Distributed ловит и посетителя и дня число строк `dds.session_v` с `uniqExact(VisitID)`
склеенную, и разрезанную сессию — мимо него проходит лишь взаимная из `ods.event_v`. Эталон читается из ODS, потому что `dds.event_v`
компенсация ошибок внутри одной куки за день; эталон в модель не тащим — скрывает `VisitID`.
строка сессии остаётся нашей, а не генераторной. Поштучное сопоставление
границ — материал лабы, где менти разбирает найденное расхождение руками. Дом проверки — Нулевое число расхождений подтверждает только число сессий каждого
контур DQ этапа 4. Сверке нужен `VisitID`, а сборке сессий — нет; контракт посетителя за день. Например, ошибочное объединение двух визитов
`dds.event_v` его скрывает, и за эталоном проверка идёт в `ods.event_v` и разделение другого на два сохранят их общее число. Поэтому в заданиях
([контракт события](event.md)). вы дополнительно сопоставляете границы сессий по исходным событиям и проверяете страницы,
UTM, устройство и город.
## Читатели ## Читатели
+172 -142
View File
@@ -1,179 +1,202 @@
# Слой DM: витрины # Слой DM: витрины
Как модель DDS превращается в витрины для потребителя BI: какую форму Витрины готовят данные DDS для отчетов и дашбордов. Здесь описаны форма
принимает витрина, кто какие витрины строит и как слой проверяется. Состав хранения, правила обновления и проверки. Состав колонок задан в
колонок каждой витрины задаёт раздел 7 мастер-спеки; здесь — форма, [спецификации](../specs/2026-07-30-stand-v2-realism.md#витрины-dm)
наполнение и контроль. и [учебных заданиях](../labs/README.md).
## Таблица или представление ## Таблица или представление
Дневной агрегат и сверка по заказам меняют зерно источника. На этом стенде такие Зерно — то, чему соответствует одна строка. Например, в заказах это заказ,
результаты хранятся в `dm.<имя>_rep` / `_dist` и читаются через публичное а в дневной выручке — день и категория товара. На стенде результат
представление `dm.<имя>_v` с явным списком колонок. Имена и устройство объектов с новым зерном хранится в таблице: отчет читает готовые итоги без
следуют общим [конвенциям хранилища](storage.md). Таблицы наполняет повторного расчета. Таблицу наполняет `dm_transform`.
`dm_transform`; ритм и механика пересборки описаны ниже. В задании этот путь
приходится пройти целиком: рассчитать агрегат, загрузить его и включить
обновление в конвейер.
Переименование колонок и обогащение строки зерно не меняют. Такие витрины на У такой витрины есть локальная `_rep`, распределенная `_dist` и публичное
стенде остаются обычными представлениями и не хранят вторую копию событий. представление `_v` с явным списком колонок. Для обновления по дням
нужна промежуточная таблица `_stage_rep`, в которую пишут через `_stage_dist`.
В нее записывают рассчитанные строки, пока основная таблица хранит прежние.
Затем дневные разделы основной таблицы заменяют готовыми разделами
промежуточной. Такие разделы называются партициями. Подробности — в
[справочнике хранилища](storage.md#имена-объектов).
На объёмах стенда все витрины могли бы остаться представлениями: условия Если запрос только переименовывает или дополняет поля и сохраняет одну
проходят сквозь их цепочку строку на событие, достаточно обычного представления. Оно выполняет
запрос при чтении, не хранит вторую копию событий и не требует пересборки.
На небольших данных стенда и агрегаты могли бы вычисляться при чтении
([исследование](../research/2026-08-22-view-predicate-pushdown.md)). ([исследование](../research/2026-08-22-view-predicate-pushdown.md)).
Мы все равно оставили материализованные агрегаты ради практики: без них в Готовые таблицы нужны в программе для практики загрузки, обновления
программе не было бы загрузки и пересборки витрин. Представления дают другой и включения витрин в конвейер.
случай: преобразование строк на прежнем зерне.
## Что готово в стенде, а что остаётся заданием ## Что готово в стенде, а что остаётся заданием
Стенд привозит готовыми витрины трёх сюжетов дашборда этапа 8 и Готовы три витрины дашборда и сводка качества:
инфраструктуру контроля:
- `revenue_daily` — выручка по дням и категориям; образец материализованной - `revenue_daily` — одна строка на день заказа и категорию товара;
витрины, на него ссылаются учебные задания; образец дневной пересборки для учебных заданий.
- `purchase_vs_orders` — сверка A+C с классами расхождений; зерно — заказ, - `purchase_vs_orders` — одна строка на заказ: сопоставление покупки
шардирование сохраняет ко-локацию заказов по `cityHash64(order_id)`; из кликстрима с заказом магазина и класс расхождения.
- `daily_traffic` — пара «посетители» / «известные пользователи», обогащение - `daily_traffic` — одна строка на день: число посетителей и известных
[картой идентичностей](dds/identity.md); пользователей с учетом [карты идентичностей](dds/identity.md).
- `dq_summary` — сводка проверок качества (ниже). - `dq_summary` — одна строка на день данных и проверку качества.
Остальные витрины спеки нужно построить в учебных заданиях по готовому ТЗ и В [заданиях ступени 1](../labs/README.md) вы строите `events_enriched_v`,
образцу. Разминка — представление `events_enriched_v` (зерно события), затем `top_pages_daily`, `session_overview`, `dq_errors_daily` и `utm_effectiveness`.
агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`, последней — Для каждой физической витрины нужно добавить сборку в `dm_transform`.
самая содержательная `utm_effectiveness` с уроком `declared_*`. В финале Представление `events_enriched_v` отдельной задачи не требует.
каждого задания с агрегатом его задачу нужно добавить в `dm_transform`. До
этого конвейер о витрине не знает, после — пересобирает её вместе со
стендовыми. Представление из разминки добавлять некуда: его не нужно
пересобирать. Тексты заданий лежат в [`docs/labs/`](../labs/README.md).
Дашборд этапа 8 читает только готовые витрины стенда, поэтому его приёмка не Стендовый дашборд читает готовые витрины и работает до выполнения заданий.
зависит от домашних заданий. Отдельный дашборд по учебным витринам можно Дашборд по своим витринам можно собрать в
собрать в [факультативном задании](../labs/optional/superset-dashboard.md), [факультативе](../labs/optional/superset-dashboard.md) после ступени 2.
доступном после ступени 2. Устройство стендового дашборда описано в Устройство стендового дашборда — в [справочнике Superset](superset.md).
[справочнике 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, событийная — из трех дат: первая дата оформления заказа в последнем слепке, первый
последний прожитый день с `purchase`. Левая граница возвращает первую дыру и пропущенный день и первый день с `awaiting_order` — покупкой, для которой
самый ранний день с `awaiting_order`: предварительная строка уже есть в заказ пока не поступил. День ожидания проверяется отдельно: строки за него
витрине, поэтому поиск отсутствующего дня ее не заметит. Правая граница уже есть в витрине, поэтому он не считается пропущенным.
доходит до более позднего из двух хвостов. Так один прогон догоняет пакетный Конец диапазона — более поздняя из последней даты заказов в слепке и последнего
разгон длиннее K и не оставляет старое ожидание замороженным. прожитого дня с покупкой. Один запуск пересобирает весь отрезок,
даже если с предыдущего запуска прошло больше семи модельных дней.
Для учебной сводки брака действует отдельное правило: дни выбираются
по времени загрузки, а прежние итоги сохраняются после удаления
источника по TTL. Оно разобрано в
[задании `dq_errors_daily`](../labs/1-4-dq-errors-daily.md).
## Витрины стенда ## Витрины стенда
Публичные представления перечисляют деловые колонки и координаты запуска Представления `_v` явно перечисляют поля для отчетов, а также `_load_id`
`_load_id`, `_load_ts` явно. Новая колонка физической таблицы сама в договор не и `_load_ts` — идентификатор запуска и время записи. Новая колонка
попадет. физической таблицы появится в представлении только после изменения его SQL.
### `dm.revenue_daily_v` ### `dm.revenue_daily_v`
Зерно: день заказа × категория товара. Витрина читает только оплаченные Одна строка содержит итоги оплаченных заказов за день по категории товара.
заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает Запрос читает `dds.order_v` и разворачивает массивы артикулов, количеств
категорию артикула через `dic.products`. Один заказ с несколькими позициями и цен в строки позиций заказа. Артикул, количество и цена с одним индексом
категории считается в `orders` один раз. `units` складывает количество единиц, становятся одной строкой. Категория артикула берется из `dic.products`.
`revenue` складывает цену позиции × количество. Скидка, доставка и итог заказа
между категориями не делятся. `aov` равен `revenue / orders` в том же срезе и
хранит два знака после запятой.
Первый запуск строит всю доступную историю. Следующие берут границы дней из `orders` считает разные заказы: несколько позиций одной категории
последнего слепка ODS: только источник знает, какие даты еще может изменить. в заказе не увеличивают этот счетчик. `units` — сумма количества товаров,
Пропущенный оплаченный день расширяет левую границу, чтобы частичный отказ `revenue` — сумма произведений цены на количество. Скидка и доставка
не оставил дыру после следующего прогона. Агрегат при этом читает DDS; ODS в нее не входят. `aov` равен `revenue / orders` для того же дня и категории
отвечает только на вопрос об объеме. с округлением до двух знаков после запятой.
`dm.revenue_daily_rep` нарезана по `report_date`; день собирается в Первый запуск рассчитывает выручку за всю доступную историю. Следующие
таблице-доноре и заменяется через `REPLACE PARTITION`. Цель и донор шардируются берут диапазон дат оформления заказов из последнего слепка ODS.
по `cityHash64(product_category)`. Кроме дня, у витрины только категория, а Если в DDS есть оплаченные заказы за день, отсутствующий в витрине,
шесть категорий стартового мира близки по объему. Более сложный составной ключ левая граница расширяется до него. Агрегат читает 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` сначала сворачиваются по Одна строка сопоставляет события покупки с заказом магазина. События
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную из `dds.event_v` группируются по номеру заказа `purchase_id[1]`:
сумму, а отдельный счет событий оставляет класс `duplicate_event` по формату источника у покупки один номер и одна заявленная сумма.
наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны Отдельный счет событий позволяет обнаружить повтор покупки
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая и присвоить класс `duplicate_event`. Заказы берутся из `dds.order_v`.
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда
Полное соединение `FULL OUTER GLOBAL JOIN` сохраняет и покупки без заказа,
и заказы без покупки. Обе стороны записаны подзапросами: при прямом чтении
левой Distributed-таблицы каждый непарный заказ повторился бы по числу шардов
([исследование](../research/2026-08-22-distributed-join.md)). ([исследование](../research/2026-08-22-distributed-join.md)).
Непарная сторона остается `NULL` благодаря `join_use_nulls = 1`. При Если для строки нет пары, поля отсутствующей стороны получают `NULL`
`join_use_nulls = 0` ClickHouse заполнил бы ее нулем или пустой строкой и скрыл благодаря `join_use_nulls = 1`. При `join_use_nulls = 0` они заполнились бы
отсутствие источника. Клиентская `declared_revenue` и серверная `items_total` нулями или пустыми строками, которые сложнее отличить от данных источника.
сравниваются как `Decimal(18, 2)`. Классы проверяются по порядку: Клиентская `declared_revenue` и серверная `items_total` сравниваются
как `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`, но дают одного Несколько кук, связанных с одним пользователем магазина, учитываются
`known_user`, поэтому `known_users <= visitors`. в `visitors` по отдельности, а в `known_users` дают одного пользователя.
Поэтому `known_users <= visitors`.
Сессии и карта лежат по `client_id`, но публичные `_v` скрывают Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3
`Distributed`-таблицы от локальной подстановки. На ClickHouse 26.3 чтение через публичные `_v` с `distributed_product_mode = 'local'`
`EXPLAIN PLAN` показал чтение обоих шардов на инициаторе: настройка не дало локального соединения. Поэтому в запросе явно указан
`distributed_product_mode = 'local'` локального соединения не дала. Поэтому `GLOBAL LEFT JOIN`. Он сохраняет сессии без известного пользователя;
витрина использует явный `GLOBAL LEFT JOIN` через публичные договоры, а не в `known_users` учитываются только ненулевые `user_id`.
подменяет их физическими таблицами. Это дороже по сети, зато граница слоев Обновление всей истории новой версией описано в разделе
остается явной. Новая связь куки меняет прошлые дни, поэтому каждый запуск [«Ритм пересборки»](#ритм-пересборки).
пишет полную историю новой версией в `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`, объект — число строк
@@ -192,43 +215,47 @@ Distributed-таблица вернула бы каждый непарный з
сравниваются с прямыми агрегатами `dds.session_v` и сравниваются с прямыми агрегатами `dds.session_v` и
`dds.identity_map_v`. `dds.identity_map_v`.
Область каждой проверки выводится из объединения сторон. Полностью потерянный Каждая проверка берет дни и ключи из обоих сравниваемых наборов данных.
день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут Поэтому потерянная строка остается видна на другой стороне сравнения.
скомпенсировать друг друга общим итогом: `failed_rows` считается после Расхождения считаются по ключам: завышение одного показателя не скроет
сопоставления деловых ключей. занижение другого в общей сумме за день.
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она `classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая
подтверждает, что до витрины доехал записанный в паспорте мир, но не проверяет проверка доставки и обработки эталонных данных стенда. Она не проверяет
сам генератор: у настоящего магазина такого оракула нет. сам генератор; у настоящего магазина такого эталона нет.
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой сводку и несколько примеров расхождений. Техническая сверка с паспортом читает небольшой
файл JSON в Python. Все пять сборок пишут в общий донор. После их успеха даг файл JSON в Python. Результаты всех пяти проверок записываются в общую
заменяет проверенные дневные партиции. Затем именованные задачи читают промежуточную таблицу. Когда расчеты завершились, ее дневные разделы
опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает заменяют разделы `dm.dq_summary_rep`. Затем отдельные задачи читают
сохраниться до красного завершения, а ошибка сборки не затирает прежний сохраненную сводку и завершаются ошибкой при `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-проверки открывайте после собственного решения: они содержат
расчет существенной части задания.
## Отклонённые варианты ## Отклонённые варианты
@@ -254,7 +281,10 @@ business_key`.
- **Инкрементальный пересчёт затронутых дней по `first_matched_at`.** - **Инкрементальный пересчёт затронутых дней по `first_matched_at`.**
Производственный ход, но на объёмах стенда его экономия неизмерима, а Производственный ход, но на объёмах стенда его экономия неизмерима, а
след новых связок требует вернуть колонку, выкинутую в #133. Стал лабой след новых связок требует вернуть колонку, выкинутую в #133. Стал лабой
повышенной сложности — issue #149. повышенной сложности —
[«Как обновлять витрину без полной пересборки»](../labs/optional/incremental-daily-traffic.md).
В ней новые связи находятся сравнением наблюдений: время покупки
не заменяет момент обнаружения связи.
- **Superset поверх DDS без слоя DM.** Терялся бы урок «витрина — контракт - **Superset поверх DDS без слоя DM.** Терялся бы урок «витрина — контракт
под потребителя», против раздела 7 спеки. под потребителя», против раздела 7 спеки.
+107 -82
View File
@@ -1,28 +1,23 @@
# Дневной конвейер трансформаций # Дневной конвейер трансформаций
Как принятые источники превращаются в модель DDS и витрины DM: кто запускает Даг `etl_pipeline` собирает модель DDS, обновляет витрины DM и проверяет
трансформации, когда и в каком объёме. Правила самих сущностей — зерно, результат. Здесь описаны запуск, выбор дней и запись результата в таблицы.
движки, что именно пересобирается — живут в доках слоя Состав моделей — в [справочниках DDS](dds/README.md) и [DM](dm.md).
([карта модели DDS](dds/README.md)); здесь — только конвейер.
Дневная сборка вперёд появляется на этапе 4 и остаётся основным режимом
конвейера. На этапе 5 к ней добавляется партиционная переобработка: запуск
произвольного прошлого дня по команде.
## Форма ## Форма
Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы `etl_pipeline` последовательно запускает `dds_transform`, `dm_transform`
у него нет: он дёргает слойные даги последовательно и ждёт каждый — и `dq_check`. Каждый следующий даг начинается после успеха предыдущего.
`dds_transform`, затем `dm_transform` и `dq_check`. Состав проверок описан в Для этого `TriggerDagRunOperator` ждет завершения запущенного им дага.
[доке слоя DM](dm.md). Если тот завершился ошибкой, задача ожидания в конвейере тоже завершится
ошибкой.
Поведение проверено через Context7 по документации Airflow 3.x (3.1.6).
Ждущие вызовы сделаны `TriggerDagRunOperator`. Проверка через Context7 по У этих дагов нет расписания. Их вызывает конвейер; для отдельного разбора
ближайшей доступной документации Airflow 3.x (версия 3.1.6) подтвердила, что можно запустить даг слоя вручную. У каждого разрешен один активный запуск:
оператор опрашивает точный созданный им прогон и краснеет вслед за потомком. два запуска одного дага используют одни промежуточные таблицы и могли бы
стереть результаты друг друга. Расчет каждой модели выполняется отдельной
Слойные даги устроены по образцу `orders_ingest`: без расписания, один задачей или группой задач.
активный прогон, запускаются триггером конвейера или рукой при разборе
происшествий. Витрины внутри `dm_transform` — отдельные задачи.
## Три пути запуска ## Три пути запуска
@@ -34,83 +29,113 @@ 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`. Если трансформация упала, день
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но уже прожит и позиция не откатывается. После исправления причины
зеленеть, не узнав, разложился ли день, шаговый даг не должен ([ADR запустите `etl_pipeline` вручную.
0003](../adr/0003-dag-run-state.md)). Такой отказ чинится перезапуском - `world_next_day` отправляет дни пачкой и ждет приема заказов, но конвейер
`etl_pipeline` рукой — перезапуску ничего не нужно помнить, см. «Объём не запускает. После него запустите `etl_pipeline` сами.
прогона». - `world_initialize` запускает конвейер последним шагом: при успешном
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: даги модельного первом `make up` начальные данные уже собраны в DDS и DM.
времени управляют генератором и только им. После разгона менти запускает
конвейер сам — и видит, как слои догоняют уехавший вперёд мир.
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
ручного запуска начинаются со следующих дней.
## Объём прогона ## Объём прогона
Параметров у конвейера нет: объём он выводит сам из `world_position` и Объем прогона — дни, которые обрабатывает один запуск. Даты передавать
состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по не нужно: даги определяют их по состоянию таблиц и `world_position`.
последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта Например, после генерации семи дней без ETL один запуск рассчитает сессии
идентичностей пересчитывается целиком; правила витрин — у за все семь дней.
[слоя DM](dm.md).
Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и
после падения, а разгону на семь дней хватает одного прогона.
Окно заказов задаёт последний слепок, наблюдаемый в ODS. У каждого заказа Для расчета сессий выбираются дни от первого, за который в DDS еще нет
`snapshot_date` равен последнему слепку, в котором источник его показал. сессий, до последнего прожитого.
Трансформация выбирает строки с максимальной `snapshot_date` и берёт среди них `world_position` хранит номер следующего дня для генерации, поэтому
минимальный и максимальный `order_date`. Это границы дней, состояние которых последний прожитый день равен `min(EventDate) + world_position - 1`.
источник ещё может изменить Начало календаря берется из ODS; события ранних дней там не удаляются по TTL.
([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Кроме живого окна, Один `max(EventDate)` не годится: события за день есть и во время его
трансформация закрывает пропуски: находит самый ранний день, представленный в генерации, когда последние визиты еще не закончились.
ODS и ещё не собранный в DDS, затем при необходимости расширяет до него левую Точный запрос — [`session_scope.sql`](../../sql/dds/session_scope.sql).
границу.
Позиция мира заказам не отвечает: она говорит, какой день прожит, а не какой Для заказов диапазон задает последний принятый слепок ODS. Среди строк
доехал. Прогон дня D везёт слепок дня D − 1, да и ставится позиция раньше с максимальной `snapshot_date` выбираются самая ранняя и самая поздняя
трансформации, так что после неудачного приёма обгоняет хранилище. Слой обязан даты оформления заказа. Состояние заказов за эти дни еще может измениться.
считать объём по тому, что у него есть, а не по тому, что мир о себе заявляет. Если в ODS есть заказы за более ранний день, а в DDS за него нет строк,
начало диапазона сдвигается до этого дня. Запрос заново рассчитывает
заказы за все дни выбранного отрезка.
Для сессий границу задаёт `world_position`: она отличает полностью прожитый Позиция мира для выбора дней заказов не подходит: с событиями дня D
день от ещё идущего. Сами события этой границы не показывают: и посреди дня D, приходит слепок за D−1. Объем рассчитывается по принятым строкам ODS;
и сразу после его конца их позднейший день равен D. Если собрать сессии посреди их наличие еще не доказывает полноту слепка.
дня, визит, начатый в 23:50, останется обрезанным: завершённый день слой
повторно не собирает. Поэтому последний прожитый день равен первому дню Карта идентичностей пересчитывается целиком. Витрины выбирают дни
событий плюс позиция минус один по своим источникам — см. [ритм пересборки DM](dm.md#ритм-пересборки).
([`sql/dds/session_scope.sql`](../../sql/dds/session_scope.sql)). Если за день сохранилась хотя бы одна строка, запрос не считает его
Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не пропущенным. Полноту данных проверяют отдельно.
позиция, а «Готовность источника».
## Готовность источника ## Готовность источника
Первый шаг `dds_transform` проходит два барьера за общий срок в 300 секунд. Перед сборкой DDS задача `wait_for_source` последовательно ждет двух
Сначала клиент `confluent-kafka` сверяет подтвержденные смещения группы условий:
`clickstream_hits` с верхними смещениями обоих разделов `hits`. Группа, оба
раздела и все смещения должны существовать. Суммарное отставание должно стать
нулевым. Каждый опрос пишет в журнал оба смещения и отставание по разделу.
Только после этого шаг проверяет обе ноды ClickHouse через 1. Группа `clickstream_hits` дочитала оба раздела топика `hits`.
`clusterAllReplicas`: у `ods.event_dist` в `system.distribution_queue` не Подтвержденные смещения сравниваются с верхними смещениями Kafka.
должно остаться файлов. Каждый опрос пишет в журнал ноды, число файлов и Группа и смещения должны существовать, суммарное отставание — равняться нулю.
`last_exception` очереди. Оба барьера опрашиваются раз в три секунды и делят 2. В `system.distribution_queue` у `ods.event_dist` не осталось файлов
один крайний срок. После Kafka срок не отсчитывается заново. доставки на шарды. Запрос через `clusterAllReplicas` читает обе ноды.
Зелёный приём заказов о событиях не говорит ничего — топики разные и Опросы идут с паузой в три секунды; на обе стадии задан общий срок
потребители разные. Одного нулевого отставания Kafka тоже мало: чтец мог 300 секунд. Журнал показывает смещения и отставание Kafka, затем число
подтвердить сообщение раньше, чем Distributed-таблица доставила файл на шард. файлов и `last_exception` очереди на каждой ноде. Если готовность не
Два барьера закрывают обе части пути до того, как сессии выберут свой объем. подтверждена за срок ожидания, задача завершается ошибкой.
Одного нулевого отставания Kafka мало: сообщение может быть подтверждено,
пока Distributed-таблица еще доставляет его на шард. Успех `orders_ingest`
тоже не заменяет эти проверки: у заказов другой топик и потребитель.
Эти условия проверяют доставку на момент опроса. Они не останавливают
поступление новых событий и не сверяют содержимое таблиц. Поэтому
`world_position` отдельно ограничивает сборку сессий прожитыми днями,
а качество результата проверяет `dq_check`.
## Идемпотентность ## Идемпотентность
Партиция не заменяется простой вставкой. День собирается в таблицу-двойник — Идемпотентность означает, что повторная обработка тех же исходных данных
вставка через Distributed раскладывает строки по шардам, — затем не добавляет копии деловых строк. Служебные `_load_id` и `_load_ts`
`REPLACE PARTITION` на кластере атомарно подменяет кусок каждого шарда. Эта при новой сборке меняются.
механика — часть самих трансформаций и строится этапом 4: без неё дневной
прогон не повторяем. Сущности, пересчитываемые целиком, идемпотентны и без Результат дневного расчета сначала записывается в промежуточную таблицу.
замены: полная перезапись в `ReplacingMergeTree` — так живут карта Она нужна, чтобы читатели продолжали видеть прежние данные, пока новые
идентичностей и витрина `daily_traffic` ([слой DM](dm.md)). еще рассчитываются. Например, для сессий это `dds.session_stage_rep`,
а основная таблица — `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`. Старые версии
могут физически оставаться до фонового слияния; повторяемость относится
к результату чтения, а не к числу строк на диске.
## Чего конвейер о себе не знает ## Чего конвейер о себе не знает
+135 -204
View File
@@ -1,241 +1,172 @@
# Приём заказов из Kafka в ODS # Прием заказов из Kafka в ODS
Учебный результат: менти различает версию бизнес-сущности, наблюдение источника Здесь разобран путь одного заказа: сообщение Kafka сохраняется в STG,
и запуск загрузки, а затем читает физические версии через явную поверхность проверяется и попадает либо в ODS, либо в таблицу ошибок. Следующие слои
текущего состояния. читают из ODS последнюю принятую версию каждого заказа.
## Проблема
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
отдельными сообщениями и не сообщает потребителю, где слепок закончился.
Прямое чтение Kafka Engine возвращает одну порцию без признака полноты.
Поэтому `snapshot_date` не может служить границей публикации: `REPLACE
PARTITION` могла бы заменить партицию дня неполным набором строк.
Одновременно типизированный ODS не должен принимать правдоподобные значения по
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
## Цели
- сохранить пакетный забор как контраст потоковому приёму событий;
- один раз принять байты в STG и независимо разложить строки на годные и брак;
- хранить в ODS типизированные версии заказов, не публикуя слепок заменой
партиции;
- дать следующим слоям один корректный способ прочитать текущее состояние;
- оставить код проверки коротким и ограничить его контрактом провода.
## Не входит
- модель заказа в DDS: её зерно, связи, материализация и способ наполнения;
- проверка полей внутри `items`, переходов статуса и равенств денежных сумм;
- удаление заказа по отсутствию в следующем слепке;
- маркер конца слепка, опись ожидаемых строк и транзакция между целями ODS.
## Поток данных ## Поток данных
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и Источник отправляет в топик `orders` дневной слепок: по одному сообщению
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а на заказ. Слепок дня D содержит состояния заказов, оформленных в дни
запускает его шаговый даг модельного времени, который положил слепок в топик и D−6…D, с учетом [задержек первого появления](fate.md).
ждёт конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow. В начале модельного времени окно короче. Выгрузку за D отправляет
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта. генератор при запуске дня D+1; подробности — в [описании слепка](snapshot.md).
Запрос забора лежит в
[`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
`SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два После отправки шаговый даг модельного времени запускает
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый [`orders_ingest`](../../../dags/orders_ingest.py) и ждет его завершения.
пишет годные строки в `ods.order`, второй — брак в `ods.order_errors`. У `orders_ingest` нет расписания; его два шага выполняются по порядку:
Транзакции между запросами нет. При частичном сбое Airflow повторяет весь
`task_id`; одинаковые исходные строки и служебные метки не вычисляются заново.
Обе вставки лежат в одном самодостаточном файле
[`order_parse.sql`](../../../sql/ods/order_parse.sql). Airflow сначала
шаблонизирует файл, затем делит получившийся SQL на выражения и исполняет их по
порядку в одной задаче `parse_batch`.
Условия запросов взаимодополняющие: один общий предикат определяет годную 1. `pull_batch` читает порцию из `stg.orders_raw_kafka` и записывает ее
строку, а ветвь брака использует его буквальное отрицание. Все функции предиката через `stg.orders_raw_dist`. Содержимое сообщения остается строкой `raw`,
возвращают результат без исключения, а сам предикат всегда заканчивается в рядом сохраняются координаты Kafka, `_load_id` и `_load_ts`.
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для 2. `parse_batch` читает сохраненные строки с этим `_load_id`. Первый
этого не нужен. Предикат объявлен локальным макросом Jinja внутри `INSERT SELECT` пишет годные записи в `ods.order_dist`, второй —
`order_parse.sql` и разворачивается в оба выражения. Макрос убирает остальные в `ods.order_errors_dist`.
дублирование, но не вводит отдельный фрагмент, который нельзя исполнить
самостоятельно. `_load_id` равен `run_id` Airflow и связывает строки одного запуска.
`_load_ts` — реальное время записи в STG; при разборе оно переносится
без пересчета. Запросы лежат в
[`orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql) и
[`order_parse.sql`](../../../sql/ods/order_parse.sql).
`SQLExecuteQueryOperator` передает `run_id` параметром ClickHouse `load_id`.
Две вставки разбора используют одно условие годности, записанное макросом
Jinja внутри `order_parse.sql`. Перед выполнением макрос подставляется
в оба запроса; ветвь брака берет отрицание этого условия. Оно возвращает
только истину или ложь и не вызывает исключений на некорректном `raw`.
Так каждая строка сохраненной порции получает ровно один результат.
Транзакции между вставками нет: первая может завершиться, а вторая упасть.
Повтор `parse_batch` выполняет обе вставки над тем же срезом STG.
Текущее состояние заказа останется прежним, но таблица ошибок может
получить повторные строки с теми же `_load_id` и координатами Kafka.
Обе вставки ждут доставки строк на шарды и используют ключ целевой таблицы;
настройки объяснены в [справочнике хранилища](../storage.md#раскладка-по-шардам).
## Граница строгого приёма ## Граница строгого приёма
Единица решения — одна строка `stg.orders_raw`. Корень должен быть Проверяется одна строка `stg.orders_raw_dist`. Корень JSON должен быть
JSON-объектом с точным набором ключей: `order_id`, `user_id`, `status`, объектом с точным набором из одиннадцати ключей:
`created_at`, `updated_at`, `items_total`, `discount`, `delivery`, `total`, `order_id`, `user_id`, `status`, `created_at`, `updated_at`,
`items`, `snapshot_date`. `items_total`, `discount`, `delivery`, `total`, `items`, `snapshot_date`.
Скалярные поля проверяются по типу JSON. Деньги дополнительно обязаны быть | Поля | Условия приема |
строками неотрицательной суммы с ровно двумя знаками после точки — минус в эту |---|---|
форму не входит; времена — строками RFC 3339 в UTC с обязательными | `order_id`, `status` | Строки JSON |
миллисекундами; дата слепка — строкой `YYYY-MM-DD`. `items` проверяется только | `user_id` | Целое число JSON, которое разбирается в `UInt64` |
как JSON-массив. Каноническая форма и основания выбора зафиксированы в | `created_at`, `updated_at` | Строки UTC с миллисекундами, например `2026-06-01T12:34:56.000Z`; проверяются форма и разбор времени |
[исследовании формата](../../research/2026-08-16-order-snapshot-wire-format.md). | `items_total`, `discount`, `delivery`, `total` | Неотрицательные суммы в рублях, записанные строками с двумя знаками после точки; проверяется разбор в `Decimal(18, 2)` |
| `snapshot_date` | Строка даты `YYYY-MM-DD`, которая проходит разбор |
| `items` | Массив JSON; сохраняется целиком в строковой колонке |
Проверять все верхнеуровневые поля здесь уместно: их одиннадцать, и десять ODS проверяет формат верхнего уровня. Здесь нет проверки допустимых
скалярных значений непосредственно образуют типизированную строку заказа. У статусов, их переходов, полей внутри `items` или равенства денежных сумм.
события из 47 полей проверяются только пять опорных; переносить то сокращение на Позиции разбираются в [DDS](../dds/order.md); проверку их суммы участник
малый контракт заказа нет причины. Граница строгости заканчивается на форме добавляет в [задании ступени 5](../../labs/5-1-order-checks.md).
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже. Основания выбора формата — в [исследовании](../../research/2026-08-16-order-snapshot-wire-format.md).
Контракт источника задаёт частоту: **одной дате соответствует один слепок**. Это Класс ошибки записывается в `error_class` по первому нарушенному условию:
свойство всего слепка, поэтому предикат одной строки его не проверяет. Вторая
выгрузка того же дня даст ничью по `snapshot_date`, и выбор строки потеряет
однозначность. Модельный источник выполняет условие; в настоящей интеграции
его фиксируют в договоре с источником
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)).
Класс брака выбирается первым совпадением: 1. `not_an_object` — корень JSON не объект;
2. `keyset_mismatch` — набор ключей отличается от ожидаемого;
3. `field_invalid` — тип, форма или разбор значения не прошли проверку.
1. `not_an_object`; Имя поля в класс не входит. Для разбора конкретной ошибки в
2. `keyset_mismatch`; `ods.order_errors_dist` есть исходный `raw`, координаты Kafka и метки загрузки.
3. `field_invalid`.
Имя отдельного поля в класс не включается. В таблице ошибок остаются сырой
текст, метаданные доставки и `_load_id`, поэтому единичный случай можно разобрать
без постоянной детализации предиката.
## Роль ODS ## Роль ODS
`ods.order_rep` хранит физически принятые версии в `ods.order_rep` использует `ReplicatedReplacingMergeTree(snapshot_date)`.
`ReplacingMergeTree(snapshot_date)`. Ключ сортировки — `order_id`, партиция — Строки с одним ключом сортировки `order_id` считаются версиями одного заказа;
день неизменного `created_at`. `ods.order_dist` шардирует по остается строка с максимальной `snapshot_date`. Партиция —
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и `toDate(created_at)` в UTC, ключ шардирования — `cityHash64(order_id)`.
`FINAL` даёт корректный результат через распределённую таблицу. Неизменный `created_at` и общий ключ направляют все версии заказа
в одну партицию и на один шард.
Четыре координаты отвечают на разные вопросы: | Колонка | На какой вопрос отвечает |
|---|---|
| `snapshot_date` | На конец какого модельного дня источник показал это состояние? |
| `updated_at` | Когда запись заказа последний раз меняли в магазине? |
| `_load_id` | Какой запуск Airflow принял сообщение? |
| `_load_ts` | Когда сообщение записали в STG по реальным часам стенда? |
- `snapshot_date` — в слепке какого модельного дня источник показал строку; Например, заказ не менялся с 1 июня, но входит в слепки за 1 и 2 июня.
- `updated_at` — когда строку последний раз меняли в источнике; `updated_at` у строк одинаков, а `snapshot_date` различается. ODS оставит
- `_load_id` — какой запуск Airflow принял строку; строку за 2 июня вместе с метками ее доставки. Новая дата наблюдения
- `_load_ts` — когда строка приехала в хранилище. нужна DDS для определения окна пересборки, даже если состояние не изменилось.
Версию выбирает `snapshot_date`: выживает строка с максимальной датой слепка Источник выпускает **один слепок на дату**. Повторная отправка дает то же
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Поэтому содержимое, а более поздний слепок не возвращает устаревшее состояние.
`snapshot_date` показывает последнее наблюдение заказа. По строкам последней Эти условия не проверяются на одной строке при приеме. Если источник
даты слепка DDS определяет окно пересборки. `_load_id` и `_load_ts` принадлежат пришлет два разных состояния с одной `snapshot_date`, эта колонка
той физической строке, которую сохранило слияние, и описывают её доставку. не позволит определить, какое из них актуально.
Обоснование версии — [ADR 0014](../../adr/0014-order-version-snapshot-date.md).
Схема версий стоит на дисциплине источника: слепок с поздней датой не `ods.order_v` читает `ods.order_dist FINAL` и возвращает одну текущую строку
показывает состояния старше. Модельный мир её держит — состояние читается на на `order_id`, даже если фоновые слияния еще не убрали старые версии.
границе суток, и слепок дня выходит чистой функцией зерна и дня; источник, Без `FINAL` физическая таблица может показать несколько строк заказа.
собирающий слепок из отставшей реплики, её ломает. Деловая метка `updated_at` Историю ODS не гарантирует: старые версии постепенно удаляются при слияниях.
при этом монотонна по слепкам, поэтому выбор по дате слепка даёт тот же ответ, Отсутствие заказа в новом слепке не удаляет его из ODS.
что выбор по метке (замер — «Что проверено»). Тихая правка строки — ручным
`UPDATE` мимо приложения, миграцией — метку не двигает, но следующий слепок
перекроет её сам, пока заказ в окне изменяемости. За окном источник заказ
больше не показывает, и там помогает только сверка самих полей, в терминах
dbt snapshots — стратегия `check` вместо `timestamp`.
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для DDS читает `ods.order_v`, переводит времена в пояс счетчика и разбирает
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории. позиции. Для своей загрузки DDS записывает собственные служебные метки —
`ods.order_v` сохраняет те же источник-ориентированные поля без обогащения [модель заказа](../dds/order.md#служебные-колонки).
данными модели, но возвращает одну актуальную версию на `order_id`. Сначала оно
может быть простым представлением над `_dist FINAL`; способ выбора можно
заменить, не меняя потребителей.
Это представление остаётся ответственностью ODS: оно скрывает механику чтения
версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает,
какие сущности, связи и производные признаки ему нужны. Судьба служебных
колонок выше ODS решена вместе с моделью DDS: чужие ярлыки туда не переезжают
([модель заказа](../dds/order.md)).
## Одно чтение Kafka ## Одно чтение Kafka
Стандартный слепок содержит около полутора тысяч строк, тогда как предел одной Один запуск делает один прямой `SELECT`, без чтения в цикле до пустоты.
порции на стенде — десятки тысяч сообщений. Поэтому один запуск Airflow делает Обычный слепок содержит около полутора тысяч строк, а предел порции
один прямой `SELECT`, без цикла до пустоты и без фиксации конечных офсетов. на стенде — десятки тысяч сообщений. Это допущение о размере данных:
Kafka не сообщает запросу, что прочитан весь слепок.
Это допущение о размере стенда, а не доказательство полноты слепка. Если Kafka Короткая порция оставляет непрочитанные сообщения для следующих запусков.
вернёт короткую порцию, непрочитанный хвост останется в топике и приедет в один Поэтому ODS добавляет версии заказов по мере поступления. Замена партиции
из следующих запусков. После отказа от замены партиции это задержка, а не потеря по `snapshot_date` могла бы опубликовать неполный набор заказов
или публикация неполного дня. ([ADR 0010](../../adr/0010-order-versions-in-ods.md)).
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты Сбой после чтения опаснее короткой порции. Чтец подтверждает позиции
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с сообщений в Kafka — офсеты — при прямом чтении. Если последующая вставка
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде. в STG упадет, эти сообщения уже не будут прочитаны той же группой повторно;
Позиция мира при этом не двигается, и тот же день уедет заново — в STG он часть из них может успеть записаться на шарды. Позиция модельного времени
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся при неуспехе не продвигается. Повтор шагового дага снова отправит слепок
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»). того же дня, но не восстановит старые сообщения других слепков,
если они попали в неудачную порцию.
## Отклонённые варианты Граница восстановления описана в [ADR 0008](../../adr/0008-order-ingestion.md#следствия).
- Партиционная идемпотентность — `REPLACE PARTITION snapshot_date` или
`ReplacingMergeTree` по `(snapshot_date, order_id)`: у потребителя нет
признака полноты партиции, а дата наблюдения становится частью ключа
сущности.
- Обычный `MergeTree` в ODS с дедупликацией только в DDS: навсегда сохраняет
технические повторы там, где семантика версии уже известна.
- Маркер, опись, чтение до пустоты или конечные офсеты: добавляют протокол ради
объёма, который с большим запасом помещается в одну порцию.
- Два `task_id` или материализованный классификатор: дробят один короткий
переход слоя, не добавляя транзакционности.
- Представление текущего состояния в DDS и готовая схема `dds.order` в этой
задаче: перекладывают механику ODS на следующий слой и преждевременно задают
модель данных.
## Риски и проверка
- На малой управляемой порции дать по одной строке каждого класса брака и две
годные версии одного `order_id`. Две цели должны сохранить все непустые
сообщения, а `ods.order_v` — вернуть новую версию независимо от фонового
слияния.
- Повторить переход с тем же `_load_id`: строка в `ods.order_v` и её `_load_ts`
не должны измениться; версии одного заказа должны остаться на одном шарде и
в одной партиции. Таблица ошибок может снова записать тот же брак: совпавшие
`_load_id` и Kafka-координаты показывают повтор задачи.
## Что проверено ## Что проверено
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94. Это результаты прежних опытов на стенде; при редактуре они заново
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение строк STG на не выполнялись.
годные строки и брак полное и непересекающееся; две версии одного заказа легли в
одну партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от
фонового слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
`_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
опирается, — [документ хранилища](../storage.md), «Что проверено».
После исправления - 18 августа 2026 года (#93, #94) прямое чтение приняло слепок,
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102) а разбор разделил строки на годные и ошибки без потерь и пересечений.
честный прогон дня снят на живом стенде 20 августа 2026 года: все 1694 строки Две версии заказа попали на один шард в одну партицию; `ods.order_v`
слепка дня 7 приняты, `ods.order_errors` осталась пустой. У заказа вернул позднюю. Повтор разбора с тем же `_load_id` сохранил текущую строку
`20260608-0007`, который прежде приходил с отрицательным итогом, теперь и ее `_load_ts`, но повторил ошибки. Подробности —
`5490.00 − 823.50 + 299.00 = 4965.50`. [справочник хранилища](../storage.md#что-проверено) и
[ADR 0008](../../adr/0008-order-ingestion.md#что-проверено).
- 20 августа после исправления
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
приняты все 1694 строки слепка дня 7 без ошибок. Итог заказа
`20260608-0007`: `5490.00 − 823.50 + 299.00 = 4965.50`.
- 21 августа (#124) пересборка через `SQLExecuteQueryOperator` завершила
`pull_batch` и `parse_batch`; в `system.query_log` осталось по одному
завершенному запросу `etl` на забор, годные строки и ошибки.
- 22 августа (#137) во всех 3034 парах с одинаковым `updated_at`
деловые поля совпали побайтово; по слепкам время изменения не убывало.
- 26 августа (#165) после двух слепков физическая таблица содержала
3356 версий 1917 заказов. В `ods.order_v` у всех заказов была дата
последнего слепка, а деловые поля совпали с `argMax` по `updated_at`
из сырых данных.
При исполнении #124 21 августа 2026 года адресная пересборка мира прошла через На ClickHouse `26.3.17.56` отдельно проверены одна порция при прямом
`SQLExecuteQueryOperator`: `pull_batch` и `parse_batch` завершились успешно. В `SELECT` Kafka и выполнение `FINAL` на шардах через Distributed.
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу При проектировании Context7 был недоступен; чтение сверяли с исходниками
пользователя `etl` для забора в STG, записи годных строк и записи брака. той же версии:
Дисциплина источника замерена 22 августа 2026 года при исполнении #137.
Во всех 3034 парах строк с равным `updated_at` деловые поля совпали
побайтово, а по слепкам метка ни разу не убывала.
Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
исполнении #165. После двух слепков физическая таблица содержала 3356 версий
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним слепком
заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из сырых
данных. В обеих сверках расхождений нет.
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
офсеты коммитятся, а сбой забора не двигает позицию мира. Числа — [ADR
0008](../../adr/0008-order-ingestion.md), раздел «Что проверено».
MCP Context7 в сессии проектирования был недоступен. На локальном ClickHouse
`26.3.17.56` проверено, что прямой `SELECT` Kafka Engine завершается после
одной порции, а `FINAL` через `Distributed` исполняется на таблицах шардов.
Поэтому версии одного `order_id` направляются на один шард. Фоновое схлопывание
`ReplacingMergeTree` и необходимость точного чтения сверены с
[официальной документацией](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree),
поведение чтения — с исходниками той же версии
[`StorageKafka.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/StorageKafka.cpp) и [`StorageKafka.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/StorageKafka.cpp) и
[`KafkaSource.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/KafkaSource.cpp). [`KafkaSource.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/KafkaSource.cpp).
Повторная сверка правил версий с документацией — в
[справочнике хранилища](../storage.md#актуальные-события).
## Связанные решения ## Связанные решения
+7 -1
View File
@@ -22,7 +22,7 @@
## Пользователи и роли ClickHouse ## Пользователи и роли ClickHouse
В ClickHouse семь пользователей и шесть ролей: В ClickHouse восемь пользователей и семь ролей:
- `etl` — учётная запись рабочих дагов Airflow; роль `etl_writer` читает и - `etl` — учётная запись рабочих дагов Airflow; роль `etl_writer` читает и
пишет слои хранилища; пишет слои хранилища;
@@ -33,6 +33,9 @@
справочники `dic`, а запросы Superset обращаются к публичным `_v`; справочники `dic`, а запросы Superset обращаются к публичным `_v`;
- `analyst` — учётная запись аналитика с правом чтения всех слоёв и - `analyst` — учётная запись аналитика с правом чтения всех слоёв и
справочников; справочников;
- `student` — учебная учётная запись: роль `analyst_reader` дает чтение
источников, а `student_writer` — создание, заполнение и удаление своих
таблиц в `default`. Записи в слои хранилища у нее нет;
- `grafana` — учётная запись Grafana; роль `monitoring_reader` читает все слои, - `grafana` — учётная запись Grafana; роль `monitoring_reader` читает все слои,
системные таблицы и данные со всех реплик кластера; системные таблицы и данные со всех реплик кластера;
- `dict` — беспарольная учётная запись словаря товаров с правом чтения базы - `dict` — беспарольная учётная запись словаря товаров с правом чтения базы
@@ -49,6 +52,9 @@ Superset получает пароль `bi`, а Grafana — пароль `grafan
## Применение настроек ## Применение настроек
Для существующего стенда сначала добавьте `CLICKHOUSE_STUDENT_PASSWORD`
из `.env.example` в свой `.env`.
Чтобы применить новые значения ClickHouse из `.env` или изменения файлов в Чтобы применить новые значения ClickHouse из `.env` или изменения файлов в
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/`, пересоздайте ноды: `infra/clickhouse/config.d/` и `infra/clickhouse/users.d/`, пересоздайте ноды:
+277 -319
View File
@@ -30,223 +30,202 @@ 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`
только по `Distributed`» такую тишину переживает плохо. обращается к каталогу товаров, а `dic.products_file` читает его файл.
Второе свойство — в списке по алфавиту объекты группируются по сущности, а не по В STG имя берется из топика: `hits_raw`, `orders_raw`. В типизированных
технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются слоях оно называет сущность в единственном числе: `event`, `order`,
хвостом, а не началом имени. Речь про дерево в клиенте и про `ORDER BY name`: `session`. Объекты одной сущности различаются суффиксом и стоят рядом
порядок выдачи `SHOW TABLES` документация не оговаривает. при сортировке имен по алфавиту.
Начало имени — сущность, и берётся она в разных слоях из разных мест. В STG имя `_rep` и `_dist` нужны для загрузки, диагностики и обслуживания. Роль
приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем `bi_reader` может читать их в ODS, DDS и DM, но для обычного анализа
топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях принято использовать `_v`. Это соглашение о чтении, а не ограничение прав;
имя приходит от предметной области и стоит в единственном числе: `event`, причина широкого доступа описана в
`order`, `session`. Граница между «как привезли» и «что это такое» [ADR 0007](../adr/0007-clickhouse-access.md) и
проходит по 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(order_id)` у У событий ключ — `cityHash64(ClientID)`, у заказов —
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш `cityHash64(order_id)`, у 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` ее не показывает.
Разные ключи у слоёв ставят капкан на вставке `INSERT INTO цель_dist SELECT … События, `dds.session_dist` и `dds.identity_map_dist` распределены
FROM источник_dist`. Заводское значение `parallel_distributed_insert_select` — по `cityHash64(client_id)` (в ODS поле называется `ClientID`). Данные
2: если обе таблицы на одном кластере, вставка исполняется локально на каждом одного посетителя лежат вместе — это ко-локация. Заказы DDS и их сверка
шарде, минуя ключ шардирования цели. Строки наследуют раскладку источника, и сохраняют раскладку по `cityHash64(order_id)`. Обычное соединение допускается
ошибки при этом нет — расхождение видно только пересчётом ключей по по ключу ко-локации; для остальных соединений применяется `GLOBAL`.
`hostName()`. Так половина заказов и легла на оба шарда, где Само совпадение ключей еще не гарантирует локального выполнения запроса
`ReplacingMergeTree` через границу шарда дубли не схлопывает (замер — «Что через представления — пример есть в [описании `daily_traffic`](dm.md#dmdaily_traffic_v).
проверено», #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` для чтения с прежними данными. Например, заказы записываются через
шардируются по `cityHash64(client_id)`. Объекты DDS с зерном заказа сохраняют `dds.order_stage_dist` в `dds.order_stage_rep`, затем ее дневные разделы
ко-локацию по `cityHash64(order_id)`; ключи остальных частей модели и витрин заменяют разделы основной `dds.order_rep`.
появляются вместе с ними.
Замена партиций не используется для `ods.order`: версии прошлых дней У промежуточной и основной таблиц должны совпадать структура, ключи
доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR партиции и сортировки, первичный ключ и политика хранения. Поэтому
0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка `dds.order_stage_rep` объявлена через `AS dds.order_rep` с совпадающими
понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее ключами. Требования сверены через Context7 с
разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким [документацией ClickHouse](https://clickhouse.com/docs/sql-reference/statements/alter/partition#replace-partition)
объектом, а не переносить на ODS заранее. для схемы на ClickHouse 26.3. DDL показан в
Первым такой донор понадобился заказу 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)).
## Служебные колонки ## Служебные колонки
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок: STG сохраняет исходное сообщение в строке `raw` и добавляет сведения
`_topic`, `_partition`, `_offset`, `_timestamp` у Kafka, `_shard_num` у о доставке. JSON на этом шаге не разбирается: некорректное содержимое
`Distributed` и прочие. Если положить на диск колонку с таким же именем, в тоже попадает в таблицу.
матвью перестанет читаться, что дано движком, а что положено нами, — а это
ровно то различие, ради которого метаданные доставки и хранятся. Поэтому они
ложатся под именами `kafka_topic`, `kafka_partition`, `kafka_offset`,
`kafka_timestamp`, рядом — `consumer_host`, имя читавшей ноды: виртуальные
колонки его не несут, а после записи в `Distributed` он уже невосстановим.
Типы у них такие: `kafka_topic` и `consumer_host` — `LowCardinality(String)`, | Колонка STG | Что хранит |
значений мало и они повторяются; `kafka_partition` и `kafka_offset` — `UInt64`. |---|---|
С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок | `kafka_topic` | Имя топика из виртуальной колонки Kafka `_topic` |
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и | `kafka_partition` | Номер партиции из `_partition` |
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена | `kafka_offset` | Позицию сообщения внутри партиции из `_offset` |
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера | `kafka_timestamp` | Метку времени Kafka из `_timestamp_ms`, если она есть |
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а сырой слой | `consumer_host` | Имя ноды, прочитавшей Kafka |
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от | `_load_ts` | Реальное время записи сообщения в STG |
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы либо | `_load_id` | Только у заказов: `run_id` запуска Airflow, который принял порцию |
падение приёма на первом сообщении, либо тихие нули за 1970 год.
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в Топик, партиция и смещение вместе позволяют найти сообщение Kafka.
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется `kafka_topic` и `consumer_host` имеют тип `LowCardinality(String)`,
на шарде-получателе, то есть назвал бы не ту ноду, которая читала топик, — `kafka_partition` и `kafka_offset` — `UInt64`.
колонка молча отвечала бы на другой вопрос. `kafka_timestamp` имеет тип `Nullable(DateTime64(3, 'UTC'))`: сохраняет
миллисекунды и допускает отсутствие метки.
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего Виртуальные колонки предоставляет движок, а колонки STG хранят значения
не проверяет, поэтому там оказываются и целые сообщения, и мусор. События ниже на диске. Их имена различаются, чтобы не скрыть виртуальную колонку
разбирают матвью ODS ([ADR 0005](../adr/0005-event-ingestion.md)), заказы — собственной. Значения вычисляются в запросе приема. В частности,
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR `hostName()` должен выполняться на ноде, читающей Kafka:
0010](../adr/0010-order-versions-in-ods.md)). `DEFAULT hostName()` только в локальной таблице назвал бы шард-получатель.
Движок таблицы STG — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition, Таблицы STG используют `ReplicatedMergeTree` с
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у `ORDER BY (kafka_partition, kafka_offset)`. Повторная доставка остается
таблицы STG и нет. Замену версий сюда ставить нельзя — она отменила бы свойство отдельной строкой; STG нужен в том числе для разбора таких повторов.
слоя, ради которого он заведён: повтор доставки в STG обязан быть виден.
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится `_load_ts` имеет тип `DateTime64(3, 'UTC')`. При переходе в ODS метка
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка копируется из STG без пересчета. У событий она также выбирает последнюю
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на доставку; у заказов версию задает `snapshot_date`. `_load_id` заказа
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В переносится в годную строку ODS и в таблицу ошибок.
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает У непрерывного потока событий нет запуска Airflow, поэтому `_load_id`
повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка в его STG и ODS отсутствует.
источника; `_load_ts` только показывает, когда конкретная строка приехала.
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся. Время события `UTCEventTime` и дата `EventDate` приходят из счетчика сайта.
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в Поля заказа `created_at` и `updated_at` приходят из базы магазина.
`DateTime64(3, 'UTC')`; ClickHouse их не создаёт и добавляет рядом собственную Они относятся к модельному времени; `_load_ts` — к реальным часам стенда.
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью. Например, событие июня можно загрузить в сентябре: его `EventDate`
останется июньской, а `_load_ts` будет сентябрьской.
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание События разбираются материализованными представлениями. Автоматической
Airflow. Переделать разобранное руками можно вставкой из STG с фильтром по пакетной переобработки у них нет; сохраненный срез STG можно разобрать
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу отдельным запросом до истечения срока хранения. При повторной вставке
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по с тем же ключом и `_load_ts` `ReplacingMergeTree` оставляет более поздно
`_load_id`; дневные партиции ODS он не заменяет. вставленную строку. Заказы разбирает задача Airflow по `_load_id`.
В DDS метки загрузки уже относятся к собственной сборке слоя —
Имя согласовано с каноном служебных полей соседнего учебного стенда на [пример заказа](dds/order.md#служебные-колонки).
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
технических колонок — распространённая запись,
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
Общего идентификатора пачки загрузки (`_load_id`) нет. У потока событий нет ни
пачки, ни `run_id`, и колонка была бы пустой формальностью. У заказов читатель
назван: `_load_id` равен `run_id` Airflow и переносится из STG в годную строку
ODS и в таблицу ошибок. Нужен ли он выше ODS, решается вместе с моделью DDS.
## Актуальные события ## Актуальные события
`ods.event_rep` хранит физически принятые версии события в Повторная отправка события может дать несколько строк в `ods.event_rep`.
`ReplacingMergeTree(_load_ts)`, а `ods.event_dist` служит для записи и Движок `ReplicatedReplacingMergeTree(_load_ts)` считает версиями строки
диагностики доставки. Обычное чтение этой пары может показать несколько строк с одинаковым полным ключом сортировки:
одного `WatchID`, пока фоновые слияния их не схлопнули. `(CounterID, EventDate, intHash32(ClientID), WatchID)`.
При слиянии остается строка с максимальным `_load_ts`, то есть последняя
доставка. Один `WatchID` обозначает событие, но физический ключ замены шире.
`ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет `ods.event_v` читает `ods.event_dist FINAL`: выбирает актуальную строку
имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное при выполнении запроса, не дожидаясь фоновых слияний. Поэтому события
состояние читают через него, а не повторяют `FINAL` в каждом запросе. считают через `_v`; число строк в `_dist` зависит от уже прошедших слияний.
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том `dds.event_v` использует этот результат, дает колонкам имена в `snake_case`
числе snake_case-имена и расшифровку кодов. и расшифровывает коды.
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` — Выбор по полному `ORDER BY` и максимальной версии, включая чтение с `FINAL`,
исключение: источник объявляет `Array(Float64)`, а материализованное сверен через Context7 с [документацией ClickHouse](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree).
представление ODS разбирает числа сырого JSON сразу в `Array(Decimal(18, 2))`. В стенде закреплена версия 26.3.17.56; `_v` использует `FINAL`, чтобы
DDS наследует этот тип без нового преобразования. Прямой разбор подтвержден результат не зависел от фоновых слияний.
документацией ClickHouse через Context7 и запросом к ClickHouse 26.3; сравнение
с сырыми данными подтвердило: копейки всех покупок целы. Типы ODS повторяют [описание выгрузки](../formats/clickstream-event.md)
с одним денежным исключением: `purchaseRevenue` объявлен источником
как `Array(Float64)`, а из JSON разбирается сразу в
`Array(Decimal(18, 2))`. Это сумма в рублях с копейками; DDS наследует тип.
`productPrice` содержит округленные целые рубли и не подходит для
восстановления точной суммы покупки. Прямой разбор `purchaseRevenue`
проверен на ClickHouse 26.3; результаты сохранены в разделе «Что проверено».
## Версии заказов ## Версии заказов
Слепки повторяют состояние заказов из семидневного окна источника.
`ods.order_rep` хранит принятые версии в `ods.order_rep` хранит принятые версии в
`ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция — `ReplicatedReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`,
`toDate(created_at)`. `ods.order_dist` шардирует по партиция — `toDate(created_at)` в UTC. Запись через `ods.order_dist`
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли распределяет их по `cityHash64(order_id)`. У всех версий заказа одинаковы
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL` день создания и шард, поэтому они могут заменять друг друга.
выбрал одного победителя.
Физическая пара нужна для загрузки и диагностики. Обычное чтение показывает `ods.order_v` читает `_dist FINAL` и оставляет строку с максимальной датой
версии, которые ещё не убрали фоновые слияния, и не является архивом истории. слепка. Дата `snapshot_date` показывает последнее принятое наблюдение,
`ods.order_v` служит поверхностью точного текущего состояния для следующих а `updated_at` — время изменения заказа в магазине. У неизменившегося
слоёв. Представление сохраняет язык источника и не решает, какой станет модель заказа в следующем слепке дата наблюдения обновится, время изменения — нет.
DDS. `snapshot_date` в нём одновременно дата наблюдения и колонка версии. Она
не доказывает полноту принятой порции, поэтому ODS не публикует партиции по Источник выпускает один слепок на дату, а поздний слепок показывает
дате слепка. Модель версионного ODS записана в не менее новое состояние. Отсутствие заказа в следующем слепке
[ADR 0010](../adr/0010-order-versions-in-ods.md), выбор колонки версии описан в не означает удаления. Физическая таблица не служит архивом:
[ADR 0014](../adr/0014-order-version-snapshot-date.md), пакетный приём описан в старые версии исчезают при фоновых слияниях.
[спецификации приёма заказов](orders/ingestion.md). Контракт следующего слоя
задан в [модели заказа в DDS](dds/order.md). Прием добавляет версии отдельными строками. `snapshot_date` не доказывает,
что Kafka отдала весь слепок, поэтому партиция по этой дате не заменяется.
Подробности — [прием заказов](orders/ingestion.md),
[ADR 0010](../adr/0010-order-versions-in-ods.md) и
[ADR 0014](../adr/0014-order-version-snapshot-date.md).
Следующий слой описан в [модели заказа DDS](dds/order.md).
## Часовые пояса ## Часовые пояса
@@ -308,169 +287,148 @@ UTC+4), и пересчёт идёт один раз при наполнении
## Приём: поток и его свойства ## Приём: поток и его свойства
Раздел — про события. У заказов приём устроен иначе: слепок забирает по команде События принимаются непрерывно. Заказы читает отдельным запросом
даг `orders_ingest`, а не подписанная навсегда матвью — [ADR даг `orders_ingest` — [описание пакетного приема](orders/ingestion.md).
0008](../adr/0008-order-ingestion.md) и [спецификация приёма У событий путь такой:
заказов](orders/ingestion.md).
Цепочка одна: чтец топика → матвью → STG → матвью разбора → событие и таблица ```text
ошибок ODS. stg.hits_raw_kafka → stg.hits_raw_mv → stg.hits_raw_dist
├→ ods.event_mv → ods.event_dist
└→ ods.event_errors_mv → ods.event_errors_dist
```
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы Таблицы Kafka на обеих нодах используют одну группу `clickstream_hits`.
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт Ноды делят чтение партиций топика. Разные группы прочитали бы каждая
`ON CLUSTER` и макросов в имени не содержит. Разные группы дали бы каждой ноде свою полную копию потока. Имя группы одинаково в DDL на всем
полную копию топика, и это отдельная сцена для лабы, а не рабочий режим. Имя `clickstream_cluster`; конфигурация кластера —
кластера в `ON CLUSTER` и в движке `Distributed` — `clickstream_cluster`, оно `infra/clickhouse/config.d/cluster.xml`.
задано в `infra/clickhouse/config.d/cluster.xml`.
**Источник матвью разбора — `stg.hits_raw_dist`, а не локальная таблица.** Материализованные представления разбора срабатывают на вставку
У матвью две привязки: источник, на вставку в который она срабатывает, и цель, в `stg.hits_raw_dist`, до распределения строк по шардам.
куда пишет. Распределённая таблица — лицо слоя, локальная — его хранилище; Оба видят вставляемую порцию на той ноде, которая читает Kafka.
потребитель слоя цепляется к лицу. Практически это значит, что разбор идёт на `stg.hits_raw_mv` создается после них: его подключение запускает
той же ноде, что читала Kafka, в момент вставки первой матвью — до раскладки по чтение топика. Если начать чтение раньше, сообщения успеют попасть
шардам. в STG без разбора в ODS.
**Вставка фоновая, и окно потери мы принимаем.** Вставка в распределённую Условие приема проверяет три вещи:
таблицу кладёт блок в локальный спул и сразу возвращает управление, а Kafka
коммитит офсеты по факту работы матвью — то есть по факту записи в спул. Топик
уже считает сообщение прочитанным, хотя на шарде его ещё нет: умри нода в этом
промежутке — сообщения не перечитаются.
Закрывает окно настройка `distributed_foreground_insert = 1`, и на ETL-вставках - корень `raw` — объект JSON;
Airflow она стоит — там это обычный `SETTINGS` у запроса. На пути приёма её нет, - набор верхнеуровневых ключей совпадает с 47 полями формата;
и по трём причинам. Вставку выполняет фоновый поток Kafka-движка, своего запроса - пять опорных полей, `WatchID`, `VisitID`, `ClientID`, `EventDate`
у него не бывает, так что настройка уровня запроса доехала бы только профилем и `UTCEventTime`, разбираются в ожидаемые типы.
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
как при фоновом первая нода продолжает принимать и копит спул для соседа.
Платится при этом не одно ожидание на блок, а три распределённые вставки — STG,
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
компромисс полезнее показать, чем спрятать за галочкой.
Ещё одно место, где сырой слой хранит не всё приехавшее: запись с пустым Остальные поля извлекаются по описанию выгрузки без отдельных проверок.
значением и запись-надгробие проходят молча, не оставляя строки. Свойство Это граница приема: годная строка не означает, что проверен смысл
измерено и принято осознанно — подробности в разделе «Что проверено». всех 47 значений. Подробности — в [ADR 0005](../adr/0005-event-ingestion.md).
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано `ods.event_mv` принимает строки, для которых условие истинно,
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля `ods.event_errors_mv` — для которых оно ложно. Условие не должно
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при возвращать `NULL`: такую строку не выбрали бы ни оно, ни его отрицание.
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы Разбор использует функции без исключений на некорректном сообщении,
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны. поскольку ошибка материализованного представления прерывает вставку
и останавливает чтение Kafka до устранения причины.
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет ### Доставка и повторы
`ReplacingMergeTree`, а таблицы STG вовсе не устраняют дубли: перезаливка
модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения
срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности
конвейера к сырому слою не относится.
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с Вставка в Distributed на этом пути фоновая: блок сначала записывается
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`). в локальную очередь на диске, затем отправляется на шарды.
Удвоение строк STG проходит мимо неё только потому, что при повторном чтении Kafka подтверждает обработку после выполнения материализованных
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на представлений, когда данные еще могут находиться в очереди.
отсутствии механизма. Если после подтверждения потерять ноду вместе с очередью, сообщения
не перечитаются. Возможен и повтор: данные уже записались на шард,
но подтверждение Kafka не успело сохраниться.
Поэтому прием не гарантирует доставку каждого сообщения хотя бы один раз.
**Матвью разбора две, и их условия обязаны делить поток без зазора и без В ETL-запросах Airflow `distributed_foreground_insert = 1` дожидается
нахлёста.** Одна забирает годные строки в `ods.event_dist`, вторая — брак в записи на шарды. У фонового приема событий этой настройки нет:
`ods.event_errors_dist`. Строка, подошедшая обеим, задвоится; не подошедшая ни недоступность одного шарда тогда остановила бы весь прием. В принятом
одной — исчезнет молча. Держится это формой: второе условие пишется буквальным режиме доступная нода продолжает принимать и накапливает очередь
отрицанием первого, а сам предикат NULL не возвращает ни в одной своей части, — для недоступного шарда. Ограничение сохранено для учебного стенда;
иначе трёхзначная логика даст строку, которую не возьмёт ни `условие`, ни подробности и наблюдения — в разделе «Что проверено».
`NOT условие`. Обнуляемый разбор в предикате поэтому есть, но заканчивается
`IS NOT NULL`, а сравнения дают 0 или 1. Функции предиката не должны и бросать
исключений: упавшая матвью
роняет вставку и останавливает потребление до починки
([ADR 0005](../adr/0005-event-ingestion.md)).
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырого В STG повтор сохраняется, в ODS его устраняет выбор актуальной версии.
слоя срок жизни трое суток, а ODS хранит всё, поэтому равенство «строки STG = Хотя `Replicated*` умеет отбрасывать повторно вставленные одинаковые блоки,
события + ошибки» разъедется само: строки STG истекут раньше, а перезаливка при новом чтении Kafka меняется `_load_ts`, а вместе с ним и хеш блока.
модельного дня задвоит их, тогда как в ODS тот же повтор схлопнется. Правило, Поэтому повторная отправка модельного дня увеличивает число строк STG.
красное в норме, учит не смотреть на оповещения ([ADR
0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при
исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк
STG и N в сумме событий и ошибок. Постоянной целью такой опыт не становится, и
почему — в [карте проверок](testing.md), раздел «Интеграционная проверка
постоянной целью не становится». Счёт актуальных событий идёт через
`ods.event_v`, которое скрывает `FINAL`: голый `count()` по `ReplacingMergeTree`
зависит от того, сколько мержей успело пройти, и спека это прямо запрещает
(раздел 6).
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS Пустое значение сообщения и значение `null` не дают строки даже в STG:
против [паспорта мира](../../data/world-inventory.json), лежащего в git (`make чтец продвигает позицию Kafka, но не передает строку на разбор.
check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в этом вся Генератор таких сообщений не создает. Поведение измерено на стенде
разница: паспорт описывает восемь дней стартового мира, счёт обрамлён их датами, и описано в разделе «Что проверено».
повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не помеха — ODS
хранит всё. Обе стороны равенства зафиксированы: одна кодом генератора, другая ### Что можно сверять
файлом в git. У межслойной сверки такой опоры нет ни с одной стороны.
Для короткого неизменного среза доставки можно проверить баланс:
число строк STG равно сумме годных строк и ошибок до удаления повторов.
Постоянно сравнивать полные таблицы так нельзя: STG удаляет старые данные,
ODS оставляет события и объединяет повторные доставки.
Счетчики по слоям тогда расходятся при штатной работе.
`make check-clickhouse` сравнивает актуальные события в `ods.event_v`
с [паспортом мира](../../data/world-inventory.json) только за восемь
начальных дней. Эта проверка учитывает повторную отправку через `FINAL`
и не зависит от срока жизни STG. Границы остальных проверок —
в [карте проверок](testing.md).
## Срок жизни сырых данных ## Срок жизни сырых данных
Сырые данные в STG живут трое суток реального времени и уходят сами. Трое — это TTL — правило автоматического удаления старых данных. В обеих таблицах
окно отладки: столько сырые данные хранятся на ноутбуке, чтобы менти успел STG срок задан как три суток от `_load_ts`, по реальному времени загрузки.
разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню Партиции тоже нарезаны по дню `_load_ts`. Это оставляет короткий период
загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками для поиска ошибок и повторного разбора, после которого данные освобождают
(`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не место на диске.
зависело от умолчания версии.
Нарезать сырые данные по модельному дню события было бы соблазнительно — он Настройка `ttl_only_drop_parts = 1` удаляет только целые части данных —
единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок файлы, которые ClickHouse создает при вставках и объединяет при слияниях.
снимается целиком, только когда в нём истекли все строки, а в партиции Часть удаляется, когда срок истек у всех ее строк. Внутри партиции
модельного дня лежит приехавшее в разное реальное время: мерж склеит куски дня загрузки возраст строк отличается не более чем на сутки.
разного возраста, самая свежая строка удержит весь кусок, и данные переживут Поэтому к трем суткам может добавиться до суток ожидания самой свежей
срок неограниченно. строки части, а затем ожидание фоновой обработки TTL. Точного времени
удаления правило не обещает.
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного Модельная дата для TTL не подходит: события июня могут загружаться
возраста — но не более чем на сутки, потому что партицию закрывает календарный осенью, и срок от `EventDate` истек бы сразу. Партиция по модельному дню
день. Отсюда и оценка: сырые данные живут трое суток плюс хвост до суток, а не тоже смешала бы доставки разного возраста: очередная отправка дня могла бы
ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, продлевать хранение старых строк после слияния частей.
так что «уходит само» здесь обещано, а «уходит вовремя» — нет.
Модельного дня среди колонок STG нет вовсе. Он свойство содержимого, а Для разбора сохраненных сообщений выбирайте небольшой срез по `_load_ts`.
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырые Так запрос ограничивает партиции и сохраняет некорректные сообщения,
данные режутся по своим координатам: и разбор полётов, и переобработка фильтруют из которых модельную дату извлечь нельзя. `EventDate` появляется
по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по отдельной колонкой только в ODS. В [задании ступени 6](../labs/6-1-event-parsing.md)
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается. такой срез разбирается рядом с действующим приемом.
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели
модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска
разные часы, и обслуживают их разные средства. Декларативный TTL по модельной
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
эталонные дни прямо на входе.
В бою сырой слой иногда собирают на движке `Null` — тогда он не хранится вовсе.
Такой вариант отвергнут: на стенде сырые данные нужны для отладки, поэтому окно,
а не ноль.
## Таблица ошибок ## Таблица ошибок
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым `ods.event_errors_dist` содержит сообщения, отвергнутые разбором событий:
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что исходный `raw`, сведения о доставке и класс ошибки `error_class`.
у брака нет разобранных полей: шардируется `cityHash64` сырой строки — Класс имеет тип `LowCardinality(String)` и выбирается по первому
`ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню нарушенному условию:
загрузки, как и сырые данные; живёт месяц. Дольше сырых данных — намеренно: если
брак истекает вместе с ними, разбираться к моменту разбирательства будет уже
нечем. Снятие — целыми кусками (`ttl_only_drop_parts`), как у STG и по той же
причине: строки в партиции дня загрузки разного возраста не более чем на сутки,
и куску незачем переживать срок из-за самой свежей строки.
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё 1. `not_an_object` — корень JSON не объект;
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине 2. `keyset_mismatch` — набор ключей не совпал с форматом;
качества не на что опереться. Сами классы, их порядок и довод, почему порядок 3. `key_field_unparsed` — одно из пяти опорных полей не разобралось.
обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md).
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по Порядок важен: строка вместо объекта нарушает сразу несколько условий,
чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition, но получает один класс. Полные условия — в
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам [ADR 0005](../adr/0005-event-ingestion.md).
доставки.
`ods.order_errors` держит тот же диагностический минимум и `_load_id` `ods.order_errors_dist` хранит такой же набор сведений и `_load_id`
запуска. У заказов три класса по приоритету: `not_an_object`, запуска приема. Первые два класса совпадают с событиями,
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не третий называется `field_invalid`: у заказа проверяются все поля
разрастается до имени отдельного поля. Точная граница приёма — в верхнего уровня. Точные правила — в [описании приема заказов](orders/ingestion.md#граница-строгого-приёма).
[спецификации заказов](orders/ingestion.md).
Таблицы ошибок используют `ReplicatedMergeTree` без замены версий:
у некорректного сообщения может не быть разобранного ключа сущности.
Шард выбирается по `cityHash64(raw)`, ключ сортировки —
`(error_class, kafka_partition, kafka_offset)`, партиция — день `_load_ts`.
Начать поиск можно с класса ошибки, затем перейти к сообщениям
по координатам Kafka.
Срок хранения — один месяц от `_load_ts`, удаление целыми частями
с `ttl_only_drop_parts = 1`, как в STG. Ошибка вместе с исходным текстом
остается доступна дольше остальных сырых сообщений. Повторная обработка
может добавить тот же брак еще раз; у заказов повтор виден по совпадению
`_load_id` и координат Kafka.
## Справочники ## Справочники
+1 -1
View File
@@ -98,7 +98,7 @@ DDS складывает свою модель и называет атрибу
| 41 | `productID` | `Array(String)` | `object` | `product_id` | id товаров события | | 41 | `productID` | `Array(String)` | `object` | `product_id` | id товаров события |
| 42 | `productName` | `Array(String)` | `object` | `product_name` | названия тех же товаров | | 42 | `productName` | `Array(String)` | `object` | `product_name` | названия тех же товаров |
| 43 | `productCategory` | `Array(String)` | `object` | `product_category` | категории тех же товаров | | 43 | `productCategory` | `Array(String)` | `object` | `product_category` | категории тех же товаров |
| 44 | `productPrice` | `Array(Int64)` | `int64` | `product_price` | цена за штуку целым числом: деньги генератор считает целыми | | 44 | `productPrice` | `Array(Int64)` | `int64` | `product_price` | цена за штуку, округленная до целых рублей |
| 45 | `productQuantity` | `Array(UInt64)` | `uint64` | `product_quantity` | количество штук каждого товара | | 45 | `productQuantity` | `Array(UInt64)` | `uint64` | `product_quantity` | количество штук каждого товара |
| 46 | `productEventType` | `Array(String)` | `object` | `product_event_type` | действие с товаром: стенд шлёт add и purchase, полный словарь Метрики (detail, remove, impressions) не берём | | 46 | `productEventType` | `Array(String)` | `object` | `product_event_type` | действие с товаром: стенд шлёт add и purchase, полный словарь Метрики (detail, remove, impressions) не берём |
| 47 | `ecommerce` | `String` | `object` | `ecommerce` | сырой JSON события, как отдаёт Метрика — материал лабы про разбор JSON внутри колонки | | 47 | `ecommerce` | `String` | `object` | `ecommerce` | сырой JSON события, как отдаёт Метрика — материал лабы про разбор JSON внутри колонки |
+12 -10
View File
@@ -4,9 +4,11 @@
и попадают в отчеты. В главах есть короткие примеры, которые можно выполнить и попадают в отчеты. В главах есть короткие примеры, которые можно выполнить
на запущенном стенде перед переходом к учебным заданиям. на запущенном стенде перед переходом к учебным заданиям.
## Как читать ## Порядок чтения
Читайте главы в порядке ниже. Незнакомые термины можно найти в Сначала запустите стенд по [инструкции](../../README.md#быстрый-старт).
Затем читайте главы по порядку и выполняйте примеры из них.
Незнакомые термины можно найти в
[словаре понятий](../../CONTEXT.md), а подробности устройства — [словаре понятий](../../CONTEXT.md), а подробности устройства —
в [технических справочниках](../architecture/). К ним достаточно обращаться в [технических справочниках](../architecture/). К ним достаточно обращаться
по мере необходимости. После путеводителя переходите к по мере необходимости. После путеводителя переходите к
@@ -19,27 +21,27 @@ Gitea показывает HTML как текст. Откройте файл и
или скачайте его кнопкой «Raw» и откройте в браузере. или скачайте его кнопкой «Raw» и откройте в браузере.
Сверяйтесь с картой при чтении главы «Хранилище и его слои». Сверяйтесь с картой при чтении главы «Хранилище и его слои».
## Главы ## Содержание
### [Хранилище и его слои](storage-layers.md) ### [Хранилище и его слои](storage-layers.md)
Как меняются данные при переходе из Kafka в слои STG, ODS, DDS и DM. Как меняются данные при переходе из Kafka в слои STG, ODS, DDS и DM.
В конце главы вы посмотрите события, сессии и выручку с помощью SQL. В конце главы вы посмотрите события, сессии и выручку с помощью SQL.
### [Потоки Airflow и даги модельного времени](airflow-dags.md) ### [Генерация и обработка данных в Airflow](airflow-dags.md)
Какие даги создают новые данные, а какие обрабатывают их в хранилище. Какие даги создают новые данные, а какие обрабатывают их в хранилище.
Вы запустите генерацию и обработку за день и посмотрите результат в витринах. Вы сравните содержимое витрин до и после обработки нового дня.
### [Мониторинг: куда смотреть](monitoring.md) ### [Мониторинг в Grafana](monitoring.md)
Что показывают панели Grafana и как меняются их показания, пока в стенд Что показывают панели Grafana и как меняются их показания, пока в стенд
поступают события и заказы. поступают события и заказы. Вы увидите, почему заказы обновляются реже событий.
### [Немного Superset](superset.md) ### [Отчеты в Superset](superset.md)
Как Superset строит графики по витринам и что происходит с вашими отчетами Как Superset строит графики по витринам. Вы сравните числа на готовом графике
при перезапуске стенда. с результатом SQL и узнаете, какие отчеты сохраняются при перезапуске стенда.
### [Генератор: откуда берутся данные](generator.md) ### [Генератор: откуда берутся данные](generator.md)
+14 -12
View File
@@ -1,19 +1,20 @@
# Потоки Airflow и даги модельного времени # Генерация и обработка данных в Airflow
Airflow управляет генерацией данных и их обработкой в хранилище. Это разные Airflow управляет генерацией данных и их обработкой в хранилище. Это разные
этапы: сначала в Kafka поступают события и заказы за день работы магазина, этапы: сначала в Kafka поступают события и заказы за день работы магазина,
затем по ним рассчитываются данные DDS и витрины DM. затем по ним рассчитываются данные DDS и витрины DM.
Даг описывает задачи Airflow и порядок их выполнения.
```mermaid ```mermaid
flowchart LR flowchart LR
world_initialize --> etl_pipeline world_initialize --> etl_pipeline
world_live_day --> etl_pipeline world_live_day --> etl_pipeline
manual[запуск рукой] --> etl_pipeline manual[ручной запуск] --> etl_pipeline
etl_pipeline --> dds_transform --> dm_transform --> dq_check etl_pipeline --> dds_transform --> dm_transform --> dq_check
world_next_day --> sources[источники без ETL] world_next_day --> sources[источники без ETL]
``` ```
## Два потока входят по-разному ## Загрузка событий и заказов
События поступают в топик `hits` и обрабатываются по мере поступления: События поступают в топик `hits` и обрабатываются по мере поступления:
материализованные представления переносят строки из STG в ODS без участия материализованные представления переносят строки из STG в ODS без участия
@@ -24,7 +25,7 @@ Airflow.
Даг `orders_ingest` загружает ее целиком. Такой способ приема называется Даг `orders_ingest` загружает ее целиком. Такой способ приема называется
[пакетным](../../CONTEXT.md). [пакетным](../../CONTEXT.md).
## Даги мира ## Генерация модельных дней
В документации миром называется учебный магазин с его посетителями, В документации миром называется учебный магазин с его посетителями,
товарами и заказами. Время в нем идет отдельно от календарного. товарами и заказами. Время в нем идет отдельно от календарного.
@@ -54,7 +55,7 @@ Airflow.
номер дня остается прежним. Подробности — в [справочнике генератора номер дня остается прежним. Подробности — в [справочнике генератора
и модельного времени](../architecture/world.md#даги-модельного-времени). и модельного времени](../architecture/world.md#даги-модельного-времени).
## Дневной конвейер ## Обработка данных в хранилище
Даг `etl_pipeline` по очереди запускает `dds_transform`, `dm_transform` Даг `etl_pipeline` по очереди запускает `dds_transform`, `dm_transform`
и `dq_check`, дожидаясь завершения каждого. Они рассчитывают данные DDS, и `dq_check`, дожидаясь завершения каждого. Они рассчитывают данные DDS,
@@ -84,20 +85,21 @@ Airflow.
проверок описан в [разделе «Звено DQ проверок описан в [разделе «Звено DQ
конвейера»](../architecture/dm.md#звено-dq-конвейера). конвейера»](../architecture/dm.md#звено-dq-конвейера).
## Как найти в интерфейсе ## Поиск дагов в Airflow
Даги мира ищите по тегам «модельное время» и «жизненный цикл мира». Остальные Даги мира ищите по тегам «модельное время» и «жизненный цикл мира». Остальные
даги ищите по тегам «заказы», `etl`, `dds`, `dm` и `dq`. `homework_check` с даги ищите по тегам «заказы», `etl`, `dds`, `dm` и `dq`. `homework_check` с
тегами `dm` и `labs` проверяет домашние работы; о нем рассказано в учебных тегами `dm` и `labs` проверяет домашние работы; о нем рассказано в учебных
заданиях. заданиях.
## Посмотрите сами ## Один день от генерации до витрин
Откройте Airflow по адресу из раздела [«Адреса и Откройте Airflow по адресу из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли). В Admin → Variables найдите пароли»](../../README.md#адреса-и-пароли). В Admin → Variables найдите
`world_position`. Затем запустите `world_next_day` на один день по [инструкции `world_position` и запомните его значение. Затем запустите `world_next_day`
из README](../../README.md#добавить-данные-за-следующие-дни). Убедитесь, что на один день по [инструкции из README](../../README.md#добавить-данные-за-следующие-дни). Убедитесь, что
`orders_ingest` завершился успешно, а в витринах нового дня еще нет. `orders_ingest` завершился успешно, а в витринах нового дня еще нет.
Значение `world_position` должно увеличиться на один.
Запустите `etl_pipeline` вручную и дождитесь его завершения. Запустите `etl_pipeline` вручную и дождитесь его завершения.
В DBeaver, подключенном по В DBeaver, подключенном по
@@ -121,8 +123,8 @@ LIMIT 3;
приходит слепок за день D−1. Выручка дня D появится после генерации приходит слепок за день D−1. Выручка дня D появится после генерации
дня D+1 и запуска конвейера. дня D+1 и запуска конвейера.
## Куда дальше ## Следующая глава и справочники
Детали — в справочниках [конвейера](../architecture/etl.md) и Детали — в справочниках [конвейера](../architecture/etl.md) и
[мира](../architecture/world.md). Следующая глава — [«Мониторинг: куда [мира](../architecture/world.md). Следующая глава —
смотреть»](monitoring.md). [«Мониторинг в Grafana»](monitoring.md).
+152 -154
View File
@@ -149,15 +149,14 @@
<script> <script>
const MAP = { const MAP = {
"title": "Карта слоев хранилища", "title": "Карта слоев хранилища",
"lead": "Проследите, как один смысл меняет имя и поверхность между слоями.", "lead": "Проследите путь событий и заказов от Kafka до отчетов.",
"chapter_url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои", "chapter_url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои",
"intro": [ "intro": [
"Эта карта показывает путь событий и заказов от Kafka до витрин DM.", "Выберите объект, чтобы узнать, что он хранит, как его читать и откуда в него поступают данные.",
"Щелкните по узлу, чтобы увидеть его назначение, устройство и ключи.", "Выберите стрелку, чтобы посмотреть способ обработки и открыть SQL-запрос.",
"Щелкните по стрелке, чтобы узнать, кто и каким запросом переносит данные.", "Для выбранного объекта подсвечиваются все пути, по которым данные поступают в него и используются дальше.",
"Когда вы выбираете узел, карта подсвечивает все пути к нему и от него.", "Кнопки «События» и «Заказы» выделяют нужный поток. Остальные объекты остаются на месте, но становятся бледнее.",
"Переключатель потока приглушает другую часть карты, но не меняет раскладку.", "Если вы впервые знакомитесь со слоями хранилища, начните с главы по ссылке ниже."
"Сначала прочитайте главу «Хранилище и его слои», затем вернитесь к карте и рассмотрите объекты."
], ],
"streams": { "streams": {
"all": "Все", "all": "Все",
@@ -166,39 +165,39 @@
}, },
"dictionary": { "dictionary": {
"title": "Словарь", "title": "Словарь",
"caption": "сбоку цепочки: справочники" "caption": "справочные данные"
}, },
"layers": [ "layers": [
{ {
"id": "kafka", "id": "kafka",
"title": "Kafka", "title": "Kafka",
"caption": "откуда приходит" "caption": "сообщения источников"
}, },
{ {
"id": "stg", "id": "stg",
"title": "STG", "title": "STG",
"caption": "как поступило" "caption": "исходные сообщения"
}, },
{ {
"id": "ods", "id": "ods",
"title": "ODS", "title": "ODS",
"caption": "язык источника" "caption": "данные разобраны по полям"
}, },
{ {
"id": "dds", "id": "dds",
"title": "DDS", "title": "DDS",
"caption": "язык бизнеса" "caption": "события, заказы и сессии"
}, },
{ {
"id": "dm", "id": "dm",
"title": "DM", "title": "DM",
"caption": "данные для дашбордов" "caption": "витрины для отчетов"
} }
], ],
"mechanisms": [ "mechanisms": [
{ {
"id": "materialized_view", "id": "materialized_view",
"label": "матвью", "label": "материализованное представление",
"line": "solid", "line": "solid",
"rhythm": "непрерывно, по мере прихода сообщений" "rhythm": "непрерывно, по мере прихода сообщений"
}, },
@@ -206,13 +205,13 @@
"id": "airflow_dag", "id": "airflow_dag",
"label": "даг Airflow", "label": "даг Airflow",
"line": "dashed", "line": "dashed",
"rhythm": "пачкой: раз в модельные сутки (`orders_ingest`) или на каждом прогоне конвейера" "rhythm": "пакетно: раз в модельные сутки (`orders_ingest`) или при каждом запуске конвейера"
}, },
{ {
"id": "view", "id": "view",
"label": "представление", "label": "представление",
"line": "dotted", "line": "dotted",
"rhythm": "без копии, данные читаются на месте" "rhythm": "при запросе к представлению, без копирования данных"
} }
], ],
"nodes": [ "nodes": [
@@ -225,9 +224,9 @@
"events" "events"
], ],
"what": "Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие.", "what": "Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие.",
"read": "Топик напрямую не читают: таблица `stg.hits_raw_kafka` забирает сообщения из топика. Для данных событий используйте `stg.hits_raw_dist`.", "read": "Для просмотра загруженных сообщений используйте `stg.hits_raw_dist`. Таблица `stg.hits_raw_kafka` служит для приема из топика.",
"fill": [ "fill": [
"Источник событий пишет сообщения в топик в темпе выбранного режима мира." "Генератор отправляет события без пауз в пакетном режиме или постепенно в живом режиме."
], ],
"links": [ "links": [
{ {
@@ -248,10 +247,10 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Топик Kafka со слепками заказов магазина. После модельного дня приходит снимок изменяемого окна.", "what": "Топик Kafka с дневными выгрузками заказов — слепками. Каждый слепок содержит состояние заказов за последние семь модельных дней.",
"read": "Топик напрямую не читают: таблица `stg.orders_raw_kafka` забирает сообщения из топика. Для заказов используйте `stg.orders_raw_dist`.", "read": "Для просмотра загруженных сообщений используйте `stg.orders_raw_dist`. Таблица `stg.orders_raw_kafka` служит для приема из топика.",
"fill": [ "fill": [
"Источник заказов публикует слепок пачкой после каждого модельного дня." "После генерации событий дня D источник отправляет слепок заказов за предыдущий день D−1."
], ],
"links": [ "links": [
{ {
@@ -272,17 +271,17 @@
"streams": [ "streams": [
"events" "events"
], ],
"what": "Сырая строка события и метаданные доставки. STG сохраняет сообщение без разбора.", "what": "Исходное сообщение события и сведения о его доставке из Kafka. Содержимое сохраняется целой строкой, без разбора на поля.",
"read": "Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.", "read": "Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.",
"parts": [ "parts": [
"`stg.hits_raw_kafka` — чтец топика `hits`.", "`stg.hits_raw_kafka` — таблица для чтения сообщений топика `hits`.",
"`stg.hits_raw_rep` — реплицируемая таблица на шарде.", "`stg.hits_raw_rep` — реплицируемая таблица на шарде.",
"`stg.hits_raw_dist` — распределенная поверхность записи и чтения.", "`stg.hits_raw_dist` — распределенная таблица для записи и чтения.",
"`stg.hits_raw_mv` — матвью приема сообщений из чтеца." "`stg.hits_raw_mv` — материализованное представление, которое записывает сообщения из Kafka в STG."
], ],
"fill": [ "fill": [
"`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.", "`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.",
"Сырые строки живут трое суток реального времени плюс хвост до суток." "Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL."
], ],
"links": [ "links": [
{ {
@@ -294,7 +293,7 @@
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql" "url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql"
}, },
{ {
"label": "DDL матвью", "label": "DDL материализованного представления",
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql" "url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql"
} }
] ]
@@ -307,16 +306,16 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Сырые строки слепка заказов, метаданные доставки и идентификатор пачки `_load_id`.", "what": "Исходные строки выгрузки заказов, сведения о доставке и идентификатор загруженной партии `_load_id`.",
"read": "Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.", "read": "Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.",
"parts": [ "parts": [
"`stg.orders_raw_kafka` — чтец топика `orders` на первой ноде.", "`stg.orders_raw_kafka` — таблица для чтения топика `orders` на первом узле.",
"`stg.orders_raw_rep` — реплицируемая таблица на шарде.", "`stg.orders_raw_rep` — реплицируемая таблица на шарде.",
"`stg.orders_raw_dist` — распределенная поверхность записи и чтения." "`stg.orders_raw_dist` — распределенная таблица для записи и чтения."
], ],
"fill": [ "fill": [
"Задача `pull_batch` дага `orders_ingest` забирает слепок пачкой раз в модельные сутки.", "Задача `pull_batch` дага `orders_ingest` загружает слепок раз в модельные сутки.",
"Сырые строки живут трое суток реального времени плюс хвост до суток." "Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL."
], ],
"links": [ "links": [
{ {
@@ -337,13 +336,13 @@
"streams": [ "streams": [
"events" "events"
], ],
"what": "Типизированное широкое событие с именами полей источника. Запрос представления с `FINAL` возвращает актуальную версию события.", "what": "События, разобранные на колонки с типами данных. Имена полей сохранены как в источнике.",
"read": "Читайте `ods.event_v`: запрос представления с `FINAL` возвращает актуальные строки вместо физических версий.", "read": "Читайте `ods.event_v`: представление использует `FINAL` и возвращает последнюю версию каждого события.",
"parts": [ "parts": [
"`ods.event_rep` — реплицируемая таблица версий события на шарде.", "`ods.event_rep` — реплицируемая таблица версий события на шарде.",
"`ods.event_dist` — распределенная поверхность записи и диагностики.", "`ods.event_dist` — распределенная таблица записи и диагностики.",
"`ods.event_v` — представление актуального состояния с `FINAL`.", "`ods.event_v` — представление актуального состояния с `FINAL`.",
"`ods.event_mv` — матвью разбора годных строк STG." "`ods.event_mv` — материализованное представление для разбора сообщений STG без ошибок."
], ],
"keys": [ "keys": [
"`WatchID` — тот же идентификатор, что `event_id` в DDS.", "`WatchID` — тот же идентификатор, что `event_id` в DDS.",
@@ -377,16 +376,16 @@
"streams": [ "streams": [
"events" "events"
], ],
"what": "Сырые события, которые не прошли строгий прием, с классом ошибки и метаданными доставки.", "what": "Сообщения событий, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и сведения о доставке.",
"read": "Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.", "read": "Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.",
"parts": [ "parts": [
"`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.", "`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.",
"`ods.event_errors_dist` — распределенная поверхность чтения и записи.", "`ods.event_errors_dist` — распределенная таблица чтения и записи.",
"`ods.event_errors_mv` — матвью, которое отбирает негодные строки STG." "`ods.event_errors_mv` — материализованное представление для отбора сообщений STG с ошибками."
], ],
"fill": [ "fill": [
"`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.", "`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.",
"Ошибки живут один месяц и не останавливают прием годных событий." "Ошибки хранятся один месяц. Остальные события продолжают загружаться."
], ],
"links": [ "links": [
{ {
@@ -398,7 +397,7 @@
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql" "url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql"
}, },
{ {
"label": "DDL матвью", "label": "DDL материализованного представления",
"url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql" "url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql"
} }
] ]
@@ -411,11 +410,11 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Текущая версия заказа на языке источника. Позиции пока остаются сырым фрагментом JSON.", "what": "Последняя загруженная версия заказа с именами полей из источника. Состав заказа пока хранится как фрагмент JSON.",
"read": "Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.", "read": "Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.",
"parts": [ "parts": [
"`ods.order_rep` — реплицируемая таблица версий заказа на шарде.", "`ods.order_rep` — реплицируемая таблица версий заказа на шарде.",
"`ods.order_dist` — распределенная поверхность записи и диагностики.", "`ods.order_dist` — распределенная таблица записи и диагностики.",
"`ods.order_v` — представление текущего состояния с `FINAL`." "`ods.order_v` — представление текущего состояния с `FINAL`."
], ],
"keys": [ "keys": [
@@ -423,7 +422,7 @@
"`user_id` — пользователь магазина, который переходит в заказ DDS." "`user_id` — пользователь магазина, который переходит в заказ DDS."
], ],
"fill": [ "fill": [
"Задача `parse_batch` дага `orders_ingest` разбирает пачку раз в модельные сутки.", "Задача `parse_batch` дага `orders_ingest` разбирает загруженный слепок раз в модельные сутки.",
"`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка." "`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка."
], ],
"links": [ "links": [
@@ -449,15 +448,15 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Сырые строки слепка, которые не прошли строгий прием, с классом ошибки и `_load_id`.", "what": "Строки выгрузки заказов, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и `_load_id`.",
"read": "Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.", "read": "Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.",
"parts": [ "parts": [
"`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.", "`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.",
"`ods.order_errors_dist` — распределенная поверхность чтения и записи." "`ods.order_errors_dist` — распределенная таблица чтения и записи."
], ],
"fill": [ "fill": [
"Задача `parse_batch` дага `orders_ingest` пишет ошибки из той же пачки, что и годные заказы.", "Задача `parse_batch` дага `orders_ingest` сохраняет строки с ошибками при разборе той же выгрузки, из которой загружаются заказы.",
"Ошибки живут один месяц и не останавливают прием годных заказов." "Ошибки хранятся один месяц. Остальные заказы продолжают загружаться."
], ],
"links": [ "links": [
{ {
@@ -478,8 +477,8 @@
"streams": [ "streams": [
"events" "events"
], ],
"what": "Публичное представление события на языке бизнеса. Его запрос переименовывает поля и расшифровывает категорию устройства.", "what": "События с названиями полей, принятыми в DDS. Например, `WatchID` переименован в `event_id`, а числовой код устройства заменен названием категории.",
"read": "Читайте `dds.event_v`: запрос представления возвращает явный список колонок DDS.", "read": "Читайте `dds.event_v`: представление возвращает явно перечисленные колонки DDS.",
"parts": [ "parts": [
"`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет." "`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет."
], ],
@@ -490,7 +489,7 @@
"`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM." "`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM."
], ],
"fill": [ "fill": [
"Данные для `dds.event_v` читаются из актуальных событий ODS без копии и отдельного расписания." "При запросе к `dds.event_v` данные читаются из актуальных событий ODS. Отдельная загрузка в DDS не нужна."
], ],
"links": [ "links": [
{ {
@@ -511,22 +510,22 @@
"streams": [ "streams": [
"events" "events"
], ],
"what": "Сессия, которую запрос дага `dds_transform` собирает из последовательных событий одной куки. В источнике готовой сессии нет.", "what": "Сессии, рассчитанные дагом `dds_transform` по событиям посетителя. Новая сессия начинается после паузы дольше 30 минут или при смене модельной даты.",
"read": "Читайте `dds.session_v`: запрос представления возвращает деловые и служебные колонки.", "read": "Читайте `dds.session_v`: здесь есть начало, конец, длительность сессии, показатели событий и служебные поля загрузки.",
"parts": [ "parts": [
"`dds.session_rep` — реплицируемая таблица сессий на шарде.", "`dds.session_rep` — реплицируемая таблица сессий на шарде.",
"`dds.session_dist` — распределенная поверхность загрузки и диагностики.", "`dds.session_dist` — распределенная таблица загрузки и диагностики.",
"`dds.session_stage_rep` — донор дневных партиций на шарде.", "`dds.session_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.",
"`dds.session_stage_dist` — распределенная поверхность донора.", "`dds.session_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
"`dds.session_v` — публичное представление." "`dds.session_v` — представление для чтения готовых данных."
], ],
"keys": [ "keys": [
"`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.", "`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.",
"`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`." "`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`."
], ],
"fill": [ "fill": [
"Даг `dds_transform` пересобирает данные прожитых дней при каждом прогоне конвейера.", "При каждом запуске `dds_transform` выбирает дни от первого пропущенного в DDS до последнего дня с завершенной генерацией.",
"Задачи `session.rebuild` и `session.replace` собирают день в доноре и заменяют партицию." "Задача `session.rebuild` рассчитывает данные в промежуточной таблице (доноре). `session.replace` заменяет ими дневные партиции основной таблицы."
], ],
"links": [ "links": [
{ {
@@ -551,14 +550,14 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Заказ в текущем состоянии на языке бизнеса. Артикулы, количества и цены позиций хранятся в согласованных массивах.", "what": "Последнее известное состояние заказа, подготовленное для анализа. Артикулы, количества и цены товаров хранятся в массивах; элементы с одинаковым индексом относятся к одной позиции заказа.",
"read": "Читайте `dds.order_v`: запрос представления возвращает данные без таблиц и донора партиций.", "read": "Читайте `dds.order_v`, чтобы получить готовые заказы из основной таблицы.",
"parts": [ "parts": [
"`dds.order_rep` — реплицируемая таблица заказов на шарде.", "`dds.order_rep` — реплицируемая таблица заказов на шарде.",
"`dds.order_dist` — распределенная поверхность загрузки и диагностики.", "`dds.order_dist` — распределенная таблица загрузки и диагностики.",
"`dds.order_stage_rep` — донор дневных партиций на шарде.", "`dds.order_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.",
"`dds.order_stage_dist` — распределенная поверхность донора.", "`dds.order_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
"`dds.order_v` — публичное представление." "`dds.order_v` — представление для чтения готовых данных."
], ],
"keys": [ "keys": [
"`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.", "`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.",
@@ -567,8 +566,8 @@
"`order_date` — день, который в витринах называется `report_date` или `order_day`." "`order_date` — день, который в витринах называется `report_date` или `order_day`."
], ],
"fill": [ "fill": [
"Даг `dds_transform` пересобирает изменяемое окно при каждом прогоне конвейера.", "При каждом запуске `dds_transform` пересчитывает дни заказов из последнего слепка и заполняет пропуски в DDS.",
"Задачи `order.rebuild` и `order.replace` собирают дни в доноре и заменяют партиции." "Задача `order.rebuild` рассчитывает данные в промежуточной таблице (доноре). `order.replace` заменяет ими дневные партиции основной таблицы."
], ],
"links": [ "links": [
{ {
@@ -594,20 +593,20 @@
"events", "events",
"orders" "orders"
], ],
"what": "Известные пары куки трекера и пользователя магазина. Связь появляется после сопоставленной покупки.", "what": "Связи между посетителем сайта и пользователем магазина. Они появляются, когда событие покупки сопоставлено с заказом по его номеру.",
"read": "Читайте `dds.identity_map_v`: запрос представления с `FINAL` возвращает актуальные строки полного пересчета.", "read": "Читайте `dds.identity_map_v`: представление с `FINAL` возвращает последнюю версию каждой пары посетителя и пользователя.",
"parts": [ "parts": [
"`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.", "`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.",
"`dds.identity_map_dist` — распределенная поверхность записи.", "`dds.identity_map_dist` — распределенная таблица записи.",
"`dds.identity_map_v` — точное публичное представление с `FINAL`." "`dds.identity_map_v` — представление последних версий строк с `FINAL`."
], ],
"keys": [ "keys": [
"`client_id` — кука из события и сессии DDS.", "`client_id` — идентификатор посетителя из cookie браузера, как в событии и сессии DDS.",
"`user_id` — пользователь магазина из заказа DDS." "`user_id` — пользователь магазина из заказа DDS."
], ],
"fill": [ "fill": [
"Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов.", "Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов.",
"Новая версия полного набора пишется при каждом прогоне; `FINAL` скрывает физические повторы." "При каждом запуске записывается новая версия набора связей."
], ],
"links": [ "links": [
{ {
@@ -632,19 +631,18 @@
"streams": [ "streams": [
"events" "events"
], ],
"what": "Дневные счетчики посетителей и известных пользователей после склейки идентичностей.", "what": "Число посетителей и известных пользователей за каждый день. Известными считаются пользователи магазина, связанные с посетителями сайта через карту идентичностей.",
"read": "Читайте `dm.daily_traffic_v`: запрос представления с `FINAL` возвращает актуальные строки полного пересчета.", "read": "Читайте `dm.daily_traffic_v`: представление с `FINAL` возвращает последний результат расчета за каждый день.",
"parts": [ "parts": [
"`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.", "`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.",
"`dm.daily_traffic_dist` — распределенная поверхность записи.", "`dm.daily_traffic_dist` — распределенная таблица записи.",
"`dm.daily_traffic_v` — точное публичное представление с `FINAL`." "`dm.daily_traffic_v` — представление последних версий строк с `FINAL`."
], ],
"keys": [ "keys": [
"`report_date` — тот же день, что `session_date` в сессии DDS." "`report_date` — тот же день, что `session_date` в сессии DDS."
], ],
"fill": [ "fill": [
"Задача `daily_traffic` дага `dm_transform` при каждом прогоне пересчитывает всю историю и записывает новую версию.", "Задача `daily_traffic` дага `dm_transform` при каждом запуске пересчитывает всю историю и записывает новую версию."
"`FINAL` оставляет текущую строку каждого дня."
], ],
"links": [ "links": [
{ {
@@ -669,22 +667,22 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Дневная выручка, число заказов и единиц товара по категориям.", "what": "Выручка, число заказов и единиц товара за день по категориям. В расчет входят оплаченные заказы.",
"read": "Читайте `dm.revenue_daily_v`: это публичное представление готовой дневной витрины.", "read": "Читайте `dm.revenue_daily_v`, чтобы получить готовые показатели по дням и категориям товаров.",
"parts": [ "parts": [
"`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.", "`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.",
"`dm.revenue_daily_dist` — распределенная поверхность загрузки.", "`dm.revenue_daily_dist` — распределенная таблица загрузки.",
"`dm.revenue_daily_stage_rep` — донор дневных партиций на шарде.", "`dm.revenue_daily_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.",
"`dm.revenue_daily_stage_dist` — распределенная поверхность донора.", "`dm.revenue_daily_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
"`dm.revenue_daily_v` — публичное представление." "`dm.revenue_daily_v` — представление для чтения готовых данных."
], ],
"keys": [ "keys": [
"`report_date` — тот же день, что `order_date` в заказе DDS.", "`report_date` — тот же день, что `order_date` в заказе DDS.",
"`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря." "`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря."
], ],
"fill": [ "fill": [
"Даг `dm_transform` пересобирает изменяемое окно заказов при каждом прогоне конвейера.", "При каждом запуске `dm_transform` пересчитывает дни заказов из последнего слепка и заполняет пропущенные дни витрины.",
"Задачи `revenue_daily.rebuild` и `revenue_daily.replace` собирают дни в доноре и заменяют партиции." "Задача `revenue_daily.rebuild` рассчитывает данные в промежуточной таблице (доноре). `revenue_daily.replace` заменяет ими дневные партиции витрины."
], ],
"links": [ "links": [
{ {
@@ -710,22 +708,22 @@
"events", "events",
"orders" "orders"
], ],
"what": "Сверка события покупки с заказом магазина. Одна строка показывает совпадение или класс расхождения.", "what": "Результат сверки покупок из кликстрима с заказами магазина. Для каждого номера заказа указано, совпали ли данные двух источников и какое расхождение найдено.",
"read": "Читайте `dm.purchase_vs_orders_v`: в результате запроса отсутствующей стороне соответствует `NULL`.", "read": "Читайте `dm.purchase_vs_orders_v`: если покупка или заказ отсутствует, поля этой стороны содержат `NULL`.",
"parts": [ "parts": [
"`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.", "`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.",
"`dm.purchase_vs_orders_dist` — распределенная поверхность загрузки.", "`dm.purchase_vs_orders_dist` — распределенная таблица загрузки.",
"`dm.purchase_vs_orders_stage_rep` — донор дневных партиций на шарде.", "`dm.purchase_vs_orders_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.",
"`dm.purchase_vs_orders_stage_dist` — распределенная поверхность донора.", "`dm.purchase_vs_orders_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
"`dm.purchase_vs_orders_v` — публичное представление." "`dm.purchase_vs_orders_v` — представление для чтения готовых данных."
], ],
"keys": [ "keys": [
"`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS.", "`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS.",
"`order_day` — общий день события покупки и заказа." "`order_day` — дата заказа; если заказа еще нет, берется дата события покупки."
], ],
"fill": [ "fill": [
"Даг `dm_transform` пересобирает изменяемый хвост обоих источников при каждом прогоне конвейера.", "При каждом запуске `dm_transform` пересчитывает дни последнего слепка, добавляет новые дни покупок, заполняет пропуски и повторяет сверку дней с покупками без заказа.",
"Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` собирают дни в доноре и заменяют партиции." "Задача `purchase_vs_orders.rebuild` рассчитывает данные в промежуточной таблице (доноре). `purchase_vs_orders.replace` заменяет ими дневные партиции витрины."
], ],
"links": [ "links": [
{ {
@@ -751,19 +749,19 @@
"events", "events",
"orders" "orders"
], ],
"what": "Текущее состояние пяти атомарных проверок качества по дням. История запусков остается в Airflow.", "what": "Последние результаты пяти проверок качества данных за каждый день. Историю запусков можно посмотреть в Airflow.",
"read": "Читайте `dm.dq_summary_v`: запрос к публичному представлению возвращает последнюю опубликованную сводку дня.", "read": "Читайте `dm.dq_summary_v`, чтобы увидеть результат каждой проверки за день.",
"parts": [ "parts": [
"`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.", "`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.",
"`dm.dq_summary_dist` — распределенная поверхность чтения и записи.", "`dm.dq_summary_dist` — распределенная таблица чтения и записи.",
"`dm.dq_summary_stage_rep` — общий донор дневных партиций.", "`dm.dq_summary_stage_rep` — промежуточная таблица для результатов всех проверок за день.",
"`dm.dq_summary_stage_dist` — распределенная поверхность донора.", "`dm.dq_summary_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
"`dm.dq_summary_v` — публичное представление." "`dm.dq_summary_v` — представление для чтения готовых данных."
], ],
"fill": [ "fill": [
"Даг `dq_check` при каждом прогоне сначала выполняет все пять проверок, затем заменяет готовые дневные партиции.", "Даг `dq_check` сначала рассчитывает результаты всех пяти проверок в промежуточной таблице, затем заменяет ими дневные партиции сводки.",
"`sessions_vs_reference` — `sql/dq/sessions_vs_reference.sql`.", "`sessions_vs_reference` — `sql/dq/sessions_vs_reference.sql`.",
"`classes_vs_inventory` — `sql/dq/classes_vs_inventory.sql` и паспорт мира.", "`classes_vs_inventory` — `sql/dq/classes_vs_inventory.sql` и эталонные числа из паспорта мира `data/world-inventory.json`.",
"`revenue_daily_vs_dds` — `sql/dq/revenue_daily_vs_dds.sql`.", "`revenue_daily_vs_dds` — `sql/dq/revenue_daily_vs_dds.sql`.",
"`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.", "`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.",
"`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`." "`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`."
@@ -791,17 +789,17 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"what": "Словарь товаров вне цепочки слоев. По артикулу доступны название, бренд, категория и цена.", "what": "Словарь товаров. По артикулу можно получить название, бренд, категорию и цену. Эти данные доступны для расчетов в разных слоях.",
"read": "Читайте `dic.products`: у словаря одна поверхность без суффикса.", "read": "Читайте `dic.products`: к словарю обращаются напрямую, суффикса `_v` у него нет.",
"parts": [ "parts": [
"`dic.products_file` — чтец CSV-каталога из файла репозитория.", "`dic.products_file` — таблица, которая читает каталог товаров из CSV-файла репозитория.",
"`dic.products` — словарь в памяти поверх подложки." "`dic.products` — словарь в памяти, загруженный из этой таблицы."
], ],
"keys": [ "keys": [
"`sku` — тот же артикул, что `item_sku` в позиции заказа DDS." "`sku` — тот же артикул, что `item_sku` в позиции заказа DDS."
], ],
"fill": [ "fill": [
"ClickHouse перечитывает подложку каждые 60–90 секунд по настройке `LIFETIME`." "ClickHouse обновляет словарь из `dic.products_file` каждые 60–90 секунд согласно настройке `LIFETIME`."
], ],
"links": [ "links": [
{ {
@@ -825,8 +823,8 @@
"events" "events"
], ],
"rhythm": "непрерывно, по мере прихода сообщений", "rhythm": "непрерывно, по мере прихода сообщений",
"actor": "Чтец `stg.hits_raw_kafka` и матвью `stg.hits_raw_mv`.", "actor": "Таблица `stg.hits_raw_kafka` и материализованное представление `stg.hits_raw_mv`.",
"join": "Соединения нет: сообщение кладется строкой целиком.", "join": "Каждое сообщение Kafka записывается целой строкой, без разбора на поля.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -847,8 +845,8 @@
"events" "events"
], ],
"rhythm": "непрерывно, по мере прихода сообщений", "rhythm": "непрерывно, по мере прихода сообщений",
"actor": "Матвью `ods.event_mv` разбирает годные строки.", "actor": "Материализованное представление `ods.event_mv` разбирает сообщения без ошибок.",
"join": "Соединения нет: матвью разбирает строку в колонки.", "join": "Материализованное представление разбирает сообщение из STG на колонки с нужными типами данных.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -869,8 +867,8 @@
"events" "events"
], ],
"rhythm": "непрерывно, по мере прихода сообщений", "rhythm": "непрерывно, по мере прихода сообщений",
"actor": "Матвью `ods.event_errors_mv` отбирает строки, которые не прошли строгий прием.", "actor": "Материализованное представление `ods.event_errors_mv` отбирает сообщения с ошибками.",
"join": "Соединения нет: негодная строка уходит целиком вместе с классом ошибки.", "join": "Сообщение, которое не удалось разобрать по правилам ODS, сохраняется целиком вместе с классом ошибки.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -890,9 +888,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "пачкой, раз в модельные сутки", "rhythm": "одной выгрузкой, раз в модельные сутки",
"actor": "Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`.", "actor": "Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`.",
"join": "Соединения нет: сообщение слепка кладется целиком, пачка помечена `_load_id`.", "join": "Строки выгрузки сохраняются целиком. Общий `_load_id` позволяет найти все строки одной загрузки.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -912,9 +910,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "пачкой, раз в модельные сутки", "rhythm": "одной выгрузкой, раз в модельные сутки",
"actor": "Задача `parse_batch` дага `orders_ingest` пишет годные версии заказа.", "actor": "Задача `parse_batch` дага `orders_ingest` записывает версии заказов без ошибок разбора.",
"join": "Соединения нет: строка разбирается в колонки, версия задается `snapshot_date`.", "join": "Строка разбирается на колонки заказа. Версия определяется датой слепка `snapshot_date`.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -934,9 +932,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "пачкой, раз в модельные сутки", "rhythm": "одной выгрузкой, раз в модельные сутки",
"actor": "Задача `parse_batch` дага `orders_ingest` пишет негодные строки той же пачки.", "actor": "Задача `parse_batch` дага `orders_ingest` записывает строки с ошибками из загруженной выгрузки.",
"join": "Соединения нет: негодная строка пачки уходит целиком.", "join": "Строка с ошибкой сохраняется целиком вместе с классом ошибки и идентификатором загрузки.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -956,9 +954,9 @@
"streams": [ "streams": [
"events" "events"
], ],
"rhythm": "без копии, данные читаются на месте", "rhythm": "при запросе к представлению, без копирования данных",
"actor": "Запрос `dds.event_v` читает данные из `ods.event_v` и переименовывает поля на языке бизнеса.", "actor": "Представление `dds.event_v` читает данные из `ods.event_v`.",
"join": "Соединения нет: представление переименовывает колонки той же строки.", "join": "Колонки события переименовываются по правилам DDS. Данные не копируются в отдельную таблицу.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -978,9 +976,9 @@
"streams": [ "streams": [
"events" "events"
], ],
"rhythm": "пачкой, при каждом прогоне `dds_transform`", "rhythm": "при каждом запуске `dds_transform`",
"actor": "Задачи `session.rebuild` и `session.replace` дага `dds_transform`.", "actor": "Задачи `session.rebuild` и `session.replace` дага `dds_transform`.",
"join": "События одной `client_id` за `event_date` режутся по паузам; `session_id` - `cityHash64(client_id, min(event_time))`.", "join": "События одного `client_id` за день `event_date` упорядочиваются по времени. Пауза дольше 30 минут начинает новую сессию. Ее `session_id` рассчитывается как `cityHash64(client_id, min(event_time))`.",
"links": [ "links": [
{ {
"label": "SQL сборки", "label": "SQL сборки",
@@ -1004,9 +1002,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "пачкой, при каждом прогоне `dds_transform`", "rhythm": "при каждом запуске `dds_transform`",
"actor": "Задачи `order.rebuild` и `order.replace` дага `dds_transform`.", "actor": "Задачи `order.rebuild` и `order.replace` дага `dds_transform`.",
"join": "Соединения нет: из заказа берутся поля и позиции, `order_date` выводится из `created_at`.", "join": "Поля заказа приводятся к типам DDS, а позиции из JSON разбираются в массивы. Дата `order_date` рассчитывается из `created_at`.",
"links": [ "links": [
{ {
"label": "SQL сборки", "label": "SQL сборки",
@@ -1030,9 +1028,9 @@
"streams": [ "streams": [
"events" "events"
], ],
"rhythm": "пачкой, при каждом прогоне `dds_transform`", "rhythm": "при каждом запуске `dds_transform`",
"actor": "Задача `identity_map` дага `dds_transform` соединяет покупки с заказами.", "actor": "Задача `identity_map` дага `dds_transform` соединяет покупки с заказами.",
"join": "Строки покупки и заказа соединяются по `purchase_id[1] = order_id`.", "join": "Событие покупки соединяется с заказом по `purchase_id[1] = order_id`. В результате связываются посетитель сайта и пользователь магазина.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -1052,9 +1050,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "пачкой, при каждом прогоне `dds_transform`", "rhythm": "при каждом запуске `dds_transform`",
"actor": "Задача `identity_map` дага `dds_transform` соединяет заказы с покупками.", "actor": "Задача `identity_map` дага `dds_transform` соединяет заказы с покупками.",
"join": "Строки заказа и покупки соединяются по `order_id = purchase_id[1]`; берется `user_id`.", "join": "Заказ соединяется с событием покупки по `order_id = purchase_id[1]`. Из заказа берется `user_id` пользователя магазина.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -1074,9 +1072,9 @@
"streams": [ "streams": [
"events" "events"
], ],
"rhythm": "пачкой, при каждом прогоне `dm_transform`", "rhythm": "при каждом запуске `dm_transform`",
"actor": "Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям.", "actor": "Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям.",
"join": "Соединения нет: сессии группируются по `session_date`, который становится `report_date`.", "join": "К сессиям присоединяется карта идентичностей по `client_id`, затем данные группируются по `session_date`. Эта дата записывается в `report_date`.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -1096,9 +1094,9 @@
"streams": [ "streams": [
"events" "events"
], ],
"rhythm": "пачкой, при каждом прогоне `dm_transform`", "rhythm": "при каждом запуске `dm_transform`",
"actor": "Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`.", "actor": "Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`.",
"join": "GLOBAL LEFT JOIN по `client_id`; сессии без пары остаются в витрине.", "join": "`GLOBAL LEFT JOIN` присоединяет карту идентичностей к сессиям по `client_id`. Сессии без известного пользователя тоже участвуют в расчете посетителей.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -1118,9 +1116,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "пачкой, при каждом прогоне `dm_transform`", "rhythm": "при каждом запуске `dm_transform`",
"actor": "Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`.", "actor": "Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`.",
"join": "Соединения таблиц нет: ARRAY JOIN раскрывает `item_sku`, `item_quantity`, `item_price`; `order_date` становится `report_date`.", "join": "`ARRAY JOIN` разворачивает массивы `item_sku`, `item_quantity`, `item_price` в строки позиций заказа. Выручка считается по оплаченным заказам; `order_date` записывается в `report_date`.",
"links": [ "links": [
{ {
"label": "SQL сборки", "label": "SQL сборки",
@@ -1144,9 +1142,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "пачкой, при каждом прогоне `dm_transform`", "rhythm": "при каждом запуске `dm_transform`",
"actor": "Даг `dm_transform` получает категорию через `dictGet` по `item_sku`; ключ словаря — `sku`.", "actor": "Задача `revenue_daily.rebuild` дага `dm_transform`.",
"join": "Категория читается через `dictGet` по `item_sku`; ключ справочника - `sku`.", "join": "Функция `dictGet` получает категорию товара по артикулу `item_sku` из заказа. В словаре этому артикулу соответствует ключ `sku`.",
"links": [ "links": [
{ {
"label": "SQL", "label": "SQL",
@@ -1166,9 +1164,9 @@
"streams": [ "streams": [
"events" "events"
], ],
"rhythm": "пачкой, при каждом прогоне `dm_transform`", "rhythm": "при каждом запуске `dm_transform`",
"actor": "Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки.", "actor": "Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки.",
"join": "Покупки группируются по `purchase_id[1]` как `purchase_order_id`; `event_date` становится `purchase_day`, затем GLOBAL FULL OUTER JOIN `purchase_order_id = backend_order_id`.", "join": "События покупки группируются по номеру `purchase_id[1]`, который получает имя `purchase_order_id`. Дата `event_date` записывается в `purchase_day`. Затем `GLOBAL FULL OUTER JOIN` сопоставляет покупки с заказами по `purchase_order_id = backend_order_id`.",
"links": [ "links": [
{ {
"label": "SQL сборки", "label": "SQL сборки",
@@ -1192,9 +1190,9 @@
"streams": [ "streams": [
"orders" "orders"
], ],
"rhythm": "пачкой, при каждом прогоне `dm_transform`", "rhythm": "при каждом запуске `dm_transform`",
"actor": "Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают заказы.", "actor": "Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают заказы.",
"join": "GLOBAL FULL OUTER JOIN: `purchase_order_id = backend_order_id` (`purchase_id[1]` и `order_id`); отсутствующая сторона получает NULL.", "join": "`order_id` заказа получает имя `backend_order_id`, а `purchase_id[1]` покупки — `purchase_order_id`. `GLOBAL FULL OUTER JOIN` соединяет их по `purchase_order_id = backend_order_id`. Покупки без заказа и заказы без покупки сохраняются; поля отсутствующей стороны содержат `NULL`.",
"links": [ "links": [
{ {
"label": "SQL сборки", "label": "SQL сборки",
@@ -1308,9 +1306,9 @@
details.append(heading, meta); details.append(heading, meta);
addSection("Что это", node.what); addSection("Что это", node.what);
addSection("Как читать", node.read); addSection("Как читать", node.read);
addSection("Из чего состоит", node.parts); addSection("Связанные таблицы и представления", node.parts);
addSection("Ключи", node.keys); addSection("Как связать данные", node.keys);
addSection("Кто наполняет и как часто", node.fill); addSection("Как обновляются данные", node.fill);
addLinks(node.links); addLinks(node.links);
} }
function renderEdgeCard(edge) { function renderEdgeCard(edge) {
@@ -1318,10 +1316,10 @@
const heading = document.createElement("h2"); const heading = document.createElement("h2");
heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`; heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`;
details.append(heading); details.append(heading);
addSection("Механизм", mechanismById.get(edge.mechanism).label); addSection("Способ обработки", mechanismById.get(edge.mechanism).label);
addSection("Ритм", edge.rhythm); addSection("Когда выполняется", edge.rhythm);
addSection("Кто именно", edge.actor); addSection("Что выполняет обработку", edge.actor);
addSection("Как соединяет", edge.join); addSection("Что происходит с данными", edge.join);
addLinks(edge.links); addLinks(edge.links);
} }
function layoutMap() { function layoutMap() {
+150 -152
View File
@@ -1,13 +1,12 @@
title: Карта слоев хранилища title: Карта слоев хранилища
lead: Проследите, как один смысл меняет имя и поверхность между слоями. lead: Проследите путь событий и заказов от Kafka до отчетов.
chapter_url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои chapter_url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои
intro: intro:
- Эта карта показывает путь событий и заказов от Kafka до витрин DM. - Выберите объект, чтобы узнать, что он хранит, как его читать и откуда в него поступают данные.
- Щелкните по узлу, чтобы увидеть его назначение, устройство и ключи. - Выберите стрелку, чтобы посмотреть способ обработки и открыть SQL-запрос.
- Щелкните по стрелке, чтобы узнать, кто и каким запросом переносит данные. - Для выбранного объекта подсвечиваются все пути, по которым данные поступают в него и используются дальше.
- Когда вы выбираете узел, карта подсвечивает все пути к нему и от него. - Кнопки «События» и «Заказы» выделяют нужный поток. Остальные объекты остаются на месте, но становятся бледнее.
- Переключатель потока приглушает другую часть карты, но не меняет раскладку. - Если вы впервые знакомитесь со слоями хранилища, начните с главы по ссылке ниже.
- Сначала прочитайте главу «Хранилище и его слои», затем вернитесь к карте и рассмотрите объекты.
streams: streams:
all: Все all: Все
@@ -16,38 +15,38 @@ streams:
dictionary: dictionary:
title: Словарь title: Словарь
caption: 'сбоку цепочки: справочники' caption: справочные данные
layers: layers:
- id: kafka - id: kafka
title: Kafka title: Kafka
caption: откуда приходит caption: сообщения источников
- id: stg - id: stg
title: STG title: STG
caption: как поступило caption: исходные сообщения
- id: ods - id: ods
title: ODS title: ODS
caption: язык источника caption: данные разобраны по полям
- id: dds - id: dds
title: DDS title: DDS
caption: язык бизнеса caption: события, заказы и сессии
- id: dm - id: dm
title: DM title: DM
caption: данные для дашбордов caption: витрины для отчетов
mechanisms: mechanisms:
- id: materialized_view - id: materialized_view
label: матвью label: материализованное представление
line: solid line: solid
rhythm: непрерывно, по мере прихода сообщений rhythm: непрерывно, по мере прихода сообщений
- id: airflow_dag - id: airflow_dag
label: даг Airflow label: даг Airflow
line: dashed line: dashed
rhythm: 'пачкой: раз в модельные сутки (`orders_ingest`) или на каждом прогоне конвейера' rhythm: 'пакетно: раз в модельные сутки (`orders_ingest`) или при каждом запуске конвейера'
- id: view - id: view
label: представление label: представление
line: dotted line: dotted
rhythm: без копии, данные читаются на месте rhythm: при запросе к представлению, без копирования данных
nodes: nodes:
- id: kafka_hits - id: kafka_hits
@@ -56,9 +55,9 @@ nodes:
order: 0 order: 0
streams: [events] streams: [events]
what: Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие. what: Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие.
read: 'Топик напрямую не читают: таблица `stg.hits_raw_kafka` забирает сообщения из топика. Для данных событий используйте `stg.hits_raw_dist`.' read: "Для просмотра загруженных сообщений используйте `stg.hits_raw_dist`. Таблица `stg.hits_raw_kafka` служит для приема из топика."
fill: fill:
- Источник событий пишет сообщения в топик в темпе выбранного режима мира. - "Генератор отправляет события без пауз в пакетном режиме или постепенно в живом режиме."
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
@@ -70,10 +69,10 @@ nodes:
layer: kafka layer: kafka
order: 1 order: 1
streams: [orders] streams: [orders]
what: Топик Kafka со слепками заказов магазина. После модельного дня приходит снимок изменяемого окна. what: "Топик Kafka с дневными выгрузками заказов — слепками. Каждый слепок содержит состояние заказов за последние семь модельных дней."
read: 'Топик напрямую не читают: таблица `stg.orders_raw_kafka` забирает сообщения из топика. Для заказов используйте `stg.orders_raw_dist`.' read: "Для просмотра загруженных сообщений используйте `stg.orders_raw_dist`. Таблица `stg.orders_raw_kafka` служит для приема из топика."
fill: fill:
- Источник заказов публикует слепок пачкой после каждого модельного дня. - "После генерации событий дня D источник отправляет слепок заказов за предыдущий день D−1."
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных
@@ -85,22 +84,22 @@ nodes:
layer: stg layer: stg
order: 0 order: 0
streams: [events] streams: [events]
what: Сырая строка события и метаданные доставки. STG сохраняет сообщение без разбора. what: "Исходное сообщение события и сведения о его доставке из Kafka. Содержимое сохраняется целой строкой, без разбора на поля."
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.' read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
parts: parts:
- '`stg.hits_raw_kafka` — чтец топика `hits`.' - "`stg.hits_raw_kafka` — таблица для чтения сообщений топика `hits`."
- '`stg.hits_raw_rep` — реплицируемая таблица на шарде.' - "`stg.hits_raw_rep` — реплицируемая таблица на шарде."
- '`stg.hits_raw_dist` — распределенная поверхность записи и чтения.' - "`stg.hits_raw_dist` — распределенная таблица для записи и чтения."
- '`stg.hits_raw_mv` — матвью приема сообщений из чтеца.' - "`stg.hits_raw_mv` — материализованное представление, которое записывает сообщения из Kafka в STG."
fill: fill:
- '`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.' - '`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.'
- Сырые строки живут трое суток реального времени плюс хвост до суток. - Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
- label: DDL таблиц - label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql
- label: DDL матвью - label: DDL материализованного представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql
- id: stg_orders - id: stg_orders
@@ -108,15 +107,15 @@ nodes:
layer: stg layer: stg
order: 1 order: 1
streams: [orders] streams: [orders]
what: Сырые строки слепка заказов, метаданные доставки и идентификатор пачки `_load_id`. what: "Исходные строки выгрузки заказов, сведения о доставке и идентификатор загруженной партии `_load_id`."
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.' read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
parts: parts:
- '`stg.orders_raw_kafka` — чтец топика `orders` на первой ноде.' - "`stg.orders_raw_kafka` — таблица для чтения топика `orders` на первом узле."
- '`stg.orders_raw_rep` — реплицируемая таблица на шарде.' - "`stg.orders_raw_rep` — реплицируемая таблица на шарде."
- '`stg.orders_raw_dist` — распределенная поверхность записи и чтения.' - "`stg.orders_raw_dist` — распределенная таблица для записи и чтения."
fill: fill:
- Задача `pull_batch` дага `orders_ingest` забирает слепок пачкой раз в модельные сутки. - Задача `pull_batch` дага `orders_ingest` загружает слепок раз в модельные сутки.
- Сырые строки живут трое суток реального времени плюс хвост до суток. - Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных
@@ -128,13 +127,13 @@ nodes:
layer: ods layer: ods
order: 0 order: 0
streams: [events] streams: [events]
what: Типизированное широкое событие с именами полей источника. Запрос представления с `FINAL` возвращает актуальную версию события. what: "События, разобранные на колонки с типами данных. Имена полей сохранены как в источнике."
read: 'Читайте `ods.event_v`: запрос представления с `FINAL` возвращает актуальные строки вместо физических версий.' read: "Читайте `ods.event_v`: представление использует `FINAL` и возвращает последнюю версию каждого события."
parts: parts:
- '`ods.event_rep` — реплицируемая таблица версий события на шарде.' - '`ods.event_rep` — реплицируемая таблица версий события на шарде.'
- '`ods.event_dist` — распределенная поверхность записи и диагностики.' - '`ods.event_dist` — распределенная таблица записи и диагностики.'
- '`ods.event_v` — представление актуального состояния с `FINAL`.' - '`ods.event_v` — представление актуального состояния с `FINAL`.'
- '`ods.event_mv` — матвью разбора годных строк STG.' - '`ods.event_mv` — материализованное представление для разбора сообщений STG без ошибок.'
keys: keys:
- '`WatchID` — тот же идентификатор, что `event_id` в DDS.' - '`WatchID` — тот же идентификатор, что `event_id` в DDS.'
- '`ClientID` — тот же посетитель, что `client_id` в DDS.' - '`ClientID` — тот же посетитель, что `client_id` в DDS.'
@@ -155,21 +154,21 @@ nodes:
layer: ods layer: ods
order: 1 order: 1
streams: [events] streams: [events]
what: Сырые события, которые не прошли строгий прием, с классом ошибки и метаданными доставки. what: "Сообщения событий, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и сведения о доставке."
read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.' read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.'
parts: parts:
- '`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.' - '`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.'
- '`ods.event_errors_dist` — распределенная поверхность чтения и записи.' - '`ods.event_errors_dist` — распределенная таблица чтения и записи.'
- '`ods.event_errors_mv` — матвью, которое отбирает негодные строки STG.' - '`ods.event_errors_mv` — материализованное представление для отбора сообщений STG с ошибками.'
fill: fill:
- '`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.' - '`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.'
- Ошибки живут один месяц и не останавливают прием годных событий. - Ошибки хранятся один месяц. Остальные события продолжают загружаться.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
- label: DDL таблиц - label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
- label: DDL матвью - label: DDL материализованного представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
- id: ods_order - id: ods_order
@@ -177,17 +176,17 @@ nodes:
layer: ods layer: ods
order: 2 order: 2
streams: [orders] streams: [orders]
what: Текущая версия заказа на языке источника. Позиции пока остаются сырым фрагментом JSON. what: "Последняя загруженная версия заказа с именами полей из источника. Состав заказа пока хранится как фрагмент JSON."
read: 'Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.' read: 'Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.'
parts: parts:
- '`ods.order_rep` — реплицируемая таблица версий заказа на шарде.' - '`ods.order_rep` — реплицируемая таблица версий заказа на шарде.'
- '`ods.order_dist` — распределенная поверхность записи и диагностики.' - '`ods.order_dist` — распределенная таблица записи и диагностики.'
- '`ods.order_v` — представление текущего состояния с `FINAL`.' - '`ods.order_v` — представление текущего состояния с `FINAL`.'
keys: keys:
- '`order_id` — тот же заказ в `dds.order_v` и `dm.purchase_vs_orders_v`.' - '`order_id` — тот же заказ в `dds.order_v` и `dm.purchase_vs_orders_v`.'
- '`user_id` — пользователь магазина, который переходит в заказ DDS.' - '`user_id` — пользователь магазина, который переходит в заказ DDS.'
fill: fill:
- Задача `parse_batch` дага `orders_ingest` разбирает пачку раз в модельные сутки. - Задача `parse_batch` дага `orders_ingest` разбирает загруженный слепок раз в модельные сутки.
- '`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка.' - '`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка.'
links: links:
- label: Справочник - label: Справочник
@@ -202,14 +201,14 @@ nodes:
layer: ods layer: ods
order: 3 order: 3
streams: [orders] streams: [orders]
what: Сырые строки слепка, которые не прошли строгий прием, с классом ошибки и `_load_id`. what: "Строки выгрузки заказов, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и `_load_id`."
read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.' read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.'
parts: parts:
- '`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.' - '`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.'
- '`ods.order_errors_dist` — распределенная поверхность чтения и записи.' - '`ods.order_errors_dist` — распределенная таблица чтения и записи.'
fill: fill:
- Задача `parse_batch` дага `orders_ingest` пишет ошибки из той же пачки, что и годные заказы. - Задача `parse_batch` дага `orders_ingest` сохраняет строки с ошибками при разборе той же выгрузки, из которой загружаются заказы.
- Ошибки живут один месяц и не останавливают прием годных заказов. - Ошибки хранятся один месяц. Остальные заказы продолжают загружаться.
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
@@ -221,8 +220,8 @@ nodes:
layer: dds layer: dds
order: 0 order: 0
streams: [events] streams: [events]
what: Публичное представление события на языке бизнеса. Его запрос переименовывает поля и расшифровывает категорию устройства. what: "События с названиями полей, принятыми в DDS. Например, `WatchID` переименован в `event_id`, а числовой код устройства заменен названием категории."
read: 'Читайте `dds.event_v`: запрос представления возвращает явный список колонок DDS.' read: "Читайте `dds.event_v`: представление возвращает явно перечисленные колонки DDS."
parts: parts:
- '`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет.' - '`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет.'
keys: keys:
@@ -231,7 +230,7 @@ nodes:
- '`event_date` — тот же день, что `EventDate` в ODS.' - '`event_date` — тот же день, что `EventDate` в ODS.'
- '`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM.' - '`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM.'
fill: fill:
- Данные для `dds.event_v` читаются из актуальных событий ODS без копии и отдельного расписания. - "При запросе к `dds.event_v` данные читаются из актуальных событий ODS. Отдельная загрузка в DDS не нужна."
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма
@@ -243,20 +242,20 @@ nodes:
layer: dds layer: dds
order: 1 order: 1
streams: [events] streams: [events]
what: Сессия, которую запрос дага `dds_transform` собирает из последовательных событий одной куки. В источнике готовой сессии нет. what: "Сессии, рассчитанные дагом `dds_transform` по событиям посетителя. Новая сессия начинается после паузы дольше 30 минут или при смене модельной даты."
read: 'Читайте `dds.session_v`: запрос представления возвращает деловые и служебные колонки.' read: "Читайте `dds.session_v`: здесь есть начало, конец, длительность сессии, показатели событий и служебные поля загрузки."
parts: parts:
- '`dds.session_rep` — реплицируемая таблица сессий на шарде.' - '`dds.session_rep` — реплицируемая таблица сессий на шарде.'
- '`dds.session_dist` — распределенная поверхность загрузки и диагностики.' - '`dds.session_dist` — распределенная таблица загрузки и диагностики.'
- '`dds.session_stage_rep` — донор дневных партиций на шарде.' - '`dds.session_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
- '`dds.session_stage_dist` — распределенная поверхность донора.' - '`dds.session_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dds.session_v` — публичное представление.' - '`dds.session_v` — представление для чтения готовых данных.'
keys: keys:
- '`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.' - '`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.'
- '`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`.' - '`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`.'
fill: fill:
- Даг `dds_transform` пересобирает данные прожитых дней при каждом прогоне конвейера. - "При каждом запуске `dds_transform` выбирает дни от первого пропущенного в DDS до последнего дня с завершенной генерацией."
- Задачи `session.rebuild` и `session.replace` собирают день в доноре и заменяют партицию. - "Задача `session.rebuild` рассчитывает данные в промежуточной таблице (доноре). `session.replace` заменяет ими дневные партиции основной таблицы."
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение
@@ -270,22 +269,22 @@ nodes:
layer: dds layer: dds
order: 2 order: 2
streams: [orders] streams: [orders]
what: Заказ в текущем состоянии на языке бизнеса. Артикулы, количества и цены позиций хранятся в согласованных массивах. what: "Последнее известное состояние заказа, подготовленное для анализа. Артикулы, количества и цены товаров хранятся в массивах; элементы с одинаковым индексом относятся к одной позиции заказа."
read: 'Читайте `dds.order_v`: запрос представления возвращает данные без таблиц и донора партиций.' read: "Читайте `dds.order_v`, чтобы получить готовые заказы из основной таблицы."
parts: parts:
- '`dds.order_rep` — реплицируемая таблица заказов на шарде.' - '`dds.order_rep` — реплицируемая таблица заказов на шарде.'
- '`dds.order_dist` — распределенная поверхность загрузки и диагностики.' - '`dds.order_dist` — распределенная таблица загрузки и диагностики.'
- '`dds.order_stage_rep` — донор дневных партиций на шарде.' - '`dds.order_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
- '`dds.order_stage_dist` — распределенная поверхность донора.' - '`dds.order_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dds.order_v` — публичное представление.' - '`dds.order_v` — представление для чтения готовых данных.'
keys: keys:
- '`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.' - '`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.'
- '`user_id` — пользователь магазина, который входит в карту идентичностей.' - '`user_id` — пользователь магазина, который входит в карту идентичностей.'
- '`item_sku` — тот же артикул, что `sku` в `dic.products`.' - '`item_sku` — тот же артикул, что `sku` в `dic.products`.'
- '`order_date` — день, который в витринах называется `report_date` или `order_day`.' - '`order_date` — день, который в витринах называется `report_date` или `order_day`.'
fill: fill:
- Даг `dds_transform` пересобирает изменяемое окно при каждом прогоне конвейера. - "При каждом запуске `dds_transform` пересчитывает дни заказов из последнего слепка и заполняет пропуски в DDS."
- Задачи `order.rebuild` и `order.replace` собирают дни в доноре и заменяют партиции. - "Задача `order.rebuild` рассчитывает данные в промежуточной таблице (доноре). `order.replace` заменяет ими дневные партиции основной таблицы."
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение
@@ -299,18 +298,18 @@ nodes:
layer: dds layer: dds
order: 3 order: 3
streams: [events, orders] streams: [events, orders]
what: Известные пары куки трекера и пользователя магазина. Связь появляется после сопоставленной покупки. what: "Связи между посетителем сайта и пользователем магазина. Они появляются, когда событие покупки сопоставлено с заказом по его номеру."
read: 'Читайте `dds.identity_map_v`: запрос представления с `FINAL` возвращает актуальные строки полного пересчета.' read: "Читайте `dds.identity_map_v`: представление с `FINAL` возвращает последнюю версию каждой пары посетителя и пользователя."
parts: parts:
- '`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.' - '`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.'
- '`dds.identity_map_dist` — распределенная поверхность записи.' - '`dds.identity_map_dist` — распределенная таблица записи.'
- '`dds.identity_map_v` — точное публичное представление с `FINAL`.' - '`dds.identity_map_v` — представление последних версий строк с `FINAL`.'
keys: keys:
- '`client_id` — кука из события и сессии DDS.' - "`client_id` — идентификатор посетителя из cookie браузера, как в событии и сессии DDS."
- '`user_id` — пользователь магазина из заказа DDS.' - "`user_id` — пользователь магазина из заказа DDS."
fill: fill:
- Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов. - "Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов."
- Новая версия полного набора пишется при каждом прогоне; `FINAL` скрывает физические повторы. - "При каждом запуске записывается новая версия набора связей."
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка
@@ -324,17 +323,16 @@ nodes:
layer: dm layer: dm
order: 0 order: 0
streams: [events] streams: [events]
what: Дневные счетчики посетителей и известных пользователей после склейки идентичностей. what: "Число посетителей и известных пользователей за каждый день. Известными считаются пользователи магазина, связанные с посетителями сайта через карту идентичностей."
read: 'Читайте `dm.daily_traffic_v`: запрос представления с `FINAL` возвращает актуальные строки полного пересчета.' read: "Читайте `dm.daily_traffic_v`: представление с `FINAL` возвращает последний результат расчета за каждый день."
parts: parts:
- '`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.' - '`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.'
- '`dm.daily_traffic_dist` — распределенная поверхность записи.' - '`dm.daily_traffic_dist` — распределенная таблица записи.'
- '`dm.daily_traffic_v` — точное публичное представление с `FINAL`.' - '`dm.daily_traffic_v` — представление последних версий строк с `FINAL`.'
keys: keys:
- '`report_date` — тот же день, что `session_date` в сессии DDS.' - '`report_date` — тот же день, что `session_date` в сессии DDS.'
fill: fill:
- Задача `daily_traffic` дага `dm_transform` при каждом прогоне пересчитывает всю историю и записывает новую версию. - "Задача `daily_traffic` дага `dm_transform` при каждом запуске пересчитывает всю историю и записывает новую версию."
- '`FINAL` оставляет текущую строку каждого дня.'
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v
@@ -348,20 +346,20 @@ nodes:
layer: dm layer: dm
order: 1 order: 1
streams: [orders] streams: [orders]
what: Дневная выручка, число заказов и единиц товара по категориям. what: "Выручка, число заказов и единиц товара за день по категориям. В расчет входят оплаченные заказы."
read: 'Читайте `dm.revenue_daily_v`: это публичное представление готовой дневной витрины.' read: "Читайте `dm.revenue_daily_v`, чтобы получить готовые показатели по дням и категориям товаров."
parts: parts:
- '`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.' - '`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.'
- '`dm.revenue_daily_dist` — распределенная поверхность загрузки.' - '`dm.revenue_daily_dist` — распределенная таблица загрузки.'
- '`dm.revenue_daily_stage_rep` — донор дневных партиций на шарде.' - '`dm.revenue_daily_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
- '`dm.revenue_daily_stage_dist` — распределенная поверхность донора.' - '`dm.revenue_daily_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dm.revenue_daily_v` — публичное представление.' - '`dm.revenue_daily_v` — представление для чтения готовых данных.'
keys: keys:
- '`report_date` — тот же день, что `order_date` в заказе DDS.' - '`report_date` — тот же день, что `order_date` в заказе DDS.'
- '`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря.' - '`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря.'
fill: fill:
- Даг `dm_transform` пересобирает изменяемое окно заказов при каждом прогоне конвейера. - "При каждом запуске `dm_transform` пересчитывает дни заказов из последнего слепка и заполняет пропущенные дни витрины."
- Задачи `revenue_daily.rebuild` и `revenue_daily.replace` собирают дни в доноре и заменяют партиции. - "Задача `revenue_daily.rebuild` рассчитывает данные в промежуточной таблице (доноре). `revenue_daily.replace` заменяет ими дневные партиции витрины."
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v
@@ -375,20 +373,20 @@ nodes:
layer: dm layer: dm
order: 2 order: 2
streams: [events, orders] streams: [events, orders]
what: Сверка события покупки с заказом магазина. Одна строка показывает совпадение или класс расхождения. what: "Результат сверки покупок из кликстрима с заказами магазина. Для каждого номера заказа указано, совпали ли данные двух источников и какое расхождение найдено."
read: 'Читайте `dm.purchase_vs_orders_v`: в результате запроса отсутствующей стороне соответствует `NULL`.' read: "Читайте `dm.purchase_vs_orders_v`: если покупка или заказ отсутствует, поля этой стороны содержат `NULL`."
parts: parts:
- '`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.' - '`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.'
- '`dm.purchase_vs_orders_dist` — распределенная поверхность загрузки.' - '`dm.purchase_vs_orders_dist` — распределенная таблица загрузки.'
- '`dm.purchase_vs_orders_stage_rep` — донор дневных партиций на шарде.' - '`dm.purchase_vs_orders_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
- '`dm.purchase_vs_orders_stage_dist` — распределенная поверхность донора.' - '`dm.purchase_vs_orders_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dm.purchase_vs_orders_v` — публичное представление.' - '`dm.purchase_vs_orders_v` — представление для чтения готовых данных.'
keys: keys:
- '`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS.' - "`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS."
- '`order_day` — общий день события покупки и заказа.' - "`order_day` — дата заказа; если заказа еще нет, берется дата события покупки."
fill: fill:
- Даг `dm_transform` пересобирает изменяемый хвост обоих источников при каждом прогоне конвейера. - "При каждом запуске `dm_transform` пересчитывает дни последнего слепка, добавляет новые дни покупок, заполняет пропуски и повторяет сверку дней с покупками без заказа."
- Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` собирают дни в доноре и заменяют партиции. - "Задача `purchase_vs_orders.rebuild` рассчитывает данные в промежуточной таблице (доноре). `purchase_vs_orders.replace` заменяет ими дневные партиции витрины."
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v
@@ -402,18 +400,18 @@ nodes:
layer: dm layer: dm
order: 3 order: 3
streams: [events, orders] streams: [events, orders]
what: Текущее состояние пяти атомарных проверок качества по дням. История запусков остается в Airflow. what: "Последние результаты пяти проверок качества данных за каждый день. Историю запусков можно посмотреть в Airflow."
read: 'Читайте `dm.dq_summary_v`: запрос к публичному представлению возвращает последнюю опубликованную сводку дня.' read: "Читайте `dm.dq_summary_v`, чтобы увидеть результат каждой проверки за день."
parts: parts:
- '`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.' - '`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.'
- '`dm.dq_summary_dist` — распределенная поверхность чтения и записи.' - '`dm.dq_summary_dist` — распределенная таблица чтения и записи.'
- '`dm.dq_summary_stage_rep` — общий донор дневных партиций.' - '`dm.dq_summary_stage_rep` — промежуточная таблица для результатов всех проверок за день.'
- '`dm.dq_summary_stage_dist` — распределенная поверхность донора.' - '`dm.dq_summary_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dm.dq_summary_v` — публичное представление.' - '`dm.dq_summary_v` — представление для чтения готовых данных.'
fill: fill:
- Даг `dq_check` при каждом прогоне сначала выполняет все пять проверок, затем заменяет готовые дневные партиции. - Даг `dq_check` сначала рассчитывает результаты всех пяти проверок в промежуточной таблице, затем заменяет ими дневные партиции сводки.
- '`sessions_vs_reference` — `sql/dq/sessions_vs_reference.sql`.' - '`sessions_vs_reference` — `sql/dq/sessions_vs_reference.sql`.'
- '`classes_vs_inventory` — `sql/dq/classes_vs_inventory.sql` и паспорт мира.' - '`classes_vs_inventory` — `sql/dq/classes_vs_inventory.sql` и эталонные числа из паспорта мира `data/world-inventory.json`.'
- '`revenue_daily_vs_dds` — `sql/dq/revenue_daily_vs_dds.sql`.' - '`revenue_daily_vs_dds` — `sql/dq/revenue_daily_vs_dds.sql`.'
- '`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.' - '`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.'
- '`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`.' - '`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`.'
@@ -430,15 +428,15 @@ nodes:
layer: dic layer: dic
order: 0 order: 0
streams: [orders] streams: [orders]
what: Словарь товаров вне цепочки слоев. По артикулу доступны название, бренд, категория и цена. what: "Словарь товаров. По артикулу можно получить название, бренд, категорию и цену. Эти данные доступны для расчетов в разных слоях."
read: 'Читайте `dic.products`: у словаря одна поверхность без суффикса.' read: "Читайте `dic.products`: к словарю обращаются напрямую, суффикса `_v` у него нет."
parts: parts:
- '`dic.products_file` — чтец CSV-каталога из файла репозитория.' - "`dic.products_file` — таблица, которая читает каталог товаров из CSV-файла репозитория."
- '`dic.products` — словарь в памяти поверх подложки.' - "`dic.products` — словарь в памяти, загруженный из этой таблицы."
keys: keys:
- '`sku` — тот же артикул, что `item_sku` в позиции заказа DDS.' - '`sku` — тот же артикул, что `item_sku` в позиции заказа DDS.'
fill: fill:
- ClickHouse перечитывает подложку каждые 60–90 секунд по настройке `LIFETIME`. - "ClickHouse обновляет словарь из `dic.products_file` каждые 60–90 секунд согласно настройке `LIFETIME`."
links: links:
- label: Справочник - label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-справочники url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-справочники
@@ -452,8 +450,8 @@ edges:
mechanism: materialized_view mechanism: materialized_view
streams: [events] streams: [events]
rhythm: непрерывно, по мере прихода сообщений rhythm: непрерывно, по мере прихода сообщений
actor: Чтец `stg.hits_raw_kafka` и матвью `stg.hits_raw_mv`. actor: "Таблица `stg.hits_raw_kafka` и материализованное представление `stg.hits_raw_mv`."
join: 'Соединения нет: сообщение кладется строкой целиком.' join: "Каждое сообщение Kafka записывается целой строкой, без разбора на поля."
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql
@@ -466,8 +464,8 @@ edges:
mechanism: materialized_view mechanism: materialized_view
streams: [events] streams: [events]
rhythm: непрерывно, по мере прихода сообщений rhythm: непрерывно, по мере прихода сообщений
actor: Матвью `ods.event_mv` разбирает годные строки. actor: Материализованное представление `ods.event_mv` разбирает сообщения без ошибок.
join: 'Соединения нет: матвью разбирает строку в колонки.' join: "Материализованное представление разбирает сообщение из STG на колонки с нужными типами данных."
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
@@ -480,8 +478,8 @@ edges:
mechanism: materialized_view mechanism: materialized_view
streams: [events] streams: [events]
rhythm: непрерывно, по мере прихода сообщений rhythm: непрерывно, по мере прихода сообщений
actor: Матвью `ods.event_errors_mv` отбирает строки, которые не прошли строгий прием. actor: "Материализованное представление `ods.event_errors_mv` отбирает сообщения с ошибками."
join: 'Соединения нет: негодная строка уходит целиком вместе с классом ошибки.' join: "Сообщение, которое не удалось разобрать по правилам ODS, сохраняется целиком вместе с классом ошибки."
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
@@ -493,9 +491,9 @@ edges:
to: stg_orders to: stg_orders
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: пачкой, раз в модельные сутки rhythm: одной выгрузкой, раз в модельные сутки
actor: Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`. actor: Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`.
join: 'Соединения нет: сообщение слепка кладется целиком, пачка помечена `_load_id`.' join: "Строки выгрузки сохраняются целиком. Общий `_load_id` позволяет найти все строки одной загрузки."
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/stg/orders_raw_load.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/stg/orders_raw_load.sql
@@ -507,9 +505,9 @@ edges:
to: ods_order to: ods_order
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: пачкой, раз в модельные сутки rhythm: одной выгрузкой, раз в модельные сутки
actor: Задача `parse_batch` дага `orders_ingest` пишет годные версии заказа. actor: Задача `parse_batch` дага `orders_ingest` записывает версии заказов без ошибок разбора.
join: 'Соединения нет: строка разбирается в колонки, версия задается `snapshot_date`.' join: "Строка разбирается на колонки заказа. Версия определяется датой слепка `snapshot_date`."
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql
@@ -521,9 +519,9 @@ edges:
to: ods_order_errors to: ods_order_errors
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: пачкой, раз в модельные сутки rhythm: одной выгрузкой, раз в модельные сутки
actor: Задача `parse_batch` дага `orders_ingest` пишет негодные строки той же пачки. actor: Задача `parse_batch` дага `orders_ingest` записывает строки с ошибками из загруженной выгрузки.
join: 'Соединения нет: негодная строка пачки уходит целиком.' join: "Строка с ошибкой сохраняется целиком вместе с классом ошибки и идентификатором загрузки."
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql
@@ -535,9 +533,9 @@ edges:
to: dds_event to: dds_event
mechanism: view mechanism: view
streams: [events] streams: [events]
rhythm: без копии, данные читаются на месте rhythm: при запросе к представлению, без копирования данных
actor: Запрос `dds.event_v` читает данные из `ods.event_v` и переименовывает поля на языке бизнеса. actor: "Представление `dds.event_v` читает данные из `ods.event_v`."
join: 'Соединения нет: представление переименовывает колонки той же строки.' join: "Колонки события переименовываются по правилам DDS. Данные не копируются в отдельную таблицу."
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
@@ -549,9 +547,9 @@ edges:
to: dds_session to: dds_session
mechanism: airflow_dag mechanism: airflow_dag
streams: [events] streams: [events]
rhythm: пачкой, при каждом прогоне `dds_transform` rhythm: при каждом запуске `dds_transform`
actor: Задачи `session.rebuild` и `session.replace` дага `dds_transform`. actor: Задачи `session.rebuild` и `session.replace` дага `dds_transform`.
join: 'События одной `client_id` за `event_date` режутся по паузам; `session_id` - `cityHash64(client_id, min(event_time))`.' join: "События одного `client_id` за день `event_date` упорядочиваются по времени. Пауза дольше 30 минут начинает новую сессию. Ее `session_id` рассчитывается как `cityHash64(client_id, min(event_time))`."
links: links:
- label: SQL сборки - label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/session_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/session_rebuild.sql
@@ -565,9 +563,9 @@ edges:
to: dds_order to: dds_order
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: пачкой, при каждом прогоне `dds_transform` rhythm: при каждом запуске `dds_transform`
actor: Задачи `order.rebuild` и `order.replace` дага `dds_transform`. actor: Задачи `order.rebuild` и `order.replace` дага `dds_transform`.
join: 'Соединения нет: из заказа берутся поля и позиции, `order_date` выводится из `created_at`.' join: "Поля заказа приводятся к типам DDS, а позиции из JSON разбираются в массивы. Дата `order_date` рассчитывается из `created_at`."
links: links:
- label: SQL сборки - label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_rebuild.sql
@@ -581,9 +579,9 @@ edges:
to: dds_identity to: dds_identity
mechanism: airflow_dag mechanism: airflow_dag
streams: [events] streams: [events]
rhythm: пачкой, при каждом прогоне `dds_transform` rhythm: при каждом запуске `dds_transform`
actor: Задача `identity_map` дага `dds_transform` соединяет покупки с заказами. actor: Задача `identity_map` дага `dds_transform` соединяет покупки с заказами.
join: 'Строки покупки и заказа соединяются по `purchase_id[1] = order_id`.' join: "Событие покупки соединяется с заказом по `purchase_id[1] = order_id`. В результате связываются посетитель сайта и пользователь магазина."
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql
@@ -595,9 +593,9 @@ edges:
to: dds_identity to: dds_identity
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: пачкой, при каждом прогоне `dds_transform` rhythm: при каждом запуске `dds_transform`
actor: Задача `identity_map` дага `dds_transform` соединяет заказы с покупками. actor: Задача `identity_map` дага `dds_transform` соединяет заказы с покупками.
join: 'Строки заказа и покупки соединяются по `order_id = purchase_id[1]`; берется `user_id`.' join: "Заказ соединяется с событием покупки по `order_id = purchase_id[1]`. Из заказа берется `user_id` пользователя магазина."
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql
@@ -609,9 +607,9 @@ edges:
to: dm_daily_traffic to: dm_daily_traffic
mechanism: airflow_dag mechanism: airflow_dag
streams: [events] streams: [events]
rhythm: пачкой, при каждом прогоне `dm_transform` rhythm: при каждом запуске `dm_transform`
actor: Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям. actor: Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям.
join: 'Соединения нет: сессии группируются по `session_date`, который становится `report_date`.' join: "К сессиям присоединяется карта идентичностей по `client_id`, затем данные группируются по `session_date`. Эта дата записывается в `report_date`."
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql
@@ -623,9 +621,9 @@ edges:
to: dm_daily_traffic to: dm_daily_traffic
mechanism: airflow_dag mechanism: airflow_dag
streams: [events] streams: [events]
rhythm: пачкой, при каждом прогоне `dm_transform` rhythm: при каждом запуске `dm_transform`
actor: Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`. actor: Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`.
join: 'GLOBAL LEFT JOIN по `client_id`; сессии без пары остаются в витрине.' join: "`GLOBAL LEFT JOIN` присоединяет карту идентичностей к сессиям по `client_id`. Сессии без известного пользователя тоже участвуют в расчете посетителей."
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql
@@ -637,9 +635,9 @@ edges:
to: dm_revenue to: dm_revenue
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: пачкой, при каждом прогоне `dm_transform` rhythm: при каждом запуске `dm_transform`
actor: Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`. actor: Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`.
join: 'Соединения таблиц нет: ARRAY JOIN раскрывает `item_sku`, `item_quantity`, `item_price`; `order_date` становится `report_date`.' join: "`ARRAY JOIN` разворачивает массивы `item_sku`, `item_quantity`, `item_price` в строки позиций заказа. Выручка считается по оплаченным заказам; `order_date` записывается в `report_date`."
links: links:
- label: SQL сборки - label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql
@@ -653,9 +651,9 @@ edges:
to: dm_revenue to: dm_revenue
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: пачкой, при каждом прогоне `dm_transform` rhythm: при каждом запуске `dm_transform`
actor: Даг `dm_transform` получает категорию через `dictGet` по `item_sku`; ключ словаря — `sku`. actor: "Задача `revenue_daily.rebuild` дага `dm_transform`."
join: 'Категория читается через `dictGet` по `item_sku`; ключ справочника - `sku`.' join: "Функция `dictGet` получает категорию товара по артикулу `item_sku` из заказа. В словаре этому артикулу соответствует ключ `sku`."
links: links:
- label: SQL - label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql
@@ -667,9 +665,9 @@ edges:
to: dm_purchase to: dm_purchase
mechanism: airflow_dag mechanism: airflow_dag
streams: [events] streams: [events]
rhythm: пачкой, при каждом прогоне `dm_transform` rhythm: при каждом запуске `dm_transform`
actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки. actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки.
join: 'Покупки группируются по `purchase_id[1]` как `purchase_order_id`; `event_date` становится `purchase_day`, затем GLOBAL FULL OUTER JOIN `purchase_order_id = backend_order_id`.' join: "События покупки группируются по номеру `purchase_id[1]`, который получает имя `purchase_order_id`. Дата `event_date` записывается в `purchase_day`. Затем `GLOBAL FULL OUTER JOIN` сопоставляет покупки с заказами по `purchase_order_id = backend_order_id`."
links: links:
- label: SQL сборки - label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql
@@ -683,9 +681,9 @@ edges:
to: dm_purchase to: dm_purchase
mechanism: airflow_dag mechanism: airflow_dag
streams: [orders] streams: [orders]
rhythm: пачкой, при каждом прогоне `dm_transform` rhythm: при каждом запуске `dm_transform`
actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают заказы. actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают заказы.
join: 'GLOBAL FULL OUTER JOIN: `purchase_order_id = backend_order_id` (`purchase_id[1]` и `order_id`); отсутствующая сторона получает NULL.' join: "`order_id` заказа получает имя `backend_order_id`, а `purchase_id[1]` покупки — `purchase_order_id`. `GLOBAL FULL OUTER JOIN` соединяет их по `purchase_order_id = backend_order_id`. Покупки без заказа и заказы без покупки сохраняются; поля отсутствующей стороны содержат `NULL`."
links: links:
- label: SQL сборки - label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql
+7 -7
View File
@@ -243,9 +243,9 @@
details.append(heading, meta); details.append(heading, meta);
addSection("Что это", node.what); addSection("Что это", node.what);
addSection("Как читать", node.read); addSection("Как читать", node.read);
addSection("Из чего состоит", node.parts); addSection("Связанные таблицы и представления", node.parts);
addSection("Ключи", node.keys); addSection("Как связать данные", node.keys);
addSection("Кто наполняет и как часто", node.fill); addSection("Как обновляются данные", node.fill);
addLinks(node.links); addLinks(node.links);
} }
function renderEdgeCard(edge) { function renderEdgeCard(edge) {
@@ -253,10 +253,10 @@
const heading = document.createElement("h2"); const heading = document.createElement("h2");
heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`; heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`;
details.append(heading); details.append(heading);
addSection("Механизм", mechanismById.get(edge.mechanism).label); addSection("Способ обработки", mechanismById.get(edge.mechanism).label);
addSection("Ритм", edge.rhythm); addSection("Когда выполняется", edge.rhythm);
addSection("Кто именно", edge.actor); addSection("Что выполняет обработку", edge.actor);
addSection("Как соединяет", edge.join); addSection("Что происходит с данными", edge.join);
addLinks(edge.links); addLinks(edge.links);
} }
function layoutMap() { function layoutMap() {
+7 -7
View File
@@ -5,7 +5,7 @@
магазина раз в модельные сутки выгружает состояние заказов в топик `orders`. магазина раз в модельные сутки выгружает состояние заказов в топик `orders`.
Эта дневная выгрузка называется слепком. Эта дневная выгрузка называется слепком.
## Что он производит ## События сайта и заказы
Событие содержит сведения о действии посетителя: например, время, Событие содержит сведения о действии посетителя: например, время,
адрес страницы и идентификатор посетителя. Формат соответствует образцу адрес страницы и идентификатор посетителя. Формат соответствует образцу
@@ -20,11 +20,11 @@
разобраны в [отдельном разобраны в [отдельном
справочнике](../architecture/orders/snapshot.md). справочнике](../architecture/orders/snapshot.md).
## Почему данные всегда одни и те же ## Почему начальные данные повторяются
Случайные решения генератора зависят от заданного числа — Случайные решения генератора зависят от заданного числа —
[зерна генерации](../architecture/generator/seeds.md) — и номера дня. [зерна генерации](../architecture/generator/seeds.md) — и номера дня.
При неизменных настройках и версии генератора одинаковые зерно и день При неизменных коде, каталоге товаров и настройках одинаковые зерно и день
дают одинаковые события. Поэтому при создании стенда вы каждый раз дают одинаковые события. Поэтому при создании стенда вы каждый раз
получаете те же начальные данные. Повторный `make up` на работающем стенде получаете те же начальные данные. Повторный `make up` на работающем стенде
сохраняет уже загруженные данные. сохраняет уже загруженные данные.
@@ -34,10 +34,10 @@
В документации этот файл называется паспортом мира. С ним можно сверить В документации этот файл называется паспортом мира. С ним можно сверить
результат генерации и проверить, все ли события загрузились в ODS. результат генерации и проверить, все ли события загрузились в ODS.
## Кто его запускает ## Запуск генератора
На стенде генератором управляют [даги модельного На стенде генератором управляют [даги модельного
времени](airflow-dags.md#даги-мира). Его можно запустить и напрямую. Команды времени](airflow-dags.md#генерация-модельных-дней). Его можно запустить и напрямую. Команды
собраны в [справочнике генератора и модельного собраны в [справочнике генератора и модельного
времени](../architecture/world.md#как-запустить-генератор). времени](../architecture/world.md#как-запустить-генератор).
@@ -45,7 +45,7 @@
В живом режиме события поступают постепенно, как в работающем магазине, В живом режиме события поступают постепенно, как в работающем магазине,
но время идет ускоренно: модельные сутки занимают около 24 минут. но время идет ускоренно: модельные сутки занимают около 24 минут.
## Посмотрите сами ## Сверка с паспортом мира
Подключитесь из DBeaver по [инструкции](../../README.md#подключиться-из-dbeaver) Подключитесь из DBeaver по [инструкции](../../README.md#подключиться-из-dbeaver)
и выполните запрос: и выполните запрос:
@@ -62,7 +62,7 @@ ORDER BY EventDate;
Числа должны совпасть. Если вы уже генерировали следующие дни, Числа должны совпасть. Если вы уже генерировали следующие дни,
в результате запроса будут дополнительные даты; в паспорте их нет. в результате запроса будут дополнительные даты; в паспорте их нет.
## Куда дальше ## Учебные задания и справочники
Подробнее генератор разобран в [README папки Подробнее генератор разобран в [README папки
`generator/`](../../generator/README.md) и в [справочнике его `generator/`](../../generator/README.md) и в [справочнике его
+18 -15
View File
@@ -1,20 +1,19 @@
# Мониторинг: куда смотреть # Мониторинг в Grafana
В Grafana настроены три дашборда. «Данные» показывает загрузку и обработку В Grafana настроены три дашборда — экрана с показателями и графиками.
событий и заказов, «Кластер» — состояние узлов и самого ClickHouse, «Данные» показывает загрузку и обработку событий и заказов,
а «Запросы» — выполненные запросы и затраченные на них ресурсы. «Кластер» — состояние узлов и самого ClickHouse, а «Запросы» — выполненные
запросы и затраченные на них ресурсы.
Prometheus собирает метрики с двух узлов ClickHouse, ClickHouse Keeper
и экспортера Kafka — службы, которая предоставляет метрики Kafka.
Grafana получает данные из Prometheus и напрямую из ClickHouse.
На панелях не заданы пороги для цветовых предупреждений: оценивать показания На панелях не заданы пороги для цветовых предупреждений: оценивать показания
вы будете самостоятельно. Качество данных автоматически проверяет вы будете самостоятельно. Качество данных автоматически проверяет
даг `dq_check`. даг `dq_check`.
## Дашборд «Данные» ## Дашборд «Данные»
«Свежесть» показывает, сколько времени прошло с последнего события и с «Свежесть» показывает, сколько реального времени прошло с поступления
последнего заказа в ODS. Показатель для заказов растет весь модельный день последних успешно разобранных событий и заказов. Время поступления хранится
в колонке `_load_ts` слоя ODS. Показатель для заказов растет весь модельный день
и снижается, когда поступает очередная дневная выгрузка — слепок. и снижается, когда поступает очередная дневная выгрузка — слепок.
Это ожидаемо: заказы загружаются раз в день. Это ожидаемо: заказы загружаются раз в день.
@@ -33,7 +32,11 @@ STG: по нему видно, поступила ли очередная выг
Источники и правила чтения всех панелей разобраны в [справочнике дашборда Источники и правила чтения всех панелей разобраны в [справочнике дашборда
«Данные»](../architecture/monitoring/data.md). «Данные»](../architecture/monitoring/data.md).
## Почему у панелей разные источники ## Источники показателей
Grafana получает данные напрямую из ClickHouse и через Prometheus.
Prometheus собирает метрики с двух узлов ClickHouse, ClickHouse Keeper
и экспортера Kafka — службы, которая предоставляет метрики Kafka.
Свежесть считается по ODS: если новые сообщения перестали разбираться, Свежесть считается по ODS: если новые сообщения перестали разбираться,
показатель будет расти. Число поступивших сообщений берется из STG, показатель будет расти. Число поступивших сообщений берется из STG,
@@ -45,7 +48,7 @@ STG: по нему видно, поступила ли очередная выг
всех доставленных сообщений. Подробности — в [разделе о слоях всех доставленных сообщений. Подробности — в [разделе о слоях
панелей](../architecture/monitoring/data.md#почему-слои-у-панелей-разные). панелей](../architecture/monitoring/data.md#почему-слои-у-панелей-разные).
## Два других дашборда ## Состояние кластера и запросы
Открывайте «Кластер», чтобы проверить нагрузку на процессор, использование Открывайте «Кластер», чтобы проверить нагрузку на процессор, использование
памяти и дисков, число частей таблиц и ход их слияний в ClickHouse. Здесь же памяти и дисков, число частей таблиц и ход их слияний в ClickHouse. Здесь же
@@ -58,11 +61,11 @@ STG: по нему видно, поступила ли очередная выг
[справочник дашборда [справочник дашборда
«Запросы»](../architecture/monitoring/queries.md). «Запросы»](../architecture/monitoring/queries.md).
## Посмотрите сами ## Показатели за один модельный день
Откройте Grafana по адресу из раздела [«Адреса и Откройте Grafana по адресу из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли), затем дашборд «Данные». Запустите в пароли»](../../README.md#адреса-и-пароли), затем дашборд «Данные». Запустите в
Airflow [`world_live_day`](airflow-dags.md#даги-мира): модельный день займет Airflow [`world_live_day`](airflow-dags.md#генерация-модельных-дней): модельный день займет
около двадцати четырех минут. около двадцати четырех минут.
Смотрите свежесть событий и свежесть заказов по отдельности. Пока день идет, Смотрите свежесть событий и свежесть заказов по отдельности. Пока день идет,
@@ -73,11 +76,11 @@ Airflow [`world_live_day`](airflow-dags.md#даги-мира): модельны
при генерации следующего дня. Даг пока продолжает работу, ожидая завершения при генерации следующего дня. Даг пока продолжает работу, ожидая завершения
обработки данных в дневном конвейере. обработки данных в дневном конвейере.
## Куда дальше ## Следующая глава и справочники
Устройство мониторинга описано в [техническом Устройство мониторинга описано в [техническом
справочнике](../architecture/monitoring/README.md), панели дашборда «Данные» — справочнике](../architecture/monitoring/README.md), панели дашборда «Данные» —
в [отдельном разделе](../architecture/monitoring/data.md). в [отдельном разделе](../architecture/monitoring/data.md).
Почему выбраны именно эти показатели, объясняет Почему выбраны именно эти показатели, объясняет
[ADR 0002](../adr/0002-monitoring-scope.md). Следующая глава — [ADR 0002](../adr/0002-monitoring-scope.md). Следующая глава —
[«Немного Superset»](superset.md). [«Отчеты в Superset»](superset.md).
+26 -16
View File
@@ -1,16 +1,17 @@
# Хранилище и его слои # Хранилище и его слои
В хранилище приходят два потока из Kafka: в топике `hits` — события В Kafka поступают события сайта, или кликстрим, и заказы магазина.
кликстрима, в `orders` — заказы магазина. Оба проходят один путь: STG → ODS → Для событий выделен топик `hits`, для заказов — `orders`.
DDS → DM. В STG сообщения сохраняются как есть, в ODS разбираются по полям, Из Kafka данные проходят четыре слоя хранилища. В STG сообщения сохраняются
в DDS из них собираются события, заказы и сессии, а в DM — витрины для отчетов. как есть, в ODS разбираются по полям, в DDS из них собираются события,
заказы и сессии, а в DM — витрины для отчетов.
```mermaid ```mermaid
flowchart LR flowchart LR
Kafka --> STG --> ODS --> DDS --> DM Kafka --> STG --> ODS --> DDS --> DM
``` ```
## Как читать имена ## Имена таблиц и представлений
Для обычного чтения выбирайте представления с суффиксом `_v`. Они позволяют Для обычного чтения выбирайте представления с суффиксом `_v`. Они позволяют
менять устройство таблиц, сохраняя привычные запросы к данным. менять устройство таблиц, сохраняя привычные запросы к данным.
@@ -20,7 +21,7 @@ flowchart LR
Подробный разбор имен есть в [справочнике Подробный разбор имен есть в [справочнике
хранилища](../architecture/storage.md#имена-объектов). хранилища](../architecture/storage.md#имена-объектов).
## STG: как поступило ## STG: исходные сообщения
STG — сырой слой. Он сохраняет сообщение целой строкой, без разбора. Вместе STG — сырой слой. Он сохраняет сообщение целой строкой, без разбора. Вместе
с ним записываются сведения о доставке: топик, партиция, смещение и время с ним записываются сведения о доставке: топик, партиция, смещение и время
@@ -31,7 +32,7 @@ STG — сырой слой. Он сохраняет сообщение цело
удаляются по правилу из [справочника удаляются по правилу из [справочника
хранилища](../architecture/storage.md#срок-жизни-сырых-данных). хранилища](../architecture/storage.md#срок-жизни-сырых-данных).
## ODS: язык источника ## ODS: разбор сообщений
В ODS содержимое сообщения разбирается на колонки с нужными типами данных: В ODS содержимое сообщения разбирается на колонки с нужными типами данных:
датами, числами, строками. Колонки сохраняют имена из источника. Например, датами, числами, строками. Колонки сохраняют имена из источника. Например,
@@ -41,7 +42,7 @@ STG — сырой слой. Он сохраняет сообщение цело
`*_errors` вместе с исходной строкой и классом ошибки. Правильно записанные `*_errors` вместе с исходной строкой и классом ошибки. Правильно записанные
сообщения обрабатываются дальше. сообщения обрабатываются дальше.
## DDS: язык бизнеса ## DDS: события, заказы и сессии
DDS — детальный слой: в нем данные подготовлены для анализа событий, DDS — детальный слой: в нем данные подготовлены для анализа событий,
заказов и сессий. Здесь же хранится карта идентичностей — таблица связей заказов и сессий. Здесь же хранится карта идентичностей — таблица связей
@@ -51,6 +52,8 @@ DDS — детальный слой: в нем данные подготовле
В стенде визитом, или сессией, считают подряд идущие события одного В стенде визитом, или сессией, считают подряд идущие события одного
`ClientID` без пауз дольше 30 минут. Визит не пересекает границу модельных суток. `ClientID` без пауз дольше 30 минут. Визит не пересекает границу модельных суток.
Модельные сутки — день в календаре учебного магазина. Этот календарь
не зависит от текущей даты на вашем компьютере.
Источник передает готовый идентификатор визита `VisitID` по образцу Источник передает готовый идентификатор визита `VisitID` по образцу
Яндекс Метрики. В учебном хранилище сессии рассчитываются заново по времени Яндекс Метрики. В учебном хранилище сессии рассчитываются заново по времени
@@ -61,7 +64,7 @@ DDS — детальный слой: в нем данные подготовле
Готовые сессии читайте через `dds.session_v`. События для самостоятельного Готовые сессии читайте через `dds.session_v`. События для самостоятельного
расчета доступны в `dds.event_v`: в этом представлении `VisitID` скрыт. расчета доступны в `dds.event_v`: в этом представлении `VisitID` скрыт.
## DM: данные для дашбордов ## DM: витрины для отчетов
DM — слой витрин. Витрина — готовый набор показателей для отчета или DM — слой витрин. Витрина — готовый набор показателей для отчета или
дашборда. Здесь уже посчитаны, например, дневная выручка по категориям и дашборда. Здесь уже посчитаны, например, дневная выручка по категориям и
@@ -71,7 +74,7 @@ DM — слой витрин. Витрина — готовый набор по
путь расчета. Состав готовых витрин смотрите в [карте путь расчета. Состав готовых витрин смотрите в [карте
таблиц](../architecture/storage.md#карта-таблиц). таблиц](../architecture/storage.md#карта-таблиц).
## Справочные данные: зона `dic` ## Справочники в базе `dic`
В базе `dic` хранятся готовые справочные данные. Они загружаются отдельно В базе `dic` хранятся готовые справочные данные. Они загружаются отдельно
от событий и заказов и могут использоваться в расчетах любого слоя. от событий и заказов и могут использоваться в расчетах любого слоя.
@@ -81,7 +84,7 @@ DM — слой витрин. Витрина — готовый набор по
устроена зона, смотрите в [разделе о устроена зона, смотрите в [разделе о
справочниках](../architecture/storage.md#справочники). справочниках](../architecture/storage.md#справочники).
## Сквозные ключи ## Связь данных между слоями
Идентификаторы позволяют связать данные из разных слоев. При переходе в DDS Идентификаторы позволяют связать данные из разных слоев. При переходе в DDS
часть колонок переименовывается: например, `WatchID` становится `event_id`. часть колонок переименовывается: например, `WatchID` становится `event_id`.
@@ -99,7 +102,7 @@ DM — слой витрин. Витрина — готовый набор по
[словарь понятий](../../CONTEXT.md#язык). Точные имена объектов и полей сверяйте с [словарь понятий](../../CONTEXT.md#язык). Точные имена объектов и полей сверяйте с
[картой таблиц](../architecture/storage.md#карта-таблиц). [картой таблиц](../architecture/storage.md#карта-таблиц).
## Посмотрите сами ## Примеры запросов
Подключитесь к ClickHouse по инструкции из разделов [«Адреса и Подключитесь к ClickHouse по инструкции из разделов [«Адреса и
пароли»](../../README.md#адреса-и-пароли) и [«Подключиться из пароли»](../../README.md#адреса-и-пароли) и [«Подключиться из
@@ -122,6 +125,8 @@ ORDER BY UTCEventTime
LIMIT 20; LIMIT 20;
``` ```
Каждая строка здесь — отдельное действие посетителя.
Второй запрос покажет 20 сессий того же дня с наибольшим числом событий. Второй запрос покажет 20 сессий того же дня с наибольшим числом событий.
Сессии рассчитаны по всем событиям дня: Сессии рассчитаны по всем событиям дня:
@@ -140,7 +145,12 @@ ORDER BY events_total DESC
LIMIT 20; LIMIT 20;
``` ```
Третий запрос покажет готовую выручку по категориям за тот же день: Теперь одна строка представляет целый визит. `events_total` показывает,
сколько событий в него вошло; начало и конец визита записаны в
`started_at` и `finished_at`.
Третий запрос покажет выручку магазина по категориям за тот же день.
Эта витрина рассчитана по заказам:
```sql ```sql
SELECT SELECT
@@ -153,13 +163,13 @@ WHERE report_date = '2026-06-01'
ORDER BY revenue DESC; ORDER BY revenue DESC;
``` ```
Здесь одна строка содержит итоги дня для категории товаров.
Все три запроса читают представления `_v`. Все три запроса читают представления `_v`.
## Куда дальше ## Следующая глава и справочники
Откройте [интерактивную карту](architecture-map.html) из своей копии Откройте [интерактивную карту](architecture-map.html) из своей копии
репозитория и проследите путь данных. репозитория и проследите путь данных.
Список объектов, сроки хранения и устройство таблиц — в [справочнике Список объектов, сроки хранения и устройство таблиц — в [справочнике
хранилища](../architecture/storage.md). Как данные обрабатываются между слоями — в хранилища](../architecture/storage.md). Как данные обрабатываются между слоями — в
следующей главе: [потоки Airflow и даги модельного следующей главе: [«Генерация и обработка данных в Airflow»](airflow-dags.md).
времени](airflow-dags.md).
+12 -6
View File
@@ -1,4 +1,4 @@
# Немного Superset # Отчеты в Superset
Superset показывает витрины слоя DM в виде графиков. К ClickHouse он Superset показывает витрины слоя DM в виде графиков. К ClickHouse он
подключается пользователем `bi`. Дашборд стенда «Бизнес-сюжеты» собран из трех подключается пользователем `bi`. Дашборд стенда «Бизнес-сюжеты» собран из трех
@@ -24,7 +24,7 @@ Superset показывает витрины слоя DM в виде графи
Затем на основе датасета создается график: выбираются показатели и способ Затем на основе датасета создается график: выбираются показатели и способ
их отображения. Несколько графиков можно разместить на одном дашборде. их отображения. Несколько графиков можно разместить на одном дашборде.
## Стендовые объекты и ваши ## Готовые и собственные отчеты
Готовые датасеты, графики и дашборд сохранены в файлах репозитория Готовые датасеты, графики и дашборд сохранены в файлах репозитория
и импортируются при каждом `make up`. Если изменить их через интерфейс, и импортируются при каждом `make up`. Если изменить их через интерфейс,
@@ -43,13 +43,14 @@ Superset](../architecture/superset.md#импорт-при-каждой-иниц
Последняя дата с выручкой на день раньше последней даты с событиями Последняя дата с выручкой на день раньше последней даты с событиями
кликстрима. Выгрузка заказов за день D поступает при генерации следующего кликстрима. Выгрузка заказов за день D поступает при генерации следующего
дня D+1, а затем обрабатывается конвейером. Вы уже видели эту разницу дня D+1, а затем обрабатывается конвейером. Вы уже видели эту разницу
в [главе об Airflow](airflow-dags.md#посмотрите-сами). Начальное состояние в [главе об Airflow](airflow-dags.md#один-день-от-генерации-до-витрин). Начальное состояние
показано в разделе [«Стартовый мир»](../architecture/world.md#стартовый-мир). показано в разделе [«Стартовый мир»](../architecture/world.md#стартовый-мир).
## Посмотрите сами ## Сверка графика с витриной
Откройте Superset по адресу из раздела [«Адреса и Откройте Superset по адресу из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли) и выберите дашборд «Бизнес-сюжеты». пароли»](../../README.md#адреса-и-пароли) и выберите дашборд «Бизнес-сюжеты».
Найдите график «Посетители и известные пользователи».
Затем откройте SQL Lab и выполните запрос: Затем откройте SQL Lab и выполните запрос:
```sql ```sql
@@ -59,9 +60,14 @@ ORDER BY report_date DESC
LIMIT 7; LIMIT 7;
``` ```
Как построить свой отчет, рассказывают [учебные задания](../labs/README.md). Выберите одну дату из результата. На дашборде задайте «Период», в который
она входит, и обновите график. Наведите указатель на точку этой даты:
числа посетителей и известных пользователей должны совпасть с `visitors`
и `known_users` из SQL.
## Куда дальше Как построить собственный отчет, рассказывают [учебные задания](../labs/README.md).
## Следующая глава и справочники
Подключение и настройка Superset описаны в [справочнике Подключение и настройка Superset описаны в [справочнике
Superset](../architecture/superset.md), а состав витрин — в [справочнике Superset](../architecture/superset.md), а состав витрин — в [справочнике
+24 -26
View File
@@ -1,37 +1,38 @@
# Задание 1: `dm.events_enriched_v` # Задание 1: события для анализа, `dm.events_enriched_v`
**Чему учит:** подготовить удобный для анализа набор полей события Вы подготовите представление с полями для анализа событий. Оно будет
и обойтись представлением, сохранив одну строку на событие. добавлять вычисляемые поля при чтении, сохраняя одну строку на событие.
## Сначала прочитать ## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md). - [«Хранилище и его слои»](../handbook/storage-layers.md).
- [Как применить свой DDL](README.md#как-применить-свой-ddl): файл для решения - [Выполнение SQL в DBeaver](README.md#выполнение-sql-в-dbeaver): файл для решения
и подключение с правами на создание витрины. и подключение с правами на создание витрины.
## Для чего нужна витрина ## Данные для анализа событий
В `dds.event_v` 44 колонки, включая технические поля источника и массивы В `dds.event_v` 44 колонки, включая технические поля источника и массивы
товаров. Для анализа событий в Superset нужны деловые поля и три готовых товаров. Для анализа событий в Superset нужны сведения о посетителе и его
значения: час события, путь страницы и домен перехода. Тогда вам не придется действиях, а также три вычисляемых поля: час события, путь страницы и домен,
с которого пришел посетитель. Тогда вам не придется
повторять эти выражения в каждом запросе. повторять эти выражения в каждом запросе.
Витрины следующих заданий читают DDS напрямую. Использовать Витрины следующих заданий читают DDS напрямую. Использовать
`dm.events_enriched_v` как источник для них не нужно: задания независимы, их `dm.events_enriched_v` как источник для них не нужно: задания независимы, их
можно выполнять в любом порядке. можно выполнять в любом порядке.
## Что сделать ## Создание представления
Создайте обычное представление `CREATE VIEW dm.events_enriched_v`. Создайте обычное представление `CREATE VIEW dm.events_enriched_v`.
Единственный источник — `dds.event_v`. Зерно — **событие**: каждой строке Единственный источник: `dds.event_v`. Каждой строке источника должна
источника соответствует одна строка витрины. соответствовать ровно одна строка витрины: одно событие.
Физическая таблица, таблица-донор и задача в `dm_transform` не нужны. Представление выполняет запрос при чтении и не хранит отдельную копию данных.
Представление не хранит строки, поэтому ключ шардирования и пересборка ему Для него не нужно создавать таблицы или добавлять задачу в `dm_transform`.
не требуются.
Укажите колонки явно. Иначе новая колонка источника попадет в витрину Укажите колонки явно. Иначе новая колонка источника попадет в витрину
автоматически, и договор с потребителем изменится без вашего участия. автоматически. Отчет получит ее, даже если вы не собирались менять его набор
полей.
| Группа | Колонки | | Группа | Колонки |
|---|---| |---|---|
@@ -57,9 +58,7 @@ DDS читают из ODS без отдельной загрузки. Эти с
## Типичная ошибка ## Типичная ошибка
Не разворачивайте массивы товаров через `ARRAY JOIN`. У события с тремя Не разворачивайте массивы товаров через `ARRAY JOIN`. У события с тремя
позициями получится три строки. Зерно изменится. Витрину с новым зерном на позициями получится три строки вместо одной.
стенде хранят готовым агрегатом — причины описаны в разделе [«Как принято на
ступени 1»](README.md#как-принято-на-ступени-1).
Главная опасность — потеря событий с пустым массивом товаров: `ARRAY JOIN` Главная опасность — потеря событий с пустым массивом товаров: `ARRAY JOIN`
исключает их из результата. Товары есть только в событиях корзины и покупки. исключает их из результата. Товары есть только в событиях корзины и покупки.
@@ -68,9 +67,9 @@ DDS читают из ODS без отдельной загрузки. Эти с
по-прежнему будет выглядеть таблицей событий. по-прежнему будет выглядеть таблицей событий.
Сохраните массивы как в источнике. Для анализа отдельных товарных позиций Сохраните массивы как в источнике. Для анализа отдельных товарных позиций
нужна другая витрина с другим зерном и именем. нужна отдельная витрина, где одна строка соответствует товарной позиции.
## Как проверить себя ## Проверка результата
После применения DDL откройте Airflow по [адресу из README](../../README.md#адреса-и-пароли), После применения DDL откройте Airflow по [адресу из README](../../README.md#адреса-и-пароли),
найдите даг `homework_check` и запустите его вручную. В новом запуске откройте найдите даг `homework_check` и запустите его вручную. В новом запуске откройте
@@ -79,16 +78,15 @@ DDS читают из ODS без отдельной загрузки. Эти с
расхождениях с источником — дни и примеры несовпадений. Задачи еще не расхождениях с источником — дни и примеры несовпадений. Задачи еще не
созданных витрин будут красными; это ожидаемо и не мешает проверке первой. созданных витрин будут красными; это ожидаемо и не мешает проверке первой.
Деловой ключ — `event_id` внутри дня. Получите результат прямо из Выберите день и прочитайте его события из `dds.event_v` и своей витрины.
`dds.event_v` за тот же день. Соедините его с витриной полным внешним Сопоставьте строки по `event_id` через `FULL OUTER JOIN`.
соединением по `event_id`. Посчитайте строки, где ключ отсутствует с одной Найдите события, которые есть только с одной стороны, и события, у которых
из сторон или различаются значения деловых колонок. Расхождений должно быть различаются перенесенные из источника поля. Расхождений должно быть ноль.
ноль.
Отдельно сверьте `event_hour`, `page_path` и `referer_host` с теми же Отдельно сверьте `event_hour`, `page_path` и `referer_host` с теми же
выражениями, рассчитанными прямо по `dds.event_v`. выражениями, рассчитанными прямо по `dds.event_v`.
## Что должно получиться ## Результат работы
- [ ] у вас есть `dm.events_enriched_v` с явно перечисленными колонками; - [ ] у вас есть `dm.events_enriched_v` с явно перечисленными колонками;
- [ ] вы сверили число строк и набор `event_id` с `dds.event_v` за каждый - [ ] вы сверили число строк и набор `event_id` с `dds.event_v` за каждый
+45 -39
View File
@@ -1,14 +1,15 @@
# Задание 2: `dm.top_pages_daily` # Задание 2: показатели страниц по дням, `dm.top_pages_daily`
**Чему учит:** собрать дневные показатели страниц и настроить их В этой работе вы рассчитаете показатели страниц за каждый день и сохраните
обновление в `dm_transform` через замену партиций. их в таблице. Затем подключите обновление витрины к `dm_transform`:
новый расчет будет заменять дневные партиции таблицы.
## Сначала прочитать ## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md); - [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md). - [«Генерация и обработка данных в Airflow»](../handbook/airflow-dags.md).
## Для чего нужна витрина ## Дневные показатели страниц
По дневным показателям страниц аналитик сравнивает просмотры, посетителей и По дневным показателям страниц аналитик сравнивает просмотры, посетителей и
действия с товарами. Добавление в корзину происходит на карточке товара, действия с товарами. Добавление в корзину происходит на карточке товара,
@@ -19,19 +20,23 @@
действия происходят на разных страницах, а связи между ними в агрегате нет. действия происходят на разных страницах, а связи между ними в агрегате нет.
Путь человека исследуют по событиям или сессиям. Путь человека исследуют по событиям или сессиям.
## Что сделать ## Создание витрины
Источник — `dds.event_v`. Зерно витрины — **день события × страница**. Читайте события из `dds.event_v`. В результате нужна одна строка на страницу
Читайте DDS напрямую: так выполнение задания не зависит от витрины за день. Например, все просмотры одной карточки товара за один день попадут
задания 1. в одну строку. Витрина задания 1 для этого расчета не нужна.
На стенде витрину с новым зерном хранят как готовый агрегат, чтобы не Сохраните рассчитанные показатели в таблице, чтобы отчеты читали готовые
рассчитывать ее заново для каждого запроса. Причины описаны в разделе итоги. Создайте пять объектов:
[«Как принято на ступени 1»](README.md#как-принято-на-ступени-1). Создайте
пять объектов: - основные таблицы `dm.top_pages_daily_rep` и `dm.top_pages_daily_dist`;
`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и - промежуточные таблицы `dm.top_pages_daily_stage_rep` и `dm.top_pages_daily_stage_dist`;
публичное представление `dm.top_pages_daily_v`. Ключ партиции — день, он же - представление для чтения `dm.top_pages_daily_v`.
единица пересборки.
Их назначение и порядок обновления разобраны во вводной:
[«Создание и обновление витрин»](README.md#создание-и-обновление-витрин).
Разбейте данные на партиции по дням, чтобы можно было заменить результат
одного дня целиком.
### Состав колонок ### Состав колонок
@@ -50,25 +55,26 @@
адрес разделил бы показатели одной страницы на сотни строк. Учитывайте все адрес разделил бы показатели одной страницы на сотни строк. Учитывайте все
три типа событий, а их счетчики вычисляйте через `countIf`. три типа событий, а их счетчики вычисляйте через `countIf`.
### Ритм пересборки ### Дни для пересчета
События прожитого дня больше не меняются. Определяйте объем прогона по События завершившегося модельного дня больше не меняются. Найдите первый
состоянию витрины: от первого несобранного прожитого дня до последнего такой день, которого еще нет в витрине, и рассчитайте все дни от него до
прожитого. Отсчет от последнего собранного дня оставит пропуски после последнего завершившегося. Если начать после последнего дня, который уже
частичного сбоя. есть в витрине, можно пропустить более ранний день, не загруженный из-за сбоя.
Как у `revenue_daily`, заполните донор за весь диапазон одной вставкой, Как у `revenue_daily`, запишите расчет за все выбранные дни в промежуточную
затем замените дневные партиции по одной. таблицу одной вставкой. Затем замените дневные партиции основной таблицы по
одной. Добавьте запуск этих запросов в `dm_transform`.
### Ключ шардирования ### Ключ шардирования
Выберите ключ самостоятельно. У `revenue_daily` кроме дня одно измерение с Выберите ключ самостоятельно. У `revenue_daily` строки сгруппированы по дню
шестью близкими по объему значениями. Здесь страниц много, и число событий и категории товара; категорий всего шесть. Здесь страниц много, и число
на них сильно различается. событий на них сильно различается.
У цели и донора используйте одинаковый ключ: иначе при замене партиции У основной и промежуточной таблиц используйте одинаковый ключ шардирования.
строка потеряется на одном шарде и удвоится на другом. О том, [как устроен Замена партиции не перераспределяет строки между шардами. Подробнее об этом
донор](../architecture/storage.md), читайте в разделе «Раскладка по шардам». в разделе [«Раскладка по шардам»](../architecture/storage.md#раскладка-по-шардам).
## Типичная ошибка ## Типичная ошибка
@@ -82,13 +88,13 @@
Выбирайте проверку под возможную ошибку. Этот подход описан в Выбирайте проверку под возможную ошибку. Этот подход описан в
[справочнике проверок](../architecture/testing.md). [справочнике проверок](../architecture/testing.md).
## Как проверить себя ## Проверка результата
Деловой ключ — `page_path` внутри дня. Получите прямой агрегат из Рассчитайте те же показатели по `dds.event_v` за выбранный день.
`dds.event_v` за день и соедините его с `dm.top_pages_daily_v` полным Сопоставьте результат со строками `dm.top_pages_daily_v` за этот день
внешним соединением по ключу. Посчитайте строки, где ключ отсутствует с через `FULL OUTER JOIN` по `page_path`. Найдите страницы, которые есть
одной из сторон или различается любой из четырех счетчиков. Расхождений только с одной стороны, и строки, где различается хотя бы один из четырех
должно быть ноль. счетчиков. Расхождений должно быть ноль.
Перед полным сравнением проверьте дневные суммы: Перед полным сравнением проверьте дневные суммы:
@@ -98,10 +104,10 @@
Сумма `visitors` по страницам повторно учитывает посетителя, открывшего Сумма `visitors` по страницам повторно учитывает посетителя, открывшего
несколько страниц. Число посетителей за день берите из `dm.daily_traffic_v`. несколько страниц. Число посетителей за день берите из `dm.daily_traffic_v`.
## Что должно получиться ## Результат работы
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и - [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
использует тот же ключ шардирования, что и целевая таблица; `AS` и использует тот же ключ шардирования, что и основная;
- [ ] в `dm.top_pages_daily_v` явно перечислены колонки, включая - [ ] в `dm.top_pages_daily_v` явно перечислены колонки, включая
`_load_id` и `_load_ts`; `_load_id` и `_load_ts`;
- [ ] ваш запрос сам определяет дни пересборки по состоянию витрины; - [ ] ваш запрос сам определяет дни пересборки по состоянию витрины;
+47 -38
View File
@@ -1,14 +1,15 @@
# Задание 3: `dm.session_overview` # Задание 3: визиты по устройствам, `dm.session_overview`
**Чему учит:** хранить слагаемые для расчета средних и долей и понимать, Вы соберете дневные показатели визитов по устройствам. В таблице будут
храниться суммы и счетчики для расчета средних и долей. Заодно проверите,
когда сложение числа посетителей по устройствам завышает аудиторию. когда сложение числа посетителей по устройствам завышает аудиторию.
## Сначала прочитать ## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md); - [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md). - [«Генерация и обработка данных в Airflow»](../handbook/airflow-dags.md).
## Для чего нужна витрина ## Показатели визитов
Витрина нужна, чтобы по дням сравнивать визиты с телефона и компьютера: их Витрина нужна, чтобы по дням сравнивать визиты с телефона и компьютера: их
число, длительность, просмотры страниц и частоту покупок. число, длительность, просмотры страниц и частоту покупок.
@@ -17,13 +18,19 @@
устройствам. Почему такие итоги хранят в отдельной таблице, объясняет устройствам. Почему такие итоги хранят в отдельной таблице, объясняет
[справочник DM](../architecture/dm.md). [справочник DM](../architecture/dm.md).
## Что сделать ## Создание витрины
Источник — `dds.session_v`. Зерно — **день сессии × категория устройства**. Читайте готовые сессии из `dds.session_v`. В результате нужна одна строка
Зерно новое, поэтому витрина хранится готовым агрегатом — см. [«Как принято на категорию устройства за день: например, итоги всех визитов с телефонов
на ступени 1»](README.md#как-принято-на-ступени-1). Создайте пять объектов: за один день.
`dm.session_overview_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
публичное представление `dm.session_overview_v`. Ключ партиции — день. Создайте пять объектов по [общим правилам ступени 1](README.md#создание-и-обновление-витрин):
- основные таблицы `dm.session_overview_rep` и `dm.session_overview_dist`;
- промежуточные таблицы `dm.session_overview_stage_rep` и `dm.session_overview_stage_dist`;
- представление для чтения `dm.session_overview_v`.
Разбейте данные на партиции по дням.
### Состав колонок ### Состав колонок
@@ -47,19 +54,20 @@
категорий встречаются `desktop`, `mobile` и `tablet`. Строку для `tv` категорий встречаются `desktop`, `mobile` и `tablet`. Строку для `tv`
создавайте только при появлении таких визитов. создавайте только при появлении таких визитов.
Вместо `avg_duration_seconds` храните слагаемые. Среднее из средних по трем Для расчета средней длительности храните общую длительность и число сессий.
устройствам даст неверный ответ за день: число сессий в категориях Готовую среднюю `avg_duration_seconds` сохранять не нужно. Число сессий
различается, поэтому средние нужно взвешивать. Потребитель делит сумму в категориях различается, поэтому среднее из трех средних даст неверный
`duration_seconds_total` на сумму `sessions`. Долю отказов считают так же: ответ за день. Чтобы получить его правильно, разделите сумму
сумму `bounce_sessions` делят на сумму `sessions`, а не усредняют готовые `duration_seconds_total` на сумму `sessions`. Для доли отказов разделите
доли. сумму `bounce_sessions` на сумму `sessions`. Усреднение готовых долей тоже
даст неверный результат.
### Ритм пересборки ### Дни для пересчета
Сессии прожитого дня больше не меняются. Как в задании 2, определяйте Сессии завершившегося модельного дня больше не меняются. Как в задании 2,
диапазон от первого несобранного прожитого дня до последнего прожитого. найдите первый завершившийся день, которого еще нет в витрине. Рассчитайте
Соберите его в донор и замените дневные партиции. Добавьте задачу в все дни от него до последнего завершившегося, запишите их в промежуточную
`dm_transform`. таблицу и замените дневные партиции основной. Добавьте задачу в `dm_transform`.
### Ключ шардирования ### Ключ шардирования
@@ -68,18 +76,18 @@
`device_category`, сначала посчитайте строки на каждом шарде. Затем `device_category`, сначала посчитайте строки на каждом шарде. Затем
объясните, почему такое распределение вас устраивает. объясните, почему такое распределение вас устраивает.
Ключ донора — тот же, что у цели (см. [«Как принято на ступени У основной и промежуточной таблиц должен быть одинаковый ключ шардирования:
1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам» см. [общие правила ступени 1](README.md#создание-и-обновление-витрин).
[справочника хранилища](../architecture/storage.md)).
## Почему сумма `visitors` сходится с `dm.daily_traffic_v` ## Число посетителей за день
Сравните сумму `visitors` по устройствам за день с `dm.daily_traffic_v`. На Сравните сумму `visitors` по устройствам за день с `dm.daily_traffic_v`. На
стенде значения совпадут: устройство приписано куке на всю жизнь, поэтому за стенде значения совпадут: одному `client_id` всегда соответствует одно
день она учитывается только в одной строке. Это свойство мира стенда; см. устройство, поэтому за день он учитывается только в одной категории.
Это свойство мира стенда; см.
раздел «Паспорт куки» в [словаре понятий](../../CONTEXT.md). раздел «Паспорт куки» в [словаре понятий](../../CONTEXT.md).
Если появятся визиты одной куки с двух устройств, сумма начнет завышать Если появятся визиты одного `client_id` с двух устройств, сумма начнет завышать
аудиторию. Проверки счетчиков по категориям этого не обнаружат. Число аудиторию. Проверки счетчиков по категориям этого не обнаружат. Число
посетителей дня берите из `dm.daily_traffic_v`, где оно рассчитано для дня посетителей дня берите из `dm.daily_traffic_v`, где оно рассчитано для дня
целиком. целиком.
@@ -91,12 +99,13 @@
событий совпало с источником, а число сессий завышено, вероятная причина — событий совпало с источником, а число сессий завышено, вероятная причина —
сборка витрины из событий вместо сессий. сборка витрины из событий вместо сессий.
## Как проверить себя ## Проверка результата
Деловой ключ — `device_category` внутри дня. Получите прямой агрегат из Рассчитайте те же показатели по `dds.session_v` за выбранный день.
`dds.session_v` за день. Соедините его с `dm.session_overview_v` полным Сопоставьте результат со строками `dm.session_overview_v` за этот день
внешним соединением по ключу. Посчитайте строки, где ключ отсутствует с через `FULL OUTER JOIN` по `device_category`. Найдите категории, которые
одной из сторон или различается любой счетчик. Расхождений должно быть ноль. есть только с одной стороны, и строки, где различается хотя бы один
счетчик. Расхождений должно быть ноль.
Еще две проверки дневных итогов: Еще две проверки дневных итогов:
@@ -104,12 +113,12 @@
повторного учета визитов; повторного учета визитов;
- сумма `events_total` равна сумме `events_total` по сессиям того же дня. - сумма `events_total` равна сумме `events_total` по сессиям того же дня.
## Что должно получиться ## Результат работы
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и - [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
использует ключ шардирования целевой таблицы; `AS` и использует ключ шардирования основной;
- [ ] вы явно перечислили колонки в `dm.session_overview_v` и собрали - [ ] вы явно перечислили колонки в `dm.session_overview_v` и собрали
показатели из готовых сессий `dds.session_v`; показатели из готовых сессий `dds.session_v`;
- [ ] в таблице хранятся слагаемые для средних и долей; - [ ] в таблице хранятся суммы и счетчики для расчета средних и долей;
- [ ] проверка `session_overview` в `homework_check` прошла успешно; - [ ] проверка `session_overview` в `homework_check` прошла успешно;
- [ ] витрина обновляется при запуске `dm_transform`. - [ ] витрина обновляется при запуске `dm_transform`.
+52 -50
View File
@@ -1,28 +1,42 @@
# Задание 4: `dm.dq_errors_daily` # Задание 4: ошибки загрузки по дням, `dm.dq_errors_daily`
**Чему учит:** считать брак по дню загрузки и сохранять сводку дольше Вы соберете дневную сводку ошибок загрузки. Ее итоги должны сохраниться
исходных строк. и после того, как исходные сообщения будут удалены из хранилища.
## Сначала прочитать ## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md); - [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md). - [«Генерация и обработка данных в Airflow»](../handbook/airflow-dags.md).
## Для чего нужна витрина ## Ошибки загрузки и их источники
Сводку открывают при росте брака в потоке: смотрят, каких ошибок стало Если сообщение не удалось разобрать, стенд записывает его в таблицу ошибок.
больше и сколько строк забраковано. Причину конкретной ошибки исследуют Такие строки дальше называются браком. Вы соберете сводку, по которой можно
в `ods.event_errors_dist` и `ods.order_errors_dist`, где есть сырой текст и увидеть, каких ошибок стало больше и сколько строк забраковано.
координаты доставки.
## Что сделать Источники сводки:
Зерно — **день загрузки × источник × класс брака**. В источнике зерно — - `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`. Ключ они пригодятся при поиске причины ошибки. Представлений `_v` у таблиц ошибок
партиции — день загрузки. нет, создавать их для задания не нужно. Чтение локальной таблицы `_rep`
даст строки только одного шарда из двух, поэтому используйте `_dist`.
## Создание сводки
В результате нужна одна строка на день загрузки, источник и класс ошибки.
Например, ошибки `field_invalid` в заказах за один день загрузки попадут
в одну строку с их количеством.
Создайте пять объектов по [общим правилам ступени 1](README.md#создание-и-обновление-витрин):
- основные таблицы `dm.dq_errors_daily_rep` и `dm.dq_errors_daily_dist`;
- промежуточные таблицы `dm.dq_errors_daily_stage_rep` и `dm.dq_errors_daily_stage_dist`;
- представление для чтения `dm.dq_errors_daily_v`.
Разбейте данные на партиции по дням загрузки.
### Состав колонок ### Состав колонок
@@ -41,16 +55,17 @@
Служебная `_load_ts` витрины описывает сборку сводки; ее дата может Служебная `_load_ts` витрины описывает сборку сводки; ее дата может
отличаться от `load_date`. отличаться от `load_date`.
У событий и заказов свои классы брака. `source` входит в зерно, чтобы У событий и заказов свои классы брака. Группируйте строки также по `source`,
одинаковые имена классов с разным смыслом не объединялись в одну строку. чтобы одноименные классы разных источников не объединялись.
### Ритм пересборки ### Пересчет и хранение итогов
Брак хранится месяц по `TTL`, сырые данные — трое суток. Сводка хранится Правило `TTL` удаляет строки брака через месяц, сырые данные через трое суток.
бессрочно: итоги нужны и после удаления исходных строк. Сводку храните бессрочно: итоги нужны и после удаления исходных строк.
Получите список дней загрузки, еще имеющихся в источниках. Соберите их в Получите список дней загрузки, еще имеющихся в источниках. Рассчитайте их
донор и замените только эти партиции, сохранив остальные. итоги в промежуточной таблице и замените только эти партиции основной таблицы.
Более старые итоги сохраните.
В течение текущих суток ошибки еще поступают: пересчитайте этот день на В течение текущих суток ошибки еще поступают: пересчитайте этот день на
следующем прогоне. следующем прогоне.
@@ -60,36 +75,23 @@
Выберите ключ самостоятельно. Классов брака у событий и заказов вместе Выберите ключ самостоятельно. Классов брака у событий и заказов вместе
меньше десятка. Объясните, насколько существенен перекос при таком объеме. меньше десятка. Объясните, насколько существенен перекос при таком объеме.
Ключ донора — тот же, что у цели (см. [«Как принято на ступени У основной и промежуточной таблиц должен быть одинаковый ключ шардирования:
1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам» см. [общие правила ступени 1](README.md#создание-и-обновление-витрин).
[справочника хранилища](../architecture/storage.md)).
## Источники: таблицы `_dist`
Только это задание читает данные не через `_v`: брак нужен для диагностики
приема и не входит в бизнес-модель. Публичных представлений у таблиц ошибок
нет, создавать их для задания не нужно. Источники:
- `ods.event_errors_dist` — ошибки разбора событий;
- `ods.order_errors_dist` — ошибки разбора слепка заказов.
Чтение локальной `_rep` даст строки одного шарда из двух: часть брака не
попадет в сводку, хотя запрос выполнится без ошибки.
## Типичная ошибка ## Типичная ошибка
Если заменить всю таблицу данными из нового донора, исчезнут итоги дней, Если заменить всю сводку новым расчетом, исчезнут итоги дней, за которые
истекших по `TTL` источника. Восстановить их неоткуда. исходные строки уже удалены по `TTL`. Восстановить эти итоги неоткуда.
## Как проверить себя ## Проверка результата
Деловой ключ — `source` и `error_class` внутри `load_date`. Проверяйте дни, Сопоставляйте строки по `load_date`, `source` и `error_class`. Проверяйте дни,
оставшиеся хотя бы в одной таблице ошибок. Если дня за пределами `TTL` уже оставшиеся хотя бы в одной таблице ошибок. Старые итоги, для которых исходные
нет в источнике, это не ошибка: сверять его уже не с чем. строки уже удалены по `TTL`, сверять не с чем.
Посчитайте строки прямо в обеих таблицах ошибок и соедините результат с Посчитайте строки прямо в обеих таблицах ошибок и соедините результат с
`dm.dq_errors_daily_v` полным внешним соединением по ключу. Затем посчитайте `dm.dq_errors_daily_v` через `FULL OUTER JOIN` по этим трем полям. Посчитайте
строки, где ключ отсутствует с одной из сторон или различается `error_rows`. строки, которые есть только с одной стороны или у которых различается `error_rows`.
Расхождений должно быть ноль. Расхождений должно быть ноль.
Сумма `error_rows` по проверяемым дням равна общему числу строк в Сумма `error_rows` по проверяемым дням равна общему числу строк в
@@ -99,10 +101,10 @@
Если в источниках нет брака, витрина должна быть пустой. В этом случае Если в источниках нет брака, витрина должна быть пустой. В этом случае
расхождений тоже ноль и проверка успешна. расхождений тоже ноль и проверка успешна.
## Что должно получиться ## Результат работы
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и - [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
использует ключ шардирования целевой таблицы; `AS` и использует ключ шардирования основной;
- [ ] сводка включает ошибки событий и заказов из таблиц `_dist`; - [ ] сводка включает ошибки событий и заказов из таблиц `_dist`;
- [ ] ваш запрос пересобирает только дни, еще доступные в источниках, - [ ] ваш запрос пересобирает только дни, еще доступные в источниках,
сохраняя более старые итоги; сохраняя более старые итоги;
+45 -36
View File
@@ -1,30 +1,38 @@
# Задание 5: `dm.utm_effectiveness` # Задание 5: показатели рекламы, `dm.utm_effectiveness`
**Чему учит:** различать сумму покупки со слов клиента (`declared_`) и Вы подготовите показатели рекламных каналов по событиям сайта. Суммы покупок
серверную выручку, находить причины их расхождения. в этой витрине будут отличаться от выручки по заказам магазина. В конце
работы вы сравните их и объясните причины расхождения.
## Сначала прочитать ## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md); - [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md). - [«Генерация и обработка данных в Airflow»](../handbook/airflow-dags.md).
## Для чего нужна витрина ## Покупки по рекламным каналам
Аналитик сравнивает рекламные каналы по дням: трафик, добавления в корзину, Аналитик сравнивает рекламные каналы по дням: трафик, добавления в корзину,
покупки и заявленные клиентом суммы. покупки и их суммы из событий сайта.
На стенде поведение и атрибуцию считают по трекеру, деньги — по бэкенду. События сайта показывают действия посетителя и рекламные метки его переходов.
Здесь сделано исключение: UTM-метки есть только в кликстриме, поэтому для Заказы магазина служат источником выручки. Но в заказах нет UTM-меток, поэтому
оценки покупок по меткам используют клиентскую сумму. в этой витрине вы возьмете суммы из событий покупки. Их передает браузер
посетителя; дальше они называются клиентскими суммами. Приставка `declared_`
в имени показателя напоминает об этом источнике.
## Что сделать ## Создание витрины
Читайте `dds.event_v` напрямую, без соседних витрин DM. Зерно — **день Читайте события из `dds.event_v`, без других витрин DM. В результате нужна
события × `utm_source` × `utm_medium` × `utm_campaign`**. Зерно новое, одна строка на день и сочетание трех меток: `utm_source`, `utm_medium`,
поэтому витрина хранится готовым агрегатом — см. [«Как принято на ступени `utm_campaign`. События одного дня с одинаковыми метками попадают в одну группу.
1»](README.md#как-принято-на-ступени-1). Создайте пять объектов:
`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и Создайте пять объектов по [общим правилам ступени 1](README.md#создание-и-обновление-витрин):
публичное представление `dm.utm_effectiveness_v`. Ключ партиции — день.
- основные таблицы `dm.utm_effectiveness_rep` и `dm.utm_effectiveness_dist`;
- промежуточные таблицы `dm.utm_effectiveness_stage_rep` и `dm.utm_effectiveness_stage_dist`;
- представление для чтения `dm.utm_effectiveness_v`.
Разбейте данные на партиции по дням.
### Состав колонок ### Состав колонок
@@ -54,31 +62,32 @@
Для `utm_campaign` выбран тип `String`: имен кампаний со временем становится Для `utm_campaign` выбран тип `String`: имен кампаний со временем становится
много, и затраты на словарь `LowCardinality` перестают окупаться. много, и затраты на словарь `LowCardinality` перестают окупаться.
### Ритм пересборки ### Дни для пересчета
События прожитого дня больше не меняются. Как в задании 2, соберите в донор События завершившегося модельного дня больше не меняются. Как в задании 2,
дни от первого несобранного до последнего прожитого и замените дневные найдите первый завершившийся день, которого еще нет в витрине. Рассчитайте
партиции. Добавьте задачу в `dm_transform`. все дни от него до последнего завершившегося, запишите их в промежуточную
таблицу и замените дневные партиции основной. Добавьте задачу в `dm_transform`.
### Ключ шардирования ### Ключ шардирования
Выберите ключ самостоятельно. Если берете хеш одной из трех UTM-колонок, Выберите ключ самостоятельно. Если берете хеш одной из трех UTM-колонок,
посчитайте долю строк с ее самым частым значением и обоснуйте выбор. посчитайте долю строк с ее самым частым значением и обоснуйте выбор.
Ключ донора — тот же, что у цели (см. [«Как принято на ступени У основной и промежуточной таблиц должен быть одинаковый ключ шардирования:
1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам» см. [общие правила ступени 1](README.md#создание-и-обновление-витрин).
[справочника хранилища](../architecture/storage.md)).
## Атрибуция по событию, а не по визиту ## Рекламные метки событий
Группируйте события по их собственным меткам. Модели «первое касание» и Группируйте события по их собственным меткам. Модели «первое касание» и
«последнее касание» здесь не рассчитываются. «последнее касание» здесь не рассчитываются.
В `dds.session_v` записаны UTM первого события визита. Распределение по В `dds.session_v` записаны UTM первого события визита. Распределение по
каналам может отличаться от этой витрины, и это не означает ошибку в каналам может отличаться от этой витрины, и это не означает ошибку в
расчете. В отчете различайте атрибуцию визитов и событий. расчете. Укажите в отчете, что распределяете по рекламным каналам отдельные
события. Такое распределение называют атрибуцией.
## Прямой трафик остается в витрине ## События без рекламных меток
Сохраните события без меток: прямой трафик обычно крупнейший канал, и без Сохраните события без меток: прямой трафик обычно крупнейший канал, и без
него доля рекламы будет завышена. Если отфильтровать такие строки, дневные него доля рекламы будет завышена. Если отфильтровать такие строки, дневные
@@ -108,13 +117,13 @@
причины можно посмотреть в `dm.purchase_vs_orders_v`, где выделены классы причины можно посмотреть в `dm.purchase_vs_orders_v`, где выделены классы
расхождений. расхождений.
## Как проверить себя ## Проверка результата
Деловой ключ — `utm_source`, `utm_medium`, `utm_campaign` внутри дня. Рассчитайте те же показатели по `dds.event_v` за выбранный день.
Получите прямой агрегат из `dds.event_v` за день и соедините его с Сопоставьте результат со строками `dm.utm_effectiveness_v` за этот день
`dm.utm_effectiveness_v` полным внешним соединением по ключу. Посчитайте через `FULL OUTER JOIN` по `utm_source`, `utm_medium` и `utm_campaign`.
строки, где ключ отсутствует с одной из сторон или различается любая из пяти Найдите сочетания меток, которые есть только с одной стороны, и строки,
величин. Расхождений должно быть ноль. где различается хотя бы один из пяти показателей. Расхождений должно быть ноль.
Дополнительно сверьте дневные итоги: Дополнительно сверьте дневные итоги:
@@ -126,10 +135,10 @@
Сумма `visitors` по строкам повторно учитывает одного посетителя, Сумма `visitors` по строкам повторно учитывает одного посетителя,
приходящего по разным меткам. Для числа посетителей дня она не подходит. приходящего по разным меткам. Для числа посетителей дня она не подходит.
## Что должно получиться ## Результат работы
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и - [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
использует ключ шардирования целевой таблицы; `AS` и использует ключ шардирования основной;
- [ ] строки без UTM-меток сохранены, а `declared_revenue` хранится - [ ] строки без UTM-меток сохранены, а `declared_revenue` хранится
в `Decimal(18, 2)`; в `Decimal(18, 2)`;
- [ ] вы записали, почему сумма отличается от `dm.revenue_daily_v`; - [ ] вы записали, почему сумма отличается от `dm.revenue_daily_v`;
+22 -19
View File
@@ -1,24 +1,27 @@
# Свой график в Superset # Свой график в Superset
**Чему учит:** увидеть, как собственный результат из хранилища становится В этой работе вы построите график по своей витрине и проверите числа
отчетом в BI. на нем SQL-запросом.
## Сначала прочитать ## Подготовка
- [«Немного Superset»](../handbook/superset.md); - [«Отчеты в Superset»](../handbook/superset.md);
- [задание 3: `dm.session_overview`](1-3-session-overview.md). - [задание 3: `dm.session_overview`](1-3-session-overview.md).
## На какой вопрос ответит график ## График отказов по устройствам
В задании 3 вы собрали витрину `dm.session_overview_v`. Теперь покажите, с В задании 3 первой ступени вы собрали витрину `dm.session_overview_v`.
каких устройств чаще уходят после первой страницы. Постройте столбиковый Теперь покажите, с каких устройств чаще уходят после единственного события.
график доли отказов по `device_category` за весь период витрины. Измерение Такие визиты в стенде считаются отказами. Постройте столбиковый график доли
одно — `device_category`, метрика тоже одна. отказов по `device_category` за весь период витрины. Каждый столбик должен
показывать долю для одной категории устройств.
## Что сделать ## Настройка графика
Заведите физический датасет в подключении к `ClickHouse`: схема `dm`, объект Добавьте представление как источник данных для графиков. В Superset такой
`session_overview_v`. Назовите по-русски хотя бы две колонки: источник называется физическим датасетом. Выберите подключение `ClickHouse`,
схему `dm` и объект `session_overview_v`. Задайте русские подписи хотя бы двум
колонкам:
- `device_category` — «Категория устройства»; - `device_category` — «Категория устройства»;
- `report_date` — «Дата отчета». - `report_date` — «Дата отчета».
@@ -32,10 +35,10 @@
| `SQL expression` | `SUM(bounce_sessions) / SUM(sessions)` | | `SQL expression` | `SUM(bounce_sessions) / SUM(sessions)` |
| `D3 format` | `.1%` | | `D3 format` | `.1%` |
Доля считается из сумм слагаемых: среднее готовых долей соврет. Поэтому Выражение делит общее число отказов на общее число сессий. Усреднение дневных
метрике нужно это выражение. Сохраните ее в датасете, а не в графике: долей может дать неверный результат: число сессий в разные дни различается.
подписи и формат задаются в датасете, а график их использует. Так во всех Сохраните метрику в датасете, чтобы все будущие графики использовали одно
будущих графиках у метрики будут одинаковые подписи. Это правило описано в выражение, подпись и формат. Это правило описано в
[справочнике Superset](../architecture/superset.md#дерево-импорта). [справочнике Superset](../architecture/superset.md#дерево-импорта).
Не используйте вкладку «Custom SQL» конструктора графика. Созданная там Не используйте вкладку «Custom SQL» конструктора графика. Созданная там
@@ -56,9 +59,9 @@ FROM dm.session_overview_v;
``` ```
Стендовые объекты не меняйте. Что переживает `make up`, а что стирает Стендовые объекты не меняйте. Что переживает `make up`, а что стирает
`make clean`, описано в главе [«Немного Superset»](../handbook/superset.md#стендовые-объекты-и-ваши). `make clean`, описано в главе [«Отчеты в Superset»](../handbook/superset.md#готовые-и-собственные-отчеты).
## Как проверить себя ## Проверка результата
Автоматической проверки этого задания нет. Сверьте числа на графике Автоматической проверки этого задания нет. Сверьте числа на графике
с результатом запроса в SQL Lab: с результатом запроса в SQL Lab:
@@ -76,7 +79,7 @@ ORDER BY device_category;
проценты с одним знаком, как формат `.1%`, поэтому числа должны совпасть проценты с одним знаком, как формат `.1%`, поэтому числа должны совпасть
один в один. один в один.
## Что должно получиться ## Результат работы
- [ ] у вас есть физический датасет `dm.session_overview_v` в подключении - [ ] у вас есть физический датасет `dm.session_overview_v` в подключении
`ClickHouse`; `ClickHouse`;
+36 -33
View File
@@ -1,35 +1,35 @@
# Нарежьте сессии сами # Сессии из событий
**Чему учит:** собрать сессии из событий, сравнить их число с `VisitID` Вы самостоятельно объедините события сайта в сессии и сравните их число
источника и найти события, из-за которых расчеты расходятся. с готовыми визитами источника, обозначенными `VisitID`. Затем найдете
события, из-за которых расчеты расходятся.
## Сначала прочитать ## Подготовка
- [Хранилище и его слои, раздел DDS](../handbook/storage-layers.md#dds-язык-бизнеса); - [Хранилище и его слои, раздел DDS](../handbook/storage-layers.md#dds-события-заказы-и-сессии);
- [формат события: Идентификаторы и время](../formats/clickstream-event.md#идентификаторы-и-время). - [формат события: Идентификаторы и время](../formats/clickstream-event.md#идентификаторы-и-время).
## Для чего нужны сессии ## События и визиты
Визит, или сессия, объединяет действия посетителя за один заход на сайт. Визит, или сессия, объединяет действия посетителя за один заход на сайт.
По визитам аналитик считает длительность и глубину просмотра. По визитам аналитик считает длительность и глубину просмотра.
В хранилище сессии собираются из отдельных событий. В хранилище сессии собираются из отдельных событий.
Сверьте самостоятельный расчет с `VisitID`, чтобы увидеть, как из событий
получается сессия.
## Что сделать ## Расчет сессий
### Нарезка ### Границы визитов
В SQL Lab или DBeaver под `analyst` напишите запрос к `ods.event_v`. В SQL Lab или DBeaver под `analyst` напишите запрос к `ods.event_v`.
Для своей нарезки и эталона возьмите одни и те же строки за все доступные Для своего расчета и эталона возьмите одни и те же строки за все доступные
дни: для расследования понадобятся события соседних дней. дни: для расследования понадобятся события соседних дней.
Выберите `ClientID`, `UTCEventTime`, `EventDate`, `EventType` и `VisitID`. Выберите `ClientID`, `UTCEventTime`, `EventDate`, `EventType` и `VisitID`.
В `dds.event_v` эталонного `VisitID` нет. В `dds.event_v` эталонного `VisitID` нет.
Для начала считайте визитом последовательные события одной куки (`ClientID`); `ClientID` хранится в cookie браузера и обозначает посетителя в событиях
пауза дольше 30 минут открывает новый визит. сайта. Для начала считайте визитом последовательные события одного
`ClientID`: пауза дольше 30 минут открывает новый визит.
Учитывайте события всех типов. Упорядочите их по `UTCEventTime`. Учитывайте события всех типов. Упорядочите их по `UTCEventTime`.
Присвойте каждому событию номер своей сессии внутри куки. Присвойте каждому событию номер своей сессии внутри `ClientID`.
Подсказка: оконные функции. Подсказка: оконные функции.
Днем сессии считайте `EventDate` ее первого события: дата из `UTCEventTime` Днем сессии считайте `EventDate` ее первого события: дата из `UTCEventTime`
@@ -38,7 +38,8 @@
### Сверка с эталоном ### Сверка с эталоном
В эталоне посчитайте число визитов каждой куки за модельный день: Посчитайте по эталонному `VisitID` число визитов каждого `ClientID`
за модельный день:
```sql ```sql
SELECT ClientID, EventDate, uniqExact(VisitID) AS reference_sessions SELECT ClientID, EventDate, uniqExact(VisitID) AS reference_sessions
@@ -47,46 +48,48 @@ GROUP BY ClientID, EventDate
ORDER BY EventDate, ClientID; ORDER BY EventDate, ClientID;
``` ```
Посчитайте свои сессии по куке и дню сессии. Посчитайте свои сессии по `ClientID` и дню сессии. Соедините результаты двух
Соедините два готовых агрегата через `FULL OUTER JOIN` по куке и дню. расчетов через `FULL OUTER JOIN` по этим полям. Учтите строки, которые есть
В результате полного соединения останутся ключи обеих сторон: при расхождении только с одной стороны: полное соединение сохранит их в результате.
на вашей стороне ключа может не быть совсем. Учтите такие строки при сравнении.
Выведите сводку по дням с числом разошедшихся кук и список этих кук Выведите сводку по дням с числом `ClientID`, для которых расчеты разошлись,
с днем и обоими значениями счета. Общая сумма за день могла бы скрыть и список этих идентификаторов с днем и обоими значениями. Общая сумма за день
взаимную компенсацию ошибок разных кук. По списку видно, у кого искать причину. могла бы скрыть ошибки: для одного посетителя вы посчитали лишнюю сессию,
для другого пропустили. По списку видно, чьи события нужно исследовать.
Расхождений будет всего несколько. Если их нет, мир прожил мало дней. Расхождений будет всего несколько. Если их нет, мир прожил мало дней.
Прогоните несколько дней через [даги модельного времени](../handbook/airflow-dags.md#даги-мира) Прогоните несколько дней через [даги модельного времени](../handbook/airflow-dags.md#генерация-модельных-дней)
и повторите сверку. и повторите сверку.
### Расследование ### Поиск причины расхождения
Выберите одну разошедшуюся куку. Выведите ее события за два соседних дня Выберите один `ClientID` с расхождением. Выведите его события за два соседних дня
с `UTCEventTime`, `EventDate`, `VisitID` и номером своей сессии. с `UTCEventTime`, `EventDate`, `VisitID` и номером своей сессии.
Сопоставьте сессии через события: общего ключа у двух нарезок нет, Сопоставьте сессии через события: общего ключа у двух расчетов нет,
зато у каждого события есть и `VisitID`, и присвоенный вами номер. зато у каждого события есть и `VisitID`, и присвоенный вами номер.
Найдите два соседних события, между которыми границы визитов разошлись. Найдите два соседних события, между которыми границы визитов разошлись.
Запишите куку, день, время обоих событий и причину одним предложением. Запишите `ClientID`, день, время обоих событий и причину одним предложением.
Уточните определение визита и добейтесь нуля расхождений во всех днях. Уточните определение визита и добейтесь нуля расхождений во всех днях.
## Как проверить себя ## Проверка результата
Сохраните исходный и уточненный расчеты, результаты сверки и найденные строки. Сохраните исходный и уточненный расчеты, результаты сверки и найденные строки.
Таблицы и представления создавать не нужно. Задачи в `homework_check` Таблицы и представления создавать не нужно. Задачи в `homework_check`
для этого задания нет: вы проверяете расчет своим запросом. для этого задания нет: вы проверяете расчет своим запросом.
В `sql/dds/session_rebuild.sql` и В `sql/dds/session_rebuild.sql` и
`sql/dq/sessions_vs_reference.sql` лежат готовые запросы нарезки и сверки. `sql/dq/sessions_vs_reference.sql` лежат готовые запросы расчета сессий и сверки.
Откройте их после собственного решения и расследования. Сравните запросы Откройте их после собственного решения и расследования. Сравните запросы
со своими. со своими.
Сопоставление со стендовой нарезкой по `session_id` остается на ступень 4. На ступени 4 вы сравните свой результат с готовыми сессиями стенда
по `session_id`.
## Что должно получиться ## Результат работы
- [ ] ваш запрос собирает сессии из всех событий `ods.event_v` за все - [ ] ваш запрос собирает сессии из всех событий `ods.event_v` за все
доступные дни; доступные дни;
- [ ] у вас есть сводка расхождений по дням и список кук, полученные через - [ ] у вас есть сводка расхождений по дням и список `ClientID`, полученные через
`FULL OUTER JOIN`; `FULL OUTER JOIN`;
- [ ] вы объяснили причину одного расхождения и записали куку, день и два - [ ] вы объяснили причину одного расхождения и записали `ClientID`, день и два
события, на которых оно видно; события, на которых оно видно;
- [ ] уточненный расчет дает ноль расхождений во всех днях. - [ ] уточненный расчет дает ноль расхождений во всех днях.
+57 -55
View File
@@ -1,16 +1,17 @@
# Перепишите нарезку стенда # Расчет сессий в стенде
**Чему учит:** переписать расчет сессий в готовом конвейере и проверить На прошлой ступени вы рассчитали сессии отдельным запросом. Теперь
перепишете расчет внутри стенда, запустите его через Airflow и проверите
результат по исходным событиям. результат по исходным событиям.
## Сначала прочитать ## Подготовка
- [задание «Нарежьте сессии сами»](3-1-sessions-by-hand.md); - [задание «Сессии из событий»](3-1-sessions-by-hand.md);
- [модель сессии DDS](../architecture/dds/session.md); - [модель сессии DDS](../architecture/dds/session.md);
- [дневной конвейер трансформаций](../architecture/etl.md#объём-прогона); - [дневной конвейер трансформаций](../architecture/etl.md#объём-прогона);
- [раскладка по шардам](../architecture/storage.md#раскладка-по-шардам). - [раскладка по шардам](../architecture/storage.md#раскладка-по-шардам).
## Что предстоит сделать ## Файлы для изменения
Даг, таблицы сессий и проверка их числа уже созданы. Даг, таблицы сессий и проверка их числа уже созданы.
Перепишите только три файла: Перепишите только три файла:
@@ -19,56 +20,57 @@
- `sql/dds/session_rebuild.sql`; - `sql/dds/session_rebuild.sql`;
- `sql/dds/session_replace.sql`. - `sql/dds/session_replace.sql`.
Удалите из них готовый SQL и соберите свою реализацию. Параметры и порядок Удалите из них готовый SQL и напишите свой. Даг `dds_transform` передаст
запросов заданы в `dds_transform`. DDL задает целевые таблицы и донор. Донор — параметры и выполнит запросы по порядку. Таблицы уже созданы: основные хранят
служебная таблица, где день собирается перед заменой. В готовые сессии, промежуточные нужны для нового расчета перед заменой данных.
`sessions_vs_reference.sql` число сессий сравнивается с `VisitID` из ODS. Запрос `sessions_vs_reference.sql` сравнивает число сессий с `VisitID` из ODS.
## Как даг запускает ваши запросы ## Выполнение запросов в Airflow
Объем прогона — дни, которые соберет один запуск. Его вычисляет первый файл. Объем прогона — дни, которые соберет один запуск. Его вычисляет первый файл.
| Файл | Вход | Результат | | Файл | Вход | Результат |
|---|---|---| |---|---|---|
| `session_scope.sql` | `{position:UInt16}` — номер первого несыгранного дня | `first_day` и `last_day` | | `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_rebuild.sql` | `{first_day:Date}`, `{last_day:Date}`, `{load_id:String}` | Сессии в `dds.session_stage_dist` |
| `session_replace.sql` | День в `{{ params.day }}` | Партиция дня в `dds.session_rep` | | `session_replace.sql` | День в `{{ params.day }}` | Партиция дня в `dds.session_rep` |
Первый прожитый день, которого еще нет в DDS, становится `first_day`. `first_day` должен быть первым завершившимся модельным днем, которого еще
`last_day` — последний прожитый день. Начало шкалы нужно взять из событий ODS: нет в DDS, а `last_day` последним завершившимся днем. Начальную дату возьмите
`position` считает дни от него. Живой модельный день еще не закончен, поэтому из событий ODS: `position` считает дни от нее. Текущий модельный день еще
сессии за него не собираются. не закончен, поэтому сессии за него не рассчитываются.
Если в ODS нет ни одного события, запрос не возвращает строк. Если DDS уже Если в ODS нет ни одного события, запрос не возвращает строк. Если все
догнал мир, запрос возвращает строку с `first_day = NULL`. В этом случае группа завершившиеся дни уже есть в DDS, запрос возвращает строку с `first_day = NULL`.
сессий в Airflow получает состояние `skipped`. Тогда Airflow пропускает группу задач расчета сессий: ее состояние будет `skipped`.
`session_rebuild.sql` сначала очищает `dds.session_stage_rep` на всем кластере. `session_rebuild.sql` сначала очищает `dds.session_stage_rep` на всем кластере.
Затем он собирает в `dds.session_stage_dist` все дни отрезка. Добавьте к Затем он записывает в `dds.session_stage_dist` расчет за все выбранные дни. Добавьте к
вставке `SETTINGS distributed_foreground_insert = 1`. Так строки успеют попасть вставке `SETTINGS distributed_foreground_insert = 1`. Так строки успеют попасть
на шарды до замены партиций. на шарды до замены партиций.
Цель `session_replace.sql` — локальная таблица `dds.session_rep`, источник — Запрос `session_replace.sql` заменяет партицию в основной `dds.session_rep`
локальный донор `dds.session_stage_rep`. Операция выполняется на всем кластере. готовой партицией из промежуточной `dds.session_stage_rep`.
Операция выполняется на всем кластере, на локальных таблицах каждого шарда.
Не ставьте точку с запятой в конце `session_scope.sql`. Драйвер добавляет формат Не ставьте точку с запятой в конце `session_scope.sql`. Драйвер добавляет формат
ответа, и точка с запятой превратит строку в мультизапрос, который ClickHouse ответа. С точкой с запятой получится несколько команд, которые ClickHouse
отклонит. День для замены подставляет шаблон Jinja в Airflow. Параметр отклонит. День для замены подставляет шаблон Jinja в Airflow. Параметр
ClickHouse в `ALTER ... PARTITION` здесь не работает. ClickHouse в `ALTER ... PARTITION` здесь не работает.
## Нарежьте сессии ## Правила расчета сессий
Источник сборки — `dds.event_v`. Одна строка результата представляет один Источник сборки — `dds.event_v`. Одна строка результата представляет один
визит. На третьей ступени вы уже нарезали сессии: визит. На третьей ступени вы уже получили правила разделения событий на сессии:
- сессия не пересекает модельные сутки; - сессия не пересекает модельные сутки;
- пауза больше 30 минут открывает новую сессию; - пауза больше 30 минут открывает новую сессию;
- в нарезке участвуют события всех типов. - в расчете участвуют события всех типов.
Пересборка добавляет требования к повторяемости. При одинаковом времени Повторный расчет должен давать тот же результат. При одинаковом времени
порядок событий задает `event_id`, поэтому полный порядок окон — упорядочивайте события по `event_id`: порядок в оконных функциях должен быть
`(event_time, event_id)`. Один визит должен получать одинаковый `session_id` `(event_time, event_id)`. Один визит должен получать одинаковый `session_id`
при каждом запуске. Тогда его можно сравнить с сохраненным результатом. при каждом запуске.
Вычислите идентификатор как `cityHash64(client_id, min(event_time))`. Вычислите идентификатор как `cityHash64(client_id, min(event_time))`.
Состав и типы строки заданы в `dds.session_rep`. Заполните начало, конец и Состав и типы строки заданы в `dds.session_rep`. Заполните начало, конец и
@@ -76,11 +78,11 @@ ClickHouse в `ALTER ... PARTITION` здесь не работает.
покупки. Возьмите входную и выходную страницы, UTM первого события, устройство покупки. Возьмите входную и выходную страницы, UTM первого события, устройство
и город. Заполните также служебные колонки из схемы таблицы. и город. Заполните также служебные колонки из схемы таблицы.
Пишите через `dds.session_stage_dist`: место строки определит ключ Пишите через `dds.session_stage_dist`: шард для строки определяется по
`client_id`. При записи прямо в локальную таблицу оно зависело бы от ноды, `client_id`. При записи прямо в локальную таблицу он зависел бы от узла,
которая выполнила запрос. который выполнил запрос.
## Запустите свою реализацию ## Запуск нового расчета
Готовые дни повторно не собираются. Чтобы новый SQL точно выполнился, выберите Готовые дни повторно не собираются. Чтобы новый SQL точно выполнился, выберите
последний собранный день: последний собранный день:
@@ -90,8 +92,8 @@ SELECT max(session_date)
FROM dds.session_v; FROM dds.session_v;
``` ```
Берите последний день: более ранняя дыра расширит объем до всех последующих Берите последний день: если удалить более ранний, расчет придется повторить
прожитых дней. и за все последующие завершившиеся дни.
Сохраните десять строк с идентификатором, границами, длительностью и счетчиками: Сохраните десять строк с идентификатором, границами, длительностью и счетчиками:
@@ -113,7 +115,7 @@ ORDER BY client_id, started_at, session_id
LIMIT 10; LIMIT 10;
``` ```
Создайте в DBeaver подключение к первой ноде по [инструкции из Создайте в DBeaver подключение к первому узлу по [инструкции из
README](../../README.md#подключиться-из-dbeaver). Укажите пользователя `etl` и README](../../README.md#подключиться-из-dbeaver). Укажите пользователя `etl` и
пароль `CLICKHOUSE_ETL_PASSWORD` из `.env`. У этой учетной записи есть право пароль `CLICKHOUSE_ETL_PASSWORD` из `.env`. У этой учетной записи есть право
менять таблицы DDS на кластере. Подставьте найденную дату и удалите только ее менять таблицы DDS на кластере. Подставьте найденную дату и удалите только ее
@@ -128,19 +130,18 @@ DROP PARTITION 'YYYY-MM-DD';
группу `session`: в ней должны выполниться задачи `scope` и `rebuild`, а также группу `session`: в ней должны выполниться задачи `scope` и `rebuild`, а также
`replace` для выбранного дня. Если после третьей ступени мир ушел вперед, `replace` для выбранного дня. Если после третьей ступени мир ушел вперед,
в группе появятся задачи `replace` и для последующих прожитых дней. Следом в группе появятся задачи `replace` и для последующих прожитых дней. Следом
выполнятся витрины и `dq_check`. обновятся витрины и выполнится `dq_check`.
Если прогон дошел до замены, но упал на проверке, ошибочная партиция уже Если прогон дошел до замены, но упал на проверке, ошибочная партиция уже
находится в DDS. После правки снова удалите тот же день перед следующим находится в DDS. После правки снова удалите тот же день перед следующим
запуском. Иначе день останется заполненным, а новая сборка для него не запуском. Иначе день останется заполненным, а новая сборка для него не
выполнится. выполнится.
## Как проверить себя ## Проверка результата
Сначала повторите запрос с десятью строками. `session_id`, границы, Сначала повторите запрос с десятью строками. `session_id`, границы,
длительность и счетчики должны совпасть с сохраненным результатом. Так вы длительность и счетчики должны совпасть с сохраненным результатом.
завершите отложенное с третьей ступени сопоставление со стендовой нарезкой по Это проверит, что ваш расчет восстановил те же сессии с теми же идентификаторами.
`session_id`.
Задача `dq_check.sessions_vs_reference` должна стать зеленой. Та же проверка Задача `dq_check.sessions_vs_reference` должна стать зеленой. Та же проверка
видна в хранилище: видна в хранилище:
@@ -152,33 +153,34 @@ WHERE check_name = 'sessions_vs_reference'
ORDER BY data_date; ORDER BY data_date;
``` ```
Эта сверка сравнивает число сессий каждой куки за день с `VisitID`. Нулевой Эта сверка сравнивает число сессий каждого `client_id` за день с `VisitID`.
`failed_rows` не подтверждает границы, страницы и UTM: две ошибки могут При `failed_rows = 0` число сессий совпало, но их границы, страницы и UTM
оставить тот же счет. Возьмите поштучную сверку из третьей ступени. Для одной еще могут различаться. Как на третьей ступени, проверьте отдельные сессии.
куки выбранного дня сопоставьте границы `VisitID` по событиям с границами Для одного `client_id` выбранного дня найдите начало и конец каждого
строк `dds.session_v`. По исходным событиям проверьте страницы, UTM, устройство `VisitID` по событиям и сравните с границами строк `dds.session_v`.
По исходным событиям проверьте страницы, UTM, устройство
и город. Страницы и UTM не входят в сохраненные десять строк: готовый SQL и город. Страницы и UTM не входят в сохраненные десять строк: готовый SQL
стенда выбирал их через `argMin` и `argMax` только по `event_time`. При равном стенда выбирал их через `argMin` и `argMax` только по `event_time`. При равном
времени ожидаемое значение определяйте по исходным событиям и полному порядку времени ожидаемое значение определяйте по исходным событиям и полному порядку
`(event_time, event_id)`. `(event_time, event_id)`.
Осталась физическая раскладка. Напишите отдельный запрос к Проверьте также, на каком шарде лежит каждая строка. Напишите отдельный
`dds.session_dist`. Для каждой строки он должен: запрос к `dds.session_dist`. Он должен:
1. получить фактическую ноду через `hostName()`; 1. получить фактический узел строки через `hostName()`;
2. пересчитать ожидаемую ноду по `cityHash64(client_id)`; 2. определить ожидаемый узел по `cityHash64(client_id)`;
3. посчитать строки, у которых ноды не совпали. 3. посчитать строки, у которых фактический и ожидаемый узлы не совпали.
У кластера два равных шарда: остаток 0 соответствует `clickhouse-01`, остаток У кластера два равных шарда: остаток 0 соответствует `clickhouse-01`, остаток
1 — `clickhouse-02`. Сгруппируйте ответ по фактической ноде. На каждой ноде 1 — `clickhouse-02`. Сгруппируйте ответ по фактическому узлу. На каждом узле
число строк на чужом шарде должно быть нулевым. Проверка числа сессий читает число строк на чужом шарде должно быть нулевым. Проверка числа сессий читает
Distributed-таблицу и такую ошибку не заметит. Distributed-таблицу и такую ошибку не заметит.
После зеленого результата запустите `etl_pipeline` еще раз, не удаляя После зеленого результата запустите `etl_pipeline` еще раз, не удаляя
партицию. Расчет объема должен пропустить группу сессий: новых прожитых дней и партицию. Группа сессий должна быть пропущена: все завершившиеся дни уже
дыр в слое нет. рассчитаны, новых дней и пропусков нет.
## Что должно получиться ## Результат работы
- [ ] вы переписали три файла `sql/dds/session_*.sql`, сохранив даг, - [ ] вы переписали три файла `sql/dds/session_*.sql`, сохранив даг,
таблицы и проверку стенда; таблицы и проверку стенда;
@@ -186,6 +188,6 @@ Distributed-таблицу и такую ошибку не заметит.
- [ ] вы сверили десять сохраненных строк: `session_id`, границы, - [ ] вы сверили десять сохраненных строк: `session_id`, границы,
длительность и счетчики совпали после пересборки; длительность и счетчики совпали после пересборки;
- [ ] вы получили ноль расхождений в `sessions_vs_reference` за все - [ ] вы получили ноль расхождений в `sessions_vs_reference` за все
прожитые дни и вручную сверили границы и атрибуты сессии одной куки; прожитые дни и вручную сверили границы и поля сессии одного `client_id`;
- [ ] ваш запрос с `hostName()` не нашел строк на чужом шарде; - [ ] ваш запрос с `hostName()` не нашел строк на чужом шарде;
- [ ] повторный прогон без удаления данных пропустил группу сессий. - [ ] повторный прогон без удаления данных пропустил группу сессий.
+38 -36
View File
@@ -1,9 +1,10 @@
# Научите проверки замечать ошибки в заказах # Проверки качества заказов
**Чему учит:** написать проверки заказов до изменения их обработки Перед изменением обработки заказов вы добавите две проверки качества данных.
и убедиться на намеренной ошибке, что проверки ее замечают. Затем намеренно испортите один заказ и убедитесь, что проверки обнаружили
ошибки.
## Сначала прочитать ## Подготовка
- [модель заказа DDS](../architecture/dds/order.md); - [модель заказа DDS](../architecture/dds/order.md);
- [дневной конвейер](../architecture/etl.md#объём-прогона); - [дневной конвейер](../architecture/etl.md#объём-прогона);
@@ -13,30 +14,29 @@
и на ее включение в [`dq_check`](../../dags/dq_check.py). Готового ответа для и на ее включение в [`dq_check`](../../dags/dq_check.py). Готового ответа для
двух проверок из этого задания в репозитории нет. двух проверок из этого задания в репозитории нет.
## Что предстоит сделать ## Новые проверки
Добавьте две проверки и включите их в даг: Добавьте две проверки и включите их в даг:
- `sql/dq/order_items_total.sql` проверяет сумму позиций заказа; - `sql/dq/order_items_total.sql` проверяет сумму позиций заказа;
- `sql/dq/orders_vs_ods.sql` сверяет DDS с его источником; - `sql/dq/orders_vs_ods.sql` сверяет DDS с его источником;
- `dags/dq_check.py` запускает обе проверки, публикует их строки в - `dags/dq_check.py` запускает обе проверки, записывает их результаты в
`dm.dq_summary` и создает для них отдельные задачи. `dm.dq_summary` и создает для них отдельные задачи.
Разбор ODS и сборку DDS вы перепишете в следующих заданиях. Здесь сохраните Разбор ODS и сборку DDS вы перепишете в следующих заданиях. Здесь сохраните
их, даги загрузки, DDL, таблицы-доноры, Kafka и службы как есть. их, даги загрузки, DDL, промежуточные таблицы, Kafka и службы как есть.
Перед опытами остановите автоматическое движение мира: поставьте `world_live` Перед опытами остановите автоматическое движение мира: поставьте `world_live`
на паузу и дождитесь завершения активных прогонов. До завершения опытов не на паузу и дождитесь завершения активных прогонов. До завершения опытов не
запускайте руками `world_next_day`, `world_live_day`, `world_initialize` и запускайте руками `world_next_day`, `world_live_day`, `world_initialize` и
`world_recreate`. Это условие стабильного модельного времени: одной паузы `world_recreate`, чтобы новые данные не меняли результат между проверками.
`world_live` для него мало.
## Что будут проверять ваши запросы ## Требования к запросам
Чтобы `dq_check` мог запустить проверку, ее запрос возвращает по одной строке Чтобы `dq_check` мог запустить проверку, ее запрос возвращает по одной строке
на день с колонками `data_date`, `reference_rows`, `actual_rows`, на день с колонками `data_date`, `reference_rows`, `actual_rows`,
`failed_rows`, `diagnostics`. Даг сохраняет в `dm.dq_summary` только счетчики, `failed_rows`, `diagnostics`. Даг сохраняет в `dm.dq_summary` только счетчики,
статус и координаты запуска. Примеры из `diagnostics` ищите в статус и сведения о запуске. Примеры из `diagnostics` ищите в
журнале упавшей задачи Airflow. Ограничьте их тем же малым числом, что и журнале упавшей задачи Airflow. Ограничьте их тем же малым числом, что и
существующие запросы в `sql/dq/`. существующие запросы в `sql/dq/`.
@@ -44,46 +44,48 @@
Имя проверки - `order_items_total`. Для каждого заказа из `dds.order_v` Имя проверки - `order_items_total`. Для каждого заказа из `dds.order_v`
посчитайте сумму `item_price * item_quantity` и сравните ее с `items_total`. посчитайте сумму `item_price * item_quantity` и сравните ее с `items_total`.
`reference_rows` считает ключи со стороны `items_total`, `actual_rows` - ключи В `reference_rows` запишите число заказов со стороны `items_total`,
со стороны вычисленной суммы, а `failed_rows` - несовпавшие значения. В в `actual_rows` число заказов со стороны вычисленной суммы, в `failed_rows`
исправном наборе первые два счетчика равны; несовпадения сумм считает число заказов, у которых суммы не совпали. На исправных данных первые два
`failed_rows`. Для диагностики достаточно `order_id`, ожидаемой суммы и счетчика равны, а `failed_rows` равен нулю. Для примеров расхождений
суммы позиций. достаточно `order_id`, значения `items_total` и вычисленной суммы позиций.
Проверка относится к типизированному разбору массива `items` на границе Проверка поможет заметить ошибки разбора `items` при переносе заказа
ODS -> DDS. Скидку, доставку и равенство `total` здесь проверять не нужно. из ODS в DDS. Скидку, доставку и равенство `total` здесь проверять не нужно.
### DDS против ODS ### Полнота заказов в DDS
Имя проверки - `orders_vs_ods`. Источник модели DDS здесь - текущее состояние Имя проверки - `orders_vs_ods`. Источник модели DDS здесь - текущее состояние
`ods.order_v`. С ним вы и сравниваете DDS; обращаться к внутреннему состоянию `ods.order_v`. С ним вы и сравниваете DDS; обращаться к внутреннему состоянию
генератора для этой проверки не нужно. генератора для этой проверки не нужно.
Одним запросом соберите обе стороны на зерне дня заказа и сравните: Одним запросом рассчитайте итоги каждого дня заказа в обоих слоях и сравните:
- число заказов; - число заказов;
- максимальный `updated_at`. - максимальный `updated_at`.
День ODS вычисляется в `Europe/Samara`, как `order_date` в DDS. Область День заказа в ODS вычисляйте в `Europe/Samara`, как `order_date` в DDS.
проверки - объединение дней обеих сторон, включая день, который полностью Проверьте все дни, которые есть хотя бы в одном слое: так вы заметите день,
пропал из одного слоя. Перед сравнением приведите времена к одному часовому целиком пропавший из другого. Перед сравнением приведите времена к одному
поясу. `reference_rows` - число заказов ODS, `actual_rows` - число заказов DDS, часовому поясу. `reference_rows` содержит число заказов ODS, `actual_rows`
а `failed_rows` равен единице для дня, где расходится хотя бы одно значение. число заказов DDS. В `failed_rows` запишите 1, если за день расходится
хотя бы одно значение, и 0, если оба совпали.
Обе стороны распределены по `order_id`. Дневные агрегаты соедините через Обе стороны распределены по `order_id`. Дневные агрегаты соедините через
`GLOBAL FULL OUTER JOIN` с `join_use_nulls = 1`, как в существующих сверках. `GLOBAL FULL OUTER JOIN` с `join_use_nulls = 1`, как в существующих сверках.
Если разбор ODS и сборка DDS одинаково потеряют строку, слои останутся равны Если строка потерялась при разборе в ODS, она не попадет и в DDS. Эта проверка
по этой проверке. Общая потеря при разборе проявится в отдельном балансе по не заметит потерю: данные слоев совпадут. В [следующем задании](5-2-order-parsing.md)
`_load_id` из [следующего задания](5-2-order-parsing.md). вы отдельно сравните число исходных строк с числом разобранных заказов
и ошибок за один запуск, обозначенный `_load_id`.
## Получите красный и зеленый результаты ## Проверка на намеренных ошибках
Сначала запустите `dq_check` на нетронутых данных. Обе новые задачи должны Сначала запустите `dq_check` на нетронутых данных. Обе новые задачи должны
завершиться успешно, а в `dm.dq_summary_v` у обоих имен не должно быть завершиться успешно, а в `dm.dq_summary_v` у обоих имен не должно быть
нарушенных дней. дней с расхождениями.
Выберите оплаченный заказ из последнего слепка с непустым массивом позиций и Выберите оплаченный заказ из последнего слепка с непустым массивом позиций и
сохраните его `order_id` и `order_date`. Подключитесь к первой ноде из DBeaver сохраните его `order_id` и `order_date`. Подключитесь к первому узлу из DBeaver
по [инструкции из README](../../README.md#подключиться-из-dbeaver) под по [инструкции из README](../../README.md#подключиться-из-dbeaver) под
пользователем `etl`. Прибавьте одну копейку к `items_total` этого заказа в пользователем `etl`. Прибавьте одну копейку к `items_total` этого заказа в
локальной таблице `dds.order_rep` на всем кластере: локальной таблице `dds.order_rep` на всем кластере:
@@ -102,7 +104,7 @@ SETTINGS mutations_sync = 2;
Запустите `etl_pipeline`. Текущее окно заказов будет заново собрано из ODS, и Запустите `etl_pipeline`. Текущее окно заказов будет заново собрано из ODS, и
исходная строка вернется в DDS. Повторный `dq_check` должен дать зеленый исходная строка вернется в DDS. Повторный `dq_check` должен дать зеленый
результат для нового сторожа. результат для новой проверки.
Теперь удалите тот же заказ из `dds.order_rep` на всем кластере: Теперь удалите тот же заказ из `dds.order_rep` на всем кластере:
@@ -118,9 +120,9 @@ SETTINGS mutations_sync = 2;
Если `orders_vs_ods` не обнаружил удаление, исправьте запрос до следующего Если `orders_vs_ods` не обнаружил удаление, исправьте запрос до следующего
задания. задания.
## Как проверить себя ## Проверка результата
В `dm.dq_summary_v` должны быть зелены обе новые и пять прежних проверок: В `dm.dq_summary_v` обе новые и пять прежних проверок должны быть успешны:
```sql ```sql
SELECT check_name, sum(failed_rows) AS failed_rows, groupUniqArray(status) SELECT check_name, sum(failed_rows) AS failed_rows, groupUniqArray(status)
@@ -132,12 +134,12 @@ ORDER BY check_name;
Проверка суммы должна охватывать каждый день `dds.order_v`. Сверка слоев Проверка суммы должна охватывать каждый день `dds.order_v`. Сверка слоев
должна охватывать объединение дней ODS и DDS, включая дни только одной стороны. должна охватывать объединение дней ODS и DDS, включая дни только одной стороны.
## Завершите опыт ## Завершение работы
После восстановления заказа и успешного `dq_check` можно закончить работу или После восстановления заказа и успешного `dq_check` можно закончить работу или
перейти к разбору заказов, оставив модельное время на паузе. перейти к разбору заказов, оставив модельное время на паузе.
## Что должно получиться ## Результат работы
- [ ] две новые проверки запускаются в `dq_check`, а их дневные результаты - [ ] две новые проверки запускаются в `dq_check`, а их дневные результаты
появляются в `dm.dq_summary`; появляются в `dm.dq_summary`;
+42 -40
View File
@@ -1,9 +1,10 @@
# Перепишите разбор заказов в ODS # Разбор заказов в ODS
**Чему учит:** переписать разбор заказов и на пробных сообщениях проверить, Вы перепишете разбор заказов в ODS. На четырех пробных сообщениях проверите,
что годные строки и брак попадают в свои таблицы без потерь. что правильно заполненный заказ и сообщения с ошибками попадают
в соответствующие таблицы без потерь.
## Сначала прочитать ## Подготовка
- [проверки заказов](5-1-order-checks.md); - [проверки заказов](5-1-order-checks.md);
- [прием заказов в ODS](../architecture/orders/ingestion.md); - [прием заказов в ODS](../architecture/orders/ingestion.md);
@@ -11,24 +12,22 @@
- [`orders_ingest`](../../dags/orders_ingest.py); - [`orders_ingest`](../../dags/orders_ingest.py);
- [`order_parse.sql`](../../sql/ods/order_parse.sql). - [`order_parse.sql`](../../sql/ods/order_parse.sql).
Запрос для проверки пробных строк вы напишете сами. ## Порядок работы
## Что предстоит сделать
Сначала проведите опыт на готовом `order_parse.sql`. Затем удалите его SQL, Сначала проведите опыт на готовом `order_parse.sql`. Затем удалите его SQL,
соберите разбор заново и повторите опыт с другим маркером. соберите разбор заново и повторите опыт с другим маркером.
Не меняйте устройство `orders_ingest`, DDL, Kafka, таблиц и преобразований Сохраните устройство `orders_ingest`, DDL, Kafka, таблиц и преобразований
DDS. Оба опыта должны закончиться адресной очисткой только своих строк. DDS. После каждого опыта удалите только добавленные вами строки.
Перед началом убедитесь, что после [предыдущего задания](5-1-order-checks.md) Перед началом убедитесь, что после [предыдущего задания](5-1-order-checks.md)
все семь DQ остались зелеными. Поставьте `world_live` на паузу, дождитесь все семь проверок качества прошли успешно. Поставьте `world_live` на паузу, дождитесь
активных прогонов и до завершения опыта не запускайте `world_next_day`, активных прогонов и до завершения опыта не запускайте `world_next_day`,
`world_live_day`, `world_initialize` и `world_recreate`. Не запускайте `world_live_day`, `world_initialize` и `world_recreate`. Не запускайте
`etl_pipeline` между `etl_pipeline` между
отправкой пробной пачки и ее очисткой. отправкой пробной пачки и ее очисткой.
## Четыре строки для двух ветвей ## Пробные сообщения
Сделайте четыре сообщения с общим уникальным маркером: Сделайте четыре сообщения с общим уникальным маркером:
@@ -39,9 +38,9 @@ DDS. Оба опыта должны закончиться адресной оч
`field_invalid`. `field_invalid`.
`not_an_object` означает, что корень JSON не объект. `keyset_mismatch` означает, `not_an_object` означает, что корень JSON не объект. `keyset_mismatch` означает,
что у объекта отличается точный набор верхнеуровневых ключей. `field_invalid` что у объекта есть лишние верхнеуровневые ключи или не хватает обязательных.
означает, что ключи на месте, но значение не прошло проверку типа, формата или `field_invalid` означает, что ключи на месте, но значение имеет неверный тип,
вместимости. формат или не помещается в заданный тип данных.
Соберите строки в `/tmp/<маркер>.jsonl`. Заготовка дает одну годную строку и Соберите строки в `/tmp/<маркер>.jsonl`. Заготовка дает одну годную строку и
по одному примеру каждого класса брака: по одному примеру каждого класса брака:
@@ -79,31 +78,33 @@ docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server kafka:9092 --topic orders < "$LAB_BATCH" --bootstrap-server kafka:9092 --topic orders < "$LAB_BATCH"
``` ```
Запустите `orders_ingest` и найдите `_load_id` по маркеру в Запустите `orders_ingest`. Найдите свои строки по маркеру в
`stg.orders_raw_dist`. У четырех строк должен быть один идентификатор прогона. `stg.orders_raw_dist` и сохраните их `_load_id`: это идентификатор запуска,
который принял сообщения. Он должен быть одинаковым у всех четырех строк.
## Зафиксируйте исходный результат ## Проверка готового разбора
До любого запуска `etl_pipeline` проверьте срез по найденному `_load_id`: До запуска `etl_pipeline` проверьте строки с найденным `_load_id`:
- в STG четыре строки; - в STG четыре строки;
- в физической `ods.order_dist` одна годная строка; - в таблице `ods.order_dist` одна годная строка;
- в `ods.order_errors_dist` три строки, по одной каждого класса; - в `ods.order_errors_dist` три строки, по одной каждого класса;
- `STG = годные + брак`; - `STG = годные + брак`;
- текущая строка `ods.order_v` совпадает по деловым полям с результатом - поля заказа в текущей строке `ods.order_v` совпадают с полями из годного
`argMax` по `snapshot_date` из годного сырья среза. исходного сообщения; последнюю версию для сравнения выберите через
`argMax` по `snapshot_date`.
Счет физической таблицы нужен именно по `_load_id`: представление Считайте строки `ods.order_dist` именно по `_load_id`: представление
`ods.order_v` оставляет текущую версию сущности и не показывает, сколько строк `ods.order_v` оставляет только текущую версию каждого заказа и не показывает,
разобрал запуск. Запрос сверки с `argMax` напишите сами. Эталонные деловые поля сколько строк разобрал запуск. Запрос сверки с `argMax` напишите сами.
стройте только из `raw`; отдельно сравните различающиеся `created_at` и Значения для сравнения извлеките из `raw` пробных сообщений. Отдельно
`updated_at`. сравните `created_at` и `updated_at`: в заготовке они намеренно различаются.
Проверьте раскладку годной строки через `hostName()` на `ods.order_dist`. Проверьте раскладку годной строки через `hostName()` на `ods.order_dist`.
Фактическая нода должна совпасть с остатком `cityHash64(order_id) % 2`: Узел, на котором лежит строка, должен соответствовать остатку `cityHash64(order_id) % 2`:
0 - `clickhouse-01`, 1 - `clickhouse-02`. 0 - `clickhouse-01`, 1 - `clickhouse-02`.
## Уберите пробную пачку ## Удаление пробных сообщений
Удалите только строки маркера из локальных таблиц: Удалите только строки маркера из локальных таблиц:
@@ -135,40 +136,41 @@ SETTINGS mutations_sync = 2;
запустите `etl_pipeline`: пересборка текущего окна удалит заказ из DDS и запустите `etl_pipeline`: пересборка текущего окна удалит заказ из DDS и
обновит витрины. обновит витрины.
## Перепишите разбор ## Собственный разбор заказов
Удалите готовый SQL из `sql/ods/order_parse.sql` и напишите свой. Чтобы Удалите готовый SQL из `sql/ods/order_parse.sql` и напишите свой. Чтобы
`orders_ingest` мог его использовать, сохраните следующие условия: `orders_ingest` мог его использовать, сохраните следующие условия:
- вход - строки `stg.orders_raw_dist` одного `{load_id:String}`; - запрос читает строки `stg.orders_raw_dist` с переданным `{load_id:String}`;
- каждая строка среза попадает ровно в один результат: годный заказ или брак; - каждая прочитанная строка попадает ровно в один результат: годный заказ или брак;
- произвольный `raw` не останавливает весь разбор исключением; - ошибка в `raw` одной строки не прерывает разбор остальных;
- годные строки записываются в `ods.order_dist`, остальные - в - годные строки записываются в `ods.order_dist`, остальные - в
`ods.order_errors_dist`; `ods.order_errors_dist`;
- классы брака и их порядок - `not_an_object`, `keyset_mismatch`, - классы брака и их порядок - `not_an_object`, `keyset_mismatch`,
`field_invalid`; `field_invalid`;
- метки доставки и `_load_id` переходят из STG без пересчета; - метки доставки и `_load_id` переходят из STG без пересчета;
- задача завершается после доставки обоих результатов через Distributed, а - задача завершается после записи и заказов, и ошибок на шарды через
строки распределяются по ключу целевой таблицы. Distributed; строки распределяются по ключу соответствующей целевой таблицы.
Точный набор полей, типы и граница строгого приема описаны в Точный набор полей, типы и условия проверки значений описаны в
[документе приема](../architecture/orders/ingestion.md#граница-строгого-приёма), [документе приема](../architecture/orders/ingestion.md#граница-строгого-приёма),
а целевые колонки - в [`20-ods-tables.sql`](../../sql/ddl/20-ods-tables.sql). а целевые колонки - в [`20-ods-tables.sql`](../../sql/ddl/20-ods-tables.sql).
Внутренность `items` остается сырым JSON: ее разбирает DDS. Содержимое `items` сохраните как JSON: его разбирает DDS.
Повторите опыт с новым маркером. Получите тот же баланс 4 = 1 + 3, те же три Повторите опыт с новым маркером. Получите тот же баланс 4 = 1 + 3, те же три
класса, верную текущую строку по `snapshot_date`, разные времена и правильный класса, верную текущую версию заказа по `snapshot_date`, разные значения
шард. Затем очистите второй срез теми же адресными командами и запустите `created_at` и `updated_at` и правильный шард. Затем удалите строки второго
опыта теми же командами и запустите
`dq_check`. `dq_check`.
## Завершите опыт ## Завершение работы
Убедитесь, что оба маркера отсутствуют в STG, ODS, таблице брака и DDS, Убедитесь, что оба маркера отсутствуют в STG, ODS, таблице брака и DDS,
временные файлы удалены, а семь проверок успешны. Теперь можно перейти к временные файлы удалены, а семь проверок успешны. Теперь можно перейти к
сборке DDS, не снимая паузу с модельного времени. Если вы прерываетесь и до сборке DDS, не снимая паузу с модельного времени. Если вы прерываетесь и до
опыта `world_live` работал, снимите его с паузы. опыта `world_live` работал, снимите его с паузы.
## Что должно получиться ## Результат работы
- [ ] у вас сохранены результаты до переписывания: четыре строки дали - [ ] у вас сохранены результаты до переписывания: четыре строки дали
один заказ и три класса брака; вы сверили поля текущей версии и шард; один заказ и три класса брака; вы сверили поля текущей версии и шард;
+49 -40
View File
@@ -1,9 +1,10 @@
# Перепишите сборку заказов в DDS # Сборка заказов в DDS
**Чему учит:** настроить пересборку заказов по состоянию ODS и DDS Вы перепишете загрузку заказов из ODS в DDS и выбор дней для пересчета.
и проверить, что она восстанавливает пропущенный день за пределами текущего окна. Затем удалите данные за один из прошлых дней и проверите, что ваша
загрузка восстановила их.
## Сначала прочитать ## Подготовка
- [проверки заказов](5-1-order-checks.md); - [проверки заказов](5-1-order-checks.md);
- [разбор заказов в ODS](5-2-order-parsing.md); - [разбор заказов в ODS](5-2-order-parsing.md);
@@ -11,7 +12,7 @@
- [дневной конвейер](../architecture/etl.md#объём-прогона); - [дневной конвейер](../architecture/etl.md#объём-прогона);
- [раскладку по шардам](../architecture/storage.md#раскладка-по-шардам). - [раскладку по шардам](../architecture/storage.md#раскладка-по-шардам).
## Что предстоит сделать ## Файлы для изменения
Удалите готовый SQL и перепишите только три файла: Удалите готовый SQL и перепишите только три файла:
@@ -19,18 +20,18 @@
- `sql/dds/order_rebuild.sql`; - `sql/dds/order_rebuild.sql`;
- `sql/dds/order_replace.sql`. - `sql/dds/order_replace.sql`.
Даг `dds_transform`, DDL, донор, ODS и обе проверки из первого задания Даг `dds_transform`, DDL, промежуточную таблицу, ODS и обе проверки из первого задания
оставьте как есть. Перед началом убедитесь, что пробные строки предыдущего оставьте как есть. Перед началом убедитесь, что пробные строки предыдущего
задания удалены, а семь DQ дают зеленый результат. задания удалены, а семь проверок качества дают зеленый результат.
Поставьте `world_live` на паузу и дождитесь завершения активных прогонов. До Поставьте `world_live` на паузу и дождитесь завершения активных прогонов. До
завершения опыта не запускайте `world_live_day`, `world_initialize` и завершения опыта не запускайте `world_live_day`, `world_initialize` и
`world_recreate`. `world_next_day` понадобится только в явно указанном ниже `world_recreate`. `world_next_day` понадобится только в явно указанном ниже
случае. случае.
## Как даг запускает ваши запросы ## Выполнение запросов в Airflow
Сохраните интерфейс `dds_transform`: Даг `dds_transform` ожидает от файлов следующие параметры и результаты:
| Файл | Вход | Результат | | Файл | Вход | Результат |
|---|---|---| |---|---|---|
@@ -38,39 +39,46 @@
| `order_rebuild.sql` | `{first_day:Date}`, `{last_day:Date}`, `{load_id:String}` | выбранные дни в `dds.order_stage_dist` | | `order_rebuild.sql` | `{first_day:Date}`, `{last_day:Date}`, `{load_id:String}` | выбранные дни в `dds.order_stage_dist` |
| `order_replace.sql` | день в `{{ params.day }}` | партиция дня в `dds.order_rep` | | `order_replace.sql` | день в `{{ params.day }}` | партиция дня в `dds.order_rep` |
Объем состоит из текущего окна источника и хвоста от первого дня, который есть Сначала определите дни, за которые заказы еще могут измениться. Возьмите
в ODS, но отсутствует в DDS. Текущее окно дают строки последнего строки с последним `snapshot_date` и найдите минимальную и максимальную
`snapshot_date`: их минимальный и максимальный день заказа. Если источник дату заказа. Этот диапазон далее называется текущим окном источника.
пуст, `order_scope.sql` не возвращает строку. Если дыр нет, `first_day` равен
началу окна. Если дыра старше, непрерывный отрезок начинается с нее. Затем найдите первый день, который есть в ODS, но отсутствует в DDS.
Если он раньше начала текущего окна, начните расчет с него. Иначе начните
с начала окна. Полученная дата станет `first_day`, а конец окна `last_day`.
Один запуск пересчитывает все дни между ними. Если источник пуст,
`order_scope.sql` не возвращает строку.
`order_rebuild.sql` сначала очищает все партиции `dds.order_stage_rep` на `order_rebuild.sql` сначала очищает все партиции `dds.order_stage_rep` на
кластере, затем записывает в `dds.order_stage_dist` весь выбранный отрезок. Он кластере, затем записывает в `dds.order_stage_dist` весь выбранный отрезок. Он
читает текущее состояние `ods.order_v`, вычисляет `order_date`, переводит читает текущее состояние `ods.order_v`, вычисляет `order_date`, переводит
времена в `Europe/Samara`, разбирает `items` в три параллельных массива и времена в `Europe/Samara`, разбирает `items` в три параллельных массива и
ставит координаты запуска. заполняет служебные поля запуска.
Запись через Distributed должна завершиться до замены и использовать ключ Дождитесь записи всех строк на шарды через Distributed перед заменой партиций.
целевой таблицы для раскладки строк. Способ собрать весь отрезок внутри файла Строки должны распределяться по ключу целевой таблицы. Способ рассчитать
выберите сами. выбранные дни внутри файла выберите сами.
`order_replace.sql` работает с локальными `dds.order_rep` и `order_replace.sql` работает с локальными `dds.order_rep` и
`dds.order_stage_rep` на всем кластере. `REPLACE PARTITION` переносит готовую `dds.order_stage_rep` на всем кластере. `REPLACE PARTITION` заменяет дневную
дневную партицию из донора и оставляет донор на месте. Структура, ключи и партицию основной таблицы готовой партицией из промежуточной. Промежуточная
политика хранения совпадают: DDL объявляет донор через `AS dds.order_rep`. партиция при этом сохраняется. Структура, ключи и политика хранения таблиц
совпадают: DDL создает промежуточную через `AS dds.order_rep`.
## Восстановите ближайший закрытый день ## Восстановление пропущенного дня
Найдите последний по дате день DDS, который лежит левее начала текущего окна Найдите самый поздний день DDS перед началом текущего окна ODS. Заказы за этот
ODS. Это ближайший к окну закрытый день: его удаление показывает расширение день уже не меняются, поэтому он считается закрытым. Удалив его, вы проверите,
объема на дыру без пересборки всей истории. что ваш запрос заметил пропуск и включил этот день в расчет.
Если такого дня еще нет, один раз продвиньте мир через `world_next_day`, Если такого дня еще нет, один раз продвиньте мир через `world_next_day`,
дождитесь `orders_ingest`, затем запустите `etl_pipeline`. После этого снова дождитесь `orders_ingest`, затем запустите `etl_pipeline`. После этого снова
зафиксируйте модельное время до конца опыта и выберите ближайший закрытый день. оставьте модельное время неизменным до конца опыта и выберите этот день.
Сохраните для него число заказов и контрольную сумму деловых полей без Сохраните для него число заказов и контрольную сумму их полей, исключив
`_load_id` и `_load_ts`. Удалите только его партицию: служебные `_load_id` и `_load_ts`. Контрольная сумма позволит проверить,
что после восстановления значения остались прежними. Удалите только
партицию выбранного дня:
```sql ```sql
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
@@ -90,29 +98,30 @@ DROP PARTITION 'YYYY-MM-DD';
прогона число заказов и контрольная сумма выбранного дня должны совпасть с прогона число заказов и контрольная сумма выбранного дня должны совпасть с
сохраненными. сохраненными.
## Проверьте раскладку и повтор ## Распределение по шардам и повторный запуск
Напишите отдельный запрос к `dds.order_dist`, который получает фактическую Напишите отдельный запрос к `dds.order_dist`, который получает фактический
ноду через `hostName()`, вычисляет ожидаемую по `cityHash64(order_id) % 2` и узел каждой строки через `hostName()`, вычисляет ожидаемый по
считает несовпадения по каждой ноде. Остаток 0 соответствует `cityHash64(order_id) % 2` и считает несовпадения по каждому узлу.
`clickhouse-01`, остаток 1 - `clickhouse-02`. На обеих нодах число строк на Остаток 0 соответствует `clickhouse-01`, остаток 1 - `clickhouse-02`.
чужом шарде должно быть нулевым. Счетные DQ читают Distributed-таблицы и такую На обоих узлах число строк на чужом шарде должно быть нулевым.
ошибку раскладки не обнаруживают. Проверки количества строк читают данные со всех шардов через Distributed
и такую ошибку не обнаруживают.
Запустите `etl_pipeline` еще раз при неизменном модельном времени. Группа Запустите `etl_pipeline` еще раз при неизменном модельном времени. Группа
заказов снова должна пересобрать текущее изменяемое окно. Контрольные суммы заказов снова должна пересобрать текущее изменяемое окно. Контрольные суммы
деловых полей остаются прежними, а все семь проверок дают зеленый результат. полей заказов остаются прежними, а все семь проверок дают зеленый результат.
## Завершите опыт ## Завершение работы
После восстановления партиции и успешных проверок снимите `world_live` После восстановления партиции и успешных проверок снимите `world_live`
с паузы, если он работал до упражнения. с паузы, если он работал до упражнения.
## Что должно получиться ## Результат работы
- [ ] вы переписали три файла `sql/dds/order_*.sql`, сохранив остальные - [ ] вы переписали три файла `sql/dds/order_*.sql`, сохранив остальные
части стенда; части стенда;
- [ ] вы удалили ближайший к окну закрытый день и увидели ошибку DQ до - [ ] вы удалили ближайший к окну закрытый день и увидели ошибку проверки до
восстановления; восстановления;
- [ ] ваш расчет выбрал `first_day` с удаленного дня и заменил партиции - [ ] ваш расчет выбрал `first_day` с удаленного дня и заменил партиции
всего отрезка до конца окна; всего отрезка до конца окна;
@@ -120,4 +129,4 @@ DROP PARTITION 'YYYY-MM-DD';
с сохраненными значениями; они совпали; с сохраненными значениями; они совпали;
- [ ] `order_items_total`, `orders_vs_ods` и пять прежних проверок успешны; - [ ] `order_items_total`, `orders_vs_ods` и пять прежних проверок успешны;
- [ ] ваш запрос с `hostName()` не нашел заказов на чужом шарде; - [ ] ваш запрос с `hostName()` не нашел заказов на чужом шарде;
- [ ] повторный `etl_pipeline` сохранил деловые данные, проверки прошли. - [ ] после повторного `etl_pipeline` поля заказов остались прежними, проверки прошли.
+60 -54
View File
@@ -1,9 +1,9 @@
# Разберите сырые события рядом со стендом # Разбор сырых событий
**Чему учит:** пакетно разобрать сообщения отдельно от живого приема и В этой работе вы разберете сохраненные сообщения о событиях в свою таблицу.
построчно проверить свой результат по нетронутому ODS. Каждую полученную строку сравните с результатом обработки в стенде.
## Сначала прочитать ## Подготовка
- [формат события](../formats/clickstream-event.md); - [формат события](../formats/clickstream-event.md);
- [справочник хранилища](../architecture/storage.md), разделы про имена - [справочник хранилища](../architecture/storage.md), разделы про имена
@@ -15,26 +15,26 @@
Готовый стендовый разбор находится в `sql/ddl/30-ods-views.sql`. Откройте его Готовый стендовый разбор находится в `sql/ddl/30-ods-views.sql`. Откройте его
после собственной сверки: в нем лежит ответ на основную часть задания. после собственной сверки: в нем лежит ответ на основную часть задания.
## Что предстоит сделать ## Порядок работы
Создайте в базе `default` одну свою таблицу годных событий и наполните ее одним Создайте в базе `default` свою таблицу для правильно заполненных событий.
пакетным `INSERT SELECT` из `stg.hits_raw_dist`. Работайте с первой нодой под Выберите небольшой набор сообщений из `stg.hits_raw_dist`, разберите их
пользователем `etl`. и загрузите в таблицу одним `INSERT SELECT`. Работайте с первым узлом под
пользователем `etl`. Затем сравните результат с событиями в ODS.
Матвью `ods.event_mv` и `ods.event_errors_mv` продолжают принимать живой Материализованные представления `ods.event_mv` и `ods.event_errors_mv`
поток. Оставьте их, STG, ODS, Kafka и даги без изменений. Пересоздание матвью продолжают обрабатывать поступающие сообщения. Оставьте их, STG, ODS, Kafka
на работающем стенде оставило бы зазор в приеме, а для пакетного опыта оно не и даги без изменений. Если пересоздать представления на работающем стенде,
нужно. часть сообщений может остаться без обработки.
Дайте своей таблице имя, которое не пересечется с чужой работой, и запишите Дайте своей таблице имя, которое не пересечется с чужой работой, и запишите
его вместе с запросами. В конце вы удалите только эту таблицу. его вместе с запросами.
## Выберите сырой срез ## Выбор исходных сообщений
STG хранит строки примерно три дня по `_load_ts`. Эта колонка отмечает время STG хранит строки примерно три дня по `_load_ts`. Эта колонка отмечает время
поступления в хранилище. Модельный день события лежит в `EventDate`. Сначала поступления в хранилище. Модельный день события лежит в `EventDate`.
найдите среди недавних данных завершившийся интервал со строками. Посмотрите Посмотрите, сколько строк поступило за каждую из последних минут:
счет по минутам:
```sql ```sql
SELECT SELECT
@@ -48,7 +48,8 @@ ORDER BY minute DESC;
``` ```
Выберите короткий интервал `[slice_from, slice_to)`, который уже закончился и Выберите короткий интервал `[slice_from, slice_to)`, который уже закончился и
дает удобное для построчной проверки число строк. Если одна минута велика, дает удобное для построчной проверки число строк. Этот набор строк дальше
называется срезом. Если за одну минуту пришло слишком много строк,
сузьте ее точными границами `_load_ts`. Если недавних строк нет, откройте сузьте ее точными границами `_load_ts`. Если недавних строк нет, откройте
Airflow. При включенном `world_live` посмотрите его текущий запуск и дочерний Airflow. При включенном `world_live` посмотрите его текущий запуск и дочерний
`world_live_day`: дождитесь свежих строк от текущего или следующего планового `world_live_day`: дождитесь свежих строк от текущего или следующего планового
@@ -64,9 +65,9 @@ Airflow. При включенном `world_live` посмотрите его т
Во всех дальнейших запросах читайте `stg.hits_raw_dist` только с этими Во всех дальнейших запросах читайте `stg.hits_raw_dist` только с этими
границами. Новые строки с более поздним `_load_ts` в срез не попадут. Повторите границами. Новые строки с более поздним `_load_ts` в срез не попадут. Повторите
опыт до очистки по TTL; после нее прежний срез уже не восстановить из ODS. опыт до удаления исходных строк по TTL; после него прежний срез уже не восстановить из ODS.
## Соберите собственную таблицу ## Таблица для результата
Таблица содержит все 47 полей источника с именами и типами Таблица содержит все 47 полей источника с именами и типами
`ods.event_rep`, а также `_load_ts`. Тип `purchaseRevenue` возьмите из ODS: `ods.event_rep`, а также `_load_ts`. Тип `purchaseRevenue` возьмите из ODS:
@@ -75,14 +76,15 @@ Airflow. При включенном `world_live` посмотрите его т
Обработайте повторные доставки так же, как ODS. Возьмите Обработайте повторные доставки так же, как ODS. Возьмите
`ReplacingMergeTree(_load_ts)`, партицию `EventDate` и полный ключ замены: `ReplacingMergeTree(_load_ts)`, партицию `EventDate` и полный ключ замены:
`(CounterID, EventDate, intHash32(ClientID), WatchID)`. Достаточно одной `(CounterID, EventDate, intHash32(ClientID), WatchID)`. Достаточно одной
локальной таблицы: загрузка и сверка в этом опыте выполняются на одной ноде. локальной таблицы: загрузка и сверка в этом опыте выполняются на одном узле.
`WatchID` — логический идентификатор события, уникальный по `WatchID` обозначает событие и уникален по
[договору генератора](../specs/2026-08-01-generator.md). Физический ключ ODS [правилам генератора](../specs/2026-08-01-generator.md). Но `ReplacingMergeTree`
шире: в `ReplacingMergeTree` версиями считаются строки с одинаковым полным считает версиями строки, у которых совпадает весь ключ сортировки.
ключом сортировки. Используйте его при загрузке и сверке. Поэтому для загрузки и сверки используйте все четыре части ключа ODS,
перечисленные выше.
## Разберите годные строки ## Разбор сообщений
Для каждой строки среза проверьте те же условия приема, что и на стенде: Для каждой строки среза проверьте те же условия приема, что и на стенде:
@@ -92,8 +94,10 @@ Airflow. При включенном `world_live` посмотрите его т
в ожидаемые типы. в ожидаемые типы.
Напишите разбор, который не прерывает пакет из-за одной грязной строки. Сами Напишите разбор, который не прерывает пакет из-за одной грязной строки. Сами
выберите способ безопасного извлечения пяти опорных полей, форму общих выражений выберите, как извлечь пять перечисленных полей без ошибки запроса, какие
и место фильтра. Остальные поля извлеките по описанию формата. выражения вынести в общую часть и где отфильтровать неподходящие строки.
Эти пять полей дальше называются опорными. Остальные поля извлеките
по описанию формата.
Перенесите `_load_ts` из STG без пересчета: это время исходной доставки и Перенесите `_load_ts` из STG без пересчета: это время исходной доставки и
колонка версии. Время запуска вашего запроса сюда не входит. Вставьте в свою колонка версии. Время запуска вашего запроса сюда не входит. Вставьте в свою
@@ -114,25 +118,25 @@ Airflow. При включенном `world_live` посмотрите его т
условия отбора на этом срезе, но не классификатор ошибок. Подкладывать грязные условия отбора на этом срезе, но не классификатор ошибок. Подкладывать грязные
сообщения ради задания не требуется. сообщения ради задания не требуется.
## Приведите стороны к одному зерну ## Подготовка к сравнению
### Учтите повторные доставки ### Повторные доставки
В STG одна строка представляет доставку. Повтор сообщения остается второй В STG одна строка представляет доставку. Повтор сообщения остается второй
строкой. В `ods.event_v` одна строка представляет актуальное событие, потому строкой. В `ods.event_v` одна строка представляет актуальное событие, потому
что представление читает физическую таблицу с `FINAL`. что представление читает физическую таблицу с `FINAL`.
Свою сторону тоже читайте с `FINAL`. Ожидаемый счет равен числу различных Свою таблицу тоже читайте с `FINAL`. Число строк должно совпасть с числом
полных ключей среди годных доставок. Разница между числом доставок и этим различных полных ключей среди годных исходных сообщений. Разница между числом
счетом показывает повторы в исходном срезе. годных доставок и этим числом показывает повторы в исходном срезе.
### Выберите события ODS для сравнения ### События ODS для сравнения
Теперь нужно найти в ODS события из выбранного среза. Список стройте по Теперь нужно найти в ODS события из выбранного среза. Список стройте по
исходным доставкам: если взять его из своей таблицы, ошибочно отвергнутое исходным доставкам: если взять его из своей таблицы, ошибочно отвергнутое
событие исчезнет с обеих сторон сверки и потеря останется незамеченной. событие исчезнет с обеих сторон сверки и потеря останется незамеченной.
Снова возьмите весь исходный STG-срез. По координатам Kafka исключите Снова возьмите все выбранные строки STG. По координатам Kafka исключите
доставки, которые есть в `ods.event_errors_dist`. У оставшихся извлеките доставки, которые есть в `ods.event_errors_dist`. У оставшихся извлеките
`WatchID` без риска прервать запрос и выберите эти события из свежего `WatchID` без риска прервать запрос и выберите эти события из свежего
`ods.event_v`. Свое условие отбора здесь не применяйте: решение о годности `ods.event_v`. Свое условие отбора здесь не применяйте: решение о годности
@@ -141,36 +145,38 @@ Airflow. При включенном `world_live` посмотрите его т
`WatchID` связывает доставку с событием в ODS. Для удаления повторов и `WatchID` связывает доставку с событием в ODS. Для удаления повторов и
сравнения полей дальше используйте полный физический ключ из схемы таблицы. сравнения полей дальше используйте полный физический ключ из схемы таблицы.
## Сверьте 47 полей ## Сверка полей
Соедините свою таблицу и выбранные строки `ods.event_v` через `FULL OUTER Соедините свою таблицу и выбранные строки `ods.event_v` через `FULL OUTER
JOIN` по полному ключу замены. Полное соединение сохраняет строки, JOIN` по полному ключу замены. Полное соединение сохраняет строки,
которых нет на одной из сторон. Разделите результат на три исхода: которых нет на одной из сторон. Найдите три вида расхождений:
- строка есть только в ODS — вы ее потеряли; - строка есть только в ODS — вы ее потеряли;
- строка есть только у вас — вы приняли лишнее или иначе разобрали часть - строка есть только у вас — вы приняли лишнее или иначе разобрали часть
ключа; ключа;
- ключ есть с обеих сторон, но различается хотя бы одно из 47 полей. - ключ есть с обеих сторон, но различается хотя бы одно из 47 полей.
ClickHouse по умолчанию заполняет отсутствующую сторону значениями типов. ClickHouse по умолчанию заполняет отсутствующую сторону значениями по умолчанию
для соответствующих типов, например нулем или пустой строкой.
Включите для сверки `join_use_nulls = 1` или добавьте явный признак присутствия Включите для сверки `join_use_nulls = 1` или добавьте явный признак присутствия
в каждую сторону. Не определяйте отсутствие по значениям полей события. в каждую сторону. Не определяйте отсутствие по значениям полей события.
Сравните все поля явно, включая массивы, `ecommerce` и точное десятичное Сравните все поля явно, включая массивы, `ecommerce` и точное десятичное
`purchaseRevenue`. Удобный вид диагностики выберите сами: важно, чтобы по `purchaseRevenue`. Выберите, как показать расхождения, чтобы было видно,
результату можно было назвать отличающиеся колонки одной строки. Выведите число какие колонки различаются в конкретной строке. Выведите число строк каждого
строк каждого из трех исходов. В верном разборе все три числа равны нулю. из трех видов расхождений. В верном разборе все три числа равны нулю.
`_load_ts` не входит в 47 полей источника и не проверяет JSON-разбор. `_load_ts` не входит в 47 полей источника и не проверяет JSON-разбор.
Проверьте его отдельно. Свяжите каждую строку своей таблицы с меткой одной из Проверьте его отдельно. Для каждой строки своей таблицы найдите самую позднюю
доставок того же полного ключа в исходном срезе. После `FINAL` остается самая метку доставки того же полного ключа в исходном срезе. Она должна совпасть
поздняя метка этого ключа внутри среза. С ODS ее не сравнивайте: там может с `_load_ts`, который вы читаете после `FINAL`. С ODS эту метку не сравнивайте: там может
храниться более поздняя доставка того же события из-за границ среза. храниться более поздняя доставка того же события из-за границ среза.
## Расследуйте расхождение ## Поиск ошибок разбора
Если сверка не пуста, возьмите одну строку. Вернитесь к независимому списку Если нашлись расхождения, выберите одну строку. Найдите ее ключ в списке,
ключей и исходному `raw`, выведите рядом координаты Kafka и `_load_ts`. который вы получили из STG, и исходное сообщение `raw`. Выведите рядом
координаты Kafka и `_load_ts`.
Для измененной строки назовите поля, которые расходятся, и сравните каждое с Для измененной строки назовите поля, которые расходятся, и сравните каждое с
сырой строкой. Для пропавшей или лишней строки проверьте пять опорных полей и сырой строкой. Для пропавшей или лишней строки проверьте пять опорных полей и
@@ -178,28 +184,28 @@ ClickHouse по умолчанию заполняет отсутствующую
`ods.event_errors_dist`, посмотрите `error_class`. `ods.event_errors_dist`, посмотрите `error_class`.
Исправьте разбор, удалите и заново создайте только собственную таблицу, затем Исправьте разбор, удалите и заново создайте только собственную таблицу, затем
повторите вставку того же среза. Остановитесь, когда счет исходных доставок по повторите вставку того же среза. Остановитесь, когда число исходных доставок по
прежним границам не изменился, число годных и отвергнутых строк в сумме дает прежним границам не изменилось, число годных и отвергнутых строк в сумме дает
число исходных доставок, а построчная сверка пуста. число исходных доставок, а построчная сверка не находит расхождений.
## Чего не подтверждает сравнение с ODS ## Ограничения проверки
`ods.event_v` — образец для сравнения. Оба разбора могли повторить одну `ods.event_v` — образец для сравнения. Оба разбора могли повторить одну
ошибку. Для пяти опорных полей у вас есть проверка преобразования в нужный тип ошибку. Для пяти опорных полей у вас есть проверка преобразования в нужный тип
и исходный JSON. Остальные 42 поля можно вручную сопоставить с `raw`, однако и исходный JSON. Остальные 42 поля можно вручную сопоставить с `raw`, однако
второго независимого источника для всех значений на стенде нет. второго независимого источника для всех значений на стенде нет.
## Уберите свой объект ## Сохранение результата и удаление таблицы
Сохраните DDL таблицы, запрос разбора, границы среза, число исходных, годных Сохраните DDL таблицы, запрос разбора, границы среза, число исходных, годных
и отвергнутых строк и итоговую сверку без расхождений. Запишите также, чего и отвергнутых строк и итоговую сверку без расхождений. Запишите также, чего
не подтверждает сравнение с ODS и как TTL ограничивает время для повторения не подтверждает сравнение с ODS и как TTL ограничивает время для повторения
опыта. Затем удалите свою таблицу командой `DROP TABLE` с ее точным именем. опыта. Затем удалите свою таблицу командой `DROP TABLE` с ее точным именем.
Убедитесь через `system.tables`, что объекта больше нет. Убедитесь через `system.tables`, что объекта больше нет.
Прикладные строки стенда вы не меняли, поэтому удалять данные из STG, ODS или Данные стенда вы не меняли, поэтому удалять строки из STG, ODS или
Kafka после опыта не нужно. Kafka после опыта не нужно.
## Что должно получиться ## Результат работы
- [ ] у вас сохранены границы выбранного среза, число строк и координаты - [ ] у вас сохранены границы выбранного среза, число строк и координаты
доставки по партициям Kafka; доставки по партициям Kafka;
+32 -28
View File
@@ -1,9 +1,10 @@
# Мониторинг: куда смотреть # Мониторинг: куда смотреть
**Чему учит:** начать расследование с подходящего экрана, найти источник Вы познакомитесь с дашбордами мониторинга и проведете два расследования:
нагрузки и объяснить свежесть данных через путь доставки. найдете запросы, которые создают нагрузку, и выясните, почему события сайта
и заказы обновляются с разной частотой.
## Перед началом ## Знакомство с дашбордами
Откройте Grafana и Airflow по адресам из раздела [«Адреса и Откройте Grafana и Airflow по адресам из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли). Для первого знакомства в Grafana пароли»](../../README.md#адреса-и-пароли). Для первого знакомства в Grafana
@@ -11,7 +12,7 @@
- «Данные» — путь сообщений от источника до хранилища; - «Данные» — путь сообщений от источника до хранилища;
- «Кластер» — процессор, память, диски и внутренняя работа ClickHouse; - «Кластер» — процессор, память, диски и внутренняя работа ClickHouse;
- «Запросы» — выполненные запросы, их стоимость и ошибки. - «Запросы» — выполненные запросы, затраченные на них ресурсы и ошибки.
Откройте все три дашборда и запишите по одному вопросу, на который каждый из Откройте все три дашборда и запишите по одному вопросу, на который каждый из
них помогает ответить. «Данные» оставьте открытым в соседней вкладке. них помогает ответить. «Данные» оставьте открытым в соседней вкладке.
@@ -19,7 +20,7 @@
Проведите два расследования на работающем стенде. Службы останавливать и данные Проведите два расследования на работающем стенде. Службы останавливать и данные
портить не понадобится. портить не понадобится.
## Расследование 1. Кто читает больше всех ## Расследование 1. Какие запросы читают больше всего данных
Начните в спокойный момент: не запускайте одновременно свои тяжелые запросы. Начните в спокойный момент: не запускайте одновременно свои тяжелые запросы.
Откройте дашборд «Запросы». На панели «Тяжёлые запросы: что прочитало больше Откройте дашборд «Запросы». На панели «Тяжёлые запросы: что прочитало больше
@@ -27,8 +28,8 @@
Сохраните: Сохраните:
- границы окна и время запроса; - начало и конец выбранного периода, время запроса;
- место запроса в списке, пользователя и ноду; - место запроса в списке, пользователя и узел;
- текст запроса; - текст запроса;
- длительность, память, число прочитанных и отданных строк. - длительность, память, число прочитанных и отданных строк.
@@ -44,22 +45,25 @@
В меню панели «Кто спрашивает: сколько запросов и какой у них аппетит» В меню панели «Кто спрашивает: сколько запросов и какой у них аппетит»
откройте Inspect, затем Query. Ответьте на два вопроса: откройте Inspect, затем Query. Ответьте на два вопроса:
1. Почему журнал читают сразу с обеих нод? 1. Почему журнал читают сразу с обоих узлов?
2. Почему из счета исключены строки начала запроса и работа шардов? 2. Почему при подсчете запросов исключают записи о начале выполнения и
записи о выполнении частей запроса на шардах?
Проверьте второе объяснение по панели ошибок: ее фильтр не исключает записи Проверьте второе объяснение по панели ошибок: ее фильтр не исключает записи
шардов. Объясните, почему различаются вопросы панелей, а не только их SQL. шардов. Объясните, на какие разные вопросы отвечают эти две панели
и зачем им разные условия отбора.
## Расследование 2. Как меняется свежесть заказов ## Расследование 2. Как меняется свежесть заказов
Откройте дашборд «Данные». Панель «Свежесть: сколько прошло с последней Откройте дашборд «Данные». Панель «Свежесть: сколько прошло с последней
доехавшей строки» показывает текущее число, а не историю. До наблюдения доехавшей строки» показывает, сколько времени прошло с последней загрузки.
нарисуйте ожидаемую форму свежести событий и заказов в течение одного живого Истории значений на ней нет. До наблюдения нарисуйте, как, по вашему ожиданию,
дня. Отдельно отметьте момент доставки слепка заказов. будут меняться эти числа для событий и заказов в течение одного запуска
`world_live_day`. Отдельно отметьте ожидаемый момент доставки слепка заказов.
Если `world_live` снят с паузы, наблюдайте его текущий или следующий Если `world_live` снят с паузы, наблюдайте его текущий или следующий
`world_live_day`: новый ручной запуск встанет в очередь. Если выключатель на `world_live_day`: новый ручной запуск встанет в очередь. Если `world_live` на
паузе и активного живого дня нет, запустите один `world_live_day` вручную. паузе и активного `world_live_day` нет, запустите один `world_live_day` вручную.
Модельные сутки занимают около 24 реальных минут. Модельные сутки занимают около 24 реальных минут.
Запишите несколько наблюдений до конца дня и сразу после него: Запишите несколько наблюдений до конца дня и сразу после него:
@@ -69,29 +73,29 @@
| | | | | | | | | |
Дашборд обновляется раз в минуту, поэтому точного совпадения момента Дашборд обновляется раз в минуту, поэтому точного совпадения момента
доставки со строкой таблицы ждать не нужно. Рост и сброс свежести заказов доставки с записью наблюдения ждать не нужно. Перенесите рост и сброс
перенесите на свой набросок. Объясните форму линии: какую метку времени показателя заказов на свой рисунок. Объясните форму линии: какую метку времени
вычитает панель из текущего времени, почему число растет и какое событие его вычитает панель из текущего времени, почему число растет и какое событие его
сбрасывает. Модельную дату сыгранного дня подпишите отдельно от реального сбрасывает. Модельную дату наблюдаемого дня подпишите отдельно от реального
времени наблюдения. времени наблюдения.
Подтвердите сброс двумя следами: Подтвердите, что сброс совпал с загрузкой заказов:
- в Airflow завершился прием слепка `orders_ingest`; - в Airflow завершился прием слепка `orders_ingest`;
- на панели «Дневной слепок заказов: какой доехал, когда и после какой паузы» - на панели «Дневной слепок заказов: какой доехал, когда и после какой паузы»
появилась новая верхняя строка. появилась новая верхняя строка.
Если вы начали после доставки, дождитесь следующего дня либо честно запишите, Если вы начали после доставки, дождитесь следующего дня либо запишите,
какую часть формы увидели. Одно текущее значение не доказывает ни рост, ни какую часть изменений успели увидеть. Одно текущее значение не доказывает ни рост, ни
сброс. сброс.
## Объясните путь данных ## Источники показателей
Не открывая справочник, объясните своими словами: Не открывая справочник, объясните своими словами:
- почему свежесть считают в ODS, а поток — в STG; - почему время с последней загрузки считают по ODS, а число поступивших сообщений по STG;
- почему знаменатель доли брака берут из STG; - почему знаменатель доли брака берут из STG;
- почему у числителя и знаменателя доли брака одно окно. - почему числитель и знаменатель доли брака берут за один период.
Свяжите ответы с тем, что вы видели на панелях. Затем сверьте объяснение с Свяжите ответы с тем, что вы видели на панелях. Затем сверьте объяснение с
[главой о мониторинге](../handbook/monitoring.md) и справочниками дашбордов [главой о мониторинге](../handbook/monitoring.md) и справочниками дашбордов
@@ -99,20 +103,20 @@
[«Данные»](../architecture/monitoring/data.md). Устройство живого дня и [«Данные»](../architecture/monitoring/data.md). Устройство живого дня и
доставки слепка описано в [главе об Airflow](../handbook/airflow-dags.md). доставки слепка описано в [главе об Airflow](../handbook/airflow-dags.md).
## Что должно получиться ## Результат работы
- [ ] для каждого из трех дашбордов записан вопрос, на который он отвечает; - [ ] для каждого из трех дашбордов записан вопрос, на который он отвечает;
- [ ] у вас сохранены окно, текст и показатели верхнего запроса; - [ ] у вас сохранены выбранный период, текст и показатели верхнего запроса;
вы выяснили, кто его отправил и зачем; вы выяснили, кто его отправил и зачем;
- [ ] вы нашли запрос открытых дашбордов, который прочитал больше всего - [ ] вы нашли запрос открытых дашбордов, который прочитал больше всего
строк, и панель, которая его отправила; строк, и панель, которая его отправила;
- [ ] вы можете объяснить, зачем журнал собирают с двух нод и какие записи - [ ] вы можете объяснить, зачем журнал собирают с двух узлов и какие записи
учитывают при подсчете запросов; учитывают при подсчете запросов;
- [ ] у вас есть прогноз свежести, сделанный до наблюдения, и несколько - [ ] у вас есть прогноз свежести, сделанный до наблюдения, и несколько
замеров; рост и сброс на рисунке объяснены через время последней доставки; замеров; рост и сброс на рисунке объяснены через время последней доставки;
- [ ] вы подтвердили доставку заказов в Airflow и на панели слепков, - [ ] вы подтвердили доставку заказов в Airflow и на панели слепков,
подписали модельную дату отдельно от реального времени наблюдений; подписали модельную дату отдельно от реального времени наблюдений;
- [ ] вы можете объяснить выбор слоев для свежести, потока и доли брака - [ ] вы можете объяснить выбор слоев для свежести, потока и доли брака
и почему числитель и знаменатель берут за одно окно; и почему числитель и знаменатель берут за один период;
- [ ] после наблюдений службы продолжают работать, данные и стендовые - [ ] после наблюдений службы продолжают работать, данные и стендовые
дашборды остались без изменений. дашборды остались без изменений.
+142 -96
View File
@@ -1,84 +1,121 @@
# Учебные задания # Учебные задания
Программа состоит из двух частей: основная включает семь ступеней, которые вы Вы будете достраивать хранилище небольшого интернет-магазина. Стенд уже
проходите по порядку, а факультативы выбираете по желанию. Причины такого загружает события сайта и заказы, обрабатывает их и строит несколько отчетов.
порядка и формы заданий описаны в [программе учебных лаб](../specs/2026-09-01-labs-program.md#2-устройство-программы-основная-программа-и-факультативы). Сначала вы подготовите данные для новых отчетов, затем разберетесь, как
работают расчеты и загрузка, и перепишете часть из них.
## Основная программа Основная программа состоит из семи ступеней. Проходите их по порядку,
факультативы выбирайте по желанию. Перед первым заданием прочитайте главу
[«Хранилище и его слои»](../handbook/storage-layers.md) и выполните запросы
из нее: они покажут данные, с которыми предстоит работать.
| Ступень | Задание | Чему учит | ## Порядок заданий
| Ступень | Задания | Содержание |
|---|---|---| |---|---|---|
| 1 | Пять витрин слоя DM | построить результат для потребителя хранилища; уроки каждой витрины — в таблице ниже | | 1 | Пять витрин для отчетов | подготовить данные о событиях, страницах, визитах, ошибках загрузки и рекламе; задания перечислены ниже |
| 2 | [Свой график в Superset](2-1-superset-chart.md) | превратить свой результат из хранилища в отчет в BI | | 2 | [Свой график в Superset](2-1-superset-chart.md) | построить график по своей витрине и проверить числа на нем |
| 3 | [Нарежьте сессии сами](3-1-sessions-by-hand.md) | сверить число сессий в своей нарезке с эталоном и найти события, из-за которых оно расходится | | 3 | [Сессии из событий](3-1-sessions-by-hand.md) | объединить действия посетителя в визиты и сравнить их с визитами источника |
| 4 | [Перепишите нарезку стенда](4-1-session-pipeline.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) | написать проверки качества до рискованного изменения, затем переписать разбор и сборку заказов | | 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) | изучить разбор потока отдельно от живого приема и сверить его со стендовым результатом | | 6 | [Разбор сырых событий](6-1-event-parsing.md) | извлечь поля из сообщений и сравнить результат с готовым разбором стенда |
| 7 | [Мониторинг: куда смотреть](7-1-monitoring.md) | выбрать, куда и на что смотреть в живой системе | | 7 | [Мониторинг: куда смотреть](7-1-monitoring.md) | найти тяжелые запросы и разобраться, почему данные обновляются с разной частотой |
## Ступень 1: пять витрин слоя DM ## Ступень 1: витрины для отчетов
Слой DM в стенде построен наполовину: витрины трех сюжетов дашборда и контроль Витрина готовит данные для конкретного отчета. В стенде они находятся в базе
качества уже построены, остальные пять витрин строите вы. Каждая из них — `dm`: это слой DM. Витрины выручки, трафика, сверки покупок с заказами и
самостоятельное задание: в нем есть состав витрины, зерно, источники и способ сводка качества уже готовы. Вы добавите еще пять.
проверки.
| № | Результат | Чему учит | | № | Витрина | Содержание задания |
|---|---|---| |---|---|---|
| 1 | [`events_enriched_v`](1-1-events-enriched.md) | подготовить удобный для анализа набор полей, сохранив одну строку на событие | | 1 | [События для анализа: `events_enriched_v`](1-1-events-enriched.md) | выбрать нужные поля и добавить вычисляемые, сохранив одну строку на событие |
| 2 | [`top_pages_daily`](1-2-top-pages-daily.md) | создавать материализованную витрину с дневной пересборкой в конвейере | | 2 | [Показатели страниц по дням: `top_pages_daily`](1-2-top-pages-daily.md) | сохранить итоги в таблице и настроить их обновление через Airflow |
| 3 | [`session_overview`](1-3-session-overview.md) | хранить слагаемые для расчета средних и долей | | 3 | [Визиты по устройствам: `session_overview`](1-3-session-overview.md) | хранить суммы и счетчики для расчета средних и долей |
| 4 | [`dq_errors_daily`](1-4-dq-errors-daily.md) | считать брак по дню загрузки и хранить итоги дольше источника | | 4 | [Ошибки загрузки по дням: `dq_errors_daily`](1-4-dq-errors-daily.md) | сохранить число ошибок даже после удаления исходных строк |
| 5 | [`utm_effectiveness`](1-5-utm-effectiveness.md) | читать `declared_` как предупреждение о сумме со слов клиента | | 5 | [Показатели рекламы: `utm_effectiveness`](1-5-utm-effectiveness.md) | различать суммы покупок из событий сайта и выручку по заказам магазина |
Общее устройство слоя — [справочник DM](../architecture/dm.md). Он отвечает на Начните с [первого задания](1-1-events-enriched.md): для него достаточно
вопросы «почему так», задание отвечает на вопрос «что сделать». одного представления. В заданиях 2–5 понадобятся таблицы и их обновление
по дням. К общим правилам ниже возвращайтесь по мере работы.
## Как принято на ступени 1 ## Создание и обновление витрин
**Читать — через `_v`.** Физические `_rep` и `_dist` служат загрузке и ### Источники данных
обслуживанию, а публичный договор DDS — `dds.event_v` или `dds.session_v` —
остается прежним даже при изменении физических объектов. Чтение DDS
напрямую делает задания независимыми: соседняя витрина может быть еще не
построена.
Исключение — задание 4: у таблиц брака публичного договора нет.
**Писать — в `_dist`.** Так строки распределяются по ключу шардирования. При События читайте из `dds.event_v`, сессии из `dds.session_v`. Суффикс `_v`
записи в локальную таблицу распределение зависит от узла, выполнившего обозначает обычное представление: оно выполняет сохраненный запрос при
запрос, и при следующем запуске может измениться. Подробности — раздел чтении. Такие представления сохраняют привычные имена и состав колонок,
«Раскладка по шардам» [справочника хранилища](../architecture/storage.md). когда внутреннее устройство таблиц меняется.
**Форма — по зерну.** На стенде агрегат с новым зерном принято хранить Каждая учебная витрина читает готовый источник и не зависит от других
готовым: потребитель читает его много раз без повторного расчета по учебных витрин. В задании 4 источниками служат таблицы ошибок ODS:
источнику. Создайте `dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` / представлений для них нет.
`_stage_dist` и публичное `dm.<имя>_v` с явным списком колонок. При прежнем
зерне достаточно представления: таблица дублировала бы данные. Доводы —
раздел «Таблица или представление» [справочника
DM](../architecture/dm.md).
**Ключ шардирования — ваше решение.** Объясните, почему выбранный ключ не ### Таблицы и представления
дает существенного перекоса и почему более сложный ничего не добавит.
Объявите донор через `AS <цель>_rep` с ключом цели: иначе замена партиции В заданиях 2–5 вы группируете исходные строки. Например, в `top_pages_daily`
потеряет строку на одном шарде и удвоит на другом. Детали — раздел одна строка содержит итоги для одной страницы за день. То, чему соответствует
«Раскладка по шардам» [справочника хранилища](../architecture/storage.md). одна строка, называют **зерном**. На стенде итоги с новым зерном сохраняют
Пример обоснования — `dm.revenue_daily_dist` в в таблицах. Отчеты читают готовые числа.
Новый расчет сначала записывают в промежуточную таблицу. Пока он готовится,
отчеты читают прежние данные из основной. Затем готовыми разделами
промежуточной таблицы заменяют разделы основной. Раздел называется
**партицией**; в этих заданиях он содержит данные одного дня.
Промежуточную таблицу также называют **донором**.
На примере `top_pages_daily` пять объектов выглядят так:
| Объект | Для чего нужен |
|---|---|
| `dm.top_pages_daily_rep` | Хранит рассчитанные строки на каждом шарде |
| `dm.top_pages_daily_dist` | Читает строки со всех шардов и распределяет запись между ними |
| `dm.top_pages_daily_stage_rep` | Хранит новый расчет до замены данных в основной таблице |
| `dm.top_pages_daily_stage_dist` | Распределяет запись нового расчета между шардами |
| `dm.top_pages_daily_v` | Дает отчетам доступ к готовой витрине; явно перечисляет ее колонки |
### Распределение по шардам
Каждый из двух шардов стенда хранит свою часть данных. Записывайте строки
через таблицы `_dist`: они выбирают шард по ключу шардирования. Прямая запись
в `_rep` оставит строки на том узле, где выполнен запрос.
Ключ для своей витрины выберите сами. Проверьте распределение строк между
шардами и объясните, почему выбранного ключа достаточно.
Пример обоснования для `dm.revenue_daily_dist` есть в
[`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql). [`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
**Где лежит код.** DDL заданий — в `sql/ddl/70-labs.sql`. Создайте промежуточную таблицу через `AS <цель>_rep`, сохранив структуру и
При создании мира он применяется после стендовых файлов. ключи основной. У обеих таблиц `_dist` должен быть одинаковый ключ
В уже работающем стенде сохранение файла и повторный `make up` новый DDL шардирования: замена партиции выполняется внутри каждого шарда и не
не применят — выполните его сами, как описано ниже. В перераспределяет строки. Подробности есть в разделе
`sql/dm/<витрина>_scope.sql` определяются дни прогона, в `_rebuild.sql` они [«Раскладка по шардам»](../architecture/storage.md#раскладка-по-шардам).
собираются в донор, в `_replace.sql` заменяется партиция. Задачу сборки
добавьте в [`dags/dm_transform.py`](../../dags/dm_transform.py): без нее
конвейер не обновит витрину. Представлению достаточно `CREATE VIEW`.
**Образец — витрина `revenue_daily`.** У нее есть пять объектов, донор, ### SQL-файлы и запуск расчета
сборка, замена дневных партиций и задача в конвейере. Используйте ее как
образец всего порядка пересборки. Состав колонок берите из своего ТЗ, иначе
получите показатели другой витрины.
## Как применить свой DDL Команды создания таблиц и представлений (DDL) сохраняйте в
`sql/ddl/70-labs.sql`. При создании мира этот файл выполняется после
стендовых файлов. В работающем стенде примените команды сами по инструкции
ниже: сохранение файла и повторный `make up` их не выполнят.
Для витрины с таблицами нужны три файла в `sql/dm/`:
- `<витрина>_scope.sql` определяет, какие дни пересчитать;
- `<витрина>_rebuild.sql` рассчитывает их и записывает в промежуточную таблицу;
- `<витрина>_replace.sql` заменяет партицию одного дня в основной таблице.
Добавьте запуск расчета в [`dags/dm_transform.py`](../../dags/dm_transform.py),
чтобы витрина обновлялась вместе с остальными. Для представления из задания 1
достаточно выполнить `CREATE VIEW`.
Образец этого порядка работы есть у готовой витрины `revenue_daily`.
Ее таблицы, SQL-файлы и задача Airflow разобраны в
[справочнике DM](../architecture/dm.md#dmrevenue_daily_v).
Состав колонок и выбор дней для пересчета берите из своего задания.
## Выполнение SQL в DBeaver
В DBeaver создайте подключение по [инструкции в README](../../README.md#подключиться-из-dbeaver), В DBeaver создайте подключение по [инструкции в README](../../README.md#подключиться-из-dbeaver),
но укажите пользователя `etl` и пароль из `CLICKHOUSE_ETL_PASSWORD` в `.env`. но укажите пользователя `etl` и пароль из `CLICKHOUSE_ETL_PASSWORD` в `.env`.
@@ -88,51 +125,62 @@ DM](../architecture/dm.md).
Создайте файл `sql/ddl/70-labs.sql` и сохраняйте в нем DDL своих витрин. Создайте файл `sql/ddl/70-labs.sql` и сохраняйте в нем DDL своих витрин.
Откройте его в SQL-редакторе этого подключения и выполните DDL текущего Откройте его в SQL-редакторе этого подключения и выполните DDL текущего
задания. Создавайте объекты с задания. Создавайте объекты с
`ON CLUSTER clickstream_cluster`, чтобы они появились на обеих нодах; `ON CLUSTER clickstream_cluster`, чтобы они появились на обоих узлах;
образец для представления — [`sql/ddl/60-dm-views.sql`](../../sql/ddl/60-dm-views.sql). образец для представления — [`sql/ddl/60-dm-views.sql`](../../sql/ddl/60-dm-views.sql).
Сохраненный файл позволит воссоздать ваши объекты при пересборке мира. Сохраненный файл позволит воссоздать ваши объекты при пересборке мира.
## Самопроверка ступени 1 ## Проверка витрин
Даг `homework_check` запускается руками, расписания у него нет. Задача В Airflow есть даг `homework_check` с отдельной задачей для каждой учебной
витрины красная, пока витрины нет или счет не сходится, и зеленая, когда витрины. Запускайте его вручную: расписания у него нет. Задача завершится
собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет: это ошибкой, если витрина еще не создана или ее данные расходятся с ожидаемыми.
инструмент лабы, а не история качества стенда. Причину и примеры расхождений ищите в журнале задачи. Проверки еще не
выполненных заданий тоже будут красными.
Для каждой витрины проверка независимо рассчитывает ожидаемый результат Проверка заново рассчитывает показатели по источнику и сравнивает их с вашей
прямо по ее источнику. У четырех заданий источник витриной. Строки сопоставляются по полям, которые их определяют: например,
эталона тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания по дню и странице. `FULL OUTER JOIN` позволяет найти и разные значения, и
4 это таблицы брака ODS, потому что в DDS нет сущности брака. Обе стороны строки, которых нет с одной стороны. `INNER JOIN` пропустил бы отсутствующие
соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число строки. Когда расхождений нет, задача становится зеленой.
строк, где стороны разошлись или одной стороны нет. Ноль — зеленый. Полное
соединение сохраняет ключи обеих сторон: при внутреннем пропавший день или
ключ не попал бы в результат, и потеря осталась бы незамеченной.
Раздел «Как проверить себя» каждого задания по витрине называет деловой ключ и `homework_check` не сохраняет результаты в сводку качества `dm.dq_summary`.
сравниваемые величины. Тот же прием в стендовом исполнении — [даг
`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
Запросы проверок лежат в `sql/dq/`. Откройте их после собственного решения: Запросы проверок лежат в [`sql/dq/`](../../sql/dq/). Откройте их после собственного решения:
они заново рассчитывают показатели витрины, поэтому содержат ответ на они заново рассчитывают показатели витрины, поэтому содержат ответ на
существенную часть задания. Сравните свой способ расчета с готовым. существенную часть задания. Сравните свой способ расчета с готовым.
## Факультативы ## Задания по желанию
Эти задания вы берете по желанию. Полный список сюжетов — в разделе Полный список сюжетов — в разделе
[«Факультативы — пять разделов»](../specs/2026-09-01-labs-program.md#4-факультативы--пять-разделов) [«Факультативы — пять разделов»](../specs/2026-09-01-labs-program.md#4-факультативы--пять-разделов)
программы. программы.
### Почему выручка не сходится ### Почему выручка не сходится
Открывается после ступени 1. Расхождения между источниками, повторы Открывается после ступени 1. Четыре расследования качества данных:
покупок и день, после которого отчет можно считать окончательным.
### Тонкий анализ данных - [Куда пропали покупки](optional/missing-purchases.md): найти заказы без
события, выбрать источник витрины и условие проверки полноты.
- [Почему повтор покупки завышает выручку](optional/duplicate-purchases.md):
рассчитать выручку с одной строкой на заказ и проверить результат на
повторных событиях.
- [Какая позиция объясняет разницу в сумме](optional/order-amount-difference.md):
разобрать товары заказа без потери точности и найти причину разницы в суммах.
- [Почему вчерашняя выручка меняется сегодня](optional/revenue-over-time.md):
определить, за какие дни пересчитывать выручку и какую историю заказов сохранять.
Открывается после ступени 1. Знание о пользователе на дату и обогащение Первые три работы используют готовые данные. Четвертая продвигает мир через
отчета своим словарем. Airflow; для нее вы сохраняете состояния заказов после каждого модельного дня.
### Superset и BI ### Данные о посетителях
Открывается после ступени 1.
- [Как новые заказы меняют отчет о посетителях](optional/identity-over-time.md):
сохранить историю связей, сравнить расчеты на прежних сессиях и определить,
какие сведения нужны для воспроизведения результата.
### Отчеты в Superset
Открывается после ступени 2. Два самостоятельных задания: Открывается после ступени 2. Два самостоятельных задания:
@@ -141,17 +189,15 @@ DM](../architecture/dm.md).
- [В DBeaver работает, в Superset нет](optional/superset-missing-object.md): - [В DBeaver работает, в Superset нет](optional/superset-missing-object.md):
выяснить, почему клиенты по-разному видят один объект хранилища. выяснить, почему клиенты по-разному видят один объект хранилища.
### Инженерия стенда ### Загрузка и обновление данных
Открывается после ступени 1. Прием и хранение данных, раскладка по шардам, Открывается после ступени 1.
повторная обработка дня и обновление витрин.
### Что делать, когда стенд сломался - [Как обновлять витрину без полной пересборки](optional/incremental-daily-traffic.md):
продолжить работу о связях пользователей, пересчитать затронутые дни
и проверить повтор незавершенной загрузки. Повышенная сложность.
Открывается после ступени 7. Поломки стенда и инструкции по восстановлению ## Удаление временных объектов
по итогам расследований; весь раздел — повышенная сложность.
## Уборка
После опытов удаляйте свои временные объекты. Если удалили учебную витрину, После опытов удаляйте свои временные объекты. Если удалили учебную витрину,
ее задача в `homework_check` должна снова завершиться ошибкой: отсутствие ее задача в `homework_check` должна снова завершиться ошибкой: отсутствие
+84
View File
@@ -0,0 +1,84 @@
# Почему повтор покупки завышает выручку
Несколько событий покупки могут относиться к одному заказу. Вы проверите,
как они завышают сумму, и составите расчет, который учитывает каждый заказ
один раз.
Факультатив доступен после ступени 1. Работайте в DBeaver пользователем
`analyst` по [инструкции](../../../README.md#подключиться-из-dbeaver).
Дождитесь завершения `etl_pipeline`; оставьте `world_live` на паузе и
дождитесь уже начатых прогонов, чтобы данные не менялись между запросами.
## Расчет по событиям покупки
В ODS уже устранены повторные доставки событий. Достаточно ли этого,
чтобы сложить суммы всех `purchase` для витрины заказов? Проверьте на данных.
Возьмите день, за который уже получены все слепки заказов: его дата должна быть не позднее
`max(snapshot_date) - 6` из `ods.order_v`. Во всех запросах к `dds.event_v`
отбирайте этот `event_date` и `event_type = 'purchase'`.
Номер заказа находится в `purchase_id[1]`, заявленная сумма — в
`purchase_revenue[1]`. По формату источника у события покупки один номер заказа.
Сначала посчитайте число событий и сумму всех заявленных сумм. Затем найдите
номера заказов, которые встречаются несколько раз. Если таких нет, выберите
другой день с полученными слепками.
Для двух повторившихся покупок выпишите `event_id`, `client_id`, `event_time`,
номер заказа и сумму. Сопоставьте число событий и заказов в `dds.order_v`.
## Расчет по заказам
Напишите запрос с одной строкой на номер заказа. Сохраните в результате
номер, число исходных событий и одну заявленную сумму. До выбора суммы
проверьте, что во всех событиях заказа она одинакова. Затем посчитайте итог
дня по результату этого запроса и сравните с расчетом по событиям.
Проверьте, что номера в результате уникальны, а число строк совпадает с числом
разных номеров в исходных событиях. Отдельно проверьте свое решение на случае
двух разных заказов с одинаковой суммой: сколько денег оно учтет?
## Заказы с повторными событиями
Возьмите один повторившийся номер заказа. Сколько денег он добавил в первый
расчет и сколько — во второй? Подтвердите разность его событиями.
Повторите расчет для остальных повторившихся номеров. Сохраните таблицу:
номер заказа, число событий и вклад в разность сумм. Проверьте, объясняют ли
эти заказы все расхождение за день до копейки.
Слой ODS убирает повторную доставку одного события по его идентификатору;
`dds.event_v` читает уже это состояние. Сравните число разных `event_id`
и число разных номеров заказа в выбранных строках. Почему устранение
повторов по первому ключу не дало тот же результат, что по второму?
Подтвердите ответ конкретными идентификаторами.
После собственного расчета найдите эти заказы в `dm.purchase_vs_orders_v`.
Если у повторившегося заказа класс отличается от `duplicate_event`, проверьте
его статус и [порядок присвоения классов](../../architecture/dm.md#dmpurchase_vs_orders_v).
Класс витрины не заменяет поиск всех повторов в событиях.
## Сравнение с выручкой магазина
Сложите `revenue` из `dm.revenue_daily_v` за тот же `report_date` по всем
категориям. Эта витрина считает стоимость позиций оплаченных заказов,
без скидки и доставки.
Сравните результат с суммой по событиям после устранения повторов. Совпадение не требуется:
удаление повторов устраняет одну причину расхождения. Найдите хотя бы один
заказ, который объясняет, почему деньги трекера и магазина все еще могут
различаться. Для этого сопоставьте события, заказ и готовую сверку.
Рядом с запросом укажите, чему соответствует одна строка результата
и какие поля ее определяют. Обоснуйте, где нужна одна строка на событие,
а где одна на заказ: следует ли ради этого расчета
удалять повторные покупки из `dds.event_v`? Используйте найденные `event_id`.
## Результат работы
- [ ] сохранен запрос с одной строкой на заказ, числом событий и суммой;
- [ ] уникальность ключа и полнота набора номеров проверены по источнику;
- [ ] разность сумм полностью объяснена вкладом лишних событий;
- [ ] обоснованы ключ расчета и место устранения повторов;
- [ ] найден случай, показывающий границы расчета денег по трекеру.
+144
View File
@@ -0,0 +1,144 @@
# Как новые заказы меняют отчет о посетителях
Вы сохраните историю связей посетителей сайта с покупателями магазина.
На ее основе проверите, как новые связи меняют старые отчеты и что нужно,
чтобы повторить прежний расчет.
Факультатив доступен после ступени 1. Понадобятся Airflow и DBeaver с
подключением `student` по [инструкции](../../../README.md#подключиться-из-dbeaver).
Результаты запросов сохраняйте в `default` через одно подключение:
ваши таблицы будут доступны только на узле этого подключения.
Если предложенное имя таблицы занято, выберите свое.
В событиях сайта посетитель обозначен `client_id` из cookie браузера,
а в заказах покупатель обозначен `user_id`. Сопоставив событие покупки
с заказом, можно связать эти идентификаторы. Тогда и к прежним визитам
этого `client_id` можно добавить
пользователя магазина. Вы проверите, как появление такой связи меняет число
известных пользователей в отчете за прошедший день.
## Связи посетителей с покупателями
Поставьте `world_live` на паузу, дождитесь завершения начатых прогонов
генерации и обработки. Запустите `etl_pipeline` и дождитесь успеха.
На время работы мир будете продвигать вручную; новые дни останутся в стенде.
Напишите запрос по двум источникам:
| Источник | Нужные поля |
|---|---|
| `dds.event_v` | `event_type`, `event_time`, `client_id`, `purchase_id` |
| `dds.order_v` | `order_id`, `user_id` |
В результате нужна одна строка на пару `client_id`, `user_id`. Для каждой пары
найдите самое раннее время события покупки, которое удалось сопоставить
с заказом. Назовите эту колонку `first_purchase_at`.
В событии `purchase` массив `purchase_id` содержит один номер заказа.
Статус заказа не ограничивайте: отмена покупки не меняет найденную связь
посетителя с покупателем.
Источники распределены по разным ключам. Для соединения используйте
`GLOBAL INNER JOIN`, заключив выборки событий и заказов в подзапросы.
Устройство источников описано в [карте идентичностей](../../architecture/dds/identity.md).
Проверьте уникальность пар и совпадение их полного набора с
`dds.identity_map_v`: одного равенства количества недостаточно.
Для одной пары покажите номера заказов и события, по которым получили
`first_purchase_at`.
## История связей
Создайте таблицу:
```sql
CREATE TABLE default.lab_identity_history
(
observation_day Date,
client_id UInt64,
user_id UInt64,
first_purchase_at DateTime('Europe/Samara')
)
ENGINE = MergeTree
ORDER BY (observation_day, client_id, user_id);
```
Каждая копия списка должна содержать все найденные пары. В колонку
`observation_day` запишите последний обработанный день событий:
```sql
SELECT max(event_date) AS observation_day FROM dds.event_v;
```
Обозначим полученную дату **H0**. Она показывает, после обработки какого
модельного дня сохранен список; календарная дата запуска Airflow здесь
не используется. Сохраните полный результат своего запроса через
`INSERT INTO ... SELECT`, добавив H0 в `observation_day` каждой строки.
Сверьте число записанных строк с результатом исходного запроса и проверьте
уникальность пар для этой даты.
Затем дважды выполните последовательность:
1. Запустите `world_next_day` с `days = 1` и дождитесь успеха.
2. Запустите `etl_pipeline` и дождитесь успеха.
3. Получите новую `observation_day`, сохраните полный набор связей и проверьте его.
Получатся три копии списка: после дней **H0**, **H1** и **H2**.
Для каждой даты сохраняйте список один раз: обычный `MergeTree`
не устраняет повторы вставки. Сохраните и проверьте список перед тем,
как запускать следующий модельный день.
## Дата покупки и дата появления связи
Сравните списки за соседние даты: H0 с H1, затем H1 с H2. Найдите пары,
которых не было в прежнем списке. Для нескольких таких пар покажите
`client_id`, `user_id`, `first_purchase_at` и первую `observation_day`,
в которой пара появилась. Найдите пример, где покупка произошла раньше
этой даты.
Для одной такой связи вернитесь к заказу и событию. По данным и
[порядку доставки заказов](../../architecture/orders/ingestion.md) объясните,
почему пары не было в прежнем списке. Какие данные понадобились для ее
появления и когда они стали доступны?
Для пары, которая присутствовала уже в H0, нельзя определить дату появления:
у вас нет более ранних копий списка. Для новой пары можно установить
интервал между двумя сохранениями, но не точное время внутри него.
## Пересчет прежних отчетов
Возьмите один и тот же набор сессий из `dds.session_v` за даты до H0
включительно. Постройте два дневных расчета:
- со списком связей на H0;
- со списком связей на H2.
Для каждого дня посчитайте посетителей по разным `client_id` и известных
пользователей по разным `user_id`. Сессии без связи должны остаться в расчете,
но неизвестный пользователь не должен увеличивать число известных.
В соединении сессий с вашей таблицей используйте `GLOBAL LEFT JOIN`.
В таблице связей заранее отберите одну `observation_day`, чтобы копии
списка за разные дни не смешались.
Найдите день с разным числом известных пользователей. Проследите изменение
до конкретных связей и прежних сессий. Проверьте, что число посетителей
не изменилось, а число известных пользователей не больше числа посетителей.
Теперь проверьте, можно ли воспроизвести расчет на H0 без сохраненного
списка за эту дату. Возьмите список на H2 и оставьте только пары,
у которых `first_purchase_at` приходится на H0 или более ранний день.
Сравните полный набор пар с копией на H0. Выведите пары, которые есть
только в одном из списков.
На примере найденных различий объясните, какие данные нужно сохранять,
чтобы повторить прежний расчет.
## Результат работы
У вас есть запрос сопоставления посетителей с покупателями, три сохраненных
списка связей и сравнение двух расчетов на одном наборе сессий. На конкретных
парах вы можете показать разницу между датой покупки и датой появления пары
в сохраненном списке, а также объяснить, что нужно для повторения прежнего
расчета.
Сохраните SQL и таблицу `lab_identity_history`: они нужны в продолжении
[«Как обновлять витрину без полной пересборки»](incremental-daily-traffic.md).
Если продолжать не планируете, удалите свою таблицу через `DROP TABLE`.
@@ -0,0 +1,152 @@
# Как обновлять витрину без полной пересборки
Вы настроите обновление витрины только за дни, затронутые новыми данными.
Затем проверите, можно ли продолжить загрузку, которая успела записать
лишь часть результатов.
Факультатив повышенной сложности продолжает работу
[«Как новые заказы меняют отчет о посетителях»](identity-over-time.md).
Понадобятся ее запрос сохранения списка связей и таблица `default.lab_identity_history`.
Работайте под `student` через то же подключение DBeaver.
Стенд пересчитывает `dm.daily_traffic_v` целиком: новая связь может изменить
число известных пользователей в старом дне. Вы построите рядом свою витрину,
которая пересчитывает только затронутые дни и дает тот же результат.
При обычном продвижении модельного времени появляются новые дни сессий
и новые связи. Уже найденные пары сохраняются, а сессии прежних дней
не меняются.
Удаление данных, исправления старых сессий и пересоздание мира потребовали бы
другого способа обнаружения изменений.
## Витрина и журнал загрузок
Создайте две локальные таблицы. Если имена заняты, выберите свои.
```sql
CREATE TABLE default.lab_daily_traffic
(
report_date Date,
visitors UInt64,
known_users UInt64,
observation_day Date
)
ENGINE = ReplacingMergeTree(observation_day)
ORDER BY report_date;
CREATE TABLE default.lab_traffic_runs
(
observation_day Date
)
ENGINE = MergeTree
ORDER BY observation_day;
```
В витрине одна актуальная строка на день. Для обновления вставляйте полный
новый результат дня с новой `observation_day`. Читайте витрину с `FINAL`:
для каждого `report_date` останется строка с максимальной `observation_day`.
Старые версии могут исчезнуть при слиянии,
поэтому для воспроизведения прежнего расчета нужна сохраненная история связей.
В журнал записывайте `observation_day` списка связей, использованного
для расчета. Добавляйте дату только после записи всех выбранных дней
и успешной сверки. Максимальная дата в журнале укажет, какой список
использовался в последней завершенной загрузке.
## Первая загрузка
Убедитесь, что `world_live` стоит на паузе и нет активных прогонов генерации
и обработки. Запустите `etl_pipeline` и дождитесь успеха. Получите текущую
`observation_day` способом из первой работы. Если списка за эту дату еще нет
в вашей истории, сохраните его. Повторно вставлять уже сохраненный список
не требуется.
Напишите `INSERT INTO ... SELECT`, который рассчитывает все даты сессий из
`dds.session_v` со списком связей на текущую дату. Правила результата:
| Колонка | Что считать |
|---|---|
| `report_date` | `session_date` |
| `visitors` | число разных `client_id` |
| `known_users` | число разных известных `user_id` |
| `observation_day` | дата выбранной копии списка связей |
Сессии без известного пользователя сохраняйте в расчете. Несколько кук одного
пользователя не должны увеличивать `known_users`. Для соединения с вашей
таблицей связей используйте `GLOBAL LEFT JOIN`, отобрав одну `observation_day`.
Построчно сверьте свои `report_date`, `visitors`, `known_users` с
`dm.daily_traffic_v`. Сверка должна находить лишние и отсутствующие даты
и различия обоих показателей. Учебную витрину читайте с `FINAL`.
После совпадения запишите дату наблюдения в журнал один раз.
## Выбор дней для пересчета
Продвиньте мир на один день: `world_next_day` с `days = 1`, затем
`etl_pipeline`; после каждого запуска дождитесь успеха. Сохраните новый
список связей запросом из первой работы.
Обозначим максимальную `observation_day` в журнале **P**, а дату нового
списка связей — **H**. До записи результата составьте запрос, возвращающий
даты для пересчета без повторов. Он должен учесть два изменения:
- в сессиях появились дни, которых еще нет в вашей витрине;
- в списке на H появились пары, которых не было в списке на P; у этих `client_id`
есть сессии, в том числе за прежние дни.
Сначала выведите даты и выберите один прежний день: какая новая пара
потребовала его пересчета и какие сессии с ней связаны? Рядом посчитайте,
сколько дат пересчитала бы полная загрузка. Зафиксируйте оба числа;
равенство тоже возможно, если новые связи затронули всю историю.
Для сравнения выберите из списка на H пары, у которых день покупки
по `first_purchase_at` позже P. Совпадет ли результат с новыми парами,
найденными сравнением списков? Покажите пару, которую ограничение по дате
покупки пропускает.
## Обновление витрины
Напишите второй `INSERT INTO ... SELECT`. Для выбранных дат он должен
рассчитать все сессии с полным списком связей на H. Прибавление числа новых
связей к прежнему `known_users` не заменяет пересчет разных пользователей.
Вставьте результаты с версией H. Полностью сверьте актуальную учебную витрину
со стендовой, включая дни, которые не пересчитывали. Только после совпадения
добавьте H в журнал.
Без продвижения мира снова определите P по журналу и повторите выбор дат.
Запрос должен вернуть пустой результат, а витрина — остаться прежней.
Повторно записывать H в журнал не требуется.
## Повтор незавершенной загрузки
Снова запустите `world_next_day` с `days = 1`, затем `etl_pipeline`,
дожидаясь успеха каждого. Сохраните новый список связей с его датой H.
Получите даты для пересчета, но запишите результат только за одну из
них. Остальные пока не загружайте и дату в журнал не добавляйте.
Так вы воспроизведете остановку между записями дней, не ломая стенд.
Снова определите P по журналу, выполните запрос выбора дат и пересчет
этих дней. Проверьте, что результат совпал со стендовой витриной без очистки
уже записанных строк. После сверки запишите H в журнал.
Объясните на этом опыте, почему сохранение нового списка связей еще
не означает, что витрина обновлена. Что изменится, если записать H в журнал
до обновления витрины?
## Результат работы
Сохраните SQL первой загрузки, выбора дат, обновления и сверки.
После двух новых модельных дней ваша витрина совпадает со стендовой,
повтор без новых данных сохраняет результат, а незавершенную загрузку
можно продолжить без полной пересборки.
Сравните число пересчитанных дней с полной загрузкой и перечислите,
какие дополнительные данные пришлось хранить. Так можно оценить объем работы.
На небольшом стенде полный пересчет может быть дешевле.
В вашей реализации поиск связей пока читает всю историю покупок.
По новым данным обновляется только дневная витрина; такое обновление
называют инкрементальным.
После работы удалите свои `lab_daily_traffic`, `lab_traffic_runs` и
`lab_identity_history` через `DROP TABLE`, если они больше не нужны.
+105
View File
@@ -0,0 +1,105 @@
# Куда пропали покупки
Вы сравните покупки из событий сайта с заказами магазина. Найденные
расхождения помогут выбрать источник для полной витрины заказов.
Факультатив доступен после ступени 1. В DBeaver подключитесь пользователем
`analyst` по [инструкции](../../../README.md#подключиться-из-dbeaver).
Дождитесь завершения `etl_pipeline`; на время расследования оставьте
`world_live` на паузе и дождитесь уже начатых прогонов.
## Выбор дня
Можно ли построить полную витрину заказов по событиям `purchase`?
Проверьте это на одном дне: сначала сопоставьте события сайта, затем сверьте
номера заказов с выгрузкой магазина.
Слепок заказов содержит их состояние на конец модельного дня. Состояние
заказа может меняться семь дней, поэтому для окончательного сравнения нужны
все слепки за этот период. Возьмите день, для которого уже получен последний
из них. Такой день называется закрытым. Запрос показывает самую позднюю
подходящую дату:
```sql
SELECT max(snapshot_date) - 6 AS last_closed_day
FROM ods.order_v;
```
Используйте выбранный день во всех запросах. События отбирайте по
`dds.event_v.event_date`, заказы — по `dds.order_v.order_date`.
Сравнивайте все заказы дня, включая отмененные: сейчас вопрос в том,
зарегистрировал ли магазин заказ, а не в том, был ли он оплачен.
## События оформления заказа
При оформлении заказа посетитель видит страницу подтверждения, а трекер
отправляет событие покупки. Просмотр страницы и покупка — отдельные события.
Посчитайте просмотры страницы подтверждения и события покупки:
- у просмотра `event_type = 'pageview'`, а `url` заканчивается на `/confirmation`;
- у покупки `event_type = 'purchase'`.
Вычтите число событий покупки из числа просмотров подтверждения. Сохраните
оба счетчика и разность. Как соотносились бы эти числа, если бы для каждого
заказа пришло ровно по одному событию каждого вида? Что можно предположить
по вашему результату и чего эти два числа еще не доказывают?
## Сверка с заказами магазина
В событии номер заказа находится в `purchase_id[1]`, в заказах — в `order_id`.
Соберите список заказов выбранного дня, для которых нет события покупки.
Несколько событий с одним номером заказа должны оставаться одной покупкой.
Сохраните запрос сверки: на каждый такой заказ он должен возвращать дату,
номер и статус. Дату задавайте в одном месте, чтобы запрос можно было
повторить для другого дня.
Проверьте два равенства:
- число заказов магазина равно числу разных заказов из событий плюс число
заказов без события;
- разность просмотров подтверждения и событий покупки равна числу заказов
без события.
Первое равенство проверяет полноту сопоставления источников. Второе связывает
разность счетчиков с найденными заказами без события. Проверьте, как на него
влияют повторные покупки.
Найдите номер заказа с несколькими событиями `purchase`. Для его `client_id`
выведите события рядом с этими покупками по времени: `event_time`,
`event_type` и `url`. Найдите просмотры `/confirmation` перед первой
и повторной покупкой. Посчитайте, сколько повтор добавил просмотров
подтверждения и сколько — покупок. Изменилась ли от этих добавлений разность
счетчиков? Подтвердите ответ найденными строками. Второе равенство относится
к устройству этого стенда; для другого источника его нужно проверять заново.
Если в выбранном дне повторов нет, возьмите другой закрытый день.
## Сравнение с готовой витриной
Найдите свои заказы без события в `dm.purchase_vs_orders_v` по `order_id`.
Сравните их `status` и `mismatch_class`. Витрина присваивает заказу один класс,
хотя причин расхождения может быть несколько. По
[правилу классификации](../../architecture/dm.md#dmpurchase_vs_orders_v)
объясните, почему потеря события не всегда попадает в `lost_event`.
Возьмите один заказ из своего списка и сохраните два запроса: первый находит
его в магазине, второй показывает отсутствие покупки в трекере.
## Выбор источника для витрины
По результатам сверки укажите источник для витрины, в которой должен быть
каждый зарегистрированный заказ. Обоснуйте выбор найденными номерами.
Рядом с запросом запишите, когда его результат можно считать окончательным.
Что изменится, если запустить его до получения всех слепков выбранного дня?
Позволяет ли отсутствие события в `dds.event_v` установить место потери —
до приема в хранилище или при обработке? Какие данные нужны, чтобы это выяснить?
## Результат работы
- [ ] сохранен запрос сверки по дате, возвращающий заказы без события;
- [ ] оба равенства сходятся, влияние повторного просмотра объяснено;
- [ ] один случай подтвержден запросами к обоим источникам;
- [ ] источник витрины выбран по результатам сверки;
- [ ] указаны условие окончательной проверки и границы ее выводов.
@@ -0,0 +1,85 @@
# Какая позиция объясняет разницу в сумме
Вы сравните товары в событии покупки и заказе магазина, чтобы найти причину
разницы в суммах. При разборе вложенных данных понадобится сохранить точность
цен до копейки.
Факультатив доступен после ступени 1. В DBeaver используйте пользователя
`analyst` из [инструкции подключения](../../../README.md#подключиться-из-dbeaver).
Дождитесь завершения `etl_pipeline`; оставьте `world_live` на паузе и
дождитесь уже начатых прогонов.
## Выбор заказа
Суммы покупки в трекере и заказа в магазине различаются. Разберите их позиции
и проверьте отдельно точность своего запроса и различия между событием и заказом.
В `dm.purchase_vs_orders_v` выберите один заказ с
`mismatch_class = 'amount_delta'`. Возьмите закрытый день: `order_day`
не позднее `max(snapshot_date) - 6` из `ods.order_v`. Сохраните `order_id`,
`order_day`, `declared_revenue` и `items_total`.
Найдите этот заказ в `dds.order_v`. Сопоставьте `items_total`, `discount`,
`delivery` и `total`. Запишите равенство, связывающее эти четыре значения,
и проверьте его на заказе. С какой суммой магазина следует сравнивать
`declared_revenue`, если мы ищем расхождение именно в составе корзины?
Проверьте свой выбор после разбора позиций.
## Товарные позиции события и заказа
Найдите событие покупки в `ods.event_v`: `purchaseID[1]` совпадает с номером
заказа, `EventDate` — с выбранным днем, `EventType = 'purchase'`.
Откройте поле `ecommerce`. Внутри JSON массив `purchase.products` содержит
товары из события покупки с полями `id`, `price` и `quantity`.
Разверните этот массив в строки и получите артикул, точную цену, количество
и стоимость каждой позиции. Для разбора пригодятся `JSONExtractArrayRaw`
и `JSONExtract`, для разворачивания массива — `ARRAY JOIN`.
Деньги извлекайте как `Decimal` с двумя знаками после запятой.
С другой стороны используйте массивы `item_sku`, `item_price` и
`item_quantity` заказа в `dds.order_v`. Элементы с одним индексом относятся
к одной позиции; при разворачивании сохраняйте эту связь.
Сопоставьте позиции по артикулу так, чтобы остались видны товары,
присутствующие только с одной стороны. Для каждой позиции сравните наличие,
цену и количество. Что различается и сколько это добавляет к расхождению
сумм?
Сохраните запрос сверки для выбранных даты и номера заказа; в результате
оставьте артикул, значения с обеих сторон и разность стоимостей позиций.
## Проверка сумм
Подтвердите три равенства:
- сумма стоимостей клиентских позиций равна `declared_revenue`;
- сумма стоимостей позиций магазина равна `items_total`;
- сумма расхождений по позициям равна
`declared_revenue - items_total`.
Затем сравните точные цены из JSON с готовым массивом `productPrice`
события. Пересчитайте стоимость клиентской корзины по этому массиву:
сойдется ли она с `declared_revenue` до копейки? Найдите цены, на которых
результаты различаются, и сверьте наблюдение с
[форматом выгрузки](../../formats/clickstream-event.md). Если все цены
выбранной корзины целые, возьмите еще один заказ с расхождением и дробной ценой.
Какие строки подтверждают найденное расхождение? Есть ли в источниках
сведения о том, почему оно возникло? Отделите наблюдения от предположений.
## Поля для точного расчета
Укажите рядом с запросом, откуда брать цену, каким типом ее читать и по какому
ключу сопоставлять позиции. Подтвердите выбор результатами обеих сверок:
суммы разобранных позиций с суммой своего источника и позиций двух источников
между собой. Какой результат укажет на ошибку вашего разбора, а какой
потребует искать причину выше — в данных, которые получил запрос?
## Результат работы
- [ ] сохранен запрос сверки позиций, не теряющий товары без пары;
- [ ] все три равенства сходятся до копейки;
- [ ] найден пример потери точности в готовом массиве цен;
- [ ] выбор полей, типа денег и ключа сопоставления подтвержден расчетами;
- [ ] расхождение источников сверки отделено от ошибки своего разбора и различия
между стоимостью корзины и итогом со скидкой и доставкой.
+191
View File
@@ -0,0 +1,191 @@
# Почему вчерашняя выручка меняется сегодня
Вы проследите, как опоздавшие заказы и смена статусов меняют выручку
за прошедший день. По этим наблюдениям определите, какие дни пересчитывать
и какую историю заказов сохранять.
Факультатив доступен после ступени 1. Понадобятся Airflow и DBeaver с
подключением `student` по [инструкции](../../../README.md#подключиться-из-dbeaver).
Пользователь `student` может читать данные стенда и записывать ваши наблюдения
в базу `default`.
Вы будете наблюдать один день оформления заказов, продвигая мир по одному
модельному дню. Проверьте, можно ли при загрузке выручки каждый раз считать
только новый день. Новые данные останутся в стенде.
## Подготовка
Поставьте `world_live` на паузу. Дождитесь завершения начатых прогонов
генерации и обработки, затем запустите `etl_pipeline` и дождитесь успеха.
Так исходное наблюдение будет снято после обновления витрин.
Узнайте последний день событий и дату последнего полученного слепка:
```sql
SELECT
(SELECT max(event_date) FROM dds.event_v) AS event_day,
(SELECT max(snapshot_date) FROM ods.order_v) AS snapshot_day;
```
День `event_day` обозначим **D**. Наблюдайте именно его до конца работы.
Дата слепка, по которому сделано очередное наблюдение, будет **S**.
Это модельные даты магазина. Календарная дата запуска Airflow день D не задает.
Слепок содержит состояние заказов на конец модельных суток и доставляется
при следующем запуске генератора. Поэтому покупки последнего завершившегося
дня уже видны в событиях, а их заказов в
хранилище еще нет. Проверьте это для D в `dds.order_v` и
`dm.purchase_vs_orders_v`.
## Таблицы для истории заказов
Представление `dds.order_v` показывает текущее состояние заказов, а сырые слепки
хранятся ограниченное время. Сохраните историю в двух своих таблицах: потом
вы сможете сравнивать наблюдения обычными SQL-запросами в том же DBeaver.
Выполните DDL ниже. Это локальные таблицы на узле вашего подключения;
всю работу выполняйте через одно подключение. Если имена уже заняты вашей
прошлой работой, выберите другие и используйте их в дальнейших запросах.
```sql
CREATE TABLE default.lab_order_history
(
order_date Date,
snapshot_date Date,
order_id String,
status String,
items_total Decimal(18, 2)
)
ENGINE = MergeTree
ORDER BY (order_date, snapshot_date, order_id);
CREATE TABLE default.lab_revenue_history
(
order_date Date,
snapshot_date Date,
revenue Decimal(18, 2),
awaiting_order_ids Array(String)
)
ENGINE = MergeTree
ORDER BY (order_date, snapshot_date);
```
## Первое наблюдение
Напишите два `INSERT INTO ... SELECT` с явным списком колонок назначения:
| Куда записать | Что взять из стенда |
|---|---|
| `default.lab_order_history` | По строке на заказ дня D из `dds.order_v`: `order_date`, `order_id`, `status`, `items_total`; добавьте дату наблюдения S |
| `default.lab_revenue_history` | Одну строку с D, S, суммой `revenue` за `report_date = D` из `dm.revenue_daily_v` и массивом номеров `awaiting_order` за `order_day = D` из `dm.purchase_vs_orders_v` |
Во втором запросе сумму и массив получите отдельными агрегатами, чтобы
заказы не умножили строки выручки при соединении. Для массива пригодится
`groupArray(order_id)`. Дату S берите из `max(snapshot_date)` в `ods.order_v`.
Сначала выполните части `SELECT` отдельно и проверьте результаты, затем
сохраните их. До первого слепка заказов дня D еще нет: в таблицу заказов
строки не добавятся, но в таблице итогов должна появиться строка с нулевой
выручкой. Она зафиксирует, что наблюдение сделано.
Каждую пару D и S сохраняйте один раз: повторный `INSERT` добавит копии строк.
После записи проверьте, что на эту пару приходится одна строка итогов,
а число заказов и число разных `order_id` совпадают с `dds.order_v` за D.
Выручка этой витрины — стоимость позиций оплаченных заказов без скидки и
доставки. Для каждого сохраненного наблюдения посчитайте число заказов по
статусам и сумму `items_total` только у `paid`. Эта сумма должна сходиться
с сохраненной выручкой, сложенной по всем категориям.
## Наблюдения за следующие дни
Для каждого следующего наблюдения:
1. Запустите `world_next_day` с `days = 1` и дождитесь успеха.
2. Запустите `etl_pipeline` и дождитесь успеха: `world_next_day` сам витрины
не обновляет.
3. Повторите два запроса сохранения с новой датой S и проверьте записанные строки.
Во всех запросах остается прежний день D. За один запуск добавляйте один
день, чтобы сохранить промежуточные состояния. Пакетный `world_next_day`
отправляет события без пауз; ждать 24 минуты живого дня здесь не нужно.
Продолжайте до слепка **D + 7** включительно. Заказы дня D могут меняться
семь дней, от D до D + 6. Этот период называется окном изменяемости.
Последний шаг нужен для сравнения состояния в конце окна и после него.
От исходного наблюдения потребуется восемь однодневных запусков.
## Изменения заказов и выручки
Для сравнения прочитайте `default.lab_order_history` дважды: в одном
подзапросе отберите прежнюю S, в другом — новую; день D остается общим.
Соедините состояния по `order_id`, сохранив и заказы без пары. Итоги для этих
же D и S возьмите из `default.lab_revenue_history`. Найдите:
- заказы, отсутствовавшие в первом слепке D, но появившиеся в следующем;
- переходы из `created` в `paid`;
- отмены заказов, для которых раньше был виден статус `paid`.
Первый слепок, полученный на следующий день, — обычная доставка заказов.
Опоздавшими здесь считаются те, которых не было уже в этом слепке.
Покажите на нескольких номерах, как они исчезают из `awaiting_order`.
Выберите два соседних наблюдения, между которыми хотя бы один заказ перешел
из `paid` в `cancelled`. Разложите изменение выручки по заказам.
Составьте таблицу: номер заказа, прежний и новый статусы, вклад в выручку
до и после изменения. Вклад определите по правилу расчета витрины, указанному
выше; учтите и заказы, которых еще не было в первом наблюдении. Сумма
изменений вкладов должна совпасть с изменением `revenue` до копейки.
Покажите на этих наблюдениях, какой результат остался бы в витрине,
если бы конвейер после первого расчета обрабатывал только новые дни заказов.
Достаточно ли добавлять в старый день только опоздавшие заказы? Проверьте
свой ответ на найденных изменениях статусов.
## Требования к истории заказов
Возьмите отмененный заказ, у которого в вашей истории был статус `paid`.
Укажите два наблюдения, доказывающих, что он был оплачен, а затем отменен.
Теперь возьмите отмененный заказ, для которого ни в одном вашем наблюдении
нет `paid`. Проверьте утверждение «этот заказ никогда не был оплачен»:
что известно о его состоянии между двумя слепками? Достаточно ли сохраненных
наблюдений для такого вывода?
Сформулируйте требования к данным для двух задач: восстановить состояние
витрины на момент прошлого расчета и узнать, был ли заказ когда-либо оплачен.
Какие сведения для каждой задачи должен передавать источник и сохранять
хранилище? Проверьте, каких нужных сведений нет в ваших таблицах наблюдений.
## Правило пересчета витрины
Сравните сохраненные состояния при S = D + 6 и S = D + 7: состав заказов,
их статусы, суммы и выручку. Какой слепок последним мог изменить заказы дня D
и каким прогоном он доставлен? Укажите наблюдение, после которого отчет
можно считать окончательным. Было бы достаточно совпадения двух соседних
сумм, если бы окно еще не закончилось?
Запишите правило выбора дней для пересчета после очередного слепка.
Считайте, что начальная история уже загружена и ежедневные прогоны не
пропускались. Укажите, по какой дате определяются границы, какие дни нужно
пересчитать и когда день можно перестать включать в расчет. Подтвердите
правило своими наблюдениями, включая опоздание и отмену.
После собственных выводов сверьтесь с описанием
[слепка и его доставки](../../architecture/orders/snapshot.md#слепок-и-его-доставка)
и [границ истории отмен](../../architecture/orders/fate.md#судьба-заказа).
## Результат работы
- [ ] сохранена последовательность наблюдений одного дня D с датами слепков S;
- [ ] на опоздавших заказах и изменениях статусов показаны ошибки загрузки
только новых дней или только новых заказов;
- [ ] изменение выручки между наблюдениями сходится с изменением вкладов заказов;
- [ ] для одного отмененного заказа найдены два наблюдения: с оплатой и с отменой;
- [ ] сформулированы требования к истории расчетов и истории оплаты;
- [ ] правило пересчета и момент закрытия дня обоснованы наблюдениями
и правилом доставки слепков.
После работы оставьте `world_live` на паузе либо верните автоматическое
продвижение, если пользовались им до наблюдения. Таблицы истории останутся
для повторной проверки выводов. Когда они больше не нужны, удалите только
свои `default.lab_order_history` и `default.lab_revenue_history`.
+13 -14
View File
@@ -1,12 +1,13 @@
# Свой дашборд в Superset # Свой дашборд в Superset
**Чему учит:** собрать из своих витрин отчет о поведении посетителей, Из своих витрин вы соберете отчет о поведении посетителей: подберете графики
подобрать графики под вопросы и разместить их на одном понятном экране. и разместите их на одном экране так, чтобы отчет можно было понять
без ваших пояснений.
Это факультатив раздела «Superset и BI». Его можно пройти после ступени 2, Это задание раздела «Отчеты в Superset». Его можно пройти после ступени 2,
когда готовы витрины ступени 1. когда готовы витрины ступени 1.
## На какие вопросы ответит дашборд ## Отчет о поведении посетителей
Соберите обзор трафика и поведения посетителей. Он должен отвечать на три Соберите обзор трафика и поведения посетителей. Он должен отвечать на три
вопроса: что происходило в магазине, какие страницы, устройства и рекламные вопроса: что происходило в магазине, какие страницы, устройства и рекламные
@@ -17,25 +18,23 @@
стендового дашборда не нужно: выберите форму, в которой ответы читаются без стендового дашборда не нужно: выберите форму, в которой ответы читаются без
пояснений рядом с экраном. пояснений рядом с экраном.
## Свои объекты — отдельно ## Отдельный дашборд для задания
Не добавляйте графики в стендовый дашборд и не правьте его датасеты. При Не добавляйте графики в стендовый дашборд и не правьте его датасеты. При
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
таких объектов описан в [доке Superset](../../architecture/superset.md), «Импорт таких объектов описан в [справочнике Superset](../../architecture/superset.md), «Импорт
при каждой инициализации». при каждой инициализации».
Используйте свои датасеты и графики, соберите из них свой дашборд. Используйте свои датасеты и графики, соберите из них свой дашборд.
Назовите его, например, «Мои витрины DM». Это то же правило, по которому DDL Назовите его, например, «Мои витрины DM».
домашек лежит в `sql/ddl/70-labs.sql`: стендовые объекты уже есть, свои
создаете вы.
## Датасеты ## Датасеты
Датасет `session_overview_v`, его метрика доли отказов и график уже готовы Датасет `session_overview_v`, его метрика доли отказов и график уже готовы
после [ступени 2](../2-1-superset-chart.md). Используйте их здесь. Добавьте после [ступени 2](../2-1-superset-chart.md). Используйте их здесь. Добавьте
недостающие датасеты, чтобы в подключении `ClickHouse` были представлены недостающие датасеты, чтобы в подключении `ClickHouse` были представлены
пять публичных представлений схемы `dm`: пять представлений ваших витрин из схемы `dm`:
- `events_enriched_v`; - `events_enriched_v`;
- `top_pages_daily_v`; - `top_pages_daily_v`;
@@ -56,7 +55,7 @@
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу | | `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам | | `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
Для долей используйте слагаемые из витрин. Например, доля отказов считается Для долей используйте суммы счетчиков из витрин. Например, доля отказов считается
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
долям для такого графика не подходит. долям для такого графика не подходит.
@@ -73,7 +72,7 @@
по модельному времени. Для панели ошибок выберите период загрузки отдельно по модельному времени. Для панели ошибок выберите период загрузки отдельно
и укажите его в подписи. Общий фильтр времени для всего дашборда не требуется. и укажите его в подписи. Общий фильтр времени для всего дашборда не требуется.
## Как проверить себя ## Проверка результата
Автоматической проверки этого задания нет. Числа вы сверите запросами, Автоматической проверки этого задания нет. Числа вы сверите запросами,
а понятность графиков проверите по подписям. а понятность графиков проверите по подписям.
@@ -103,9 +102,9 @@
Проверьте, можно ли найти эти ответы по одним подписям графиков. Если есть Проверьте, можно ли найти эти ответы по одним подписям графиков. Если есть
возможность, попросите другого человека прочитать экран без ваших пояснений. возможность, попросите другого человека прочитать экран без ваших пояснений.
## Что должно получиться ## Результат работы
- [ ] у вас есть пять датасетов поверх публичных представлений витрин заданий 1–5; - [ ] у вас есть пять датасетов по представлениям витрин заданий 1–5;
- [ ] вы собрали свой дашборд, сохранив стендовые объекты без изменений; - [ ] вы собрали свой дашборд, сохранив стендовые объекты без изменений;
- [ ] на дашборде есть хотя бы один график от каждой витрины; - [ ] на дашборде есть хотя бы один график от каждой витрины;
- [ ] вы проверили, что графики и подписи понятны по вопросам из раздела выше; - [ ] вы проверили, что графики и подписи понятны по вопросам из раздела выше;
+13 -13
View File
@@ -1,16 +1,16 @@
# В DBeaver работает, в Superset нет # В DBeaver работает, в Superset нет
**Чему учит:** найти причину, по которой один клиент читает объект Вы создадите представление, которое читается из DBeaver, но недоступно
хранилища, а другой его не видит. в Superset. Затем найдете причину и исправите ее.
Факультатив доступен после [ступени 2](../2-1-superset-chart.md). Факультатив доступен после [ступени 2](../2-1-superset-chart.md).
Свой дашборд для опыта не нужен. Используйте готовую витрину стенда Свой дашборд для опыта не нужен. Используйте готовую витрину стенда
`dm.daily_traffic_v`. `dm.daily_traffic_v`.
## Подготовьте опыт ## Подготовка
В DBeaver подключитесь к первой ноде пользователем `etl`, как описано В DBeaver подключитесь к первому узлу пользователем `etl`, как описано
в разделе [«Как применить свой DDL»](../README.md#как-применить-свой-ddl). в разделе [«Выполнение SQL в DBeaver»](../README.md#выполнение-sql-в-dbeaver).
Для опыта создайте отдельное представление: Для опыта создайте отдельное представление:
```sql ```sql
@@ -28,19 +28,19 @@ SELECT count() FROM dm.bi_node_probe_v;
Повторите запрос в SQL Lab Superset, выбрав подключение `ClickHouse`. Повторите запрос в SQL Lab Superset, выбрав подключение `ClickHouse`.
Сохраните текст ошибки. Сохраните текст ошибки.
## Найдите причину ## Поиск причины
Выясните, к каким серверам обращаются DBeaver и Superset. Адреса подключения Выясните, к каким серверам обращаются DBeaver и Superset. Адреса подключения
показывают настройки клиентов, а имя ответившего сервера можно спросить показывают настройки клиентов, а имя ответившего сервера можно спросить
запросом `SELECT hostName()`. запросом `SELECT hostName()`.
Проверьте наличие `dm.bi_node_probe_v` на обеих нодах. Для второй ноды Проверьте наличие `dm.bi_node_probe_v` на обоих узлах. Для второго узла
заведите подключение DBeaver с портом из заведите подключение DBeaver с портом из
[README](../../../README.md#подключиться-из-dbeaver). Запишите, где объект [README](../../../README.md#подключиться-из-dbeaver). Запишите, где объект
есть, и объясните, почему успешного создания в первом подключении оказалось есть, и объясните, почему успешного создания в первом подключении оказалось
недостаточно. недостаточно.
Исправьте DDL так, чтобы представление появилось на обеих нодах. Подключение Исправьте DDL так, чтобы представление появилось на обоих узлах. Подключение
Superset должно остаться прежним. Сохраните исправленный DDL и повторите Superset должно остаться прежним. Сохраните исправленный DDL и повторите
исходный запрос в обоих клиентах. исходный запрос в обоих клиентах.
@@ -48,23 +48,23 @@ Superset должно остаться прежним. Сохраните исп
[справочнике Superset](../../architecture/superset.md#устройство-зоны) и [справочнике Superset](../../architecture/superset.md#устройство-зоны) и
[описание распределенного DDL ClickHouse](https://clickhouse.com/docs/reference/statements/distributed-ddl). [описание распределенного DDL ClickHouse](https://clickhouse.com/docs/reference/statements/distributed-ddl).
## Как проверить себя ## Проверка результата
В обоих клиентах запрос к вашему представлению должен вернуть столько же В обоих клиентах запрос к вашему представлению должен вернуть столько же
строк, сколько `SELECT count() FROM dm.daily_traffic_v`. Сравнивайте результаты строк, сколько `SELECT count() FROM dm.daily_traffic_v`. Сравнивайте результаты
при завершенном прогоне конвейера, пока мир не продвигается. при завершенном прогоне конвейера, пока мир не продвигается.
Объясните причину ошибки через три факта: куда пришел DDL, куда пришел Объясните причину ошибки через три факта: куда пришел DDL, куда пришел
запрос Superset и на каких нодах существовало представление. запрос Superset и на каких узлах существовало представление.
После опыта удалите только `dm.bi_node_probe_v` на обеих нодах. Не включайте После опыта удалите только `dm.bi_node_probe_v` на обоих узлах. Не включайте
его в постоянный DDL домашних витрин: это временный объект расследования. его в постоянный DDL домашних витрин: это временный объект расследования.
## Что должно получиться ## Результат работы
- [ ] у вас сохранены успешный запрос из DBeaver и ошибка из SQL Lab; - [ ] у вас сохранены успешный запрос из DBeaver и ошибка из SQL Lab;
- [ ] вы можете объяснить, к каким серверам подключены клиенты, где было - [ ] вы можете объяснить, к каким серверам подключены клиенты, где было
создано представление и почему Superset его не видел; создано представление и почему Superset его не видел;
- [ ] у вас есть исправленный DDL; оба клиента возвращают столько же строк, - [ ] у вас есть исправленный DDL; оба клиента возвращают столько же строк,
сколько исходная витрина; сколько исходная витрина;
- [ ] после опыта вы удалили учебное представление с обеих нод. - [ ] после опыта вы удалили учебное представление с обоих узлов.
+96 -26
View File
@@ -45,12 +45,16 @@
Пишите для учащегося: объясняйте, чему он научится, зачем нужны действия Пишите для учащегося: объясняйте, чему он научится, зачем нужны действия
и как понять результат. Историю разработки оставляйте за пределами задания. и как понять результат. Историю разработки оставляйте за пределами задания.
«Как проверить себя» описывает действия для проверки и поиска ошибок. Начинайте задание коротким описанием предстоящей работы. Заголовки должны
«Что должно получиться» — результат работы и то, что учащийся теперь может называть содержание разделов: «Подготовка», «Создание витрины», «Проверка
результата». Шаблонные вопросы «Чему учит» и «Что сделать» не нужны.
Раздел проверки описывает действия для проверки и поиска ошибок.
Раздел результата перечисляет итог работы и то, что учащийся теперь может
объяснить. Если проверка уже встроена в шаги упражнения, повторять ее объяснить. Если проверка уже встроена в шаги упражнения, повторять ее
отдельным разделом не нужно. отдельным разделом не нужно.
Остальные заголовки и структуру выбирайте по содержанию. Образцы — задания Структуру выбирайте по содержанию. Образцы — задания
про [дашборд Superset](../labs/optional/superset-dashboard.md) и про [дашборд Superset](../labs/optional/superset-dashboard.md) и
[расследование подключения](../labs/optional/superset-missing-object.md). [расследование подключения](../labs/optional/superset-missing-object.md).
@@ -149,38 +153,85 @@
## 4. Факультативы — пять разделов ## 4. Факультативы — пять разделов
Ниже перечислены только названия сюжетов и их уроки. Материал, формы и способы Ниже перечислены сюжеты, их уроки и принятые решения по форме заданий. Исходный
проверки остаются в [инвентаризации](../research/2026-08-31-labs-inventory.md). материал и способы проверки — в
[инвентаризации](../research/2026-08-31-labs-inventory.md).
### Почему выручка не сходится ### Почему выручка не сходится
Этот раздел рекомендован первым из факультативов. Он открывается после ступени Этот раздел рекомендован первым из факультативов и открывается после ступени
1 основной программы: материал живет на стенде с этапа 6. 1 основной программы. Шесть сюжетов собраны в четыре лабораторные: три
расследования готовых данных и одно наблюдение во времени. Опоздания,
изменение выручки и история отмен используют одну историю заказов, поэтому
для них выбрана общая работа вместо трех отдельных.
- **Почему трекер недосчитывает покупки (#186).** Разрыв воронки подтверждений Результат расследования — запрос сверки и инженерное решение, подтвержденное
показывает потерянные события и границу доверия к двум источникам. данными: выбор источника, ключа, полей для точного расчета или правила
- **Почему повтор покупки завышает выручку (#187).** Движок убирает технический пересчета и хранения истории. Объяснение суммы служит проверкой решения.
дубль строки, а повтор одной покупки приходится находить по деловому ключу. Вариант закончить работы только объяснением показателей отклонен: он
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно смещал акцент в анализ отчетов. Постройка новых DAG и подключение проверок
разложить до конкретной вычеркнутой позиции и объяснить до копейки. к конвейеру оставлены основной программе, чтобы факультативы не повторяли
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а ее задания.
некоторые различия после истечения срока хранения сырых данных уже не
восстановить. - **[Куда пропали покупки](../labs/optional/missing-purchases.md) (#186).**
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день Сверка по номерам заказов обосновывает источник витрины и условие
становится окончательным лишь после того, как источник больше не может окончательной проверки полноты. Счетчики событий помогают обнаружить
изменить этот день. расхождение, но не устанавливают место потери.
- **Почему выручка за день еще меняется.** Внутри окна изменяемости выручка - **[Почему повтор покупки завышает выручку](../labs/optional/duplicate-purchases.md) (#187).**
движется, а после закрытия дня фиксируется. Учащийся собирает расчет с одной строкой на заказ, проверяет ключ и полноту
набора номеров, отличает его зерно от зерна исходных событий.
- **[Какая позиция объясняет разницу в сумме](../labs/optional/order-amount-difference.md).**
Сверка позиций обосновывает выбор исходных полей и типа денег, отделяет
ошибку собственного разбора от различия данных события и заказа.
- **[Почему вчерашняя выручка меняется сегодня](../labs/optional/revenue-over-time.md) (#188).**
Наблюдение опозданий и смены статусов обосновывает правило пересчета при
ежедневной загрузке без пропусков. Учащийся определяет момент закрытия дня
и требования к истории для воспроизведения расчета и проверки факта оплаты.
В четвертой лабораторной выбрано собственное наблюдение: учащийся продвигает
мир по одному дню через Airflow и сохраняет результаты после обновления витрин
в две локальные таблицы ClickHouse под `student`: состояния заказов и итоги
наблюдений. Сравнение выполняется SQL-запросами в том же подключении DBeaver.
Учащийся может писать только в `default`; источники доступны ему на чтение.
Отдельная строка итогов фиксирует и наблюдение без заказов. Экспорт в CSV
отклонен как основной способ: он требовал неописанного перехода к другому
инструменту или обратному импорту файлов.
Готовые слепки и сочетание готового примера с собственным наблюдением
отклонены: нужную историю можно получить на стенде и самому увидеть,
как новые данные меняют уже рассчитанный отчет.
День считается закрытым после получения и обработки последнего слепка
семидневного окна изменяемости; этот слепок доставляется следующим модельным
днем. Одного совпадения сумм в соседних наблюдениях для такого вывода
недостаточно. История отмен тоже имеет границу: оплата и отмена между двумя
дневными слепками могут остаться невидимыми. Отсутствие наблюдаемой оплаты
не доказывает, что оплаты не было.
### Тонкий анализ данных ### Тонкий анализ данных
Открывается после ступени 1 основной программы. Открывается после ступени 1 основной программы.
- **Что мы знали о пользователе в тот день (#145).** Текущее знание и знание на - **[Как новые заказы меняют отчет о посетителях](../labs/optional/identity-over-time.md) (#145).**
дату дают разные отчеты; сведения из будущего меняют прошлое. Учащийся собирает связи с подтверждающими покупками и сохраняет три
ежедневных наблюдения. На одном наборе прежних сессий сравнивает расчет
с прежними и новыми связями, выбирает историю для воспроизведения расчета.
- **Добавь названия регионов в отчет (#143).** Менти собирает свой словарь по - **Добавь названия регионов в отчет (#143).** Менти собирает свой словарь по
образцу каталога и обогащает отчет через `dictGet`. образцу каталога и обогащает отчет через `dictGet`.
Работа о связях продолжается в инженерном разделе лабораторной про
инкрементальный `daily_traffic`. В обеих учащийся продвигает мир через
`world_next_day` и `etl_pipeline`, сохраняет наблюдения под `student` в
свои локальные таблицы `default` и сравнивает их SQL-запросами.
Время первой подтверждающей покупки и время обнаружения связи разделены.
Минимум времени `purchase` не принят за границу появления новых связей:
заказ может поступить позже события. Нужные изменения определяются по
разности сохраненных наборов пар. Ежедневное наблюдение ограничивает момент
появления связи интервалом; для пар начального наблюдения более ранняя
история неизвестна. История сессий в этом опыте растет без исправлений
прошлых дней, а связи не отзываются — поэтому сохраненных наборов связей
достаточно для сравнения расчетов на прежних сессиях.
### Superset и BI ### Superset и BI
Открывается после ступени 2 основной программы. Открывается после ступени 2 основной программы.
@@ -236,9 +287,28 @@
- **Что будет, если день заменился не на всех шардах.** Замена дня завершается - **Что будет, если день заменился не на всех шардах.** Замена дня завершается
отдельно на каждом шарде, поэтому целостность результата подтверждает отдельно на каждом шарде, поэтому целостность результата подтверждает
отдельная сверка. отдельная сверка.
- **Как обновлять витрину без полной пересборки (#149) — повышенная - **[Как обновлять витрину без полной пересборки](../labs/optional/incremental-daily-traffic.md)
сложность.** Экономия на пересчете требует хранить историю изменений, которую (#149) — повышенная сложность.** Продолжение работы о связях пользователей:
полная пересборка стирает. учащийся строит свою витрину, выбирает затронутые дни по новым сессиям
и связям, сверяет весь результат с `dm.daily_traffic_v`. Журнал успешных
загрузок позволяет повторить опыт с частично записанным результатом.
Инкрементальная работа начинается с полной загрузки текущего состояния,
затем охватывает два новых модельных дня. Обновление собственной витрины
выполнено вставкой версий в `ReplacingMergeTree`, чтение — с `FINAL`.
Это оставляет права `student` прежними и не требует менять конвейер стенда.
Версия — дата наблюдения; ключ — день отчета. История связей хранится
отдельно от версий витрины. Выбор опирается на
[договор движка](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replacingmergetree):
максимальная версия по ключу и удаление прежних версий при слияниях.
DDL, обновление вставкой и чтение с `FINAL` проверены под `student`
на ClickHouse 26.3.17.56.
Полный пересчет остается эталоном, а экономия оценивается числом выбранных
дней. На малом объеме ускорение не обещано. Поиск связей продолжает читать
историю источников целиком; инкрементальным становится расчет дневной витрины.
Полная замена стендового ETL отклонена: для учебного сравнения достаточно
своей витрины и журнала рядом с работающим конвейером.
### Что делать, когда стенд сломался ### Что делать, когда стенд сломался
+78 -81
View File
@@ -1,98 +1,95 @@
# Генератор кликстрима # Генератор событий и заказов
Клиентская сторона стенда: отсюда берётся поток событий — широкое событие по Генератор создает данные двух источников интернет-магазина. Счетчик сайта
образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения — отправляет в топик Kafka `hits` события: просмотр страницы, добавление товара
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md). в корзину и покупку. Серверная часть магазина выгружает в `orders` дневной
слепок — состояние заказов, оформленных за последние семь модельных дней.
Оба источника работают; хранилище принимает события непрерывно, заказы —
по команде Airflow.
События уже есть и уже уезжают: день-функция отдаёт по паре (зерно, D) Событие описывает отдельное действие посетителя. Заказ после оформления
упорядоченный поток трёх видов — просмотр страницы, корзина, покупка, — может измениться, например перейти в статус `paid` или `cancelled`.
канонический сериализатор превращает его в JSON, а проигрыватель гонит в файл Поэтому один `order_id` встречается в нескольких слепках. Часть заказов
или в Kafka. Клиентская сторона на этом целая. Второй источник строится: впервые появляется с задержкой в один или два дня; конец семидневного окна
день отдаёт и заказы бэкенда — проекцию своих покупок с деньгами магазина, — от этого не сдвигается. Правила изменений и задержек — в
а судьба заказа и отправка слепка идут следующими тикетами. [описании судьбы заказа](../docs/architecture/orders/fate.md).
Слепок снимается на конец модельного дня и отправляется на следующий день.
Команда `snapshot --day 1` отправляет слепок дня 0, а `snapshot --day 0`
не отправляет ничего: до начала модели заказов нет. События и слепки
отправляются отдельными запусками генератора.
## Как это работает ## Как это работает
Корневое зерно ветвится на подпотоки, из них складываются когорты, аудитория Зерно генерации задает случайные выборы: какие посетители придут,
дня, визиты, строки событий и заказы. План поднимает состав мира по требованию, какие страницы откроют и что купят. При неизменных коде, каталоге
а день-функция проживает один день без накопленного состояния. Код этой и настройках одинаковые зерно и номер дня дают одинаковые данные.
цепочки, ее схема и запуск D0 собраны в День можно рассчитать без генерации всех предыдущих дней.
[путеводителе](../docs/architecture/generator/README.md).
Сначала рассчитываются посещения и покупки. Из покупки получаются событие
`purchase` со стороны сайта и заказ со стороны магазина. Номер связывает
их: `purchaseID` в событии соответствует `order_id` заказа. Суммы могут
различаться: магазин учитывает скидку, доставку и удаленные позиции.
Деньги генератор считает целыми копейками; в JSON заказа это строки
в рублях с двумя знаками после точки, например `"1289.99"`.
Готовые записи превращаются в JSON и отправляются в файл или Kafka:
одна запись занимает одну строку файла или одно сообщение. Событие содержит
47 полей по [описанию выгрузки](../docs/formats/clickstream-event.md),
запись заказа — поля [формата слепка](../docs/architecture/orders/snapshot.md#запись-на-проводе).
Подробное устройство генератора разобрано в
[техническом справочнике](../docs/architecture/generator/README.md).
## Что где лежит ## Что где лежит
- `src/clickstream_generator/world.py` — конфигурация мира: все его числа Все перечисленные модули находятся в `src/clickstream_generator/`.
одним местом. Правка любого — смена мира; крутить их и предлагается.
- `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого | Модули | Назначение |
подпотока берёт случайность. На ней держится весь детерминизм. |---|---|
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в | `world.py`, `seeds.py`, `plan.py` | Настройки мира, случайные выборы и план посещений |
день D. Когорты, приток, двухкуковые пары, паспорта кук, человек за кукой | `reference.py`, `catalog.py` | Устройства, города, источники трафика и каталог товаров |
и счётчики — до генерации событий. | `day.py`, `commerce.py` | Визиты, события сайта и покупки |
- `src/clickstream_generator/weights.py` — выбор по целым весам: один приём | `orders.py` | Состояние заказов на дату слепка, изменения и задержки |
на весь генератор, чтобы дисциплина целочисленной случайности не жила | `serialize.py`, `player.py`, `sinks.py` | Запись JSON и отправка в файл или Kafka |
копиями. | `cli.py` | Команды и параметры запуска |
- `src/clickstream_generator/reference.py` — справочники: устройства, | `schema.py`, `schema_doc.py` | Описания колонок события и сборка документа выгрузки |
города, источники трафика, карта сайта. Таблицы-литералы: доля живёт в | `inventory.py` | Сборка паспорта мира с контрольными суммами и счетчиками |
строке, которой принадлежит.
- `src/clickstream_generator/catalog.py` — каталог товаров из Тесты в `tests/` проверяют формат, правила модели и повторяемость данных.
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse. Описание выгрузки и [паспорт мира](../data/world-inventory.json) собираются
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы, из кода; вручную их не редактируют.
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
который садятся торговые события; прожитый день отдаёт обе половины —
поток событий и заказы.
- `src/clickstream_generator/commerce.py` — торговые события: что легло в
корзину, что из этого куплено, деньги, номера заказов и сырой `ecommerce`.
Своя случайность, поэтому правка торговли трафик не двигает.
- `src/clickstream_generator/orders.py` — заказы бэкенда: вторая проекция
покупки. Личность покупателя и деньги магазина — скидка по промокоду,
доставка, итог; своя случайность, ветвящаяся по дню рождения заказа.
- `src/clickstream_generator/ids.py` — номера событий: неповторяющиеся и
ниже 2^53. Обещание одно на обе половины дня, поэтому и живёт отдельно.
- `src/clickstream_generator/serialize.py` — канонический сериализатор:
единственное место, где событие целиком превращается в JSON. Порядок ключей,
все 47 колонок всегда и форма на проводе — ISO-8601.
- `src/clickstream_generator/sinks.py` — приёмники: файл (одно событие — одна
строка) и Kafka (одно событие — одно сообщение). Про содержимое они не
знают; там же довод, почему у сообщения нет ключа.
- `src/clickstream_generator/player.py` — проигрыватель: гонит дни в приёмник
пачкой или с темпом живого дня. Состояния не хранит.
- `src/clickstream_generator/cli.py` — интерфейс запуска: параметры
аргументами или окружением, логи в стандартный вывод, итог кодом возврата.
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
колонках выгрузки. Собственность генератора; из него выводятся сам
генератор, его валидация и описание выгрузки в доках.
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
из контракта. Документ руками не правят — пересобирают.
- `src/clickstream_generator/inventory.py` — сборка паспорта мира
([`data/world-inventory.json`](../data/world-inventory.json)): шапка паспорта
и хеши восьми дней, которыми наполняется стенд, плюс счётчики классов
расхождений по заказам для дней с закрытым окном. Руками не правят —
пересобирают целью `make inventory`.
- `tests/` — инварианты контракта, свежесть описания и обещания мира:
чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
и сборка визитов по задокументированным правилам. Там же побайтовое
обещание, доведённое до диска: два прогона дня в файл дают тот же файл, а
строк в нём ровно столько, сколько событий.
## Команды ## Команды
Проиграть день — раздел [«Как запустить Для запуска из каталога `generator/` используйте
генератор»](../docs/architecture/world.md#как-запустить-генератор) `uv run python -m clickstream_generator <команда>`. Справку можно открыть
справочника мира и `--help`. через `--help`, в том числе после имени команды.
Из этого каталога — цели генератора живут здесь, а не в корне: | Команда | Что отправляет | Особые параметры |
|---|---|---|
| `batch` | События дня без пауз | `--limit N` ограничивает число событий за весь запуск |
| `live` | События постепенно, с ускорением | `--speed X`, по умолчанию 60: сутки занимают около 24 минут |
| `snapshot` | Слепок заказов предыдущего дня | `--day D` задает день отправки |
Для всех команд нужны номер дня `--day` и приемник: `--file` либо
`--brokers` вместе с `--topic`. Общие параметры `--seed` и `--days`
задают зерно и число последовательных дней. Параметры можно передать
и через окружение; имена и примеры собраны в
[справочнике запуска](../docs/architecture/world.md#как-запустить-генератор).
Прямой запуск генератора не продвигает сохраненную позицию времени стенда —
ей управляют [даги модельного времени](../docs/handbook/airflow-dags.md#генерация-модельных-дней).
Из каталога `generator/` доступны:
- `make test` — тесты генератора; - `make test` — тесты генератора;
- `make lint` — ruff: проверка и формат; - `make lint` — проверка кода и форматирования через ruff;
- `make typecheck` — ty: проверка типов; - `make typecheck` — проверка типов через ty;
- `make demo` — пройти стадии канонического D0 на одном экране; - `make demo` — показать стадии расчета первого дня, D0;
- `make docs` — пересобрать описание выгрузки; - `make docs` — пересобрать описание выгрузки;
- `make inventory` — пересобрать паспорт мира. - `make inventory` — пересобрать паспорт мира.
В корне репозитория `make lint` тоже есть, но он про код стенда — даги и В корне `make lint` проверяет даги, код Superset и сборку карты архитектуры.
Superset. Одноимённые цели за разными дверями охватывают разное: Корневые `make generate-batch` и `make generate-live` запускают события
[карта проверок](../docs/architecture/testing.md). через контейнер генератора. Состав проверок — в
[карте проверок](../docs/architecture/testing.md).
Python и зависимости — через `uv`, версии закреплены в `uv.lock`: на этом Python и зависимости запускаются через `uv`; версии закреплены в `uv.lock`.
держится обещание побайтовой воспроизводимости (спека, раздел 2).
@@ -420,7 +420,7 @@ COLUMNS: tuple[Column, ...] = (
numpy_dtype="int64", numpy_dtype="int64",
normalized_name="product_price", normalized_name="product_price",
group=ColumnGroup.ECOMMERCE, group=ColumnGroup.ECOMMERCE,
comment="цена за штуку целым числом: деньги генератор считает целыми", comment="цена за штуку, округленная до целых рублей",
), ),
Column( Column(
name="productQuantity", name="productQuantity",
+13
View File
@@ -61,6 +61,11 @@
<query>GRANT SELECT ON system.settings</query> <query>GRANT SELECT ON system.settings</query>
</grants> </grants>
</analyst_reader> </analyst_reader>
<student_writer>
<grants>
<query>GRANT SELECT, INSERT, CREATE TABLE, DROP TABLE ON default.*</query>
</grants>
</student_writer>
<monitoring_reader> <monitoring_reader>
<grants> <grants>
<query>GRANT SELECT ON stg.*</query> <query>GRANT SELECT ON stg.*</query>
@@ -113,6 +118,14 @@
<query>GRANT analyst_reader</query> <query>GRANT analyst_reader</query>
</grants> </grants>
</analyst> </analyst>
<student>
<password from_env="CLICKHOUSE_STUDENT_PASSWORD"/>
<quota>default</quota>
<grants>
<query>GRANT analyst_reader</query>
<query>GRANT student_writer</query>
</grants>
</student>
<grafana> <grafana>
<password from_env="CLICKHOUSE_GRAFANA_PASSWORD"/> <password from_env="CLICKHOUSE_GRAFANA_PASSWORD"/>
<quota>default</quota> <quota>default</quota>
@@ -11,7 +11,7 @@
"panels": [ "panels": [
{ {
"type": "timeseries", "type": "timeseries",
"title": "Процессор: сколько ядер занимает нода", "title": "Процессор: сколько ядер использует узел",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -51,7 +51,7 @@
}, },
{ {
"type": "timeseries", "type": "timeseries",
"title": "Память: сколько нода занимает и сколько ей отведено", "title": "Память: использование и лимит узла",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -114,7 +114,7 @@
}, },
{ {
"type": "timeseries", "type": "timeseries",
"title": "Диски: сколько нода читает и пишет", "title": "Диски: скорость чтения и записи узла",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -154,7 +154,7 @@
}, },
{ {
"type": "table", "type": "table",
"title": "Части: сколько их у таблицы и по сколько строк в каждой", "title": "Части таблиц: количество и среднее число строк в части",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -199,7 +199,7 @@
}, },
{ {
"type": "timeseries", "type": "timeseries",
"title": "Слияния: сколько частей они съедают", "title": "Слияния: число объединенных частей",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -280,11 +280,12 @@
"min": 0 "min": 0
}, },
"overrides": [] "overrides": []
} },
"description": "Узлы дерева Keeper (znode) — записи со служебными данными координации ClickHouse."
}, },
{ {
"type": "table", "type": "table",
"title": "Распределённый DDL: что прошло по кластеру и чем кончилось", "title": "Распределенный DDL: результаты команд на узлах кластера",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -11,7 +11,7 @@
"panels": [ "panels": [
{ {
"type": "stat", "type": "stat",
"title": "Свежесть: сколько прошло с последней доехавшей строки", "title": "Свежесть: время с последней загрузки в ODS",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -48,7 +48,7 @@
}, },
{ {
"type": "table", "type": "table",
"title": "Брак: сколько приехавшего не разобралось", "title": "Брак: доля строк с ошибками разбора",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -72,11 +72,28 @@
"format": 1, "format": 1,
"rawSql": "SELECT\n source AS \"Источник\",\n countIf(NOT is_error) AS \"Приехало\",\n countIf(is_error) AS \"Из них в брак\",\n 100 * countIf(is_error) / countIf(NOT is_error) AS \"Доля брака, %\"\nFROM\n(\n SELECT 'события' AS source, 0 AS is_error, _load_ts FROM stg.hits_raw_dist\n UNION ALL\n SELECT 'события', 1, _load_ts FROM ods.event_errors_dist\n UNION ALL\n SELECT 'заказы', 0, _load_ts FROM stg.orders_raw_dist\n UNION ALL\n SELECT 'заказы', 1, _load_ts FROM ods.order_errors_dist\n)\nWHERE $__timeFilter(_load_ts)\nGROUP BY source\nORDER BY source" "rawSql": "SELECT\n source AS \"Источник\",\n countIf(NOT is_error) AS \"Приехало\",\n countIf(is_error) AS \"Из них в брак\",\n 100 * countIf(is_error) / countIf(NOT is_error) AS \"Доля брака, %\"\nFROM\n(\n SELECT 'события' AS source, 0 AS is_error, _load_ts FROM stg.hits_raw_dist\n UNION ALL\n SELECT 'события', 1, _load_ts FROM ods.event_errors_dist\n UNION ALL\n SELECT 'заказы', 0, _load_ts FROM stg.orders_raw_dist\n UNION ALL\n SELECT 'заказы', 1, _load_ts FROM ods.order_errors_dist\n)\nWHERE $__timeFilter(_load_ts)\nGROUP BY source\nORDER BY source"
} }
],
"fieldConfig": {
"defaults": {},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Приехало"
},
"properties": [
{
"id": "displayName",
"value": "Загружено в STG"
}
] ]
}
]
}
}, },
{ {
"type": "table", "type": "table",
"title": "Дневной слепок заказов: какой доехал, когда и после какой паузы", "title": "Дневной слепок заказов: дата и время загрузки",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -100,11 +117,28 @@
"format": 1, "format": 1,
"rawSql": "SELECT\n \"Слепок\",\n \"Доехал\",\n dateDiff('second', prev, \"Доехал\") AS \"После прошлого, с\",\n \"Строк\"\nFROM\n(\n SELECT\n JSONExtractString(raw, 'snapshot_date') AS \"Слепок\",\n max(_load_ts) AS \"Доехал\",\n count() AS \"Строк\",\n -- Обнуляемый тип нужен, чтобы у самого раннего слепка пауза осталась\n -- пустой, а не превратилась в разницу с началом эпохи.\n lagInFrame(toNullable(max(_load_ts))) OVER (ORDER BY \"Слепок\") AS prev\n FROM stg.orders_raw_dist\n GROUP BY \"Слепок\"\n)\nORDER BY \"Слепок\" DESC\nLIMIT 10" "rawSql": "SELECT\n \"Слепок\",\n \"Доехал\",\n dateDiff('second', prev, \"Доехал\") AS \"После прошлого, с\",\n \"Строк\"\nFROM\n(\n SELECT\n JSONExtractString(raw, 'snapshot_date') AS \"Слепок\",\n max(_load_ts) AS \"Доехал\",\n count() AS \"Строк\",\n -- Обнуляемый тип нужен, чтобы у самого раннего слепка пауза осталась\n -- пустой, а не превратилась в разницу с началом эпохи.\n lagInFrame(toNullable(max(_load_ts))) OVER (ORDER BY \"Слепок\") AS prev\n FROM stg.orders_raw_dist\n GROUP BY \"Слепок\"\n)\nORDER BY \"Слепок\" DESC\nLIMIT 10"
} }
],
"fieldConfig": {
"defaults": {},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Доехал"
},
"properties": [
{
"id": "displayName",
"value": "Время загрузки"
}
] ]
}
]
}
}, },
{ {
"type": "timeseries", "type": "timeseries",
"title": "Поток: сколько строк приезжает в сырой слой", "title": "Поток: число сообщений, загруженных в STG",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -136,7 +170,7 @@
}, },
{ {
"type": "timeseries", "type": "timeseries",
"title": "Сходимость: произведено в Kafka против приземлившегося в ClickHouse", "title": "Сходимость: число событий в Kafka и ClickHouse",
"datasource": { "datasource": {
"type": "datasource", "type": "datasource",
"uid": "-- Mixed --" "uid": "-- Mixed --"
@@ -161,7 +195,7 @@
}, },
"editorMode": "code", "editorMode": "code",
"expr": "sum(kafka_topic_partition_current_offset{topic=\"hits\"}) - sum(kafka_topic_partition_current_offset{topic=\"hits\"} offset $__interval)", "expr": "sum(kafka_topic_partition_current_offset{topic=\"hits\"}) - sum(kafka_topic_partition_current_offset{topic=\"hits\"} offset $__interval)",
"legendFormat": "произведено в Kafka", "legendFormat": "поступило в Kafka",
"range": true "range": true
}, },
{ {
@@ -187,7 +221,7 @@
"properties": [ "properties": [
{ {
"id": "displayName", "id": "displayName",
"value": "приземлилось в ClickHouse" "value": "загружено в ClickHouse"
} }
] ]
} }
@@ -197,7 +231,7 @@
}, },
{ {
"type": "timeseries", "type": "timeseries",
"title": "Отставание чтения из Kafka: непрочитанных сообщений", "title": "Отставание чтения из Kafka: число непрочитанных сообщений",
"datasource": { "datasource": {
"type": "prometheus", "type": "prometheus",
"uid": "prometheus" "uid": "prometheus"
@@ -11,7 +11,7 @@
"panels": [ "panels": [
{ {
"type": "table", "type": "table",
"title": "Тяжёлые запросы: что прочитало больше всех", "title": "Запросы с наибольшим числом прочитанных строк",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -56,7 +56,7 @@
}, },
{ {
"type": "table", "type": "table",
"title": "Кто спрашивает: сколько запросов и какой у них аппетит", "title": "Пользователи: число запросов и потребление ресурсов",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -93,6 +93,10 @@
{ {
"id": "unit", "id": "unit",
"value": "bytes" "value": "bytes"
},
{
"id": "displayName",
"value": "Максимальная память запроса"
} }
] ]
} }
@@ -101,7 +105,7 @@
}, },
{ {
"type": "table", "type": "table",
"title": "Ошибки: на чём запросы споткнулись", "title": "Ошибки выполнения запросов",
"datasource": { "datasource": {
"type": "grafana-clickhouse-datasource", "type": "grafana-clickhouse-datasource",
"uid": "clickhouse" "uid": "clickhouse"
@@ -128,7 +132,20 @@
], ],
"fieldConfig": { "fieldConfig": {
"defaults": {}, "defaults": {},
"overrides": [] "overrides": [
{
"matcher": {
"id": "byName",
"options": "Чем кончилось"
},
"properties": [
{
"id": "displayName",
"value": "Ошибка"
}
]
}
]
} }
} }
] ]
@@ -1,4 +1,4 @@
slice_name: Сверка purchase и заказов slice_name: Сверка событий покупки и заказов
description: null description: null
certified_by: null certified_by: null
certification_details: null certification_details: null
@@ -153,7 +153,7 @@ position:
chartId: 2 chartId: 2
height: 44 height: 44
width: 12 width: 12
sliceName: Сверка purchase и заказов sliceName: Сверка событий покупки и заказов
uuid: 19600000-0000-4000-8000-000000000102 uuid: 19600000-0000-4000-8000-000000000102
ROW-traffic: ROW-traffic:
id: ROW-traffic id: ROW-traffic
@@ -37,7 +37,7 @@ metrics:
extra: null extra: null
warning_text: null warning_text: null
- metric_name: known_share - metric_name: known_share
verbose_name: Доля известных verbose_name: Доля известных пользователей
metric_type: null metric_type: null
expression: SUM(known_users) / nullIf(SUM(visitors), 0) expression: SUM(known_users) / nullIf(SUM(visitors), 0)
description: null description: null
@@ -55,7 +55,7 @@ columns:
datetime_format: null datetime_format: null
extra: null extra: null
- column_name: declared_revenue - column_name: declared_revenue
verbose_name: Выручка purchase verbose_name: Заявленная сумма покупки из события
is_dttm: false is_dttm: false
is_active: true is_active: true
type: NULLABLE(DECIMAL(18, 2)) type: NULLABLE(DECIMAL(18, 2))
@@ -120,7 +120,7 @@ columns:
datetime_format: null datetime_format: null
extra: null extra: null
- column_name: order_id - column_name: order_id
verbose_name: Заказ verbose_name: Номер заказа
is_dttm: false is_dttm: false
is_active: true is_active: true
type: STRING type: STRING
@@ -125,7 +125,7 @@ columns:
datetime_format: null datetime_format: null
extra: null extra: null
- column_name: units - column_name: units
verbose_name: Товары verbose_name: Продано единиц товара
is_dttm: false is_dttm: false
is_active: true is_active: true
type: UINT64 type: UINT64
+6 -6
View File
@@ -24,10 +24,10 @@ compose() {
next_step() { next_step() {
case "$DAG_ID" in case "$DAG_ID" in
world_initialize) world_initialize)
printf 'посмотрите журнал; повторите make up, а для недействительного мира — make rebuild-storage' printf 'проверьте состояние запуска в Airflow и журнал задачи с ошибкой; после устранения причины повторите make up'
;; ;;
world_recreate) printf 'устраните причину и повторите make rebuild-storage' ;; world_recreate) printf 'проверьте состояние запуска в Airflow и журнал задачи с ошибкой; после устранения причины повторите make rebuild-storage' ;;
*) printf 'проверьте журнал дага и повторите команду' ;; *) printf 'проверьте состояние запуска в Airflow и журнал задачи с ошибкой; после устранения причины повторите команду' ;;
esac esac
} }
@@ -67,7 +67,7 @@ for ((attempt = 1; attempt <= ATTEMPTS; attempt++)); do
done done
jq -e --arg dag_id "$DAG_ID" '.dag_id == $dag_id' \ jq -e --arg dag_id "$DAG_ID" '.dag_id == $dag_id' \
>/dev/null 2>&1 <<<"$dag" || \ >/dev/null 2>&1 <<<"$dag" || \
fail "даг ${DAG_ID} не появился за $((ATTEMPTS * PAUSE_SECONDS)) с; $(next_step)" fail "даг ${DAG_ID} не появился в API за $((ATTEMPTS * PAUSE_SECONDS)) с; проверьте ошибки импорта в Airflow и журнал службы airflow-dag-processor"
# GET читает DagModel по идентификатору, а POST запуска принимает только запись # GET читает DagModel по идентификатору, а POST запуска принимает только запись
# с `is_stale = false`. На холодном старте GET уже может видеть даг, пока POST # с `is_stale = false`. На холодном старте GET уже может видеть даг, пока POST
@@ -105,7 +105,7 @@ for ((attempt = 1; attempt <= TRIGGER_ATTEMPTS; attempt++)); do
fail "Airflow не запустил даг ${DAG_ID}: HTTP ${http_status}${detail:+: ${detail}}; $(next_step)" fail "Airflow не запустил даг ${DAG_ID}: HTTP ${http_status}${detail:+: ${detail}}; $(next_step)"
done done
((triggered == 1)) || \ ((triggered == 1)) || \
fail "даг ${DAG_ID} не стал доступен для запуска за $((TRIGGER_ATTEMPTS * PAUSE_SECONDS)) с; $(next_step)" fail "даг ${DAG_ID} не стал доступен для запуска за $((TRIGGER_ATTEMPTS * PAUSE_SECONDS)) с; проверьте ошибки импорта в Airflow и журнал службы airflow-dag-processor"
run_id="$(jq -r '.dag_run_id // empty' <<<"$response")" run_id="$(jq -r '.dag_run_id // empty' <<<"$response")"
state="$(jq -r '.state // "queued"' <<<"$response")" state="$(jq -r '.state // "queued"' <<<"$response")"
[[ -n "$run_id" ]] || fail "Airflow не вернул идентификатор запуска ${DAG_ID}" [[ -n "$run_id" ]] || fail "Airflow не вернул идентификатор запуска ${DAG_ID}"
@@ -121,7 +121,7 @@ for ((attempt = 1; attempt <= ATTEMPTS; attempt++)); do
state="$(jq -r '.state // empty' <<<"$response" 2>/dev/null || true)" state="$(jq -r '.state // empty' <<<"$response" 2>/dev/null || true)"
case "$state" in case "$state" in
success) success)
printf 'ЗЕЛЁНО: даг %s, запуск %s, состояние success.\n' "$DAG_ID" "$run_id" printf 'УСПЕХ: даг %s, запуск %s, состояние success.\n' "$DAG_ID" "$run_id"
exit 0 exit 0
;; ;;
failed) failed)
+27 -68
View File
@@ -1,49 +1,20 @@
-- ODS: типизированное событие и таблица ошибок разбора.
--
-- Состав, имена и типы колонок списаны с «описания выгрузки»
-- (docs/formats/clickstream-event.md) — так же, как в бою хранилище пишут по
-- документации источника. Модуль контракта генератора здесь не читается:
-- граница «трекер | хранилище» проходит по документу.
-- Механика приёма и три класса брака — ADR 0005, конвенции имён, служебных
-- колонок и сроков — docs/architecture/storage.md.
-- Локальная таблица события. -- Локальная таблица события.
-- Поля следуют docs/formats/clickstream-event.md; purchaseRevenue хранится
-- в Decimal для точного счета денег. Правила приема — ADR 0005.
-- ReplacingMergeTree оставляет последнюю доставку по _load_ts среди строк
-- с одинаковым полным ORDER BY. Метка переносится из STG без пересчета.
-- Она не входит в ключ сортировки или партиции: иначе повторные доставки
-- не считались бы версиями одной строки. Для чтения служит ods.event_v с FINAL.
-- --
-- Движок ReplacingMergeTree, колонка версии — _load_ts. Работа у версии ровно -- Партиция EventDate хранит модельный день события. CounterID на стенде
-- одна: схлопнуть повтор доставки. Метка не ставится здесь заново, а -- постоянен, EventDate постоянна внутри партиции; дальнейшая сортировка —
-- переносится из stg.hits_raw как есть — колонка отвечает на вопрос «когда -- по intHash32(ClientID) и WatchID. WatchID отличает события посетителя:
-- строка приехала в хранилище», а не «когда её разобрали». У повтора -- без него они заменяли бы друг друга.
-- содержимое то же самое, отличается только метка, поэтому какая из двух
-- строк переживёт мерж, безразлично.
-- --
-- Колонка версии не входит ни в ключ партиции, ни в ключ сортировки, и это -- SAMPLE BY использует то же выражение, что ключ сортировки, и отбирает
-- не случайность: попади она туда — версии одной строки лягут в разные куски -- посетителей целиком. Sign всегда равен 1: поле сохранено по формату
-- или в разные места ключа и не встретятся при мерже, то есть дедупликация -- источника, но исправлений парой −1/+1 генератор не отправляет.
-- перестанет работать молча.
--
-- PARTITION BY EventDate — по дню события, а не по месяцу, как у Метрики.
-- Отступление осознанное: дневная партиция здесь единица переобработки, и
-- переиграть день X значит заменить одну партицию. Месячная партиция тянула
-- бы за собой тридцать чужих дней.
--
-- ORDER BY вырожден, и это учебный факт, а не недосмотр: CounterID на стенде
-- константа (сайт один), EventDate константа внутри своей партиции — обе
-- головные колонки ключа не различают ни одной строки. Реальная сортировка
-- идёт по посетителю и событию: intHash32(ClientID), WatchID. Ключ написан в
-- боевой форме «по сайту за период по посетителю» — на стенде она
-- вырождается, в бою нет. Хвост WatchID работает ещё и на дедупликацию: без
-- него ReplacingMergeTree схлопнул бы в одну строку все события посетителя за
-- день.
--
-- SAMPLE BY intHash32(ClientID) — по тому же выражению, что стоит в ключе
-- сортировки (иначе семплирование не разрешено). Семплирование берёт целиком
-- посетителей, а не события вразнобой, поэтому SAMPLE 0.1 не портит
-- uniq-метрики.
--
-- Sign всегда равен 1: это колонка формата, взятая без механики. В бою
-- исправление записи шлют парой −1/+1 и считают через sum(Sign) поверх
-- CollapsingMergeTree; наш генератор исправлений не шлёт, поэтому колонка
-- есть, а механики за ней нет.
CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
( (
WatchID UInt64, WatchID UInt64,
@@ -100,36 +71,24 @@ PARTITION BY EventDate
ORDER BY (CounterID, EventDate, intHash32(ClientID), WatchID) ORDER BY (CounterID, EventDate, intHash32(ClientID), WatchID)
SAMPLE BY intHash32(ClientID); SAMPLE BY intHash32(ClientID);
-- Лицо слоя: пишем и читаем через него. Ключ шардирования — -- Запись и диагностика через Distributed; актуальные события — в ods.event_v.
-- cityHash64(ClientID), а не сырой ClientID: структурированный числовой -- cityHash64(ClientID) распределяет посетителей равномернее, чем остаток
-- идентификатор перекашивает остаток по модулю числа шардов, хеш — нет. -- самого структурированного идентификатора. События посетителя лежат вместе.
-- События одной куки при этом остаются на одном шарде, и сессионизация со -- STG распределен по хешу raw, поэтому сообщение и разобранное событие могут
-- склейкой идентичностей ниже по течению живут локально. -- лежать на разных шардах. Общие счетчики сравнивают через _dist.
--
-- У STG ключ другой (хеш строки), поэтому сырая строка и разобранное из неё
-- событие почти всегда лежат на разных шардах. Пошардовые счётчики слоёв
-- сходиться не должны — сверять слои можно только через _dist.
CREATE TABLE IF NOT EXISTS ods.event_dist ON CLUSTER clickstream_cluster CREATE TABLE IF NOT EXISTS ods.event_dist ON CLUSTER clickstream_cluster
AS ods.event_rep AS ods.event_rep
ENGINE = Distributed('clickstream_cluster', 'ods', 'event_rep', cityHash64(ClientID)); ENGINE = Distributed('clickstream_cluster', 'ods', 'event_rep', cityHash64(ClientID));
-- Локальная таблица ошибок разбора. -- Ошибки разбора: исходный текст, класс и сведения о доставке.
-- У сообщения может не быть разобранного ClientID, поэтому шардирование —
-- по cityHash64(raw), партиция — по реальному дню загрузки.
-- ORDER BY группирует ошибки по классу и координатам Kafka.
-- --
-- Сырой текст сообщения, метаданные доставки и класс брака. Разобранных полей -- Замены версий нет: надежного ключа события у брака может не быть.
-- у брака нет по определению: строка сюда попала как раз потому, что не -- TTL сохраняет ошибки месяц, дольше трехсуточного срока STG.
-- разобралась.
--
-- Движок — обычный ReplicatedMergeTree, без замены версий: у брака нет ключа
-- сущности, схлопывать его не по чему.
--
-- Ключи собственные. Шардирование — cityHash64(raw): ClientID у неразобранной
-- строки взять неоткуда. ORDER BY (error_class, kafka_partition,
-- kafka_offset): такую таблицу смотрят от класса брака, а внутри класса — по
-- координатам доставки. Нарезка по дню загрузки, как у STG: модельного дня
-- у брака тоже нет.
--
-- Срок жизни — месяц, вдесятеро дольше сырых данных. Истеки брак вместе с ними —
-- к моменту разбирательства не осталось бы ни того, ни другого.
CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster
( (
raw String, raw String,
+26 -84
View File
@@ -1,8 +1,6 @@
-- ODS: каноническое чтение актуальных событий. -- Актуальные события: FINAL выбирает последнюю доставку по _load_ts,
-- -- не дожидаясь фоновых слияний. Поля и типы остаются как в ods.event_dist.
-- Представление сохраняет поля и зерно ods.event_dist, но скрывает FINAL от
-- потребителя. Поэтому физическая таблица остаётся местом диагностики
-- доставленных версий, а точное чтение получает одно имя.
CREATE VIEW IF NOT EXISTS ods.event_v ON CLUSTER clickstream_cluster CREATE VIEW IF NOT EXISTS ods.event_v ON CLUSTER clickstream_cluster
AS AS
SELECT * SELECT *
@@ -25,69 +23,24 @@ AS
SELECT * SELECT *
FROM ods.order_dist FINAL; FROM ods.order_dist FINAL;
-- ODS: разбор STG в событие и в таблицу ошибок. -- Разбор STG в события и ошибки. Два представления используют одинаковое
-- условие годности; второе берет его отрицание. Условие дает только 0 или 1:
-- NULL не выбрала бы ни одна ветвь. Функции разбора не вызывают исключений
-- на некорректном raw, чтобы одно сообщение не прервало прием.
-- --
-- Матвью две, и вместе они обязаны делить поток без зазора и без нахлёста: -- Оба представления срабатывают на вставку в stg.hits_raw_dist,
-- строка, подошедшая обеим, задвоится, а не подошедшая ни одной — исчезнет -- до распределения по шардам. Порядок SELECT совпадает с целевой таблицей.
-- молча. Держится это формой: годность считает один и тот же предикат из трёх
-- частей, а вторая матвью берёт его буквальное отрицание.
-- --
-- Отсюда два запрета на выражения предиката, и оба серьёзные. -- Прием проверяет объект JSON, точный набор ключей и разбор пяти полей:
-- WatchID, VisitID, ClientID, EventDate, UTCEventTime. Остальные поля
-- извлекаются по формату без отдельной проверки. Граница — ADR 0005.
-- arraySort с обеих сторон позволяет сравнить ключи независимо от порядка.
-- --
-- Первый: ни одна его часть не возвращает NULL. Трёхзначная логика дала бы -- UTCEventTime приходит строкой вида "2026-06-01T12:34:56Z". Ее разбирает
-- строку, которую не берёт ни «условие», ни «NOT условие», — и потерялась бы -- parseDateTimeOrNull с явной маской: JSONExtract в DateTime такую форму
-- она бесшумно. Поэтому сравнения здесь дают 0 или 1, а разбор в Nullable -- не принимает, а BestEffort допускает неполные даты. Пояс UTC указан
-- заканчивается IS NOT NULL. -- отдельно, потому что Z в маске проверяется как буква. EventDate приходит
-- -- как "2026-06-01" и разбирается через JSONExtract.
-- Второй: ни одна его часть не бросает исключений. Упавшая матвью роняет
-- вставку, офсеты не коммитятся, и потребление топика встаёт до починки —
-- это единственное, на чём сейчас держится правило репозитория «грязные
-- записи не валят пайплайн». Поэтому приведение Nullable к необнуляемому типу
-- (assumeNotNull) стоит только в списке колонок, за предикатом, который NULL
-- уже отсёк, а в самом предикате живут только функции семейства
-- JSONExtract/*OrNull. Довод целиком — ADR 0005.
--
-- Источник у обеих — stg.hits_raw_dist, лицо слоя, а не локальная таблица:
-- потребитель цепляется к лицу, а матвью с источником-Distributed срабатывает
-- на вставку именно в распределённую таблицу, до раскладки по шардам.
--
-- Порядок колонок в SELECT совпадает с порядком в целевой таблице.
-- Годное событие: строка, прошедшая строгий приём.
--
-- Строгий приём — это три вопроса: объект ли это JSON, тот ли набор ключей,
-- разбираются ли в свой тип пять опорных колонок. Почему именно так — почему
-- объект, а не валидность; почему сверка ключей заменяет сорок семь проверок
-- на присутствие; почему опорных пять, а не сорок семь — ADR 0005, «Решение».
--
-- arraySort ниже стоит с обеих сторон, и убирать его нельзя: без обёртки сорок
-- семь CamelCase-имён пришлось бы держать ровно в байтовом порядке, а сбой
-- порядка увёл бы в брак вообще всё, и молча (ADR 0005).
--
-- Метка времени — единственная из пяти, кого разбирает не JSONExtract, и это
-- измеренная необходимость, а не вкус. На проводе UTCEventTime уезжает в
-- ISO-8601 с суффиксом зоны — «2026-06-01T12:34:56Z» (спека генератора,
-- раздел 4), а JSONExtract с типом DateTime такую строку не берёт и отдаёт
-- NULL. Оставь его здесь — и в брак уехали бы все события до единого.
--
-- Формат назван буквально, а не отдан parseDateTimeBestEffort, и вот почему.
-- Best-effort понимает десяток записей и на непонятной не краснеет, а
-- достраивает недостающее: обрезанное «20:00:21» он превращает в первое
-- января текущего года. Такая строка прошла бы строгий приём с тихо неверным
-- временем — ровно с той порчей, ради которой класс key_field_unparsed и
-- заведён. Источник у топика один и шлёт одну запись, так что широта здесь не
-- нужна вовсе, а стоит она отключённой проверкой. Замеры — ADR 0005,
-- «Что проверено».
--
-- Третьим аргументом назван пояс — 'UTC'. Суффикс Z маска сверяет как букву и
-- выбрасывает, зоны из строки не берёт вовсе, поэтому без имени функция читала
-- бы показания часов по поясу сессии, а тот по умолчанию серверный. Тип
-- колонки этого не чинит: разбор отдаёт готовое число, и колонка кладёт его
-- как есть — пояс приёмника решал бы судьбу строки, а не числа.
-- Правило и замер — docs/architecture/storage.md, «Часовые пояса».
--
-- EventDate в такой подпорке не нуждается: дата уезжает как «2026-06-01», и
-- JSONExtract её берёт.
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_mv ON CLUSTER clickstream_cluster CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_mv ON CLUSTER clickstream_cluster
TO ods.event_dist TO ods.event_dist
@@ -154,10 +107,8 @@ SELECT
JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached, JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached,
JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1, JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1,
JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID, JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID,
-- Выгрузка несет JSON-числа типа Float64, но деньги хранятся в Decimal. -- purchaseRevenue приходит числом рублей и разбирается сразу в Decimal.
-- JSONExtract разбирает их сразу, без промежуточного двоичного числа: -- Прямой разбор сверяли через Context7 и на ClickHouse 26.3 (#155).
-- синтаксис подтвержден документацией ClickHouse через Context7 и
-- запросом к стендовому ClickHouse 26.3 при исполнении #155.
JSONExtract(raw, 'purchaseRevenue', 'Array(Decimal(18, 2))') AS purchaseRevenue, JSONExtract(raw, 'purchaseRevenue', 'Array(Decimal(18, 2))') AS purchaseRevenue,
JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency, JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency,
JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon, JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon,
@@ -168,25 +119,16 @@ SELECT
JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity, JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity,
JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType, JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType,
JSONExtract(raw, 'ecommerce', 'String') AS ecommerce, JSONExtract(raw, 'ecommerce', 'String') AS ecommerce,
-- Метка загрузки переносится из STG как есть, а не ставится заново: -- _load_ts остается временем исходной доставки и версией события.
-- она отвечает на вопрос «когда строка приехала в хранилище». Поставь
-- здесь now64(3) — и колонка версии молча ответила бы на другой вопрос.
_load_ts _load_ts
FROM stg.hits_raw_dist FROM stg.hits_raw_dist
WHERE is_object AND keys_match AND key_fields_parsed; WHERE is_object AND keys_match AND key_fields_parsed;
-- Брак: буквальное отрицание того же предиката. -- Ошибки: отрицание того же условия годности.
-- -- Класс определяется первым нарушенным условием. Например, JSON-строка
-- Классы брака пересекаются, поэтому проверяются по порядку, а в error_class -- не проходит и проверку объекта, и проверку ключей, но получает not_an_object.
-- пишется первый совпавший. Скаляр проваливает и проверку на объект, и сверку -- Условие повторено рядом с запросом, без отдельной функции ClickHouse.
-- ключей — JSONExtractKeys от него даёт пустой массив (измерено на стенде
-- 7 августа 2026 года), — и без объявленного порядка попал бы то в один
-- класс, то в другой.
--
-- Предикат повторён здесь дословно, и это выбор, а не безвыходность: назвать
-- его один раз на две матвью позволил бы CREATE FUNCTION. Отвергнуто — условие
-- разбора ушло бы за имя, в отдельный объект со своей жизнью, и слой перестал
-- бы читаться по своему же DDL.
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_errors_mv ON CLUSTER clickstream_cluster CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_errors_mv ON CLUSTER clickstream_cluster
TO ods.event_errors_dist TO ods.event_errors_dist
AS AS
+11 -21
View File
@@ -1,26 +1,16 @@
-- STG: матвью приёма — из чтеца в таблицу STG. -- Прием событий из Kafka в STG.
-- Подключение этого представления запускает чтение топика. Поэтому файл 40
-- применяется после таблиц и представлений разбора из файлов 20 и 30:
-- иначе часть сообщений сохранится в STG, но не попадет в ODS.
-- --
-- Номер 40, а не 11, и пропуск в нумерации намеренный. Файлы 20-ods-tables.sql -- Запись через stg.hits_raw_dist распределяет сообщения по ключу таблицы.
-- и 30-ods-views.sql приносит #43, и матвью приёма обязана создаваться после -- Она фоновая; возможную потерю при сбое описывает раздел «Приём»
-- матвью разбора: Kafka-движок начинает читать топик ровно тогда, когда к нему -- в docs/architecture/storage.md.
-- привязывают первую матвью. Создай её раньше разбора — и всё, что доедет в
-- зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в ошибки.
-- На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет:
-- проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, то есть на
-- обычной отладке. Нумерацию здесь не «приводить в порядок».
-- --
-- Пишем в stg.hits_raw_dist, а не в локальную таблицу: раскладку по шардам -- Сведения о доставке вычисляются здесь, на ноде чтения Kafka.
-- обязан определять ключ шардирования, а не то, какая нода случайно читала -- DEFAULT hostName() только в локальной таблице назвал бы шард-получатель.
-- топик. Вставка при этом фоновая — окно потери принято осознанно, довод -- Порядок SELECT совпадает с порядком колонок цели.
-- целиком в docs/architecture/storage.md, раздел «Приём».
--
-- Служебные колонки заполняются выражением здесь, а не DEFAULT в таблице.
-- Для consumer_host это обязательно: проверено на стенде 5 августа 2026 года —
-- DEFAULT hostName() вычисляется на шарде-получателе и назвал бы не ту ноду,
-- которая читала топик. hostName() в SELECT снимается на вставляющей ноде,
-- то есть отвечает ровно на нужный вопрос.
--
-- Порядок колонок в SELECT совпадает с порядком в целевой таблице.
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.hits_raw_mv ON CLUSTER clickstream_cluster CREATE MATERIALIZED VIEW IF NOT EXISTS stg.hits_raw_mv ON CLUSTER clickstream_cluster
TO stg.hits_raw_dist TO stg.hits_raw_dist
AS AS
+19 -42
View File
@@ -1,21 +1,9 @@
-- DDS: сборка дней заказа в таблицу-двойник перед заменой партиций. -- Сборка выбранных дней в донор перед заменой партиций DDS.
-- Донор очищается в начале: иначе новые строки добавились бы к прежним.
-- После сборки он остается для диагностики до следующего запуска.
-- --
-- Двойник очищается целиком в начале сборки, а не в конце: собранное остаётся -- DROP PARTITION ALL доступен роли etl через ALTER; отдельного права
-- лежать до следующего запуска, и разбор происшествия видит ровно то, чем -- TRUNCATE у нее нет (ADR 0007). Весь отрезок дней собирается одной вставкой.
-- подменили день. Очистка обязана быть первой ещё и по делу — вставка поверх
-- прежних строк удвоила бы день в доноре, а REPLACE PARTITION перенёс бы
-- удвоение в цель.
--
-- Дни собираются одной вставкой, а не по дню за раз: источник читается один
-- раз на весь отрезок. Кто раскладывает строки по шардам — вставка через
-- Distributed, ключ cityHash64(order_id) — разобрано в DDL двойника.
--
-- Чистит двойник DROP PARTITION ALL, а не TRUNCATE: последнее — отдельное
-- право, роли etl оно не выдано, а снятие всех партиций
-- входит в ALTER, который у неё есть (ADR 0007). Замерено: TRUNCATE отвечает
-- ролью etl кодом 497, DROP PARTITION ALL проходит. Расширять права ради
-- операции, для которой уже есть разрешённая форма, значило бы платить
-- доступом за синоним.
ALTER TABLE dds.order_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL; ALTER TABLE dds.order_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL;
@@ -37,18 +25,13 @@ INSERT INTO dds.order_stage_dist
_load_id, _load_id,
_load_ts _load_ts
) )
-- Позиции разбираются здесь один раз, на границе ODS → DDS. Цена на проводе — -- Разбор позиций из JSON: цена в строке сразу превращается в Decimal.
-- строка с двумя знаками, и JSONExtract читает её прямо в Decimal: замерено на -- ODS проверил только тип массива items. Неверное содержимое здесь может
-- живом слепке, промежуточный toDecimal64 не нужен. -- дать значения по умолчанию: qty вида "oops" и пропущенная цена дают 0,
-- -- скаляр вместо объекта выпадает из массива. Наблюдения —
-- Разбор по типу молчалив, и знать об этом надо заранее. Строгий приём ODS -- docs/architecture/storage.md, «Что проверено».
-- проверил только, что items — массив, внутрь он не смотрел; здесь тоже никто -- Проверку суммы позиций участник добавляет в docs/labs/5-1-order-checks.md.
-- не смотрит. Замерено: qty вида "oops" даёт 0, пропущенная цена даёт 0,
-- скаляр вместо объекта выпадает из массива целиком. На каноническом слепке
-- генератора такого не бывает, и сумма «цена × количество» сходится с
-- items_total у всех заказов, но сходится она по свойству источника, а не
-- потому, что её здесь кто-то проверяет. Постоянное место такой проверки —
-- контур DQ этапа 4; пока она делается руками при приёмке.
WITH JSONExtract( WITH JSONExtract(
items, 'Array(Tuple(sku String, qty UInt32, price Decimal(18, 2)))' items, 'Array(Tuple(sku String, qty UInt32, price Decimal(18, 2)))'
) AS lines ) AS lines
@@ -56,8 +39,7 @@ SELECT
order_id, order_id,
user_id, user_id,
status, status,
-- День заказа и оба времени переезжают в пояс счётчика: DDS обслуживает -- День и время в Europe/Samara: витрины используют готовые колонки.
-- человека с дашбордом, и пояс называется здесь в последний раз.
toDate(toTimeZone(created_at, 'Europe/Samara')), toDate(toTimeZone(created_at, 'Europe/Samara')),
toTimeZone(created_at, 'Europe/Samara'), toTimeZone(created_at, 'Europe/Samara'),
toTimeZone(updated_at, 'Europe/Samara'), toTimeZone(updated_at, 'Europe/Samara'),
@@ -68,22 +50,17 @@ SELECT
arrayMap(line -> line.sku, lines), arrayMap(line -> line.sku, lines),
arrayMap(line -> line.qty, lines), arrayMap(line -> line.qty, lines),
arrayMap(line -> line.price, lines), arrayMap(line -> line.price, lines),
-- Координаты запуска, собравшего день: у всех строк дня они одни. -- Метки этой сборки DDS, общие для всех вставляемых строк.
{load_id:String}, {load_id:String},
now64(3, 'UTC') now64(3, 'UTC')
FROM ods.order_v FROM ods.order_v
WHERE toDate(toTimeZone(created_at, 'Europe/Samara')) WHERE toDate(toTimeZone(created_at, 'Europe/Samara'))
BETWEEN {first_day:Date} AND {last_day:Date} BETWEEN {first_day:Date} AND {last_day:Date}
-- Вторая граница не меняет отбор, а открывает источнику отсечение -- Дополнительный фильтр сокращает чтение партиций ODS по UTC.
-- партиций: ODS нарезан по toDate(created_at) в UTC, и условие на -- Модельные сутки Europe/Samara пересекают две даты UTC, поэтому
-- вычисленный день заказа для этого ключа непрозрачно — сервер прочёл бы -- левая граница берется на день раньше. Точный отбор — условием выше.
-- все партиции. Сутки пояса счётчика ложатся ровно на две даты UTC,
-- поэтому день слева берётся с запасом.
AND toDate(created_at) BETWEEN {first_day:Date} - 1 AND {last_day:Date} AND toDate(created_at) BETWEEN {first_day:Date} - 1 AND {last_day:Date}
-- Тот же ноль, что в приёме заказов: заводская двойка исполнила бы вставку -- Ждем доставки на шарды и распределяем строки по ключу цели
-- локально на каждом шарде, минуя ключ шардирования цели, и строки унаследовали -- (docs/architecture/storage.md, «Раскладка по шардам»).
-- бы раскладку источника. Ключи ODS и DDS здесь совпадают, и раскладка вышла бы
-- верной — но по совпадению, а не по контракту (docs/architecture/storage.md,
-- «Раскладка по шардам», капкан #137).
SETTINGS distributed_foreground_insert = 1, SETTINGS distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0; parallel_distributed_insert_select = 0;
+12 -24
View File
@@ -1,28 +1,16 @@
-- DDS: подмена одного дня заказов собранной партицией двойника. -- Замена одного дня DDS готовой партицией донора.
-- Повтор при неизменном источнике сохраняет деловые данные без дублей.
-- --
-- Это и есть идемпотентность слоя. Повтор прогона безопасен, а «дыхание» -- ON CLUSTER выполняет замену на обеих нодах по локальным таблицам.
-- статусов внутри окна выражается сменой всей партиции, а не правкой строк. -- Донор и цель имеют одинаковую структуру и ключ шардирования
-- (sql/ddl/45-dds-tables.sql); REPLACE PARTITION строки не перераспределяет.
-- Замена атомарна внутри шарда. Между заменами на разных шардах читатель
-- может увидеть часть прежних и часть новых данных. После сбоя этот период
-- длится до успешного повтора. Пустая партиция донора удаляет день из цели.
-- --
-- Замена идёт по локальным таблицам, шард за шардом: ON CLUSTER рассылает её -- День подставляет Jinja: параметры ClickHouse в ALTER ... PARTITION
-- на обе ноды, а по Distributed то же самое не работает — операции с -- не поддерживаются в закрепленной версии 26.3. Значение получено
-- партициями отвечают кодом 48 (docs/architecture/storage.md, «Что -- из результата order_scope.sql; замер — docs/architecture/storage.md.
-- проверено»). Правильность пошардовой замены держится на том, что двойник
-- шардирован тем же ключом, что цель, — см. sql/ddl/45-dds-tables.sql.
--
-- Отсюда граница обещания: атомарность здесь пошардовая. Внутри шарда подмена
-- мгновенна — читатель видит либо прежний день целиком, либо новый; но две
-- локальные замены независимы, и в зазоре между ними чтение через Distributed
-- соберёт ответ из нового дня одного шарда и прежнего дня другого. Зазор
-- короткий, а отказ одной из двух замен растягивает его до повторного прогона.
--
-- Пустая партиция двойника — законный случай, а не сбой: день, в котором
-- заказов нет, снимает день из цели, и слой перестаёт показывать то, чего в
-- источнике больше нет.
--
-- День подставляет Jinja, а не параметр ClickHouse: в ALTER … PARTITION
-- сервер параметров не подставляет — замерено, отвечает синтаксической
-- ошибкой. Общее правило репозитория «параметры передаются отдельно, а не
-- строкой» здесь не нарушено по существу: значение пришло из ответа самого
-- ClickHouse на запрос объёма, а не снаружи стенда.
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dds.order_stage_rep; REPLACE PARTITION '{{ params.day }}' FROM dds.order_stage_rep;
+16 -37
View File
@@ -1,37 +1,17 @@
-- DDS: объём пересборки заказов — с какого дня по какой собирает этот запуск. -- Дни пересборки заказов: first_day и last_day, обе границы включены.
-- Текущее окно — дни оформления заказов из последнего snapshot_date.
-- Если в DDS нет дня, который есть в ODS, отрезок расширяется до первого
-- такого дня. Это восстанавливает пропуски даже за пределами текущего окна.
-- --
-- Параметров у дага нет: объём он выводит сам из состояния слоёв -- Дата слепка у актуальной версии показывает последнее наблюдение заказа
-- (docs/architecture/etl.md, «Объём прогона»). Ответ — две даты, границы -- (ADR 0014). Поэтому окно берется из данных, без копирования константы
-- непрерывного отрезка; список дней между ними разворачивает даг. Пустой -- генератора. FINAL здесь не нужен: created_at одинаков у всех версий,
-- источник даёт пустой ответ, и даг на нём останавливается с внятной ошибкой -- а строки последнего слепка сохраняются при слияниях.
-- вместо зелёного прогона, собравшего ничего.
-- --
-- Файл кончается без точки с запятой: даг читает его целиком и шлёт одним -- Пустой ODS не дает строки результата; даг завершится ошибкой.
-- запросом, дописывая формат ответа, — точка с запятой сделала бы из этого -- Точка с запятой в конце не нужна: клиент дописывает FORMAT ответа.
-- мультизапрос, который ClickHouse не принимает. -- Порядок пересборки — docs/architecture/etl.md, «Объём прогона».
--
-- Объём складывается из двух частей. Первая — дышащее окно: дни, которые нёс
-- последний слепок, то есть те, чьё состояние источник ещё может изменить.
-- Вторая — хвост от первого дня, которого в слое нет: первая сборка застаёт
-- слой пустым и собирает весь стартовый мир, а после разгона мира пачкой слой
-- догоняет уехавший вперёд источник.
--
-- **Хвост считается от первого недостающего дня, а не от последнего
-- собранного.** Разница не косметическая: замены партиций идут отдельными
-- задачами и падают порознь, поэтому упавший день оставляет дыру, пока
-- соседние дни собираются дальше. Считай мы прогресс по максимуму собранного
-- дня, дыра позади окна не заросла бы никогда, а прогоны продолжали бы
-- зеленеть над щербатым слоем. Цена честного ответа — проход по дню заказа
-- в ODS.
--
-- Окно читается из ярлыка слепка, а не вычисляется вычитанием окна
-- изменяемости: у выжившей строки snapshot_date — последний слепок, который
-- нёс этот заказ (ADR 0014). Поэтому оба края окна знает сам источник, а
-- копии его константы на стороне стенда больше нет.
--
-- FINAL не нужен ни здесь, ни ниже: время рождения заказа у всех его версий
-- одно, а строки последнего слепка несут максимум колонки версии — мерж
-- оставляет как раз их.
WITH WITH
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot, (SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
( (
@@ -44,10 +24,9 @@ WITH
FROM ods.order_dist FROM ods.order_dist
WHERE snapshot_date = last_snapshot WHERE snapshot_date = last_snapshot
) AS window_end, ) AS window_end,
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял -- GLOBAL NOT IN сравнивает дни со всем DDS. Локальное сравнение могло бы
-- бы дни источника с локальной частью слоя на каждом шарде — день, чьи -- счесть день пропущенным, если его заказы находятся на соседнем шарде.
-- заказы легли на соседний шард, показался бы недостающим. -- minOrNull возвращает NULL, когда пропущенных дней нет.
-- minOrNull отличает «дыр нет» от даты по умолчанию.
( (
SELECT minOrNull(day) SELECT minOrNull(day)
FROM ( FROM (
@@ -57,7 +36,7 @@ WITH
WHERE day GLOBAL NOT IN (SELECT order_date FROM dds.order_dist) WHERE day GLOBAL NOT IN (SELECT order_date FROM dds.order_dist)
) AS first_missing_day ) AS first_missing_day
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
WHERE (SELECT count() FROM ods.order_dist) > 0 WHERE (SELECT count() FROM ods.order_dist) > 0
+24 -42
View File
@@ -1,35 +1,20 @@
-- DDS: нарезка событий на визиты и сборка дней в таблицу-двойник. -- DDS: расчет сессий за дни от first_day до last_day включительно.
-- Сначала очищаем промежуточную таблицу session_stage_rep, затем записываем
-- в нее рассчитанные строки через session_stage_dist. Основная session_rep
-- остается прежней до выполнения session_replace.sql.
-- --
-- Двойник очищается целиком в начале, дни собираются одной вставкой на весь -- Первое окно помечает событие единицей, если с предыдущего события того же
-- отрезок — по правилу заказа (sql/dds/order_rebuild.sql). -- посетителя за тот же день прошло больше 1800 секунд. Второе окно суммирует
-- эти единицы: получается номер визита для каждого посетителя за день.
-- 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
@@ -93,8 +78,8 @@ WITH
FROM seams FROM seams
) )
SELECT SELECT
-- Чекан визита: кука и его начало. Время входит в хеш секундой эпохи, -- Идентификатор зависит от посетителя и начала визита.
-- поэтому id держится за содержание сессии, а не за порядок сборки. -- Повторная сборка тех же событий дает тот же session_id.
cityHash64(client_id, min(event_time)), cityHash64(client_id, min(event_time)),
client_id, client_id,
event_date, event_date,
@@ -107,8 +92,10 @@ 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),
@@ -120,12 +107,7 @@ 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
-- Капкана parallel_distributed_insert_select здесь нет, и это выигрыш -- Источник и цель распределены по cityHash64(client_id): локальная вставка
-- ко-локации: ключ шардирования у источника и цели один — cityHash64 от -- сохраняет нужную раскладку, parallel_distributed_insert_select менять
-- куки, — поэтому визит ляжет туда же, где лежали его события, исполнись -- не требуется. Перед заменой партиций ждем доставки строк на шарды.
-- вставка хоть на инициаторе, хоть локально на шардах. Заказу приходится
-- называть ноль вслух: совпадение ключей у него случайное
-- (sql/dds/order_rebuild.sql). Здесь выбор сделан обратный и он тоже
-- осознанный: настройку не называть, чтобы не притворяться, будто её значение
-- что-то решает.
SETTINGS distributed_foreground_insert = 1; SETTINGS distributed_foreground_insert = 1;
+8 -8
View File
@@ -1,11 +1,11 @@
-- DDS: подмена одного дня сессий собранной партицией двойника. -- DDS: заменяем раздел session_rep за выбранный день готовым разделом
-- промежуточной session_stage_rep. Раздел таблицы называется партицией.
-- ON CLUSTER выполняет замену на локальных таблицах каждого шарда.
-- На одном шарде раздел меняется целиком; общей транзакции для кластера нет.
-- Повтор с теми же строками session_stage_rep не добавляет копий в session_rep.
-- --
-- Механика и доводы целиком — sql/dds/order_replace.sql: замена идёт по -- Дату из session_scope.sql подставляет Jinja: параметр ClickHouse в
-- локальным таблицам, шард за шардом, атомарность пошардовая, день -- ALTER ... PARTITION здесь не поддерживается. Поиск дней следующего
-- подставляет Jinja, потому что параметров ALTER … PARTITION не берёт. -- запуска не обнаружит неполный день, если часть его строк уже есть в DDS.
--
-- Разница одна, и она про отказ. Несобранный день заказа вернуло бы в работу
-- следующее окно; сессиям возвращать нечем, и упавшая замена оставляет дыру
-- до тех пор, пока её не найдёт счёт хвоста (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;
+16 -35
View File
@@ -1,50 +1,31 @@
-- DDS: объём пересборки сессий — какие дни собирает этот запуск. -- DDS: дни для сборки сессий, от first_day до last_day включительно.
-- Без событий ODS строк в ответе нет; NULL в first_day — дней для расчета нет.
-- --
-- Ответ — две даты, границы непрерывного отрезка; список дней между ними -- position — номер следующего дня для генерации. Последний прожитый день
-- разворачивает даг. Пустой ответ означает пустой источник. Пустой первый -- равен min(EventDate) + position - 1. В отличие от max(EventDate), эта
-- день — что собирать нечего: все прожитые дни уже в слое, и прогон честно -- граница исключает еще идущий день. Ранние события ODS не удаляются по TTL,
-- пропускает сборку. -- поэтому min(EventDate) сохраняет начало модельного календаря.
-- --
-- Файл кончается без точки с запятой: почему — sql/dds/order_scope.sql. -- До этого запроса даг ждет чтения hits и опустошения очереди ods.event_dist.
-- Позиция мира сама по себе не подтверждает доставку событий.
-- --
-- Правый край — последний прожитый день, и знает его только позиция мира. -- Точки с запятой в конце нет: драйвер добавляет 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
+12 -8
View File
@@ -1,7 +1,10 @@
-- DM: сборка выручки в донор перед заменой дневных партиций. -- DM: расчет выручки за дни от first_day до last_day включительно.
-- DROP PARTITION ALL очищает локальные таблицы на каждом узле; причина не -- DROP PARTITION ALL очищает промежуточную revenue_daily_stage_rep.
-- применять TRUNCATE разобрана в соседнем sql/dds/order_rebuild.sql. -- Затем INSERT записывает по одной строке итогов на день и категорию товара.
-- Основная 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;
@@ -22,8 +25,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)'
@@ -49,7 +52,8 @@ 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;
+7 -6
View File
@@ -1,8 +1,9 @@
-- DM: подмена одного дня выручки готовой партицией донора. -- DM: заменяем раздел revenue_daily_rep за выбранный день готовым разделом
-- -- промежуточной revenue_daily_stage_rep. Раздел таблицы называется партицией.
-- День приходит из результата revenue_daily_scope.sql, а не от пользователя. -- Дату из revenue_daily_scope.sql подставляет Jinja: параметр ClickHouse
-- Jinja нужна потому, что ClickHouse не подставляет параметр в PARTITION. -- в ALTER ... 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;
+10 -14
View File
@@ -1,14 +1,11 @@
-- DM: объем пересборки выручки. -- DM: диапазон пересборки выручки, first_day и last_day включительно.
-- Последний слепок ODS задает даты оформления заказов, которые еще могут
-- измениться. Саму выручку считаем по DDS; ODS нужен только для выбора дат.
-- Левая граница расширяется до первого дня с оплаченными заказами DDS,
-- которого нет в витрине. Так первый запуск охватывает историю выручки.
-- По наличию даты нельзя обнаружить частично заполненный день.
-- --
-- Последний слепок ODS называет дни, состояние которых источник еще может -- Точки с запятой в конце нет: драйвер добавляет FORMAT к этому запросу.
-- изменить. Сам агрегат читает только 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,
( (
@@ -24,8 +21,7 @@ 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
) )
@@ -33,7 +29,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
+27 -46
View File
@@ -1,24 +1,15 @@
-- Разбор одного среза заказов: сначала годные версии, затем брак. -- Разбор сохраненной порции заказов: сначала годные версии, затем ошибки.
-- Макрос Jinja подставляет одно условие годности в обе вставки.
-- Вторая вставка берет его отрицание, чтобы каждая строка получила результат.
-- Условие должно давать только 0 или 1 и не вызывать исключений на неверном raw.
-- --
-- Обе вставки должны делить один предикат. Локальный макрос Jinja -- Проверяются одиннадцать полей верхнего уровня. Внутри items поля не проверяются;
-- разворачивает его в каждое выражение до того, как оператор разделит файл по -- позиции разбирает DDS, а равенства сумм проверяются отдельно.
-- точкам с запятой. Так контракт остаётся рядом с запросами и не становится -- Точные правила — docs/architecture/orders/ingestion.md, «Граница строгого приёма».
-- третьим SQL-файлом, который нельзя исполнить самостоятельно.
--
-- Строгий приём проверяет форму провода и ничего сверх неё. Ключей одиннадцать,
-- и проверяются все: десять скалярных значений прямо образуют типизированную
-- строку заказа. Внутрь items приём не смотрит — содержимое позиций, переходы
-- статуса и равенства сумм остаются ниже этой границы
-- (docs/architecture/orders/ingestion.md, «Граница строгого приёма»).
--
-- У общего предиката два обязательных свойства. Он всегда даёт 0 или 1, не
-- NULL: иначе строку не возьмут ни условие, ни его отрицание. И он не бросает
-- исключений на произвольном raw: одна грязная строка не должна ронять весь
-- переход, ради этого и существует таблица брака.
{% macro order_wire_contract() %} {% macro order_wire_contract() %}
WITH WITH
-- Каноническое время провода: UTC, ровно три знака долей секунды. -- Время источника: UTC, ровно три знака долей секунды.
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'' AS ts_mask, 'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'' AS ts_mask,
JSONType(raw) = 'Object' AS is_object, JSONType(raw) = 'Object' AS is_object,
arraySort(JSONExtractKeys(raw)) = arraySort([ arraySort(JSONExtractKeys(raw)) = arraySort([
@@ -28,15 +19,14 @@ WITH
]) AS keys_match, ]) AS keys_match,
JSONType(raw, 'order_id') = 'String' JSONType(raw, 'order_id') = 'String'
AND JSONType(raw, 'status') = 'String' AND JSONType(raw, 'status') = 'String'
-- Целое у JSONType зовётся двумя именами, и нужны оба: с одним -- JSONType различает Int64 и UInt64; принимаются оба, если значение
-- Int64 законный идентификатор за 2^63 уехал бы в брак. -- разбирается в UInt64, в том числе идентификаторы больше 2^63−1.
AND JSONType(raw, 'user_id') IN ('Int64', 'UInt64') AND JSONType(raw, 'user_id') IN ('Int64', 'UInt64')
AND JSONExtract(raw, 'user_id', 'Nullable(UInt64)') IS NOT NULL AND JSONExtract(raw, 'user_id', 'Nullable(UInt64)') IS NOT NULL
AND JSONType(raw, 'items') = 'Array' AND JSONType(raw, 'items') = 'Array'
-- Форму держит регулярка, реальность — разбор, и порознь они дырявы: -- Регулярное выражение проверяет точную форму, включая ведущие нули.
-- маска берёт «2026-6-3T…» без ведущих нулей, а регулярка пропускает -- Разбор проверяет само время: например, отвергает 30 февраля.
-- 30 февраля. Обе половины измерены — docs/architecture/storage.md, -- Замеры функций — docs/architecture/storage.md, «Что проверено».
-- «Что проверено».
AND arrayAll(k -> AND arrayAll(k ->
JSONType(raw, k) = 'String' JSONType(raw, k) = 'String'
AND match(JSONExtractString(raw, k), AND match(JSONExtractString(raw, k),
@@ -44,9 +34,8 @@ WITH
AND parseDateTime64InJodaSyntaxOrNull( AND parseDateTime64InJodaSyntaxOrNull(
JSONExtractString(raw, k), ts_mask, 'UTC') IS NOT NULL, JSONExtractString(raw, k), ts_mask, 'UTC') IS NOT NULL,
['created_at', 'updated_at']) ['created_at', 'updated_at'])
-- Деньги — строка с ровно двумя знаками после точки. Регулярка держит -- Сумма в рублях: строка с двумя знаками после точки, без минуса.
-- форму, разбор — вместимость: у строки из двадцати девяток форма -- После проверки формы проверяется возможность разбора в Decimal.
-- хороша, а в Decimal(18, 2) она не влезает.
AND arrayAll(k -> AND arrayAll(k ->
JSONType(raw, k) = 'String' JSONType(raw, k) = 'String'
AND match(JSONExtractString(raw, k), '^\\d+\\.\\d{2}$') AND match(JSONExtractString(raw, k), '^\\d+\\.\\d{2}$')
@@ -60,11 +49,9 @@ WITH
is_object AND keys_match AND fields_valid AS row_is_valid is_object AND keys_match AND fields_valid AS row_is_valid
{% endmacro %} {% endmacro %}
-- Годные версии заказа. Срез читается по _load_id — тому же, что проставил -- Годные строки сохраненного среза по _load_id запуска приема.
-- забор: разбор идёт по неизменной порции, а не по «всему, что появилось». -- WHERE исключает строки с NULL в разобранных полях; assumeNotNull
-- -- приводит оставшиеся значения к типам целевой таблицы.
-- Разобранные значения берёт assumeNotNull: обнуляемый разбор стоит за
-- предикатом, который NULL уже отсёк, и приведение здесь не может упасть.
INSERT INTO ods.order_dist INSERT INTO ods.order_dist
{{ order_wire_contract() }} {{ order_wire_contract() }}
@@ -81,30 +68,25 @@ SELECT
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'discount'), 2)) AS discount, assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'discount'), 2)) AS discount,
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'delivery'), 2)) AS delivery, assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'delivery'), 2)) AS delivery,
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'total'), 2)) AS total, assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'total'), 2)) AS total,
-- items кладётся сырым фрагментом JSON, а не разобранной структурой. -- items сохраняется строкой JSON для разбора позиций в DDS.
JSONExtractRaw(raw, 'items') AS items, JSONExtractRaw(raw, 'items') AS items,
toDate(assumeNotNull(parseDateTimeInJodaSyntaxOrNull( toDate(assumeNotNull(parseDateTimeInJodaSyntaxOrNull(
JSONExtractString(raw, 'snapshot_date'), JSONExtractString(raw, 'snapshot_date'),
'yyyy-MM-dd', 'UTC'))) AS snapshot_date, 'yyyy-MM-dd', 'UTC'))) AS snapshot_date,
-- Метки запуска и прибытия переносятся как есть. Поставь здесь now64(3) — -- _load_ts остается временем приема в STG; _load_id связывает с исходной порцией.
-- и _load_ts молча ответила бы на другой вопрос: «когда разобрали».
_load_id, _load_id,
_load_ts _load_ts
FROM stg.orders_raw_dist FROM stg.orders_raw_dist
WHERE _load_id = {load_id:String} AND row_is_valid WHERE _load_id = {load_id:String} AND row_is_valid
-- Заводская двойка parallel_distributed_insert_select исполняет вставку из -- Ноль заставляет распределить результат по ключу цели. При значении 2
-- Distributed в Distributed локально на каждом шарде, минуя ключ шардирования -- вставка может сохранить раскладку STG, где ключ — raw, вместо order_id.
-- цели, — так заказы и расползлись по обоим шардам. Ноль отдаёт раскладку -- Подробнее — docs/architecture/storage.md, «Раскладка по шардам».
-- инициатору, по ключу цели. Капкан целиком — docs/architecture/storage.md,
-- «Раскладка по шардам».
SETTINGS distributed_foreground_insert = 1, SETTINGS distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0; parallel_distributed_insert_select = 0;
-- Брак: тот же срез и буквальное отрицание того же предиката. -- Ошибки: тот же срез и отрицание условия годности.
-- -- Класс определяется первым нарушенным условием: например, JSON-строка
-- Классы перекрываются, поэтому проверяются по порядку, а в error_class идёт -- не проходит и проверку объекта, и проверку ключей, но получает not_an_object.
-- первый совпавший: скаляр проваливает и проверку на объект, и сверку ключей —
-- без объявленного порядка он попал бы то в один класс, то в другой.
INSERT INTO ods.order_errors_dist INSERT INTO ods.order_errors_dist
{{ order_wire_contract() }} {{ order_wire_contract() }}
@@ -124,7 +106,6 @@ SELECT
_load_ts _load_ts
FROM stg.orders_raw_dist FROM stg.orders_raw_dist
WHERE _load_id = {load_id:String} AND NOT row_is_valid WHERE _load_id = {load_id:String} AND NOT row_is_valid
-- Тот же ноль: ключ брака сегодня совпадает с ключом STG, и локальная -- Та же настройка: распределяем строки по ключу целевой таблицы ошибок.
-- запись легла бы верно — но по совпадению, а не по контракту.
SETTINGS distributed_foreground_insert = 1, SETTINGS distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0; parallel_distributed_insert_select = 0;
+7 -11
View File
@@ -1,15 +1,11 @@
-- Забор порции слепка заказов из Kafka в STG. -- Порция сообщений заказов из Kafka в STG, без разбора JSON.
-- Один SELECT читает одну порцию; непрочитанное остается до следующего запуска.
-- Полнота слепка не проверяется. Если после чтения вставка упадет, подтвержденные
-- сообщения уже не перечитаются (docs/architecture/orders/ingestion.md).
-- --
-- Забор — один прямой SELECT, без цикла до пустоты: одна порция ClickHouse -- stream_like_engine_allow_direct_select разрешает прямое чтение Kafka;
-- берёт десятки тысяч сообщений, а слепок дня — порядка полутора тысяч строк. -- kafka_commit_on_select включен в sql/ddl/10-stg-tables.sql.
-- Короткая порция оставит хвост до следующего прогона, а отказ после чтения -- distributed_foreground_insert = 1 ждет записи на шарды до завершения задачи.
-- унесёт прочитанное с собой: офсеты коммитятся в момент чтения. Граница
-- целиком — ADR 0008, «Следствия».
--
-- stream_like_engine_allow_direct_select разрешает читать чтеца запросом; вторая
-- половина пары объявлена на самой таблице (sql/ddl/10-stg-tables.sql).
-- distributed_foreground_insert = 1 — конвенция ETL-вставок стенда: задача не
-- должна зеленеть раньше, чем строки легли на шарды.
INSERT INTO stg.orders_raw_dist INSERT INTO stg.orders_raw_dist
SELECT SELECT