26 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
ddadmin 915e976fc2 docs(handbook): упрощен язык путеводителя по стенду
Зачем: помочь учащемуся понять путь данных и работу служб перед выполнением заданий.

Что: вычитаны оглавление и пять глав, пояснены термины, убраны внутренние метафоры и повторы. Уточнены пояснения к SQL и показателям мониторинга.

Проверка: 62 локальные ссылки и 6 входящих ссылок на разделы проверены; 8 блоков SQL и схем сохранены. git diff --cached --check прошел.
2026-09-13 13:02:40 -04:00
ddadmin ce15836093 docs(readme): упрощен язык вводной инструкции
Зачем: помочь учащемуся понять назначение стенда и порядок работы с ним.

Что: убраны внутренние метафоры, пояснены термины, настройка перенесена перед запуском. Обновлена ссылка из путеводителя и удалена ссылка на предыдущий стенд.

Проверка: команды, адреса и пароли сохранены; 24 ссылки README и входящие ссылки на разделы проверены. git diff --cached --check прошел.
2026-09-13 12:47:16 -04:00
79 changed files with 3927 additions and 3083 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_BI_PASSWORD=bi-26-reader
CLICKHOUSE_ANALYST_PASSWORD=analyst-26-reader
CLICKHOUSE_STUDENT_PASSWORD=student-26-lab
CLICKHOUSE_GRAFANA_PASSWORD=grafana-26-reader
KAFKA_EXTERNAL_PORT=29092
PROMETHEUS_PORT=29090
+3 -8
View File
@@ -46,15 +46,10 @@
## Кому что поручать
Читатель здесь не побочный потребитель, а тот, ради кого стенд существует:
код, комментарии и документы и есть продукт. Поэтому всё, что человек будет
читать и разбирать, пишет модель с чувством меры — сейчас это Opus. Codex
берёт техническую работу без вкусовых решений: миграции, разбор журналов и
объёмного вывода, обход репозитория, повторяющиеся правки. Там его запас
лимитов работает на нас, а экономить их на читаемом коде — экономия ложная:
переписывать выйдет дороже, чем сразу написать хорошо.
Автор задачи сам пишет и редактирует код, комментарии и документы, если
владелец не задал другое распределение работы.
Делить работу между ними стоит не целыми задачами, а подзадачами, и решать
Делить работу стоит не целыми задачами, а подзадачами, и решать
это при планировании. Вкусовая часть идёт первой: она задаёт форму, имена и
границы, под которые механическая потом подстраивается.
+247 -201
View File
@@ -1,229 +1,275 @@
# Кликстрим-платформа (стенд v2)
Словарь понятий проекта: одни и те же слова для одних и тех же вещей —
у владельца, кода, документов и агентов. Только язык, никаких решений.
Словарь понятий, которые встречаются в учебных заданиях, документации и коде
стенда.
## Язык
**Хранилище**:
Аналитическая база стенда — кластер ClickHouse со слоями STG/ODS/DDS/DM.
Принимающая сторона границы «трекер | хранилище»: нормализует имена и стили
источников, строится по их документации.
_Избегать_: склад, склад данных
- [Люди и события](#люди-и-события)
- [Хранилище и форматы данных](#хранилище-и-форматы-данных)
- [Заказы](#заказы)
- [Модельное время и запуск стенда](#модельное-время-и-запуск-стенда)
- [Устройство генератора](#устройство-генератора) — для чтения его кода
и технических справочников.
**Сырые данные**:
Слой STG хранилища и его содержимое: сообщение топика, положенное строкой,
как пришло, без разбора. В прозе — сырые данные и сырой слой; про
конкретную таблицу или базу — STG.
_Избегать_: сырьё
**Состав мира**:
Постоянная часть мира генератора — популяция посетителей, их привычки,
двухкуковые пары. По дням его выдаёт план состава.
_Избегать_: состояние мира
**План состава**:
Способ спросить состав мира: функция зерна, выдающая его по дням —
когорту новых кук, их возвраты, назначенные заказы двухкуковых пар.
**Подпоток**:
Ветвь дерева случайности генератора: своё зерно у состава мира, у каждого
дня и у каждого компонента дня. Подпоток задан позицией в дереве, а не
порядком вычислений.
**Конфигурация мира**:
Модуль чистых данных со всеми числами мира: приток, профили возвратов, доли
покупателей, D0. Правка модуля — смена мира. Модуль-близнец контракта схемы:
там колонки, здесь числа.
**Когорта дня**:
Люди, впервые пришедшие в мир в один день, со всеми их куками и днями
активности. Единица плана состава: когорта — функция зерна и номера дня.
### Люди и события
**Человек**:
Устойчивая личность в составе мира, которой принадлежат одна или две куки.
Не поле источника: кликстрим видит куку, а заказ — пользователя магазина.
Участник модели магазина, который может заходить с одного или двух
устройств. Генератор знает, какие идентификаторы посетителя ему принадлежат,
но в событиях сайта этой связи нет.
**Посетитель**:
Кука, наблюдаемая в кликстриме и обозначенная `ClientID`; единица
`uniq(ClientID)`. Один человек может быть представлен несколькими посетителями.
_Избегать_: «посетитель» про человека после склейки
Браузер на устройстве, который счетчик сайта узнает по куке — сохраненному
в браузере идентификатору. В кликстриме он обозначается `ClientID`:
`uniq(ClientID)` считает посетителей, и у одного человека их может быть несколько.
_Избегать_: «посетитель» про человека после объединения его идентификаторов.
**Пользователь магазина**:
Представление человека со стороны заказов; в записи заказа обозначается
`user_id`. Через заказы связывает разные куки человека, в кликстрим напрямую
не попадает.
**Приток**:
Появление новых кук на всём протяжении оси модельного времени; единица —
кука (`ClientID`). Из-за притока накопленная аудитория растёт с
горизонтом и не совпадает с дневной.
**Хвост возвратов**:
Окно активности, отсчитанное от первого дня человека и общее на обе
его куки; дольше окна кука не возвращается.
**Предыстория**:
Когорты плана с первым днём активности до D0; событий не порождают.
**День активности**:
День, в который кука хоть раз появилась в мире: день её рождения или день
возврата. Единица плана состава: за день он у куки один, а визитов внутри
него бывает несколько.
_Избегать_: визит (визит — про сессию)
Человек, известный серверной части магазина под идентификатором `user_id`.
Он указан в заказах и позволяет связать разные `ClientID` одного человека;
в кликстриме поля `user_id` нет. Например, покупки с телефона и ноутбука
могут иметь разные `ClientID`, но один `user_id` в заказах.
**Визит**:
Подряд идущие события одной куки без пауз длиннее 30 минут, не пересекающие
границу суток. Поле `VisitID` — эталон для лабы сессий. «Сессия» — то же
понятие словами аналитики: в данных и в коде оно зовётся визитом, но имена
вроде «лаба сессий» и «сборка сессий» остаются.
Последовательность событий одного посетителя без пауз длиннее 30 минут
и без перехода через полночь по часовому поясу счетчика. Сессия означает
то же самое; поле `VisitID` служит эталоном в задании по нарезке сессий.
**День активности**:
День, в который посетитель появился на сайте хотя бы раз: впервые или
вернувшись. За один день активности у него может быть несколько визитов.
_Избегать_: визит в значении целого дня активности.
**Паспорт куки**:
Устройство и город, приписанные куке на всю жизнь: кука — это браузер на
устройстве. Держит их план состава, разворачивает в поля события
день-функция; у двухкуковой пары город один на две куки, устройства разные.
**Покупатель**:
Человек, которого план состава пометил склонным покупать. Метка значима:
помеченный доходит до заказа заметно чаще прочих, но и непомеченный иногда
покупает. Из покупателей отбираются двухкуковые пары.
_Избегать_: «покупатель» про того, кто купил в конкретный день — это визит
с заказом.
**День-функция**:
Функция (зерно, номер дня), выдающая упорядоченный поток событий этих
модельных суток. Состояния между днями нет: день D не зависит от того,
прожиты ли дни до него.
Устройство и город, закрепленные за посетителем во все дни его активности.
Генератор записывает их в поля событий. У двух посетителей одного человека
город совпадает, а устройства различаются.
**Торговое событие**:
Событие корзины или покупки — отдельная строка потока, а не просмотр
страницы. Садится на ту страницу, где случилось: корзина — на карточку
товара, покупка — на страницу подтверждения заказа.
Запись о добавлении товара в корзину или о покупке: `add_to_cart` или
`purchase`. Это отдельная строка кликстрима со страницей, где произошло
действие: карточкой товара для корзины или страницей подтверждения для покупки.
### Хранилище и форматы данных
**Хранилище**:
Аналитическая база стенда в ClickHouse. Данные проходят слои STG, ODS, DDS
и DM: от исходных сообщений до витрин для отчетов. Хранилище принимает
данные по документации источников и приводит их к своей модели и именам.
_Избегать_: склад, склад данных.
**Сырые данные**:
Исходные сообщения Kafka, сохраненные в слое STG без разбора: одно сообщение
занимает одну строку таблицы. Слой STG также называют сырым слоем.
_Избегать_: сырьё.
**Справочник**:
Данные, которые хранилище не производит, а получает готовыми со стороны:
содержимым владеет источник вне хранилища, слои только читают. Живут в базе
`dic`. Первый и пока единственный — каталог товаров.
_Избегать_: словарь — этим словом в проекте зовут и глоссарий понятий, и
объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник доступен.
Готовые сведения из внешнего источника, которыми хранилище дополняет
свои данные. На стенде они находятся в базе `dic`; пока это только каталог
товаров. Объект `DICTIONARY` ClickHouse — одна из форм доступа к справочнику.
_Избегать_: словарь в значении справочных данных: это слово уже обозначает
`DICTIONARY` и словарь понятий.
**Каталог товаров**:
`data/catalog/products.csv` — общий файл генератора и словаря
ClickHouse. Форма файла решена, длина — нет: строки дописываются.
**Уровень спроса**:
Свойство товара в каталоге: как часто открытую карточку кладут в корзину.
Три значения — магнит, обычный, залёживается. Постоянная часть мира, а не
поведение дня: он и делает конверсию «просмотр → корзина» по товарам и
брендам различимой.
**Ось модельного времени**:
Собственный календарь мира генератора. Дни считаются от фиксированного
первого дня D0; реальный календарь в модели не участвует. Между прогонами
живут только зерно и позиция на оси.
**Стартовый мир**:
Первые восемь дней оси (понедельник по понедельник), которыми стенд
наполняется при каждом `make up`. Он же эталонный мир, всегда один и тот же:
на нем принимаются следующие этапы.
_Избегать_: зерновой мир
**Паспорт мира**:
`data/world-inventory.json` — единственное, что о мире хранится в git: шапка
(зерно, версия генератора, хеш каталога), строка на каждый день событий
(число и хеш байтов) и строка на каждый отправленный слепок (хеш байтов).
Счётчик сверх этого заводится только с названным читателем. Сам мир не
хранится, а пересчитывается; паспорт отвечает на один вопрос: тот ли это мир.
_Избегать_: опись мира, манифест, мини-манифест
**Пошаговый режим**:
Базовый способ движения по оси модельного времени: «прожить следующий
день» — явное действие.
**Живой день**:
Проигрывание текущего модельного дня в реальном времени с ускорением;
включается по требованию — постоянным фоном идёт, только пока включён
выключатель `world_live`.
**Пакетный режим**:
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
стенда, пересборки и проверки.
**Даги модельного времени**:
Даги Airflow, которыми двигают ось модельного времени: шаговые даги играют
день — пачкой или в темпе, — а выключатель держит стенд живущим, пока включён.
Коротко — даги времени.
_Избегать_: пульт мира, управляющие даги
**Шаговый даг**:
Даг модельного времени, который играет день и ставит позицию на оси:
`world_next_day` — пачкой, `world_live_day` — в темпе. Запускается руками,
расписания и паузы не имеет; автоматику поверх него даёт выключатель.
_Избегать_: работник
**Граница суток**:
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
снимается на ней, день проживается только целиком.
**Контракт схемы**:
Python-модуль с описателями колонок события — собственность генератора.
Из него выводятся генератор, валидация и документация формата; хранилище
строится по документации, не по модулю.
Список товаров с артикулами, названиями, категориями, брендами, ценами
и уровнями спроса. Файл `data/catalog/products.csv` общий для генератора
и справочника ClickHouse; товары можно добавлять строками в этом формате.
**Нормализованное имя**:
Имя колонки источника, приведённое к нашему стилю (snake_case). Живёт в
контракте схемы и в описании выгрузки. Не то же, что имя атрибута в модели
данных: слой DDS складывает модель и называет атрибуты по ней.
Имя поля источника, записанное в стиле `snake_case`: например,
`ClientID` становится `client_id`. Оно указано в описании выгрузки и
контракте схемы. Слой DDS выбирает имена по своей модели данных, поэтому
они не обязаны повторять нормализованные имена источника.
**Описание выгрузки**:
Публичная документация формата события: таблица колонок, собранная из
контракта схемы. По ней пишется сторона хранилища — как в бою по документации
источника. Правится только контракт, документ пересобирается.
_Избегать_: описание схемы, документация контракта
**Канонический сериализатор**:
Единственная граница, где запись генератора превращается в байты. Фиксированный
порядок ключей и строк — основа побайтовой воспроизводимости.
**Проигрыватель**:
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
(пачкой, без темпа) и живой день.
**Слепок**:
Полная выгрузка заказов окна изменяемости, снятая бэкендом на границе суток:
состояние заказов на этот момент, а не поток их изменений. Опоздавший заказ
может отсутствовать в ранних слепках; после первого появления ездит до конца
своего окна.
**Дата слепка**:
Дата завершившегося модельного дня, состояние которого снято на исходящей
границе суток. Одна для всех записей выгрузки; не дата заказа и не время
отправки сообщения.
**Время аудита источника**:
Время создания или последнего изменения строки заказа по часам базы источника.
В записи слепка это `created_at` и `updated_at`; бизнес-время покупки живёт в
событии `purchase` отдельно.
**Судьба заказа**:
Исход, моменты изменений, вычеркнутая позиция и опоздание заказа — всё
решается бросками при его рождении и укладывается в окно изменяемости целиком.
Слепок любого дня — чтение готовой судьбы, а не накопление состояния.
**Окно изменяемости**:
Сколько модельных дней заказ ещё может измениться и потому продолжает ездить
в слепках. Константа мира: семь дней. За окном заказ замёрз, выручка дня
перестала «дышать».
**Пакетный забор**:
Способ приёма топика, при котором данные тянет запрос по команде, а не
матвью непрерывно. Так стенд принимает заказы: у слепка есть начало и конец,
и забирать его уместно тогда, когда он собран целиком. Противоположность —
потоковый приём событий.
_Избегать_: путать с приёмником и пакетным режимом — те про сторону
генератора, про отправку; забор — про сторону хранилища, про чтение.
Документ, который объясняет поля и формат событий источника:
[`docs/formats/clickstream-event.md`](docs/formats/clickstream-event.md).
По нему строится прием данных в хранилище. Документ собирается из
описаний колонок генератора: правят их, затем пересобирают документ.
_Избегать_: описание схемы, документация контракта.
**Техническая проверка**:
Проверка, чей оракул — внутренний эталон стенда (паспорт мира), которого у
настоящего магазина нет. За учебный урок 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 или файл. В пакетном режиме он отправляет их без пауз,
в режиме живого дня — с ускорением по времени событий.
+101 -77
View File
@@ -5,132 +5,156 @@
> курсовая работа. Если вам нужна помощь, обратитесь к автору материалов —
> ментору [@dementev_dev](https://t.me/dementev_dev).
Этот стенд — для изучения и экспериментов с кликстримом и ClickHouse. Здесь
можно проследить весь путь данных: от кликстрима и заказов до витрин, проверок
и графиков. Это преемник учебного стенда
[clickstream-ch-kafka-superset-demo](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo).
Здесь вы будете работать с хранилищем данных небольшого интернет-магазина:
загружать и обрабатывать данные в ClickHouse, строить витрины, проверять
качество данных и создавать графики. Стенд рассчитан на тех, кто уже изучил
основы ClickHouse и хочет попрактиковаться.
В стенде подняты кластер ClickHouse из двух шардов, отдельный keeper, Kafka,
Airflow, Superset, Prometheus и Grafana; события кликстрима и заказы бэкенда
приезжают из генератора и идут по слоям STG → ODS → DDS → DM. `make up`
наполняет стенд стартовым миром — первой неделей модельного времени. Чтобы
разобраться в устройстве, начните с [путеводителя](docs/handbook/README.md), а
учиться — с [уроков](docs/labs/README.md).
Генератор создает заказы и кликстрим — записи о действиях посетителей
магазина. Данные поступают через Kafka в ClickHouse и проходят четыре слоя
обработки: STG, ODS, DDS и DM. В кластере ClickHouse два шарда и отдельный
ClickHouse Keeper. Airflow управляет загрузкой и обработкой, Superset
показывает отчеты, а Prometheus и Grafana помогают следить за работой стенда.
При первом запуске стенд загрузит данные за неделю работы магазина.
После запуска начните с [путеводителя](docs/handbook/README.md): он объясняет
устройство хранилища. Затем переходите к [учебным заданиям](docs/labs/README.md).
## Быстрый старт
Нужны Docker с Compose и около 8 ГБ памяти, доступной Docker. Это не объём
ноутбука, а то, что отдано самому Docker: в Docker Desktop и WSL2 он живёт
внутри виртуальной машины и получает лишь часть памяти хозяина. Сколько выдано
сейчас, в байтах, покажет `docker info --format '{{.MemTotal}}'`. В WSL2 это
поднимается параметром `memory` в файле `.wslconfig` домашнего каталога
пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не
хватает, стенд одинаково хорошо живёт на недорогом VPS.
Для запуска нужны Docker с Compose, `make`, `curl` и `jq`.
Выделите Docker около 8 ГБ оперативной памяти. В Docker Desktop и WSL2
проверьте лимит памяти виртуальной машины: он может быть меньше, чем общий
объем памяти компьютера. Команда `docker info --format '{{.MemTotal}}'`
покажет доступный Docker объем в байтах. В WSL2 лимит задается параметром
`memory` в файле `.wslconfig` в домашнем каталоге пользователя Windows.
После изменения выполните `wsl --shutdown`. Если ресурсов компьютера
недостаточно, стенд можно запустить на VPS.
`make up` нужны `curl` и `jq`: первым он ходит в Airflow, вторым читает
паспорт мира, по которому ждет заливки. По умолчанию должны быть свободны
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
и `29092`. Всем целям генератора в `generator/` нужен `uv`.
По умолчанию должны быть свободны порты
`23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
и `29092`. Для работы с командами `make` в каталоге `generator/` также
понадобится `uv`.
Сначала скопируйте настройки стенда:
В каталоге репозитория скопируйте образец настроек:
```bash
cp .env.example .env
```
В `.env` задаются имя экземпляра стенда, внешние порты, учебные логины,
пароли и ключи. Проверьте `DOCKER_GID`: это числовой идентификатор группы
`docker` на вашей машине. Узнать его можно командой `getent group docker`.
Если число отличается от образца, замените значение в `.env`. От этой
настройки зависит запуск генератора из Airflow.
Запустите стенд:
```bash
make up
```
`make up` сам ждет заливки стартового мира; что он делает по шагам и какие
проверки есть у стенда, описано в [карте
проверок](docs/architecture/testing.md).
Команда запустит службы и дождется загрузки начальных данных. Порядок запуска
и проверки результата описаны в [справочнике проверок](docs/architecture/testing.md).
Если обязательной настройки в `.env` не хватает, Compose сообщит об ошибке.
Внутренние адреса служб, имена топиков и версии образов заданы в `compose.yaml`
и Dockerfile.
`.env.example` — образец обязательной локальной настройки. В нём живут имя
экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца
верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и
свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь,
кроме [дагов модельного
времени](docs/architecture/world.md#даги-модельного-времени). `compose.yaml` не
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
Учётные данные Postgres и Grafana применяются при создании их томов.
После первого запуска меняйте их только вместе с `make clean`: команда удалит
все локальные данные стенда, а следующий `make up` создаст их с новыми
значениями.
Логины и пароли Postgres и Grafana сохраняются при первом запуске в томах
Docker. Простая правка `.env` их не изменит. Чтобы задать новые значения
через `.env`, потребуется `make clean` и повторный `make up`. При этом
будут удалены все локальные данные стенда.
## Адреса и пароли
С настройками из `.env.example` порты доступны только с локальной машины:
- нода 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
- нода 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
- узел ClickHouse 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
- узел ClickHouse 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
- Kafka — `127.0.0.1:29092`;
- Airflow — `http://127.0.0.1:28080`, пользователь `admin`, пароль `airflow`;
- Superset — `http://127.0.0.1:28088`, пользователь `admin`, пароль `superset`;
- Prometheus — `http://127.0.0.1:29090`;
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
Службы, пользователи и роли, применение настроек и результаты сверки
конфигурации описаны в [справочнике стенда](docs/architecture/stand.md).
Подробнее о службах, правах пользователей и изменении настроек —
в [справочнике стенда](docs/architecture/stand.md).
## Сдвинуть мир вперед
## Добавить данные за следующие дни
В Airflow запустите даг `world_next_day`: по умолчанию он сыграет один день, а
в форме запуска можно задать другое число дней. Снимите с паузы `world_live`,
и мир поедет сам в темпе модельного времени. Подробности — в [справочнике
мира](docs/architecture/world.md#даги-модельного-времени).
Время в учебном магазине идет отдельно от календарного. В документации оно
называется модельным временем, а сам магазин с его посетителями и заказами —
миром.
## Вернуть мир к началу
В Airflow запустите `world_next_day`. Это даг — набор связанных задач,
которые Airflow выполняет в заданном порядке. Он создаст и загрузит данные
за следующий день работы магазина. В форме запуска можно задать число дней.
`make rebuild-storage` запустит в Airflow явную пересборку: удалит прикладные
базы ClickHouse, топики и позиции потребителей Kafka, затем создаст схему
и данные заново. Службы и их тома останутся на месте. Перед пересборкой
поставьте `world_live` на паузу и дайте уже начатому шаговому дагу закончить
день.
Чтобы новые данные поступали непрерывно, снимите с паузы даг `world_live`.
Он будет запускать генерацию день за днем; один модельный день занимает
около 24 минут. Подробности — в [справочнике генератора и модельного
времени](docs/architecture/world.md#даги-модельного-времени).
## Выключить или сбросить целиком
## Восстановить начальные данные
`make down` остановит контейнеры без удаления данных. `make clean` удалит весь
стенд вместе с именованными томами и для обычной работы не нужен.
Если хотите начать заново, поставьте `world_live` на паузу и дождитесь
завершения уже запущенного дага генерации. Затем выполните
`make rebuild-storage`.
Команда удалит прикладные базы ClickHouse, топики и позиции потребителей
Kafka, затем заново создаст схему хранилища и загрузит начальные данные.
Службы и их тома останутся на месте.
## Остановить или удалить стенд
`make down` остановит стенд и сохранит данные. Для повторного запуска
выполните `make up`.
`make clean` удалит контейнеры и все данные в томах Docker. Для обычного
завершения работы достаточно `make down`.
## Подключиться из DBeaver
Драйвер ClickHouse в DBeaver подключается по JDBC через HTTP-порт. Укажите хост
`127.0.0.1`, порт `28123`, пользователя `analyst` и пароль из переменной
`CLICKHOUSE_ANALYST_PASSWORD` в `.env`; в образце это `analyst-26-reader`.
Ко второй ноде — порт `28124`.
Для подключения ко второму узлу укажите порт `28124`.
Пользователь `analyst` читает все слои и справочники. Нативные порты `29000` и
`29001` нужны `clickhouse-client`, но не DBeaver. Остальные пользователи и роли
описаны в [справочнике
стенда](docs/architecture/stand.md#пользователи-и-роли-clickhouse).
Пользователь `analyst` может читать данные во всех слоях и справочниках.
Для заданий с собственными таблицами наблюдений используйте `student` и пароль
из `CLICKHOUSE_STUDENT_PASSWORD` в `.env`. Он читает те же источники, а создавать,
заполнять и удалять таблицы может только в базе `default`.
Для создания учебных витрин используйте подключение, описанное
в [инструкции к заданиям](docs/labs/README.md#выполнение-sql-в-dbeaver).
Нативные порты `29000` и `29001` нужны `clickhouse-client`, но не DBeaver.
Остальные пользователи и роли описаны в
[справочнике стенда](docs/architecture/stand.md#пользователи-и-роли-clickhouse).
## Документация
- [docs/handbook/](docs/handbook/) — путеводитель по стенду и порядок чтения.
- [docs/labs/](docs/labs/) — учебные задания с порядком прохождения и
самопроверкой.
- [docs/specs/](docs/specs/) — спеки: источник истины о задуманном.
- [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми
вариантами: почему сделано так, а не иначе.
- [docs/architecture/](docs/architecture/) — рабочие справочники по зонам
ответственности:
- [хранилище](docs/architecture/storage.md) — конвенции имён, раскладка по
шардам, приём событий, карта таблиц.
- [docs/specs/](docs/specs/) — требования к стенду и программа обучения.
- [docs/adr/](docs/adr/) — решения об устройстве стенда: что выбрали,
какие варианты отклонили и почему.
- [docs/architecture/](docs/architecture/) — технические справочники:
- [хранилище](docs/architecture/storage.md) — правила именования,
распределение данных по шардам, прием событий и список таблиц.
- [конвейер](docs/architecture/etl.md) — движение данных между слоями и
работа дагов.
- [мир и модельное время](docs/architecture/world.md) — начальные данные,
ход дней и управление генератором.
создание данных за следующие дни и управление генератором.
- [стенд](docs/architecture/stand.md) — службы, доступ и настройка.
- [проверки](docs/architecture/testing.md) — что утверждает каждая цель
`make` и куда класть новую проверку.
- [проверки](docs/architecture/testing.md) — команды `make` для проверки
стенда и правила добавления новых проверок.
- [мониторинг](docs/architecture/monitoring/README.md) — сбор метрик и
устройство дашборда.
- [Superset](docs/architecture/superset.md) — подключения, наборы данных и
граница автоматической настройки.
автоматическая настройка при запуске.
- [docs/research/](docs/research/) — исследования; сейчас это формат
кликстрима Яндекса, по которому строится модель события.
- [docs/formats/](docs/formats/) — описания форматов источников: по ним
пишется сторона хранилища. Собираются из кода командой `make docs` в
`generator/`, руками не правятся.
- [AGENTS.md](AGENTS.md) — контракт работы в репозитории.
- [docs/formats/](docs/formats/) — форматы исходных данных, необходимые
для их загрузки и обработки. Эти документы собираются из кода командой
`make docs` в `generator/`; вручную их менять не нужно.
- [AGENTS.md](AGENTS.md) — правила работы в репозитории для ИИ-агентов.
+1
View File
@@ -20,6 +20,7 @@ x-clickhouse-common: &clickhouse-common
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_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}
depends_on:
clickhouse-keeper:
+24 -32
View File
@@ -1,18 +1,17 @@
"""Трансформации слоя DDS: сборка моделей.
"""Трансформации DDS: заказы, сессии и карта идентичностей.
Заказ и сессия собираются заменой дневных партиций. Карта идентичностей
пересчитывается целиком после заказа, потому что читает его текущую модель.
Запросы рассчитывают заказы и сессии за выбранные дни и записывают результат
в промежуточные таблицы. Затем готовые дневные разделы этих таблиц заменяют
разделы основных таблиц. Пока идет расчет, читатели видят прежние данные.
Расписания у дага нет и параметров тоже: объём каждого прогона он выводит сам
из состояния слоёв и позиции мира, поэтому один и тот же запуск одинаково
верен из триггера дневного конвейера, с кнопки менти и после падения
(docs/architecture/etl.md, «Объём прогона»).
Карта идентичностей пересчитывается целиком после заказов. Новые строки
добавляются с меткой времени сборки; представление с FINAL выбирает последнюю
версию каждой строки.
Заказ и сессия гасят повтор заменой партиции целиком: день собирается в
таблицу-двойник и подменяет собой партицию цели. Карта пишет полный набор пар
новой версией, а точное чтение даёт FINAL. Замена партиции разобрана в
sql/dds/order_replace.sql; точное чтение карты описано в
sql/ddl/50-dds-views.sql.
Даг запускается без расписания и параметров. Даты он выбирает по состоянию
таблиц и позиции мира. Новый запуск заново определяет диапазон: наличие даты
не подтверждает полноту дня и не гарантирует повтор его сборки после сбоя.
Подробности — docs/architecture/etl.md, «Объём прогона» и «Идемпотентность».
"""
from __future__ import annotations
@@ -257,16 +256,11 @@ def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
@task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Разложить дни в аргументы размноженной задачи замены.
"""Подготовить параметры отдельной задачи replace для каждого дня.
Шаг существует ради ограничения Airflow: размножать задачу можно
только по целому возврату другой задачи — ни по ключу словаря, ни по
карте поверх него. Отсюда отдельный возврат под один список.
День едет в запрос подстановкой Jinja, а не параметром ClickHouse:
в ALTER … PARTITION сервер параметр не подставляет — замерено,
отвечает синтаксической ошибкой. Значение при этом не внешнее, его
вернул сам ClickHouse запросом объёма.
expand_kwargs получает список словарей и создает по одному экземпляру
задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja:
параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается.
"""
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,
start_date=START_DATE,
is_paused_upon_creation=False,
# Два прогона разом собирали бы один и тот же двойник: второй затёр бы
# чужую сборку между вставкой и заменой партиций.
# Одновременные запуски используют одни промежуточные таблицы.
# Второй мог бы очистить результат первого до замены партиций.
max_active_runs=1,
template_searchpath=str(SQL_ROOT),
tags=["dds"],
@@ -298,7 +292,7 @@ def dds_transform():
@task_group(group_id="order")
def order():
"""Заказ: дышащее окно плюс дни, которых в слое ещё нет."""
"""Заказ: изменяемые дни последнего слепка и пропущенные дни."""
@task
def scope() -> dict[str, object]:
@@ -306,8 +300,7 @@ def dds_transform():
query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8")
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query)
# Пустой ответ означает пустой источник: собирать нечего, и
# зеленеть на этом нельзя — прогон встаёт с внятной причиной.
# Без заказов ODS расчет невозможен: задача сообщает об ошибке.
if row is None:
raise AirflowException(
"в ods.order нет ни одного заказа: собирать нечего; "
@@ -342,9 +335,9 @@ def dds_transform():
do_xcom_push=False,
)
# Замена идёт по партиции за раз — другой формы у операции нет. День
# становится отдельной задачей: в интерфейсе видно, сколько дней несёт
# прогон, а повтор после отказа стоит одного дня, а не всей пересборки.
# На каждый день создается отдельная задача замены партиции.
# Неудачную замену можно повторить, пока промежуточная таблица
# сохраняет рассчитанные строки этого запуска.
replace = SQLExecuteQueryOperator.partial(
task_id="replace",
conn_id=CLICKHOUSE_CONNECTION,
@@ -370,9 +363,8 @@ def dds_transform():
"проверьте, дошёл ли поток до хранилища"
)
first_day, last_day = row
# Пропуск, а не отказ: слой полон по последний прожитый день, а
# сегодняшний ещё идёт. Это обычное состояние повторного прогона,
# и в интерфейсе оно видно пропущенной группой.
# Для каждого прожитого дня уже есть строки сессий.
# Новых дней для расчета нет: группа задач будет пропущена.
if first_day is None:
raise AirflowSkipException(
f"сессии собраны по {last_day} включительно, "
+12 -8
View File
@@ -1,9 +1,12 @@
"""Трансформации слоя DM: сборка стендовых витрин.
Выручка наследует ритм заказов. Сверка соединяет окно заказов с прожитым
хвостом событий. Обе заменяют дневные партиции. Трафик наследует
ретроспективность карты идентичностей и пересчитывает всю историю новой
версией. Объем каждого прогона выводится из состояния слоев.
Даты для расчета выручки берутся из последнего слепка заказов и списка
пропущенных дней. Сверка покупок с заказами учитывает также прожитые дни
событий и дни, где заказ еще не найден. Запросы записывают результаты
в промежуточные таблицы, затем заменяют дневные разделы основных таблиц.
Трафик пересчитывается за всю историю: новая связь посетителя с пользователем
магазина меняет число известных пользователей и за прошлые дни.
"""
from __future__ import annotations
@@ -62,10 +65,11 @@ def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
@task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Разложить дни в аргументы размноженной задачи замены.
"""Подготовить параметры отдельной задачи replace для каждого дня.
Отдельный словарь нужен каждому экземпляру ``expand_kwargs``; тот же
прием разобран в ``dds_transform.py`` на замене партиций заказов.
expand_kwargs получает список словарей и создает по одному экземпляру
задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja:
параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается.
"""
return [{"params": {"day": day}} for day in scope["days"]]
@@ -141,7 +145,7 @@ def dm_transform():
@task_group(group_id="purchase_vs_orders")
def purchase_vs_orders():
"""Сверка: окно заказов, прожитый хвост и предварительные дни."""
"""Сверка: изменяемые заказы, прожитые покупки и дни ожидания заказа."""
@task
def scope() -> dict[str, object]:
+23 -8
View File
@@ -74,7 +74,11 @@ def _publish_summary(
) -> list[str]:
"""Записать дневную сводку и вернуть малый срез готовой диагностики."""
if not summaries:
raise AirflowException(f"{check_name}: область проверки пуста")
raise AirflowException(
f"{check_name}: запрос не вернул ни одного дня для проверки; "
"проверьте наличие данных в источниках сверки. Состав проверок "
"описан в docs/architecture/dm.md"
)
diagnostics: list[str] = []
context = get_current_context()
@@ -148,7 +152,10 @@ def build_classes_vs_inventory() -> list[str]:
for mismatch_class, count in day["orders"].items()
}
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")
# GROUP BY закономерно не возвращает группу из нуля строк.
@@ -179,9 +186,9 @@ def build_classes_vs_inventory() -> list[str]:
failed_rows += 1
actual_text = actual_count if actual_count is not None else "нет ключа"
details.append(
f"{day} key={mismatch_class}: "
f"эталон={reference_count}, "
f"объект={actual_text}"
f"{day} mismatch_class={mismatch_class}: "
f"заказов в паспорте={reference_count}, "
f"в dm.purchase_vs_orders_v={actual_text}"
)
summaries.append((data_date, reference_rows, actual_rows, failed_rows, details))
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"
)
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]
@@ -239,10 +249,15 @@ def assert_check(check_name: str, diagnostics: list[str]) -> None:
logging.error("%s: %s", check_name, detail)
summary = "; ".join(
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
)
raise AirflowException(f"{check_name}: {summary}")
raise AirflowException(
f"{check_name}: {summary}. Примеры расхождений — в журнале этой задачи; "
"итоги по дням — в dm.dq_summary_v. Источники каждой сверки описаны "
"в docs/architecture/dm.md"
)
@dag(
+8 -3
View File
@@ -45,7 +45,10 @@ def check_mart(
"""Сверить одну домашнюю витрину с независимым ответом из источника."""
hook = _clickhouse_hook()
if not _mart_exists(hook, mart):
raise AirflowException(f"витрина {mart} не найдена; выполните ТЗ {lab_path}")
raise AirflowException(
f"витрина {mart} не найдена на узле подключения clickhouse_default; "
f"проверьте имя и место создания витрины по заданию {lab_path}"
)
try:
query = (SQL_ROOT / sql_name).read_text(encoding="utf-8")
@@ -72,7 +75,8 @@ def check_mart(
except Exception as error:
raise AirflowException(
f"не удалось проверить витрину {mart}: {error}; "
f"сверьте договор колонок и проверку в ТЗ {lab_path}"
f"сверьте имена и типы колонок с заданием {lab_path}. "
"Подробности ошибки — в журнале этой задачи"
) from error
failed_rows = sum(summary[3] for summary in summaries)
@@ -93,7 +97,8 @@ def check_mart(
compared_sides = diagnostics[0]
raise AirflowException(
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 у заказов один и живёт одним дагом: сначала забор
порции в `stg`, затем разбор того же среза в типизированный ODS и в таблицу
брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, —
шаговый даг модельного времени после проигрыша дня, — и ждёт конца.
Шаговый даг модельного времени отправляет слепок, запускает orders_ingest
и ждет его завершения. Собственного расписания у orders_ingest нет.
Сначала pull_batch сохраняет порцию сообщений в STG, затем parse_batch
разбирает тот же срез по _load_id в заказы и ошибки.
Контраст с приёмом событий и есть урок. События тянет матвью, навсегда
подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов
приезжает раз в модельный день целой выгрузкой, у которой есть начало и конец,
и забирает её запрос по команде. Push против pull — два режима на одном стенде,
каждый там, где ему место по природе источника.
Решения и доводы целиком — ADR 0008 (забор) и ADR 0010 (версии в ODS); форма
перехода — docs/architecture/orders/ingestion.md.
События принимаются непрерывно через материализованные представления.
Заказы читаются одним запросом; полнота слепка этим не гарантируется.
Правила и ограничения — docs/architecture/orders/ingestion.md.
"""
from __future__ import annotations
@@ -24,8 +19,7 @@ from airflow.sdk import dag
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
# Запросы лежат файлами в sql/ репозитория, разложенные по слоям хранилища;
# сюда их монтирует compose — всем службам Airflow сразу.
# Compose монтирует sql/ по этому пути во все службы Airflow.
SQL_ROOT = "/opt/airflow/sql"
@@ -34,15 +28,14 @@ SQL_ROOT = "/opt/airflow/sql"
schedule=None,
start_date=START_DATE,
is_paused_upon_creation=False,
# Чтец у топика один, и группа потребителей у него одна. Два прогона разом
# дрались бы за неё, а слепок разъехался бы по двум _load_id; второй
# прогон подождёт своей очереди.
# Один запуск за раз: параллельные чтения разделили бы порцию
# между разными _load_id.
max_active_runs=1,
template_searchpath=SQL_ROOT,
tags=["заказы"],
)
def orders_ingest():
"""Забрать приехавший слепок заказов и разложить его по слоям."""
"""Сохранить порцию заказов и разобрать ее в ODS."""
pull_batch = SQLExecuteQueryOperator(
task_id="pull_batch",
conn_id="clickhouse_default",
@@ -59,8 +52,8 @@ def orders_ingest():
do_xcom_push=False,
)
# ClickHouse не даёт транзакции между двумя вставками parse_batch. Один
# task_id сохраняет их порядок и одну точку повтора для неизменного load_id.
# Две вставки parse_batch не образуют транзакцию. Повтор задачи
# выполняет обе над тем же срезом STG по _load_id.
pull_batch >> parse_batch
+29 -27
View File
@@ -146,25 +146,25 @@ def _ingest_orders() -> TriggerDagRunOperator:
type="integer",
minimum=1,
maximum=7,
title="Сколько дней прожить",
title="Число модельных дней для генерации",
# description_md рендерится подсказкой прямо в форме запуска
# (params.rst, Airflow 3.3), чтобы правило было видно сразу.
description_md=(
"Форма задает только число дней. Номер первого несыгранного "
"дня шаговый даг берёт из переменной `world_position` (Admin → "
"Variables). Поля Logical date и Run ID нужны самому Airflow "
"и на выбор дня не влияют."
"Создать данные за следующие 1–7 модельных дней; по умолчанию "
"за один день. События отправляются без пауз. Номер первого дня "
"берется из `world_position` (Admin → Variables). Поля Logical "
"date и Run ID не меняют выбор дня. После завершения запустите "
"`etl_pipeline`, чтобы обновить DDS и витрины DM."
),
)
},
)
def world_next_day():
"""Прожить следующие дни пачкой, без пауз.
"""Создать данные за следующие модельные дни без пауз между событиями.
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
нажимом, а не десятью: сколько дней играть — параметр запуска. Первый
день шаговый даг берёт из `world_position`. Логическая дата прогона на выбор
дня не влияет.
Параметр `days` задает число дней: от 1 до 7, по умолчанию 1. Первый день
берется из `world_position`. После загрузки событий и слепков заказов
запустите `etl_pipeline` вручную, чтобы обновить DDS и витрины DM.
"""
@task
@@ -174,7 +174,10 @@ def world_next_day():
position = first_day + days
Variable.set(WORLD_POSITION, str(position))
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()
@@ -210,20 +213,23 @@ def world_next_day():
tags=TAGS,
)
def world_live_day():
"""Прожить следующий день в темпе модельного времени.
"""Постепенно отправить события за следующий модельный день.
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
реальные минуты, и суточная волна разворачивается на глазах. Запускается
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
Следующий день шаговый даг берёт из `world_position`. Логическая дата прогона
на выбор дня не влияет.
При ускорении ×60 отправка занимает около 24 реальных минут. Номер дня
берется из `world_position`; Logical date его не меняет. Затем даг
отправляет слепок заказов и ждет его загрузки и завершения `etl_pipeline`.
Для автоматического запуска следующих дней снимите с паузы `world_live`.
"""
@task
def remember_played(first_day: int) -> None:
"""Позиция ставится по сыгранному дню и только по успеху."""
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()
played = _generator("play_day", ["live", "--day", PLAYED_DAY])
@@ -254,16 +260,12 @@ def world_live_day():
tags=TAGS,
)
def world_live():
"""Выключатель: пока включён, мир живёт день за днём.
"""Автоматически запускать модельные дни, пока даг снят с паузы.
Своей работы у выключателя нет — он дёргает `world_live_day` и ждёт конца.
Ожидание тут несущая конструкция, а не вежливость: без него тик шёл бы
независимо от хода дня, лишние прогоны скопились бы очередью, и мир потом
промчался бы по ней без всякого темпа.
Ждём триггером, а не сенсором: оператор опрашивает тот прогон, который сам
и создал, и ссылка на дочерний прогон видна прямо отсюда. Упал день —
краснеет и выключатель.
Запускает `world_live_day` и ждет его завершения. Пауза останавливает
запуск следующих дней; уже запущенный день завершится. Если он завершился
с ошибкой, этот даг тоже завершится с ошибкой: откройте дочерний запуск
и журнал неуспешной задачи.
"""
TriggerDagRunOperator(
task_id="trigger_live_day",
+11 -1
View File
@@ -3,10 +3,11 @@
Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader`
и пользователь `grafana` добавлены 21 августа 2026 года (#114), права
`bi_reader` расширены до ODS, DDS и DM 25 августа 2026 года (#150).
Пользователь `student` и роль `student_writer` добавлены 22 сентября 2026 года.
## Решение
У кластера семь пользователей и шесть ролей.
У кластера восемь пользователей и семь ролей.
- `default` — с паролем, только служебный: проверки здоровья нод и работа
изнутри контейнеров. Приложения им не ходят.
@@ -20,6 +21,10 @@
шире.
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
человек подключается снаружи, из своего клиента.
- `student` с ролями `analyst_reader` и `student_writer` — то же чтение и
работа со своими таблицами в `default`: создание, вставка и удаление.
Сохранение учебных наблюдений не требует прав технической учётной записи
`etl` на изменение слоёв. Таблицы локальные; работа ведётся на одной ноде.
- `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и
системных таблиц, `CLUSTER` и `REMOTE`. Им ходит Grafana, и больше никто.
- `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 и
на живом стенде 26.3.17.56.
+1 -1
View File
@@ -30,7 +30,7 @@
`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
Учебный результат: менти проектирует бизнес-сущность поверх готового ODS —
выбирает зерно, состав строки и способ наполнения — и видит идемпотентность
трансформации, устроенную иначе, чем в приёме: заменой партиции, а не
версиями.
DDS готовит заказ для анализа: выбирает текущее состояние из ODS,
разбирает позиции и переводит время в часовой пояс счетчика. Этот слой
показывает, как собрать модель данных для витрин и повторить загрузку
без удвоения заказов.
## Зерно
Одна строка — заказ в текущем состоянии, ключ — `order_id`. Модель отвечает
на вопрос «что сейчас известно о заказе»; историю версий не хранит:
читателя у истории нет ни в одной витрине, а ODS её и не обещает —
промежуточные версии съедают фоновые слияния. «Дыхание» выручки внутри окна
K живёт на текущем состоянии само: пересборка дня меняет статусы заказов, и
число дня меняется вместе с ними.
Одна строка — один заказ в последнем известном состоянии, ключ — `order_id`.
История изменений здесь не хранится. ODS тоже не служит ее архивом:
промежуточные версии удаляются при фоновых слияниях.
## Состав строки
Деловые поля источника переезжают из `ods.order_v` без переименований —
бэкенд уже говорит в snake_case: `order_id`, `user_id`, `status`, деньги в
`Decimal` (`items_total`, `discount`, `delivery`, `total`).
Поля заказа приходят из `ods.order_v` с именами источника: `order_id`,
`user_id`, `status`, `items_total`, `discount`, `delivery`, `total`.
Денежные колонки хранят рубли с копейками в `Decimal(18, 2)`.
Времена `created_at` и `updated_at` пересчитываются при загрузке в пояс
счётчика: DDS обслуживает человека с дашбордом ([дока
хранилища](../storage.md), «Часовые пояса»). Там же один раз считается
**день заказа** — колонка `order_date`, по образцу `EventDate` у события:
суточные срезы группируются по готовой дате и пояса не упоминают.
`created_at` и `updated_at` переводятся из UTC в `Europe/Samara`, пояс
счетчика сайта. Из `created_at` в этом поясе вычисляется `order_date` —
день оформления заказа. Витрины группируют по готовой дате;
правило времени общее для [хранилища](../storage.md#часовые-пояса).
Позиции разобраны из сырой строки `items` в три типизированных массива
одной длины — артикул, количество, цена (`Decimal`). Это родная для
ClickHouse денормализация: позиция не живёт без заказа и собственного ключа
не имеет, а разворачивает массивы читатель — `ARRAY JOIN` в витрине
выручки, с обогащением категорией через `dictGet`
([мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 7). Разбор
JSON происходит один раз, на границе ODS → DDS, куда его и положила спека
(раздел 2); записывать его параллельными колонками-массивами или `Nested` —
выбор DDL, не модели.
Строка `items` из ODS разбирается в три массива одной длины:
| Колонка | Что хранит |
|---|---|
| `item_sku` | Артикулы товаров |
| `item_quantity` | Количество каждого товара, `UInt32` |
| `item_price` | Цены за единицу в рублях, `Decimal(18, 2)` |
Элементы с одинаковым номером описывают одну позицию заказа. Витрина
выручки разворачивает их через `ARRAY JOIN` и получает категорию товара
из справочника через `dictGet`. Разбор JSON выполняется один раз
при загрузке DDS.
ODS проверяет только то, что `items` — массив. Разбор в DDS не заменяет
проверку его содержимого: например, некорректное количество может стать
нулем. Проверку суммы позиций участник добавляет в
[первом задании ступени 5](../../labs/5-1-order-checks.md).
## Движок и наполнение
`dds.order_rep` — `ReplicatedMergeTree` без колонки версии: партиция —
`order_date` (день заказа неизменен), ключ сортировки — `order_id`.
`dds.order_dist` шардирует по `cityHash64(order_id)`, тем же выражением, что
ODS: это единственный устойчивый ключ сущности.
`dds.order_rep` — таблица на `ReplicatedMergeTree`, без колонки версии.
Партиция хранит один `order_date`, ключ сортировки — `order_id`.
Запись через `dds.order_dist` распределяет заказы по `cityHash64(order_id)`,
как в ODS. Для чтения служит `dds.order_v` с явным списком колонок
над `dds.order_dist`; `FINAL` в этом слое не нужен.
Идемпотентность держит замена партиции, а не версии. Сбор дня — батч после
прихода слепка: трансформация собирает партицию заново из `ods.order_v` и
атомарно подменяет. Поэтому каждая строка физической таблицы — ровно один
заказ, а чтению не нужен `FINAL`. Пока окно K живо, день «дышит» — статусы
заказов меняются, и партиция дня пересобирается очередным запуском; на выходе
из окна день замерзает.
Даг `dds_transform` пересобирает выбранные дни из `ods.order_v`:
Публичная поверхность — `dds.order_v`: обычное представление с явным списком
деловых и собственных служебных колонок над `dds.order_dist`, без `FINAL`.
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
объектов».
1. [`order_scope.sql`](../../../sql/dds/order_scope.sql) определяет
непрерывный отрезок `first_day`…`last_day`. Он включает дни заказов
из последнего слепка и начинается раньше, если в 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) заменяет
каждую дневную партицию основной таблицы готовой партицией донора.
Механика запуска — где собирается партиция и как подменяется на кластере —
сюда не входит: это устройство [дневного конвейера](../etl.md).
Повтор при неизменном ODS дает те же деловые данные без дублей —
это идемпотентность загрузки. Пока заказы дня входят в семидневное окно
источника, их состояние может меняться, и день пересобирается.
За пределами окна новые состояния не приходят, но пропущенный день
можно восстановить из ODS. Границы пересборки берутся из данных;
число семь в SQL хранилища не повторяется.
Замена атомарна внутри одного шарда: читатель видит прежнюю или новую
партицию целиком. Замены на двух шардах независимы, поэтому между ними
запрос через Distributed может увидеть смешанный результат.
Донор остается до следующей сборки и доступен для диагностики.
Порядок задач и повторы — в [описании дневного конвейера](../etl.md).
## Служебные колонки
Строка получает `_load_id` и `_load_ts` запуска трансформации, собравшего
её партицию, — по общей конвенции хранилища. При замене партиции они
одинаковы у всех строк дня и отвечают на вопрос «каким запуском и когда
собран этот день».
`_load_id` и `_load_ts` показывают, какой запуск DDS собрал строку
и когда он выполнил вставку. Они одинаковы у строк одной сборки.
При следующей пересборке эти метки обновятся, даже если заказ не изменился.
Ярлыки ODS (`snapshot_date`, `_load_id` приёма, `_load_ts` прибытия) в модель
не переезжают. Они описывают наблюдение и доставку строки в ODS. Модель DDS
хранит текущее деловое состояние заказа. В ODS `snapshot_date` задаёт версию и
называет последний слепок, в котором источник показал заказ
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Дату
наблюдения и координаты доставки читатель спрашивает у ODS по
`order_id`. Правило слоя:
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
спрашиваются у их слоя по ключу сущности**.
`snapshot_date` и метки приема ODS в DDS не переносятся: они описывают
получение сообщения источника. Их можно найти в ODS по `order_id`.
В каждом слое служебные колонки относятся к его собственной загрузке.
Происшествие качества данных расследуется цепочкой, где каждый шаг спрашивает
свой слой на его языке: по `_load_id` строки DDS видно, какой запуск собрал
день; по `order_id` ODS показывает физические версии и брак — там свой
`_load_id` приёма связывает годные строки, ошибки и данные STG; дальше читают
STG, пока данные живы. Окно расследования задано сроками хранения: сырые данные
— трое суток, брак — месяц ([дока хранилища](../storage.md)).
При поиске ошибки начните с `_load_id` в DDS: он указывает на запуск
сборки. По `order_id` найдите версии заказа в ODS, а по их `_load_id` —
соответствующие сырые сообщения в STG и ошибки того же приема.
В таблице ошибок `order_id` может быть не разобран, поэтому нужное
сообщение ищут по `raw` и координатам Kafka.
Сырые сообщения хранятся около трех реальных суток, ошибки — месяц;
уточнения к срокам — в [справочнике хранилища](../storage.md#срок-жизни-сырых-данных).
## Читатели
- Витрина выручки `dm.revenue_daily_v`: заказы в `paid`, `ARRAY JOIN`
позиций, категория через `dictGet` (спека, раздел 7).
- Сверка `dm.purchase_vs_orders_v`: `order_id`, `order_date`, `items_total`,
`status`; соединение — GLOBAL JOIN с обеими сторонами-подзапросами
([исследование соединений](../../research/2026-08-22-distributed-join.md)).
- Сборка карты идентичностей: пара `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
и пересчёт времён расползлись бы по витринам, а модель данных — то, ради
чего сущность и строится, — не была бы показана.
- `dm.revenue_daily_v` считает выручку по заказам со статусом `paid`,
разворачивает позиции и дополняет их категорией товара.
- `dm.purchase_vs_orders_v` сопоставляет заказ с покупкой на сайте
по `order_id`, используя `order_date`, `items_total` и `status`.
- `dds.identity_map` связывает идентификаторы посетителей с `user_id`
через покупки и заказы.
## Связанные решения
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) — версии заказов в
ODS, устойчивая поверхность `ods.order_v`.
- [Приём заказов](../orders/ingestion.md) — граница строгого приёма и
четыре координаты строки ODS.
- [Проталкивание условий сквозь
представления](../../research/2026-08-22-view-predicate-pushdown.md) —
резолюция #128 той же карты: цена `FINAL` и цепочек представлений, фон, на
котором здесь выбрана физическая таблица.
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) — версии заказов в ODS
и чтение через `ods.order_v`.
- [Прием заказов](../orders/ingestion.md) — проверки формата и выбор
актуальной версии.
- [Исследование соединений](../../research/2026-08-22-distributed-join.md) —
почему сверка распределенных источников использует `GLOBAL JOIN`.
- [Проталкивание условий сквозь представления](../../research/2026-08-22-view-predicate-pushdown.md) —
как фильтр запроса влияет на объем чтения ODS.
+91 -84
View File
@@ -1,111 +1,118 @@
# Модель сессии в DDS
Учебный результат: менти строит расчётную сущность, которой нет в потоке, —
режет события на сессии по правилу визита, чеканит идентификатор и сверяет
свою нарезку с эталоном. Стенд здесь играет роль сессионизатора: в бою её
играет Метрика, которая режет визиты у себя и с октября 2025 отдаёт готовый
`VisitID` прямо в хитах. Поэтому стенд считает сессии сам, а `VisitID`
генератора использует только как эталон самопроверки — иначе сборка сессий
выродилась бы в `GROUP BY` по чужому полю и центральный урок
слоя пропал бы. Без этого абзаца действия стенда выглядят странно: «зачем
считать то, что уже дано колонкой».
Сессия объединяет события одного визита. На стенде вы учитесь определять
ее границы по событиям, вычислять устойчивый идентификатор и сверять
результат с эталоном. Поэтому сборка читает `dds.event_v`, где нет готового
`VisitID`. Этот идентификатор источника остается в ODS для проверки.
## Зерно
Одна строка — один визит ([CONTEXT.md](../../../CONTEXT.md)): подряд идущие
события одной куки без пауз длиннее 30 минут, не пересекающие границу суток.
В нарезку входят все три типа событий — торговые садятся на свои страницы
и держат сессию наравне с просмотрами. События внутри сессии
упорядочиваются по метке времени события (`UTCEventTime` — опорная колонка
приёма).
Зерно описывает, чему соответствует одна строка: здесь это один
[визит](../../../CONTEXT.md#люди-и-события). В него входят подряд идущие
события одного `client_id` без пауз длиннее 30 минут и без перехода через
модельную полночь. Пауза ровно в 30 минут новую сессию не открывает.
Учитываются все три типа событий: `pageview`, `add_to_cart`, `purchase`.
Граница суток проходит по модельному дню счётчика, и он у события уже есть:
`EventDate` — свойство содержимого. Раз сессия сутки не пересекает, у всех
её событий `EventDate` один; он и становится **днём сессии** — колонкой
партиции, без пересчёта.
День сессии `session_date` берется из `event_date` событий DDS — это
`EventDate` источника, дата по часовому поясу счетчика. У всех событий
одной сессии она одинакова. Пересчитывать ее из времени UTC не нужно.
SQL рассчитывает границы визитов двумя оконными функциями: первая отмечает
начало нового визита, вторая по этим отметкам присваивает событиям номер
визита. Обе используют порядок `(event_time, event_id)`: при равном времени
события идут по идентификатору. Иначе два расчета могли бы по-разному
упорядочить события и отнести отметку начала к разным строкам.
## Идентификатор
Идентификатор сессии в природе не существует: сессия — расчётная сущность,
и id ей чеканит тот, кто сессионизирует. `VisitID` — такой же суррогат,
отчеканенный генератором в роли Метрики; наш — `session_id`,
детерминированный хеш от пары «кука + начало сессии» (`cityHash64`).
Детерминированность обязательна: партиция дня пересобирается заменой, и
повторный прогон обязан отчеканить те же id — счётчик или UUID это правило
ломают.
`session_id` вычисляется как `cityHash64(client_id, min(event_time))`.
При повторной сборке тех же событий посетитель и начало визита остаются
прежними, поэтому идентификатор не меняется. Счетчик или случайный UUID
выдали бы другие значения.
`session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка:
в соединениях он не участвует — события его не несут, мост к заказам живёт
на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со
стендовой и назвать расхождение одним числом) и разбор DQ-расхождений.
По `session_id` можно сопоставить сессии до и после пересборки и назвать
конкретную строку при разборе расхождения. Он не используется для сортировки
или шардирования. У событий этого поля нет; связь с заказами строится
по событиям покупки.
## Состав строки
Боевой образец — таблица визитов Метрики, парная к нашему формату хита; по
ней состав и собран:
- `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`, начало и конец сессии,
длительность, день сессии. Времена — в поясе счётчика по правилу слоя;
- **счётчики**: событий всего, просмотров, корзин, покупок;
- **страницы**: входная и выходная — как `StartURL`/`EndURL` визита; полного
списка страниц нет и у Метрики, топы считаются по событиям;
- **атрибуция**: UTM-метки первого события сессии — так визит атрибутирует
Метрика. В v1 витрина брала последнее касание — заменено осознанно;
- **паспорт куки**: категория устройства (расшифрованная, язык бизнес-модели)
и город — у куки они постоянны, берутся с первого события;
- **служебные**: `_load_id` и `_load_ts` собравшего запуска — правило слоя
([модель заказа](order.md), «Служебные колонки»).
В готовом SQL страницы выбираются через `argMin` и `argMax`, а UTM,
устройство и город — через `argMin`, только по `event_time`. При одинаковом
времени выбор между событиями не определен. Полный порядок окон нарезки
сам по себе эту неоднозначность не устраняет. Это ограничение учитывается
в [задании ступени 4](../../labs/4-1-session-pipeline.md).
Осознанные расхождения с образцом: **денег в сессии нет** — визит Метрики
несёт клиентскую выручку, у нас деньги считает только бэкенд (спека, раздел
7), а клиентская сумма живёт в сверке; **целей нет** — их место заняли
счётчики торговых событий.
В сессии нет суммы выручки: ее считают по заказам магазина. Клиентская
сумма покупки используется в сверке с заказами и в учебной UTM-витрине.
## Движок и наполнение
`dds.session_rep` — `ReplicatedMergeTree` без колонки версии, по образцу
заказа: партиция — день сессии, ключ сортировки — кука и начало сессии.
`dds.session_dist` шардирует по `cityHash64(client_id)`, тем же выражением,
что события, — это ко-локация с `dds.identity_map_rep` ([дока
хранилища](../storage.md), «Раскладка по шардам»).
Совпадение ключа с событиями не случайно: все события куки лежат на одном
шарде, поэтому сессионизация локальна по природе — капкан смены ключа при
вставке, который дока хранилища обещает сборкам DDS, у сессий не стреляет.
`dds.session_rep` хранит готовые сессии в `ReplicatedMergeTree` без колонки
версии. При чтении не нужно заново выполнять оконный расчет. Партиция —
раздел таблицы за один `session_date`; ключ сортировки — посетитель
и начало сессии. Читайте результат через `dds.session_v`: представление
над `dds.session_dist` явно перечисляет деловые и служебные колонки.
Сборка — дневной батч: хранилище выше ODS по смыслу батчевое, раз второй
источник приходит раз в сутки. Собирается день, который мир уже прожил, и
собирается один раз и целиком; какой день прожит, слой спрашивает у позиции
мира ([дневной конвейер](../etl.md), «Объём прогона»). Идемпотентность держит
замена партиции. В отличие от заказов, где батч пересобирает до K «дышащих»
дней, у сессии дышащего окна нет: прожитый день самодостаточен (правило
спеки, раздел 6). Второй раз он соберётся только
вынужденно — когда упавшая замена оставила позади дыру и хвост от неё накрыл
соседние дни. Дней в одном прогоне при этом бывает несколько — столько,
сколько мир успел прожить без слоя. Внутри живого дня
модель сессии про сегодня честно молчит — «сегодня» смотрят через
`dds.event_v`; сессия и по смыслу не финальна, пока день не прожит.
Источник сборки — `dds.event_v`. События, сессии и карта идентичностей
распределены по одному ключу `cityHash64(client_id)`: данные посетителя
находятся на одном шарде. Роли объектов и правило записи через `_dist`
описаны в [справочнике хранилища](../storage.md#раскладка-по-шардам).
Публичная поверхность — `dds.session_v`: обычное представление с явным
списком деловых и собственных служебных колонок над `dds.session_dist`.
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
объектов».
Три SQL-файла выполняются последовательно в группе `session` дага
`dds_transform`:
Источник сборки — `dds.event_v` ([контракт события](event.md)): сессии
нужны от него ключи нарезки, тип события, страница, UTM и паспорт куки.
Механика запуска — где собирается партиция и как подменяется на кластере —
не входит: это устройство [дневного конвейера](../etl.md).
1. `session_scope.sql` находит первую дату событий, за которую еще нет
сессий. Последний день для расчета — первый день событий ODS
плюс `world_position` минус один; `world_position` — номер следующего
дня для генерации. Так в расчет попадают только полностью
прожитые дни. При пустом 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-проверка с зерном «кука × день»: число сессий нашей нарезки
равно `uniqExact(VisitID)` по тем же событиям. Счёт по Distributed ловит и
склеенную, и разрезанную сессию — мимо него проходит лишь взаимная
компенсация ошибок внутри одной куки за день; эталон в модель не тащим —
строка сессии остаётся нашей, а не генераторной. Поштучное сопоставление
границ — материал лабы, где менти разбирает найденное расхождение руками. Дом проверки —
контур DQ этапа 4. Сверке нужен `VisitID`, а сборке сессий — нет; контракт
`dds.event_v` его скрывает, и за эталоном проверка идёт в `ods.event_v`
([контракт события](event.md)).
Проверка `sessions_vs_reference` в `dq_check` сравнивает для каждого
посетителя и дня число строк `dds.session_v` с `uniqExact(VisitID)`
из `ods.event_v`. Эталон читается из ODS, потому что `dds.event_v`
скрывает `VisitID`.
Нулевое число расхождений подтверждает только число сессий каждого
посетителя за день. Например, ошибочное объединение двух визитов
и разделение другого на два сохранят их общее число. Поэтому в заданиях
вы дополнительно сопоставляете границы сессий по исходным событиям и проверяете страницы,
UTM, устройство и город.
## Читатели
+172 -142
View File
@@ -1,179 +1,202 @@
# Слой DM: витрины
Как модель DDS превращается в витрины для потребителя BI: какую форму
принимает витрина, кто какие витрины строит и как слой проверяется. Состав
колонок каждой витрины задаёт раздел 7 мастер-спеки; здесь — форма,
наполнение и контроль.
Витрины готовят данные DDS для отчетов и дашбордов. Здесь описаны форма
хранения, правила обновления и проверки. Состав колонок задан в
[спецификации](../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)).
Мы все равно оставили материализованные агрегаты ради практики: без них в
программе не было бы загрузки и пересборки витрин. Представления дают другой
случай: преобразование строк на прежнем зерне.
Готовые таблицы нужны в программе для практики загрузки, обновления
и включения витрин в конвейер.
## Что готово в стенде, а что остаётся заданием
Стенд привозит готовыми витрины трёх сюжетов дашборда этапа 8 и
инфраструктуру контроля:
Готовы три витрины дашборда и сводка качества:
- `revenue_daily` — выручка по дням и категориям; образец материализованной
витрины, на него ссылаются учебные задания;
- `purchase_vs_orders` — сверка A+C с классами расхождений; зерно — заказ,
шардирование сохраняет ко-локацию заказов по `cityHash64(order_id)`;
- `daily_traffic` — пара «посетители» / «известные пользователи», обогащение
[картой идентичностей](dds/identity.md);
- `dq_summary` — сводка проверок качества (ниже).
- `revenue_daily` — одна строка на день заказа и категорию товара;
образец дневной пересборки для учебных заданий.
- `purchase_vs_orders` — одна строка на заказ: сопоставление покупки
из кликстрима с заказом магазина и класс расхождения.
- `daily_traffic` — одна строка на день: число посетителей и известных
пользователей с учетом [карты идентичностей](dds/identity.md).
- `dq_summary` — одна строка на день данных и проверку качества.
Остальные витрины спеки нужно построить в учебных заданиях по готовому ТЗ и
образцу. Разминка — представление `events_enriched_v` (зерно события), затем
агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`, последней —
самая содержательная `utm_effectiveness` с уроком `declared_*`. В финале
каждого задания с агрегатом его задачу нужно добавить в `dm_transform`. До
этого конвейер о витрине не знает, после — пересобирает её вместе со
стендовыми. Представление из разминки добавлять некуда: его не нужно
пересобирать. Тексты заданий лежат в [`docs/labs/`](../labs/README.md).
В [заданиях ступени 1](../labs/README.md) вы строите `events_enriched_v`,
`top_pages_daily`, `session_overview`, `dq_errors_daily` и `utm_effectiveness`.
Для каждой физической витрины нужно добавить сборку в `dm_transform`.
Представление `events_enriched_v` отдельной задачи не требует.
Дашборд этапа 8 читает только готовые витрины стенда, поэтому его приёмка не
зависит от домашних заданий. Отдельный дашборд по учебным витринам можно
собрать в [факультативном задании](../labs/optional/superset-dashboard.md),
доступном после ступени 2. Устройство стендового дашборда описано в
[справочнике Superset](superset.md).
Стендовый дашборд читает готовые витрины и работает до выполнения заданий.
Дашборд по своим витринам можно собрать в
[факультативе](../labs/optional/superset-dashboard.md) после ступени 2.
Устройство стендового дашборда — в [справочнике Superset](superset.md).
## Ритм пересборки
День витрины пересобирается, пока может измениться его источник, —
поэтому ритмов три, по числу источников:
Дни для пересчета выбирают по тому, как меняется источник:
- **от событий и сессий** — день замерзает сразу; пересборка догоняет
хвост несобранных дней заменой дневных партиций;
- **от заказов** — день дышит, пока входит в последний слепок; пересборка
заменяет партиции этого отрезка, дни за ним закрыты по построению;
- **от карты идентичностей** — не замерзает никогда: склейка
ретроспективна, покупка сегодня делает куку известной во всех её прошлых
днях, а карта пересчитывается целиком.
- События и сессии прожитого дня на стенде больше не меняются. Витрины
по ним рассчитывают показатели от первого пропущенного дня до последнего
прожитого и заменяют дневные партиции.
- Заказы могут меняться в течение семи модельных дней. Последний слепок ODS
задает диапазон дат для пересборки; пропущенные дни могут расширить его.
- Новая связь в карте идентичностей позволяет определить пользователя
по его прежним визитам: один `client_id` мог встречаться и до покупки,
которая связала его с `user_id`.
Поэтому `daily_traffic`, как и карта, пересчитывается целиком.
Поэтому `daily_traffic` наследует у своего источника и ритм, и механику:
полный пересчёт всех дней каждый прогон в `ReplacingMergeTree(_load_ts)`,
как у самой карты. Дневных партиций у неё нет: партиция — единица замены,
а эта витрина заменяется только целиком. На стенде такая работа остается
быстрой; витрина читает сессии и карту через их публичные договоры.
Пересчитывать точечно только затронутые дни не из чего: для этого надо
знать, какие связки новые, а полный пересчёт карты этот след затирает —
восстановление следа оставлено лабе повышенной сложности (issue #149).
`daily_traffic` каждый раз добавляет всю историю новой версией
в `ReplacingMergeTree(_load_ts)`. Дневных партиций у нее нет, а текущие
строки выбирает представление через `FINAL`.
У `purchase_vs_orders` два источника и одна непрерывная пересборка. Заказная
сторона задает изменяемое окно последним принятым слепком ODS, событийная —
последний прожитый день с `purchase`. Левая граница возвращает первую дыру и
самый ранний день с `awaiting_order`: предварительная строка уже есть в
витрине, поэтому поиск отсутствующего дня ее не заметит. Правая граница
доходит до более позднего из двух хвостов. Так один прогон догоняет пакетный
разгон длиннее K и не оставляет старое ожидание замороженным.
У `purchase_vs_orders` два источника. Начало диапазона — самая ранняя
из трех дат: первая дата оформления заказа в последнем слепке, первый
пропущенный день и первый день с `awaiting_order` — покупкой, для которой
заказ пока не поступил. День ожидания проверяется отдельно: строки за него
уже есть в витрине, поэтому он не считается пропущенным.
Конец диапазона — более поздняя из последней даты заказов в слепке и последнего
прожитого дня с покупкой. Один запуск пересобирает весь отрезок,
даже если с предыдущего запуска прошло больше семи модельных дней.
Для учебной сводки брака действует отдельное правило: дни выбираются
по времени загрузки, а прежние итоги сохраняются после удаления
источника по TTL. Оно разобрано в
[задании `dq_errors_daily`](../labs/1-4-dq-errors-daily.md).
## Витрины стенда
Публичные представления перечисляют деловые колонки и координаты запуска
`_load_id`, `_load_ts` явно. Новая колонка физической таблицы сама в договор не
попадет.
Представления `_v` явно перечисляют поля для отчетов, а также `_load_id`
и `_load_ts` — идентификатор запуска и время записи. Новая колонка
физической таблицы появится в представлении только после изменения его SQL.
### `dm.revenue_daily_v`
Зерно: день заказа × категория товара. Витрина читает только оплаченные
заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает
категорию артикула через `dic.products`. Один заказ с несколькими позициями
категории считается в `orders` один раз. `units` складывает количество единиц,
`revenue` складывает цену позиции × количество. Скидка, доставка и итог заказа
между категориями не делятся. `aov` равен `revenue / orders` в том же срезе и
хранит два знака после запятой.
Одна строка содержит итоги оплаченных заказов за день по категории товара.
Запрос читает `dds.order_v` и разворачивает массивы артикулов, количеств
и цен в строки позиций заказа. Артикул, количество и цена с одним индексом
становятся одной строкой. Категория артикула берется из `dic.products`.
Первый запуск строит всю доступную историю. Следующие берут границы дней из
последнего слепка ODS: только источник знает, какие даты еще может изменить.
Пропущенный оплаченный день расширяет левую границу, чтобы частичный отказ
не оставил дыру после следующего прогона. Агрегат при этом читает DDS; ODS
отвечает только на вопрос об объеме.
`orders` считает разные заказы: несколько позиций одной категории
в заказе не увеличивают этот счетчик. `units` — сумма количества товаров,
`revenue` — сумма произведений цены на количество. Скидка и доставка
в нее не входят. `aov` равен `revenue / orders` для того же дня и категории
с округлением до двух знаков после запятой.
`dm.revenue_daily_rep` нарезана по `report_date`; день собирается в
таблице-доноре и заменяется через `REPLACE PARTITION`. Цель и донор шардируются
по `cityHash64(product_category)`. Кроме дня, у витрины только категория, а
шесть категорий стартового мира близки по объему. Более сложный составной ключ
распределил бы эту маленькую таблицу без нового урока.
Первый запуск рассчитывает выручку за всю доступную историю. Следующие
берут диапазон дат оформления заказов из последнего слепка ODS.
Если в 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`
Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную
сумму, а отдельный счет событий оставляет класс `duplicate_event`
наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда
Одна строка сопоставляет события покупки с заказом магазина. События
из `dds.event_v` группируются по номеру заказа `purchase_id[1]`:
по формату источника у покупки один номер и одна заявленная сумма.
Отдельный счет событий позволяет обнаружить повтор покупки
и присвоить класс `duplicate_event`. Заказы берутся из `dds.order_v`.
Полное соединение `FULL OUTER GLOBAL JOIN` сохраняет и покупки без заказа,
и заказы без покупки. Обе стороны записаны подзапросами: при прямом чтении
левой Distributed-таблицы каждый непарный заказ повторился бы по числу шардов
([исследование](../research/2026-08-22-distributed-join.md)).
Непарная сторона остается `NULL` благодаря `join_use_nulls = 1`. При
`join_use_nulls = 0` ClickHouse заполнил бы ее нулем или пустой строкой и скрыл
отсутствие источника. Клиентская `declared_revenue` и серверная `items_total`
сравниваются как `Decimal(18, 2)`. Классы проверяются по порядку:
Если для строки нет пары, поля отсутствующей стороны получают `NULL`
благодаря `join_use_nulls = 1`. При `join_use_nulls = 0` они заполнились бы
нулями или пустыми строками, которые сложнее отличить от данных источника.
Клиентская `declared_revenue` и серверная `items_total` сравниваются
как `Decimal(18, 2)`. Классы проверяются по порядку:
`awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`,
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
события остается отмененным, а не потерянным.
Витрина нарезана по `order_day`, цель и донор шардируются по
`cityHash64(order_id)`. Диапазон собирается в доноре одной вставкой и
заменяется по дням. Первичная сборка охватывает всю историю обоих источников;
следующие прогоны идут по двустороннему ритму, описанному выше.
Партиции витрины разделены по `order_day`. Основная и промежуточная таблицы
распределяют строки по `cityHash64(order_id)`. Запрос рассчитывает весь
выбранный диапазон и одной вставкой записывает строки в промежуточную
таблицу. Затем ее дневные разделы заменяют разделы основной таблицы.
Первичная сборка охватывает всю историю обоих источников;
следующие прогоны выбирают дни по правилам обоих источников, описанным выше.
### `dm.daily_traffic_v`
В строке один день. `visitors` считает разные `client_id` из
`dds.session_v`; `known_users` считает разные `user_id` после левого соединения
с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине.
Несколько кук одного человека увеличивают `visitors`, но дают одного
`known_user`, поэтому `known_users <= visitors`.
Несколько кук, связанных с одним пользователем магазина, учитываются
в `visitors` по отдельности, а в `known_users` дают одного пользователя.
Поэтому `known_users <= visitors`.
Сессии и карта лежат по `client_id`, но публичные `_v` скрывают
`Distributed`-таблицы от локальной подстановки. На ClickHouse 26.3
`EXPLAIN PLAN` показал чтение обоих шардов на инициаторе: настройка
`distributed_product_mode = 'local'` локального соединения не дала. Поэтому
витрина использует явный `GLOBAL LEFT JOIN` через публичные договоры, а не
подменяет их физическими таблицами. Это дороже по сети, зато граница слоев
остается явной. Новая связь куки меняет прошлые дни, поэтому каждый запуск
пишет полную историю новой версией в `ReplacingMergeTree(_load_ts)`, а
публичное представление выбирает текущие строки через `FINAL`.
Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3
чтение через публичные `_v` с `distributed_product_mode = 'local'`
не дало локального соединения. Поэтому в запросе явно указан
`GLOBAL LEFT JOIN`. Он сохраняет сессии без известного пользователя;
в `known_users` учитываются только ненулевые `user_id`.
Обновление всей истории новой версией описано в разделе
[«Ритм пересборки»](#ритм-пересборки).
## `dm.dq_summary`
Зерно публичного `dm.dq_summary_v` — «день данных × атомарная проверка».
Одна строка `dm.dq_summary_v` — день данных и одна проверка.
Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
Деловые колонки сводки:
- `data_date` — проверяемый день;
- `check_name` — устойчивое имя инварианта;
- `check_name` — имя проверяемого правила;
- `reference_rows` — число деловых ключей на эталонной стороне;
- `actual_rows` — число деловых ключей в проверяемом объекте;
- `failed_rows` — число отсутствующих или различающихся ключей;
- `status` — `pass` при нуле нарушений, иначе `fail`.
Служебные `_load_id` и `_load_ts` называют собравший запуск. Представление
перечисляет колонки явно. Порогов и промежуточного состояния нет.
В `_load_id` и `_load_ts` записаны идентификатор запуска и время расчета
сводки. Даже одно расхождение дает `fail`.
## Звено DQ конвейера
Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять
атомарных договоров:
правил:
- `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон —
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк
@@ -192,43 +215,47 @@ Distributed-таблица вернула бы каждый непарный з
сравниваются с прямыми агрегатами `dds.session_v` и
`dds.identity_map_v`.
Область каждой проверки выводится из объединения сторон. Полностью потерянный
день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут
скомпенсировать друг друга общим итогом: `failed_rows` считается после
сопоставления деловых ключей.
Каждая проверка берет дни и ключи из обоих сравниваемых наборов данных.
Поэтому потерянная строка остается видна на другой стороне сравнения.
Расхождения считаются по ключам: завышение одного показателя не скроет
занижение другого в общей сумме за день.
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
подтверждает, что до витрины доехал записанный в паспорте мир, но не проверяет
сам генератор: у настоящего магазина такого оракула нет.
`classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая
проверка доставки и обработки эталонных данных стенда. Она не проверяет
сам генератор; у настоящего магазина такого эталона нет.
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой
файл JSON в Python. Все пять сборок пишут в общий донор. После их успеха даг
заменяет проверенные дневные партиции. Затем именованные задачи читают
опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
сохраниться до красного завершения, а ошибка сборки не затирает прежний
результат. Красная задача печатает не больше двадцати нарушенных ключей за
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
сводку и несколько примеров расхождений. Техническая сверка с паспортом читает небольшой
файл JSON в Python. Результаты всех пяти проверок записываются в общую
промежуточную таблицу. Когда расчеты завершились, ее дневные разделы
заменяют разделы `dm.dq_summary_rep`. Затем отдельные задачи читают
сохраненную сводку и завершаются ошибкой при `failed_rows > 0`.
Так расхождение сохраняется в таблице до отказа задачи. Если сам расчет
не выполнен, прежняя сводка остается на месте. Задача с расхождениями
выводит в журнал не больше двадцати ключей со значениями обеих сторон. Для полного списка итоговую агрегацию SQL-сверки
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
business_key`.
Если после починки день исчез с обеих сторон, его больше нет и в новом доноре.
Такой день удаляется из сводки отдельно: старая красная строка не должна
превращаться в историю попыток.
Если день исчез из обоих сравниваемых наборов, новый расчет не создаст
для него строк. Даг отдельно удаляет прежний результат за этот день
из сводки.
## Даг самопроверки
Домашние задания проверяет `homework_check` — отдельный даг без расписания,
который запускают вручную. В нём есть задача для каждого из
[пяти заданий по витринам](../labs/README.md).
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
лабы, а не история качества конвейера.
`homework_check` запускается вручную и содержит задачу для каждого из
[пяти заданий по витринам](../labs/README.md). Если витрины нет или значения
расходятся с источником, задача завершается ошибкой. Журнал показывает
причину и примеры несовпадений. Успех означает, что проверяемые значения
совпали; остальные требования задания вы проверяете сами.
Приём проверен на соседнем учебном стенде Airflow + Greenplum:
валидационный даг с группами проверок по слоям давал обратную связь по каждому
шагу задания, не трогая рабочий конвейер.
Ожидаемые значения рассчитываются по исходным данным текущего стенда.
Для четырех заданий это события или сессии DDS, для сводки брака —
таблицы ошибок ODS. Сравнение по деловому ключу учитывает отсутствующие
строки с обеих сторон. Результаты не пишутся в `dq_summary`:
самопроверка учебных витрин отделена от качества готового конвейера.
Готовые SQL-проверки открывайте после собственного решения: они содержат
расчет существенной части задания.
## Отклонённые варианты
@@ -254,7 +281,10 @@ business_key`.
- **Инкрементальный пересчёт затронутых дней по `first_matched_at`.**
Производственный ход, но на объёмах стенда его экономия неизмерима, а
след новых связок требует вернуть колонку, выкинутую в #133. Стал лабой
повышенной сложности — issue #149.
повышенной сложности —
[«Как обновлять витрину без полной пересборки»](../labs/optional/incremental-daily-traffic.md).
В ней новые связи находятся сравнением наблюдений: время покупки
не заменяет момент обнаружения связи.
- **Superset поверх DDS без слоя DM.** Терялся бы урок «витрина — контракт
под потребителя», против раздела 7 спеки.
+107 -82
View File
@@ -1,28 +1,23 @@
# Дневной конвейер трансформаций
Как принятые источники превращаются в модель DDS и витрины DM: кто запускает
трансформации, когда и в каком объёме. Правила самих сущностей — зерно,
движки, что именно пересобирается — живут в доках слоя
([карта модели DDS](dds/README.md)); здесь — только конвейер.
Дневная сборка вперёд появляется на этапе 4 и остаётся основным режимом
конвейера. На этапе 5 к ней добавляется партиционная переобработка: запуск
произвольного прошлого дня по команде.
Даг `etl_pipeline` собирает модель DDS, обновляет витрины DM и проверяет
результат. Здесь описаны запуск, выбор дней и запись результата в таблицы.
Состав моделей — в [справочниках DDS](dds/README.md) и [DM](dm.md).
## Форма
Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы
у него нет: он дёргает слойные даги последовательно и ждёт каждый —
`dds_transform`, затем `dm_transform` и `dq_check`. Состав проверок описан в
[доке слоя DM](dm.md).
`etl_pipeline` последовательно запускает `dds_transform`, `dm_transform`
и `dq_check`. Каждый следующий даг начинается после успеха предыдущего.
Для этого `TriggerDagRunOperator` ждет завершения запущенного им дага.
Если тот завершился ошибкой, задача ожидания в конвейере тоже завершится
ошибкой.
Поведение проверено через 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_live_day` дёргает конвейер с ожиданием — последним
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
зеленеть, не узнав, разложился ли день, шаговый даг не должен ([ADR
0003](../adr/0003-dag-run-state.md)). Такой отказ чинится перезапуском
`etl_pipeline` рукой — перезапуску ничего не нужно помнить, см. «Объём
прогона».
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: даги модельного
времени управляют генератором и только им. После разгона менти запускает
конвейер сам — и видит, как слои догоняют уехавший вперёд мир.
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
ручного запуска начинаются со следующих дней.
- `world_live_day` запускает конвейер после успешной генерации, приема
заказов и обновления `world_position`. Если трансформация упала, день
уже прожит и позиция не откатывается. После исправления причины
запустите `etl_pipeline` вручную.
- `world_next_day` отправляет дни пачкой и ждет приема заказов, но конвейер
не запускает. После него запустите `etl_pipeline` сами.
- `world_initialize` запускает конвейер последним шагом: при успешном
первом `make up` начальные данные уже собраны в DDS и DM.
## Объём прогона
Параметров у конвейера нет: объём он выводит сам из `world_position` и
состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по
последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта
идентичностей пересчитывается целиком; правила витрин — у
[слоя DM](dm.md).
Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и
после падения, а разгону на семь дней хватает одного прогона.
Объем прогона — дни, которые обрабатывает один запуск. Даты передавать
не нужно: даги определяют их по состоянию таблиц и `world_position`.
Например, после генерации семи дней без ETL один запуск рассчитает сессии
за все семь дней.
Окно заказов задаёт последний слепок, наблюдаемый в ODS. У каждого заказа
`snapshot_date` равен последнему слепку, в котором источник его показал.
Трансформация выбирает строки с максимальной `snapshot_date` и берёт среди них
минимальный и максимальный `order_date`. Это границы дней, состояние которых
источник ещё может изменить
([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Кроме живого окна,
трансформация закрывает пропуски: находит самый ранний день, представленный в
ODS и ещё не собранный в DDS, затем при необходимости расширяет до него левую
границу.
Для расчета сессий выбираются дни от первого, за который в DDS еще нет
сессий, до последнего прожитого.
`world_position` хранит номер следующего дня для генерации, поэтому
последний прожитый день равен `min(EventDate) + world_position - 1`.
Начало календаря берется из ODS; события ранних дней там не удаляются по TTL.
Один `max(EventDate)` не годится: события за день есть и во время его
генерации, когда последние визиты еще не закончились.
Точный запрос — [`session_scope.sql`](../../sql/dds/session_scope.sql).
Позиция мира заказам не отвечает: она говорит, какой день прожит, а не какой
доехал. Прогон дня D везёт слепок дня D − 1, да и ставится позиция раньше
трансформации, так что после неудачного приёма обгоняет хранилище. Слой обязан
считать объём по тому, что у него есть, а не по тому, что мир о себе заявляет.
Для заказов диапазон задает последний принятый слепок ODS. Среди строк
с максимальной `snapshot_date` выбираются самая ранняя и самая поздняя
даты оформления заказа. Состояние заказов за эти дни еще может измениться.
Если в ODS есть заказы за более ранний день, а в DDS за него нет строк,
начало диапазона сдвигается до этого дня. Запрос заново рассчитывает
заказы за все дни выбранного отрезка.
Для сессий границу задаёт `world_position`: она отличает полностью прожитый
день от ещё идущего. Сами события этой границы не показывают: и посреди дня D,
и сразу после его конца их позднейший день равен D. Если собрать сессии посреди
дня, визит, начатый в 23:50, останется обрезанным: завершённый день слой
повторно не собирает. Поэтому последний прожитый день равен первому дню
событий плюс позиция минус один
([`sql/dds/session_scope.sql`](../../sql/dds/session_scope.sql)).
Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не
позиция, а «Готовность источника».
Позиция мира для выбора дней заказов не подходит: с событиями дня D
приходит слепок за D−1. Объем рассчитывается по принятым строкам ODS;
их наличие еще не доказывает полноту слепка.
Карта идентичностей пересчитывается целиком. Витрины выбирают дни
по своим источникам — см. [ритм пересборки DM](dm.md#ритм-пересборки).
Если за день сохранилась хотя бы одна строка, запрос не считает его
пропущенным. Полноту данных проверяют отдельно.
## Готовность источника
Первый шаг `dds_transform` проходит два барьера за общий срок в 300 секунд.
Сначала клиент `confluent-kafka` сверяет подтвержденные смещения группы
`clickstream_hits` с верхними смещениями обоих разделов `hits`. Группа, оба
раздела и все смещения должны существовать. Суммарное отставание должно стать
нулевым. Каждый опрос пишет в журнал оба смещения и отставание по разделу.
Перед сборкой DDS задача `wait_for_source` последовательно ждет двух
условий:
Только после этого шаг проверяет обе ноды ClickHouse через
`clusterAllReplicas`: у `ods.event_dist` в `system.distribution_queue` не
должно остаться файлов. Каждый опрос пишет в журнал ноды, число файлов и
`last_exception` очереди. Оба барьера опрашиваются раз в три секунды и делят
один крайний срок. После Kafka срок не отсчитывается заново.
1. Группа `clickstream_hits` дочитала оба раздела топика `hits`.
Подтвержденные смещения сравниваются с верхними смещениями Kafka.
Группа и смещения должны существовать, суммарное отставание — равняться нулю.
2. В `system.distribution_queue` у `ods.event_dist` не осталось файлов
доставки на шарды. Запрос через `clusterAllReplicas` читает обе ноды.
Зелёный приём заказов о событиях не говорит ничего — топики разные и
потребители разные. Одного нулевого отставания Kafka тоже мало: чтец мог
подтвердить сообщение раньше, чем Distributed-таблица доставила файл на шард.
Два барьера закрывают обе части пути до того, как сессии выберут свой объем.
Опросы идут с паузой в три секунды; на обе стадии задан общий срок
300 секунд. Журнал показывает смещения и отставание Kafka, затем число
файлов и `last_exception` очереди на каждой ноде. Если готовность не
подтверждена за срок ожидания, задача завершается ошибкой.
Одного нулевого отставания Kafka мало: сообщение может быть подтверждено,
пока Distributed-таблица еще доставляет его на шард. Успех `orders_ingest`
тоже не заменяет эти проверки: у заказов другой топик и потребитель.
Эти условия проверяют доставку на момент опроса. Они не останавливают
поступление новых событий и не сверяют содержимое таблиц. Поэтому
`world_position` отдельно ограничивает сборку сессий прожитыми днями,
а качество результата проверяет `dq_check`.
## Идемпотентность
Партиция не заменяется простой вставкой. День собирается в таблицу-двойник —
вставка через Distributed раскладывает строки по шардам, — затем
`REPLACE PARTITION` на кластере атомарно подменяет кусок каждого шарда. Эта
механика — часть самих трансформаций и строится этапом 4: без неё дневной
прогон не повторяем. Сущности, пересчитываемые целиком, идемпотентны и без
замены: полная перезапись в `ReplacingMergeTree` — так живут карта
идентичностей и витрина `daily_traffic` ([слой DM](dm.md)).
Идемпотентность означает, что повторная обработка тех же исходных данных
не добавляет копии деловых строк. Служебные `_load_id` и `_load_ts`
при новой сборке меняются.
Результат дневного расчета сначала записывается в промежуточную таблицу.
Она нужна, чтобы читатели продолжали видеть прежние данные, пока новые
еще рассчитываются. Например, для сессий это `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 передаёт его
отдельными сообщениями и не сообщает потребителю, где слепок закончился.
Прямое чтение Kafka Engine возвращает одну порцию без признака полноты.
Поэтому `snapshot_date` не может служить границей публикации: `REPLACE
PARTITION` могла бы заменить партицию дня неполным набором строк.
Одновременно типизированный ODS не должен принимать правдоподобные значения по
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
## Цели
- сохранить пакетный забор как контраст потоковому приёму событий;
- один раз принять байты в STG и независимо разложить строки на годные и брак;
- хранить в ODS типизированные версии заказов, не публикуя слепок заменой
партиции;
- дать следующим слоям один корректный способ прочитать текущее состояние;
- оставить код проверки коротким и ограничить его контрактом провода.
## Не входит
- модель заказа в DDS: её зерно, связи, материализация и способ наполнения;
- проверка полей внутри `items`, переходов статуса и равенств денежных сумм;
- удаление заказа по отсутствию в следующем слепке;
- маркер конца слепка, опись ожидаемых строк и транзакция между целями ODS.
Здесь разобран путь одного заказа: сообщение Kafka сохраняется в STG,
проверяется и попадает либо в ODS, либо в таблицу ошибок. Следующие слои
читают из ODS последнюю принятую версию каждого заказа.
## Поток данных
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
запускает его шаговый даг модельного времени, который положил слепок в топик и
ждёт конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта.
Запрос забора лежит в
[`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
`SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
Источник отправляет в топик `orders` дневной слепок: по одному сообщению
на заказ. Слепок дня D содержит состояния заказов, оформленных в дни
D−6…D, с учетом [задержек первого появления](fate.md).
В начале модельного времени окно короче. Выгрузку за D отправляет
генератор при запуске дня D+1; подробности — в [описании слепка](snapshot.md).
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
пишет годные строки в `ods.order`, второй — брак в `ods.order_errors`.
Транзакции между запросами нет. При частичном сбое Airflow повторяет весь
`task_id`; одинаковые исходные строки и служебные метки не вычисляются заново.
Обе вставки лежат в одном самодостаточном файле
[`order_parse.sql`](../../../sql/ods/order_parse.sql). Airflow сначала
шаблонизирует файл, затем делит получившийся SQL на выражения и исполняет их по
порядку в одной задаче `parse_batch`.
После отправки шаговый даг модельного времени запускает
[`orders_ingest`](../../../dags/orders_ingest.py) и ждет его завершения.
У `orders_ingest` нет расписания; его два шага выполняются по порядку:
Условия запросов взаимодополняющие: один общий предикат определяет годную
строку, а ветвь брака использует его буквальное отрицание. Все функции предиката
возвращают результат без исключения, а сам предикат всегда заканчивается в
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для
этого не нужен. Предикат объявлен локальным макросом Jinja внутри
`order_parse.sql` и разворачивается в оба выражения. Макрос убирает
дублирование, но не вводит отдельный фрагмент, который нельзя исполнить
самостоятельно.
1. `pull_batch` читает порцию из `stg.orders_raw_kafka` и записывает ее
через `stg.orders_raw_dist`. Содержимое сообщения остается строкой `raw`,
рядом сохраняются координаты Kafka, `_load_id` и `_load_ts`.
2. `parse_batch` читает сохраненные строки с этим `_load_id`. Первый
`INSERT SELECT` пишет годные записи в `ods.order_dist`, второй —
остальные в `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`. Корень должен быть
JSON-объектом с точным набором ключей: `order_id`, `user_id`, `status`,
`created_at`, `updated_at`, `items_total`, `discount`, `delivery`, `total`,
`items`, `snapshot_date`.
Проверяется одна строка `stg.orders_raw_dist`. Корень JSON должен быть
объектом с точным набором из одиннадцати ключей:
`order_id`, `user_id`, `status`, `created_at`, `updated_at`,
`items_total`, `discount`, `delivery`, `total`, `items`, `snapshot_date`.
Скалярные поля проверяются по типу JSON. Деньги дополнительно обязаны быть
строками неотрицательной суммы с ровно двумя знаками после точки — минус в эту
форму не входит; времена — строками RFC 3339 в UTC с обязательными
миллисекундами; дата слепка — строкой `YYYY-MM-DD`. `items` проверяется только
как JSON-массив. Каноническая форма и основания выбора зафиксированы в
[исследовании формата](../../research/2026-08-16-order-snapshot-wire-format.md).
| Поля | Условия приема |
|---|---|
| `order_id`, `status` | Строки JSON |
| `user_id` | Целое число JSON, которое разбирается в `UInt64` |
| `created_at`, `updated_at` | Строки UTC с миллисекундами, например `2026-06-01T12:34:56.000Z`; проверяются форма и разбор времени |
| `items_total`, `discount`, `delivery`, `total` | Неотрицательные суммы в рублях, записанные строками с двумя знаками после точки; проверяется разбор в `Decimal(18, 2)` |
| `snapshot_date` | Строка даты `YYYY-MM-DD`, которая проходит разбор |
| `items` | Массив JSON; сохраняется целиком в строковой колонке |
Проверять все верхнеуровневые поля здесь уместно: их одиннадцать, и десять
скалярных значений непосредственно образуют типизированную строку заказа. У
события из 47 полей проверяются только пять опорных; переносить то сокращение на
малый контракт заказа нет причины. Граница строгости заканчивается на форме
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
ODS проверяет формат верхнего уровня. Здесь нет проверки допустимых
статусов, их переходов, полей внутри `items` или равенства денежных сумм.
Позиции разбираются в [DDS](../dds/order.md); проверку их суммы участник
добавляет в [задании ступени 5](../../labs/5-1-order-checks.md).
Основания выбора формата — в [исследовании](../../research/2026-08-16-order-snapshot-wire-format.md).
Контракт источника задаёт частоту: **одной дате соответствует один слепок**. Это
свойство всего слепка, поэтому предикат одной строки его не проверяет. Вторая
выгрузка того же дня даст ничью по `snapshot_date`, и выбор строки потеряет
однозначность. Модельный источник выполняет условие; в настоящей интеграции
его фиксируют в договоре с источником
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)).
Класс ошибки записывается в `error_class` по первому нарушенному условию:
Класс брака выбирается первым совпадением:
1. `not_an_object` — корень JSON не объект;
2. `keyset_mismatch` — набор ключей отличается от ожидаемого;
3. `field_invalid` — тип, форма или разбор значения не прошли проверку.
1. `not_an_object`;
2. `keyset_mismatch`;
3. `field_invalid`.
Имя отдельного поля в класс не включается. В таблице ошибок остаются сырой
текст, метаданные доставки и `_load_id`, поэтому единичный случай можно разобрать
без постоянной детализации предиката.
Имя поля в класс не входит. Для разбора конкретной ошибки в
`ods.order_errors_dist` есть исходный `raw`, координаты Kafka и метки загрузки.
## Роль ODS
`ods.order_rep` хранит физически принятые версии в
`ReplacingMergeTree(snapshot_date)`. Ключ сортировки — `order_id`, партиция —
день неизменного `created_at`. `ods.order_dist` шардирует по
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
`FINAL` даёт корректный результат через распределённую таблицу.
`ods.order_rep` использует `ReplicatedReplacingMergeTree(snapshot_date)`.
Строки с одним ключом сортировки `order_id` считаются версиями одного заказа;
остается строка с максимальной `snapshot_date`. Партиция —
`toDate(created_at)` в UTC, ключ шардирования — `cityHash64(order_id)`.
Неизменный `created_at` и общий ключ направляют все версии заказа
в одну партицию и на один шард.
Четыре координаты отвечают на разные вопросы:
| Колонка | На какой вопрос отвечает |
|---|---|
| `snapshot_date` | На конец какого модельного дня источник показал это состояние? |
| `updated_at` | Когда запись заказа последний раз меняли в магазине? |
| `_load_id` | Какой запуск Airflow принял сообщение? |
| `_load_ts` | Когда сообщение записали в STG по реальным часам стенда? |
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
- `updated_at` — когда строку последний раз меняли в источнике;
- `_load_id` — какой запуск Airflow принял строку;
- `_load_ts` — когда строка приехала в хранилище.
Например, заказ не менялся с 1 июня, но входит в слепки за 1 и 2 июня.
`updated_at` у строк одинаков, а `snapshot_date` различается. ODS оставит
строку за 2 июня вместе с метками ее доставки. Новая дата наблюдения
нужна 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).
Схема версий стоит на дисциплине источника: слепок с поздней датой не
показывает состояния старше. Модельный мир её держит — состояние читается на
границе суток, и слепок дня выходит чистой функцией зерна и дня; источник,
собирающий слепок из отставшей реплики, её ломает. Деловая метка `updated_at`
при этом монотонна по слепкам, поэтому выбор по дате слепка даёт тот же ответ,
что выбор по метке (замер — «Что проверено»). Тихая правка строки — ручным
`UPDATE` мимо приложения, миграцией — метку не двигает, но следующий слепок
перекроет её сам, пока заказ в окне изменяемости. За окном источник заказ
больше не показывает, и там помогает только сверка самих полей, в терминах
dbt snapshots — стратегия `check` вместо `timestamp`.
`ods.order_v` читает `ods.order_dist FINAL` и возвращает одну текущую строку
на `order_id`, даже если фоновые слияния еще не убрали старые версии.
Без `FINAL` физическая таблица может показать несколько строк заказа.
Историю ODS не гарантирует: старые версии постепенно удаляются при слияниях.
Отсутствие заказа в новом слепке не удаляет его из ODS.
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
`ods.order_v` сохраняет те же источник-ориентированные поля без обогащения
данными модели, но возвращает одну актуальную версию на `order_id`. Сначала оно
может быть простым представлением над `_dist FINAL`; способ выбора можно
заменить, не меняя потребителей.
Это представление остаётся ответственностью ODS: оно скрывает механику чтения
версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает,
какие сущности, связи и производные признаки ему нужны. Судьба служебных
колонок выше ODS решена вместе с моделью DDS: чужие ярлыки туда не переезжают
([модель заказа](../dds/order.md)).
DDS читает `ods.order_v`, переводит времена в пояс счетчика и разбирает
позиции. Для своей загрузки DDS записывает собственные служебные метки —
[модель заказа](../dds/order.md#служебные-колонки).
## Одно чтение Kafka
Стандартный слепок содержит около полутора тысяч строк, тогда как предел одной
порции на стенде — десятки тысяч сообщений. Поэтому один запуск Airflow делает
один прямой `SELECT`, без цикла до пустоты и без фиксации конечных офсетов.
Один запуск делает один прямой `SELECT`, без чтения в цикле до пустоты.
Обычный слепок содержит около полутора тысяч строк, а предел порции
на стенде — десятки тысяч сообщений. Это допущение о размере данных:
Kafka не сообщает запросу, что прочитан весь слепок.
Это допущение о размере стенда, а не доказательство полноты слепка. Если Kafka
вернёт короткую порцию, непрочитанный хвост останется в топике и приедет в один
из следующих запусков. После отказа от замены партиции это задержка, а не потеря
или публикация неполного дня.
Короткая порция оставляет непрочитанные сообщения для следующих запусков.
Поэтому ODS добавляет версии заказов по мере поступления. Замена партиции
по `snapshot_date` могла бы опубликовать неполный набор заказов
([ADR 0010](../../adr/0010-order-versions-in-ods.md)).
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
Позиция мира при этом не двигается, и тот же день уедет заново — в STG он
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
([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-координаты показывают повтор задачи.
Сбой после чтения опаснее короткой порции. Чтец подтверждает позиции
сообщений в Kafka — офсеты — при прямом чтении. Если последующая вставка
в STG упадет, эти сообщения уже не будут прочитаны той же группой повторно;
часть из них может успеть записаться на шарды. Позиция модельного времени
при неуспехе не продвигается. Повтор шагового дага снова отправит слепок
того же дня, но не восстановит старые сообщения других слепков,
если они попали в неудачную порцию.
Граница восстановления описана в [ADR 0008](../../adr/0008-order-ingestion.md#следствия).
## Что проверено
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94.
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение строк STG на
годные строки и брак полное и непересекающееся; две версии одного заказа легли в
одну партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от
фонового слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
`_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
опирается, — [документ хранилища](../storage.md), «Что проверено».
Это результаты прежних опытов на стенде; при редактуре они заново
не выполнялись.
После исправления
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
честный прогон дня снят на живом стенде 20 августа 2026 года: все 1694 строки
слепка дня 7 приняты, `ods.order_errors` осталась пустой. У заказа
`20260608-0007`, который прежде приходил с отрицательным итогом, теперь
`5490.00 − 823.50 + 299.00 = 4965.50`.
- 18 августа 2026 года (#93, #94) прямое чтение приняло слепок,
а разбор разделил строки на годные и ошибки без потерь и пересечений.
Две версии заказа попали на один шард в одну партицию; `ods.order_v`
вернул позднюю. Повтор разбора с тем же `_load_id` сохранил текущую строку
и ее `_load_ts`, но повторил ошибки. Подробности —
[справочник хранилища](../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 года адресная пересборка мира прошла через
`SQLExecuteQueryOperator`: `pull_batch` и `parse_batch` завершились успешно. В
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу
пользователя `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),
поведение чтения — с исходниками той же версии
На ClickHouse `26.3.17.56` отдельно проверены одна порция при прямом
`SELECT` Kafka и выполнение `FINAL` на шардах через Distributed.
При проектировании Context7 был недоступен; чтение сверяли с исходниками
той же версии:
[`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).
Повторная сверка правил версий с документацией — в
[справочнике хранилища](../storage.md#актуальные-события).
## Связанные решения
+7 -1
View File
@@ -22,7 +22,7 @@
## Пользователи и роли ClickHouse
В ClickHouse семь пользователей и шесть ролей:
В ClickHouse восемь пользователей и семь ролей:
- `etl` — учётная запись рабочих дагов Airflow; роль `etl_writer` читает и
пишет слои хранилища;
@@ -33,6 +33,9 @@
справочники `dic`, а запросы Superset обращаются к публичным `_v`;
- `analyst` — учётная запись аналитика с правом чтения всех слоёв и
справочников;
- `student` — учебная учётная запись: роль `analyst_reader` дает чтение
источников, а `student_writer` — создание, заполнение и удаление своих
таблиц в `default`. Записи в слои хранилища у нее нет;
- `grafana` — учётная запись Grafana; роль `monitoring_reader` читает все слои,
системные таблицы и данные со всех реплик кластера;
- `dict` — беспарольная учётная запись словаря товаров с правом чтения базы
@@ -49,6 +52,9 @@ Superset получает пароль `bi`, а Grafana — пароль `grafan
## Применение настроек
Для существующего стенда сначала добавьте `CLICKHOUSE_STUDENT_PASSWORD`
из `.env.example` в свой `.env`.
Чтобы применить новые значения ClickHouse из `.env` или изменения файлов в
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/`, пересоздайте ноды:
+277 -319
View File
@@ -30,223 +30,202 @@ keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/d
## Имена объектов
Имя объекта заканчивается тем, что это за объект:
Суффикс показывает назначение объекта:
| Суффикс | Что это |
|---|---|
| `_rep` | локальная таблица шарда, движок семейства `Replicated*` |
| `_dist` | `Distributed` поверх одноимённой локальной |
| `_kafka` | таблица на движке `Kafka` |
| `_file` | таблица на движке `File` — чтец файла |
| `_mv` | материализованное представление |
| `_v` | обычное представление, публичная поверхность чтения |
| `_rep` | локальная таблица: хранит данные своего шарда, движок семейства `Replicated*` |
| `_dist` | таблица `Distributed`: читает данные шардов и распределяет запись между ними |
| `_kafka` | таблица на движке `Kafka`: читает сообщения топика |
| `_file` | таблица на движке `File`: читает файл |
| `_mv` | материализованное представление: обрабатывает вставляемые данные |
| `_v` | обычное представление: выполняет сохраненный запрос при чтении |
Суффикс носит каждый физический объект. Голого имени у таблицы не существует:
запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым
именем в документах и разговоре понимается сущность, у которой этих объектов
несколько. Единственное исключение — словари: у них воплощение одно, шардировать
нечего, и суффикс ничего не различал бы. В зоне `dic` обе формы стоят рядом и
правило видно целиком: `dic.products_file` — чтец файла, `dic.products` — сам
словарь.
Для запросов к моделям DDS и витринам DM используйте `_v`. Представления
явно перечисляют колонки, поэтому добавление поля в физическую таблицу
не меняет набор полей для читателя. У `dds.event_v` нет отдельной таблицы
DDS: представление читает события ODS.
Распространённая конвенция, где голое имя означает локальную таблицу, а
распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо
возвращает данные одного шарда. Правило спеки «проверки и контрольные суммы —
только по `Distributed`» такую тишину переживает плохо.
В документации имя без суффикса, например `stg.hits_raw`, обозначает
сущность с несколькими объектами. Таблицы с таким именем нет: в запросе
нужно указать суффикс. Исключение — словари ClickHouse: `dic.products`
обращается к каталогу товаров, а `dic.products_file` читает его файл.
Второе свойство — в списке по алфавиту объекты группируются по сущности, а не по
технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются
хвостом, а не началом имени. Речь про дерево в клиенте и про `ORDER BY name`:
порядок выдачи `SHOW TABLES` документация не оговаривает.
В STG имя берется из топика: `hits_raw`, `orders_raw`. В типизированных
слоях оно называет сущность в единственном числе: `event`, `order`,
`session`. Объекты одной сущности различаются суффиксом и стоят рядом
при сортировке имен по алфавиту.
Начало имени — сущность, и берётся она в разных слоях из разных мест. В STG имя
приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем
топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях
имя приходит от предметной области и стоит в единственном числе: `event`,
`order`, `session`. Граница между «как привезли» и «что это такое»
проходит по STG, и имена её показывают.
В DDS публичный договор сущности заканчивается на `_v`. Потребительские запросы
и витрины называют только представление; `_rep` и `_dist` служат загрузке,
диагностике и обслуживанию. Представление перечисляет колонки явно, даже если
его запрос — простая проекция над `_dist`: договор не выдаёт устройство
кластера и случайно добавленные в физическую таблицу колонки. У события
физической копии в DDS нет, но правило то же: `dds.event_v` — его публичный
договор.
Право доступа здесь намеренно шире договора. Роль `bi_reader` может читать
любые объекты ODS, DDS и DM, но физические объекты от этого не становятся
публичной поверхностью. Конвенцию держат явный список колонок, документация и
ревью, а не запрет на физические таблицы.
Широкое право принято в [ADR 0007](../adr/0007-clickhouse-access.md): узкая
граница поверх `Distributed` потребовала бы отдельной механики. Механизм
ClickHouse и отклонённые варианты записаны в
`_rep` и `_dist` нужны для загрузки, диагностики и обслуживания. Роль
`bi_reader` может читать их в ODS, DDS и DM, но для обычного анализа
принято использовать `_v`. Это соглашение о чтении, а не ограничение прав;
причина широкого доступа описана в
[ADR 0007](../adr/0007-clickhouse-access.md) и
[исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md).
## Раскладка по шардам
Пишем только в `_dist`. Локальные таблицы остаются для чтения и обслуживания —
операций с партициями, ручной переобработки. Правило не про удобство: при записи
через распределённую таблицу раскладку определяет ключ шардирования, то есть
свойство данных, а при записи в локальную — то, какая нода случайно выполняла
код. Отсюда урок стенда: какая нода читала топик, меняется между прогонами
(видно в колонке `consumer_host`), а куда легли данные — нет.
Шард хранит часть строк. При записи через `_dist` место строки выбирает
ключ шардирования. При прямой записи в `_rep` она остается на той ноде,
где выполнена вставка. Поэтому данные записывают через `_dist`,
а операции с партициями выполняют на локальных `_rep`.
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
выбран против перекоса: структурированный числовой идентификатор распределяется
по остатку от деления неравномерно. У сырого слоя выбора нет — строку иначе не
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
шард.
У событий ключ — `cityHash64(ClientID)`, у заказов —
`cityHash64(order_id)`, у STG и таблиц ошибок — хеш сырой строки.
Хеш распределяет числовые идентификаторы по шардам и направляет одинаковые
сообщения на один шард. У сырой строки и разобранного события разные
ключи: они могут оказаться на разных шардах. Счетчики STG и ODS сравнивают
по всему кластеру через `_dist`, а не отдельно по каждой ноде.
Ключи у слоёв разные, и это имеет наблюдаемое следствие: сырая строка и
разобранное из неё событие почти всегда оказываются на разных шардах.
Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны —
сверять слои можно только через `_dist`.
При `INSERT INTO цель_dist SELECT … FROM источник_dist` важна настройка
`parallel_distributed_insert_select`. При значении `2` и одном кластере
вставка может выполняться локально на шардах, сохраняя раскладку источника
вместо ключа цели. Если ключи различаются, укажите
`parallel_distributed_insert_select = 0`: нода, принявшая запрос,
соберет результат и распределит его по ключу цели. Так сделано в
[`sql/ods/order_parse.sql`](../../sql/ods/order_parse.sql) и при сборке
выручки. Неверную раскладку проверяют по `hostName()` и ожидаемому шарду
строки; общий счет по `_dist` ее не показывает.
Разные ключи у слоёв ставят капкан на вставке `INSERT INTO цель_dist SELECT …
FROM источник_dist`. Заводское значение `parallel_distributed_insert_select` —
2: если обе таблицы на одном кластере, вставка исполняется локально на каждом
шарде, минуя ключ шардирования цели. Строки наследуют раскладку источника, и
ошибки при этом нет — расхождение видно только пересчётом ключей по
`hostName()`. Так половина заказов и легла на оба шарда, где
`ReplacingMergeTree` через границу шарда дубли не схлопывает (замер — «Что
проверено», #137). Лекарство — явное `parallel_distributed_insert_select = 0`
в `SETTINGS` такой вставки: раскладку делает инициатор, по ключу цели
(`sql/ods/order_parse.sql`). Капкан ждёт и сборки DDS: они тоже будут читать
`_dist` и писать в `_dist` с другим ключом.
События, `dds.session_dist` и `dds.identity_map_dist` распределены
по `cityHash64(client_id)` (в ODS поле называется `ClientID`). Данные
одного посетителя лежат вместе — это ко-локация. Заказы DDS и их сверка
сохраняют раскладку по `cityHash64(order_id)`. Обычное соединение допускается
по ключу ко-локации; для остальных соединений применяется `GLOBAL`.
Само совпадение ключей еще не гарантирует локального выполнения запроса
через представления — пример есть в [описании `daily_traffic`](dm.md#dmdaily_traffic_v).
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из
раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё
прочее — через `GLOBAL`. Значит `dds.session_dist` и `dds.identity_map_dist`
шардируются по `cityHash64(client_id)`. Объекты DDS с зерном заказа сохраняют
ко-локацию по `cityHash64(order_id)`; ключи остальных частей модели и витрин
появляются вместе с ними.
Для дневной пересборки DDS и DM результат сначала записывают
в промежуточную таблицу. Основная таблица в это время остается доступна
для чтения с прежними данными. Например, заказы записываются через
`dds.order_stage_dist` в `dds.order_stage_rep`, затем ее дневные разделы
заменяют разделы основной `dds.order_rep`.
Замена партиций не используется для `ods.order`: версии прошлых дней
доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR
0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка
понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее
разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким
объектом, а не переносить на ODS заранее.
Первым такой донор понадобился заказу DDS, и правило на нём видно целиком:
`dds.order_stage_rep` объявлен через `AS dds.order_rep` и шардируется тем же
`cityHash64(order_id)`. Совпадение структуры требует сама операция, а совпадение
ключа — устройство замены: она идёт по локальным таблицам, шард за шардом, и
строка, попавшая в доноре не на свой шард, при подмене пропала бы с одного шарда
и задвоилась на другом. Доводы целиком — в
У промежуточной и основной таблиц должны совпадать структура, ключи
партиции и сортировки, первичный ключ и политика хранения. Поэтому
`dds.order_stage_rep` объявлена через `AS dds.order_rep` с совпадающими
ключами. Требования сверены через Context7 с
[документацией ClickHouse](https://clickhouse.com/docs/sql-reference/statements/alter/partition#replace-partition)
для схемы на ClickHouse 26.3. DDL показан в
[`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)).
## Служебные колонки
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
`_topic`, `_partition`, `_offset`, `_timestamp` у Kafka, `_shard_num` у
`Distributed` и прочие. Если положить на диск колонку с таким же именем, в
матвью перестанет читаться, что дано движком, а что положено нами, — а это
ровно то различие, ради которого метаданные доставки и хранятся. Поэтому они
ложатся под именами `kafka_topic`, `kafka_partition`, `kafka_offset`,
`kafka_timestamp`, рядом — `consumer_host`, имя читавшей ноды: виртуальные
колонки его не несут, а после записи в `Distributed` он уже невосстановим.
STG сохраняет исходное сообщение в строке `raw` и добавляет сведения
о доставке. JSON на этом шаге не разбирается: некорректное содержимое
тоже попадает в таблицу.
Типы у них такие: `kafka_topic` и `consumer_host` — `LowCardinality(String)`,
значений мало и они повторяются; `kafka_partition` и `kafka_offset` — `UInt64`.
С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а сырой слой
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы либо
падение приёма на первом сообщении, либо тихие нули за 1970 год.
| Колонка STG | Что хранит |
|---|---|
| `kafka_topic` | Имя топика из виртуальной колонки Kafka `_topic` |
| `kafka_partition` | Номер партиции из `_partition` |
| `kafka_offset` | Позицию сообщения внутри партиции из `_offset` |
| `kafka_timestamp` | Метку времени Kafka из `_timestamp_ms`, если она есть |
| `consumer_host` | Имя ноды, прочитавшей Kafka |
| `_load_ts` | Реальное время записи сообщения в STG |
| `_load_id` | Только у заказов: `run_id` запуска Airflow, который принял порцию |
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
на шарде-получателе, то есть назвал бы не ту ноду, которая читала топик, —
колонка молча отвечала бы на другой вопрос.
Топик, партиция и смещение вместе позволяют найти сообщение Kafka.
`kafka_topic` и `consumer_host` имеют тип `LowCardinality(String)`,
`kafka_partition` и `kafka_offset` — `UInt64`.
`kafka_timestamp` имеет тип `Nullable(DateTime64(3, 'UTC'))`: сохраняет
миллисекунды и допускает отсутствие метки.
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
не проверяет, поэтому там оказываются и целые сообщения, и мусор. События ниже
разбирают матвью ODS ([ADR 0005](../adr/0005-event-ingestion.md)), заказы —
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
0010](../adr/0010-order-versions-in-ods.md)).
Виртуальные колонки предоставляет движок, а колонки STG хранят значения
на диске. Их имена различаются, чтобы не скрыть виртуальную колонку
собственной. Значения вычисляются в запросе приема. В частности,
`hostName()` должен выполняться на ноде, читающей Kafka:
`DEFAULT hostName()` только в локальной таблице назвал бы шард-получатель.
Движок таблицы STG — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у
таблицы STG и нет. Замену версий сюда ставить нельзя — она отменила бы свойство
слоя, ради которого он заведён: повтор доставки в STG обязан быть виден.
Таблицы STG используют `ReplicatedMergeTree` с
`ORDER BY (kafka_partition, kafka_offset)`. Повторная доставка остается
отдельной строкой; STG нужен в том числе для разбора таких повторов.
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка
источника; `_load_ts` только показывает, когда конкретная строка приехала.
`_load_ts` имеет тип `DateTime64(3, 'UTC')`. При переходе в ODS метка
копируется из STG без пересчета. У событий она также выбирает последнюю
доставку; у заказов версию задает `snapshot_date`. `_load_id` заказа
переносится в годную строку ODS и в таблицу ошибок.
У непрерывного потока событий нет запуска Airflow, поэтому `_load_id`
в его STG и ODS отсутствует.
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
`DateTime64(3, 'UTC')`; ClickHouse их не создаёт и добавляет рядом собственную
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
Время события `UTCEventTime` и дата `EventDate` приходят из счетчика сайта.
Поля заказа `created_at` и `updated_at` приходят из базы магазина.
Они относятся к модельному времени; `_load_ts` — к реальным часам стенда.
Например, событие июня можно загрузить в сентябре: его `EventDate`
останется июньской, а `_load_ts` будет сентябрьской.
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
Airflow. Переделать разобранное руками можно вставкой из STG с фильтром по
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
`_load_id`; дневные партиции ODS он не заменяет.
Имя согласовано с каноном служебных полей соседнего учебного стенда на
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
технических колонок — распространённая запись,
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
Общего идентификатора пачки загрузки (`_load_id`) нет. У потока событий нет ни
пачки, ни `run_id`, и колонка была бы пустой формальностью. У заказов читатель
назван: `_load_id` равен `run_id` Airflow и переносится из STG в годную строку
ODS и в таблицу ошибок. Нужен ли он выше ODS, решается вместе с моделью DDS.
События разбираются материализованными представлениями. Автоматической
пакетной переобработки у них нет; сохраненный срез STG можно разобрать
отдельным запросом до истечения срока хранения. При повторной вставке
с тем же ключом и `_load_ts` `ReplacingMergeTree` оставляет более поздно
вставленную строку. Заказы разбирает задача Airflow по `_load_id`.
В DDS метки загрузки уже относятся к собственной сборке слоя —
[пример заказа](dds/order.md#служебные-колонки).
## Актуальные события
`ods.event_rep` хранит физически принятые версии события в
`ReplacingMergeTree(_load_ts)`, а `ods.event_dist` служит для записи и
диагностики доставки. Обычное чтение этой пары может показать несколько строк
одного `WatchID`, пока фоновые слияния их не схлопнули.
Повторная отправка события может дать несколько строк в `ods.event_rep`.
Движок `ReplicatedReplacingMergeTree(_load_ts)` считает версиями строки
с одинаковым полным ключом сортировки:
`(CounterID, EventDate, intHash32(ClientID), WatchID)`.
При слиянии остается строка с максимальным `_load_ts`, то есть последняя
доставка. Один `WatchID` обозначает событие, но физический ключ замены шире.
`ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет
имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное
состояние читают через него, а не повторяют `FINAL` в каждом запросе.
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том
числе snake_case-имена и расшифровку кодов.
`ods.event_v` читает `ods.event_dist FINAL`: выбирает актуальную строку
при выполнении запроса, не дожидаясь фоновых слияний. Поэтому события
считают через `_v`; число строк в `_dist` зависит от уже прошедших слияний.
`dds.event_v` использует этот результат, дает колонкам имена в `snake_case`
и расшифровывает коды.
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
исключение: источник объявляет `Array(Float64)`, а материализованное
представление ODS разбирает числа сырого JSON сразу в `Array(Decimal(18, 2))`.
DDS наследует этот тип без нового преобразования. Прямой разбор подтвержден
документацией ClickHouse через Context7 и запросом к ClickHouse 26.3; сравнение
с сырыми данными подтвердило: копейки всех покупок целы.
Выбор по полному `ORDER BY` и максимальной версии, включая чтение с `FINAL`,
сверен через Context7 с [документацией ClickHouse](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree).
В стенде закреплена версия 26.3.17.56; `_v` использует `FINAL`, чтобы
результат не зависел от фоновых слияний.
Типы ODS повторяют [описание выгрузки](../formats/clickstream-event.md)
с одним денежным исключением: `purchaseRevenue` объявлен источником
как `Array(Float64)`, а из JSON разбирается сразу в
`Array(Decimal(18, 2))`. Это сумма в рублях с копейками; DDS наследует тип.
`productPrice` содержит округленные целые рубли и не подходит для
восстановления точной суммы покупки. Прямой разбор `purchaseRevenue`
проверен на ClickHouse 26.3; результаты сохранены в разделе «Что проверено».
## Версии заказов
Слепки повторяют состояние заказов из семидневного окна источника.
`ods.order_rep` хранит принятые версии в
`ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция —
`toDate(created_at)`. `ods.order_dist` шардирует по
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
выбрал одного победителя.
`ReplicatedReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`,
партиция — `toDate(created_at)` в UTC. Запись через `ods.order_dist`
распределяет их по `cityHash64(order_id)`. У всех версий заказа одинаковы
день создания и шард, поэтому они могут заменять друг друга.
Физическая пара нужна для загрузки и диагностики. Обычное чтение показывает
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
`ods.order_v` служит поверхностью точного текущего состояния для следующих
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
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).
`ods.order_v` читает `_dist FINAL` и оставляет строку с максимальной датой
слепка. Дата `snapshot_date` показывает последнее принятое наблюдение,
а `updated_at` — время изменения заказа в магазине. У неизменившегося
заказа в следующем слепке дата наблюдения обновится, время изменения — нет.
Источник выпускает один слепок на дату, а поздний слепок показывает
не менее новое состояние. Отсутствие заказа в следующем слепке
не означает удаления. Физическая таблица не служит архивом:
старые версии исчезают при фоновых слияниях.
Прием добавляет версии отдельными строками. `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
0008](../adr/0008-order-ingestion.md) и [спецификация приёма
заказов](orders/ingestion.md).
События принимаются непрерывно. Заказы читает отдельным запросом
даг `orders_ingest` — [описание пакетного приема](orders/ingestion.md).
У событий путь такой:
Цепочка одна: чтец топика → матвью → STG → матвью разбора → событие и таблица
ошибок ODS.
```text
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
```
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
`ON CLUSTER` и макросов в имени не содержит. Разные группы дали бы каждой ноде
полную копию топика, и это отдельная сцена для лабы, а не рабочий режим. Имя
кластера в `ON CLUSTER` и в движке `Distributed` — `clickstream_cluster`, оно
задано в `infra/clickhouse/config.d/cluster.xml`.
Таблицы Kafka на обеих нодах используют одну группу `clickstream_hits`.
Ноды делят чтение партиций топика. Разные группы прочитали бы каждая
свою полную копию потока. Имя группы одинаково в DDL на всем
`clickstream_cluster`; конфигурация кластера —
`infra/clickhouse/config.d/cluster.xml`.
**Источник матвью разбора — `stg.hits_raw_dist`, а не локальная таблица.**
У матвью две привязки: источник, на вставку в который она срабатывает, и цель,
куда пишет. Распределённая таблица — лицо слоя, локальная — его хранилище;
потребитель слоя цепляется к лицу. Практически это значит, что разбор идёт на
той же ноде, что читала Kafka, в момент вставки первой матвью — до раскладки по
шардам.
Материализованные представления разбора срабатывают на вставку
в `stg.hits_raw_dist`, до распределения строк по шардам.
Оба видят вставляемую порцию на той ноде, которая читает Kafka.
`stg.hits_raw_mv` создается после них: его подключение запускает
чтение топика. Если начать чтение раньше, сообщения успеют попасть
в STG без разбора в ODS.
**Вставка фоновая, и окно потери мы принимаем.** Вставка в распределённую
таблицу кладёт блок в локальный спул и сразу возвращает управление, а Kafka
коммитит офсеты по факту работы матвью — то есть по факту записи в спул. Топик
уже считает сообщение прочитанным, хотя на шарде его ещё нет: умри нода в этом
промежутке — сообщения не перечитаются.
Условие приема проверяет три вещи:
Закрывает окно настройка `distributed_foreground_insert = 1`, и на ETL-вставках
Airflow она стоит — там это обычный `SETTINGS` у запроса. На пути приёма её нет,
и по трём причинам. Вставку выполняет фоновый поток Kafka-движка, своего запроса
у него не бывает, так что настройка уровня запроса доехала бы только профилем
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
как при фоновом первая нода продолжает принимать и копит спул для соседа.
Платится при этом не одно ожидание на блок, а три распределённые вставки — STG,
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
компромисс полезнее показать, чем спрятать за галочкой.
- корень `raw` — объект JSON;
- набор верхнеуровневых ключей совпадает с 47 полями формата;
- пять опорных полей, `WatchID`, `VisitID`, `ClientID`, `EventDate`
и `UTCEventTime`, разбираются в ожидаемые типы.
Ещё одно место, где сырой слой хранит не всё приехавшее: запись с пустым
значением и запись-надгробие проходят молча, не оставляя строки. Свойство
измерено и принято осознанно — подробности в разделе «Что проверено».
Остальные поля извлекаются по описанию выгрузки без отдельных проверок.
Это граница приема: годная строка не означает, что проверен смысл
всех 47 значений. Подробности — в [ADR 0005](../adr/0005-event-ingestion.md).
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
`ods.event_mv` принимает строки, для которых условие истинно,
`ods.event_errors_mv` — для которых оно ложно. Условие не должно
возвращать `NULL`: такую строку не выбрали бы ни оно, ни его отрицание.
Разбор использует функции без исключений на некорректном сообщении,
поскольку ошибка материализованного представления прерывает вставку
и останавливает чтение Kafka до устранения причины.
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
`ReplacingMergeTree`, а таблицы STG вовсе не устраняют дубли: перезаливка
модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения
срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности
конвейера к сырому слою не относится.
### Доставка и повторы
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
Удвоение строк STG проходит мимо неё только потому, что при повторном чтении
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
отсутствии механизма.
Вставка в Distributed на этом пути фоновая: блок сначала записывается
в локальную очередь на диске, затем отправляется на шарды.
Kafka подтверждает обработку после выполнения материализованных
представлений, когда данные еще могут находиться в очереди.
Если после подтверждения потерять ноду вместе с очередью, сообщения
не перечитаются. Возможен и повтор: данные уже записались на шард,
но подтверждение Kafka не успело сохраниться.
Поэтому прием не гарантирует доставку каждого сообщения хотя бы один раз.
**Матвью разбора две, и их условия обязаны делить поток без зазора и без
нахлёста.** Одна забирает годные строки в `ods.event_dist`, вторая — брак в
`ods.event_errors_dist`. Строка, подошедшая обеим, задвоится; не подошедшая ни
одной — исчезнет молча. Держится это формой: второе условие пишется буквальным
отрицанием первого, а сам предикат NULL не возвращает ни в одной своей части, —
иначе трёхзначная логика даст строку, которую не возьмёт ни `условие`, ни
`NOT условие`. Обнуляемый разбор в предикате поэтому есть, но заканчивается
`IS NOT NULL`, а сравнения дают 0 или 1. Функции предиката не должны и бросать
исключений: упавшая матвью
роняет вставку и останавливает потребление до починки
([ADR 0005](../adr/0005-event-ingestion.md)).
В ETL-запросах Airflow `distributed_foreground_insert = 1` дожидается
записи на шарды. У фонового приема событий этой настройки нет:
недоступность одного шарда тогда остановила бы весь прием. В принятом
режиме доступная нода продолжает принимать и накапливает очередь
для недоступного шарда. Ограничение сохранено для учебного стенда;
подробности и наблюдения — в разделе «Что проверено».
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырого
слоя срок жизни трое суток, а ODS хранит всё, поэтому равенство «строки STG =
события + ошибки» разъедется само: строки STG истекут раньше, а перезаливка
модельного дня задвоит их, тогда как в ODS тот же повтор схлопнется. Правило,
красное в норме, учит не смотреть на оповещения ([ADR
0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при
исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк
STG и N в сумме событий и ошибок. Постоянной целью такой опыт не становится, и
почему — в [карте проверок](testing.md), раздел «Интеграционная проверка
постоянной целью не становится». Счёт актуальных событий идёт через
`ods.event_v`, которое скрывает `FINAL`: голый `count()` по `ReplacingMergeTree`
зависит от того, сколько мержей успело пройти, и спека это прямо запрещает
(раздел 6).
В STG повтор сохраняется, в ODS его устраняет выбор актуальной версии.
Хотя `Replicated*` умеет отбрасывать повторно вставленные одинаковые блоки,
при новом чтении Kafka меняется `_load_ts`, а вместе с ним и хеш блока.
Поэтому повторная отправка модельного дня увеличивает число строк STG.
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
против [паспорта мира](../../data/world-inventory.json), лежащего в git (`make
check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в этом вся
разница: паспорт описывает восемь дней стартового мира, счёт обрамлён их датами,
повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не помеха — ODS
хранит всё. Обе стороны равенства зафиксированы: одна кодом генератора, другая
файлом в git. У межслойной сверки такой опоры нет ни с одной стороны.
Пустое значение сообщения и значение `null` не дают строки даже в STG:
чтец продвигает позицию Kafka, но не передает строку на разбор.
Генератор таких сообщений не создает. Поведение измерено на стенде
и описано в разделе «Что проверено».
### Что можно сверять
Для короткого неизменного среза доставки можно проверить баланс:
число строк STG равно сумме годных строк и ошибок до удаления повторов.
Постоянно сравнивать полные таблицы так нельзя: STG удаляет старые данные,
ODS оставляет события и объединяет повторные доставки.
Счетчики по слоям тогда расходятся при штатной работе.
`make check-clickhouse` сравнивает актуальные события в `ods.event_v`
с [паспортом мира](../../data/world-inventory.json) только за восемь
начальных дней. Эта проверка учитывает повторную отправку через `FINAL`
и не зависит от срока жизни STG. Границы остальных проверок —
в [карте проверок](testing.md).
## Срок жизни сырых данных
Сырые данные в STG живут трое суток реального времени и уходят сами. Трое — это
окно отладки: столько сырые данные хранятся на ноутбуке, чтобы менти успел
разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню
загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками
(`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не
зависело от умолчания версии.
TTL — правило автоматического удаления старых данных. В обеих таблицах
STG срок задан как три суток от `_load_ts`, по реальному времени загрузки.
Партиции тоже нарезаны по дню `_load_ts`. Это оставляет короткий период
для поиска ошибок и повторного разбора, после которого данные освобождают
место на диске.
Нарезать сырые данные по модельному дню события было бы соблазнительно — он
единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
снимается целиком, только когда в нём истекли все строки, а в партиции
модельного дня лежит приехавшее в разное реальное время: мерж склеит куски
разного возраста, самая свежая строка удержит весь кусок, и данные переживут
срок неограниченно.
Настройка `ttl_only_drop_parts = 1` удаляет только целые части данных —
файлы, которые ClickHouse создает при вставках и объединяет при слияниях.
Часть удаляется, когда срок истек у всех ее строк. Внутри партиции
дня загрузки возраст строк отличается не более чем на сутки.
Поэтому к трем суткам может добавиться до суток ожидания самой свежей
строки части, а затем ожидание фоновой обработки TTL. Точного времени
удаления правило не обещает.
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
возраста — но не более чем на сутки, потому что партицию закрывает календарный
день. Отсюда и оценка: сырые данные живут трое суток плюс хвост до суток, а не
ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику,
так что «уходит само» здесь обещано, а «уходит вовремя» — нет.
Модельная дата для TTL не подходит: события июня могут загружаться
осенью, и срок от `EventDate` истек бы сразу. Партиция по модельному дню
тоже смешала бы доставки разного возраста: очередная отправка дня могла бы
продлевать хранение старых строк после слияния частей.
Модельного дня среди колонок STG нет вовсе. Он свойство содержимого, а
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырые
данные режутся по своим координатам: и разбор полётов, и переобработка фильтруют
по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели
модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска
разные часы, и обслуживают их разные средства. Декларативный TTL по модельной
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
эталонные дни прямо на входе.
В бою сырой слой иногда собирают на движке `Null` — тогда он не хранится вовсе.
Такой вариант отвергнут: на стенде сырые данные нужны для отладки, поэтому окно,
а не ноль.
Для разбора сохраненных сообщений выбирайте небольшой срез по `_load_ts`.
Так запрос ограничивает партиции и сохраняет некорректные сообщения,
из которых модельную дату извлечь нельзя. `EventDate` появляется
отдельной колонкой только в ODS. В [задании ступени 6](../labs/6-1-event-parsing.md)
такой срез разбирается рядом с действующим приемом.
## Таблица ошибок
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что
у брака нет разобранных полей: шардируется `cityHash64` сырой строки —
`ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню
загрузки, как и сырые данные; живёт месяц. Дольше сырых данных — намеренно: если
брак истекает вместе с ними, разбираться к моменту разбирательства будет уже
нечем. Снятие — целыми кусками (`ttl_only_drop_parts`), как у STG и по той же
причине: строки в партиции дня загрузки разного возраста не более чем на сутки,
и куску незачем переживать срок из-за самой свежей строки.
`ods.event_errors_dist` содержит сообщения, отвергнутые разбором событий:
исходный `raw`, сведения о доставке и класс ошибки `error_class`.
Класс имеет тип `LowCardinality(String)` и выбирается по первому
нарушенному условию:
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
качества не на что опереться. Сами классы, их порядок и довод, почему порядок
обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md).
1. `not_an_object` — корень JSON не объект;
2. `keyset_mismatch` — набор ключей не совпал с форматом;
3. `key_field_unparsed` — одно из пяти опорных полей не разобралось.
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по
чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition,
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
доставки.
Порядок важен: строка вместо объекта нарушает сразу несколько условий,
но получает один класс. Полные условия — в
[ADR 0005](../adr/0005-event-ingestion.md).
`ods.order_errors` держит тот же диагностический минимум и `_load_id`
запуска. У заказов три класса по приоритету: `not_an_object`,
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
разрастается до имени отдельного поля. Точная граница приёма — в
[спецификации заказов](orders/ingestion.md).
`ods.order_errors_dist` хранит такой же набор сведений и `_load_id`
запуска приема. Первые два класса совпадают с событиями,
третий называется `field_invalid`: у заказа проверяются все поля
верхнего уровня. Точные правила — в [описании приема заказов](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 товаров события |
| 42 | `productName` | `Array(String)` | `object` | `product_name` | названия тех же товаров |
| 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` | количество штук каждого товара |
| 46 | `productEventType` | `Array(String)` | `object` | `product_event_type` | действие с товаром: стенд шлёт add и purchase, полный словарь Метрики (detail, remove, impressions) не берём |
| 47 | `ecommerce` | `String` | `object` | `ecommerce` | сырой JSON события, как отдаёт Метрика — материал лабы про разбор JSON внутри колонки |
+26 -27
View File
@@ -1,50 +1,49 @@
# Путеводитель по стенду
Если вы впервые видите стенд, начните отсюда. Путеводитель подскажет, на что
смотреть и в каком порядке, а за подробностями отправит в справочники. Так вы
разберетесь в устройстве, прежде чем браться за уроки.
Путеводитель объясняет, как данные поступают в хранилище, обрабатываются
и попадают в отчеты. В главах есть короткие примеры, которые можно выполнить
на запущенном стенде перед переходом к учебным заданиям.
## Как читать
## Порядок чтения
Тропа такая: [словарь понятий](../../CONTEXT.md) → главы путеводителя →
[справочники `docs/architecture/`](../architecture/) →
[уроки](../labs/README.md).
Словарь не надо читать подряд. Возвращайтесь к нему за незнакомым словом.
Первая статья словаря объясняет хранилище. Следом идет несколько статей об
устройстве генератора; они понадобятся нескоро.
Сначала запустите стенд по [инструкции](../../README.md#быстрый-старт).
Затем читайте главы по порядку и выполняйте примеры из них.
Незнакомые термины можно найти в
[словаре понятий](../../CONTEXT.md), а подробности устройства —
в [технических справочниках](../architecture/). К ним достаточно обращаться
по мере необходимости. После путеводителя переходите к
[учебным заданиям](../labs/README.md).
## Карта хранилища
[Интерактивная карта](architecture-map.html) показывает путь данных через слои.
Gitea показывает HTML как текст. Откройте файл из своей копии репозитория
или скачайте его кнопкой «Raw» и откройте в браузере.
Держите карту рядом с главой «Хранилище и его слои».
Сверяйтесь с картой при чтении главы «Хранилище и его слои».
## Главы
## Содержание
### [Хранилище и его слои](storage-layers.md)
От события в Kafka до витрины: глава разбирает путь через STG, ODS, DDS и DM
и показывает, за что отвечает каждый слой.
Как меняются данные при переходе из Kafka в слои STG, ODS, DDS и DM.
В конце главы вы посмотрите события, сессии и выручку с помощью SQL.
### [Потоки Airflow и даги модельного времени](airflow-dags.md)
### [Генерация и обработка данных в Airflow](airflow-dags.md)
Шаговый даг играет модельный день, а дневной конвейер раскладывает данные по
слоям. Глава поможет увидеть их связь и роль каждого дага.
Какие даги создают новые данные, а какие обрабатывают их в хранилище.
Вы сравните содержимое витрин до и после обработки нового дня.
### [Мониторинг: куда смотреть](monitoring.md)
### [Мониторинг в Grafana](monitoring.md)
Свежесть и поток данных, брак и отставание собраны на дашборде «Данные».
Глава научит находить нужную панель и разбирать ее показания.
Что показывают панели Grafana и как меняются их показания, пока в стенд
поступают события и заказы. Вы увидите, почему заказы обновляются реже событий.
### [Немного Superset](superset.md)
### [Отчеты в Superset](superset.md)
Глава покажет, как витрина становится графиком и где проходит граница между
своими объектами и теми, которые поднимает стенд.
Как Superset строит графики по витринам. Вы сравните числа на готовом графике
с результатом SQL и узнаете, какие отчеты сохраняются при перезапуске стенда.
### [Генератор: откуда берутся данные](generator.md)
Здесь генератор — источник данных, а не предмет изучения. Глава в общих
чертах объяснит, откуда берутся события и заказы и в каком виде их получает
хранилище.
Откуда берутся события и заказы, почему начальные данные повторяются
и как сверить число загруженных событий с ожидаемым.
+65 -52
View File
@@ -1,94 +1,106 @@
# Потоки Airflow и даги модельного времени
# Генерация и обработка данных в Airflow
В Airflow два рода дагов. Даги мира создают мир и проигрывают модельные дни,
а дневной конвейер раскладывает сыгранный день по слоям хранилища.
Airflow управляет генерацией данных и их обработкой в хранилище. Это разные
этапы: сначала в Kafka поступают события и заказы за день работы магазина,
затем по ним рассчитываются данные DDS и витрины DM.
Даг описывает задачи Airflow и порядок их выполнения.
```mermaid
flowchart LR
world_initialize --> etl_pipeline
world_live_day --> etl_pipeline
manual[запуск рукой] --> etl_pipeline
manual[ручной запуск] --> etl_pipeline
etl_pipeline --> dds_transform --> dm_transform --> dq_check
world_next_day --> sources[источники без ETL]
```
## Два потока входят по-разному
## Загрузка событий и заказов
События приходят в топик `hits`. Хранилище разбирает их само и непрерывно:
События поступают в топик `hits` и обрабатываются по мере поступления:
материализованные представления переносят строки из STG в ODS без участия
Airflow.
Заказы приходят в топик `orders` один раз в модельные сутки, целым слепком. У
слепка есть начало и конец, поэтому даг `orders_ingest` забирает его одной
порцией. Такой способ приема называется [пакетным](../../CONTEXT.md).
Заказы поступают в топик `orders` раз в модельные сутки одной выгрузкой.
Она содержит состояние заказов на конец дня и называется слепком.
Даг `orders_ingest` загружает ее целиком. Такой способ приема называется
[пакетным](../../CONTEXT.md).
## Даги мира
## Генерация модельных дней
Два дага с тегом «жизненный цикл мира» отвечают за стартовый мир:
В документации миром называется учебный магазин с его посетителями,
товарами и заказами. Время в нем идет отдельно от календарного.
Два дага с тегом «жизненный цикл мира» создают начальные данные:
| Даг | Что делает |
|---|---|
| `world_initialize` | Создает стартовый мир, если его еще нет |
| `world_recreate` | Стирает данные мира и собирает стартовый мир заново |
| `world_initialize` | Создает начальные данные, если их еще нет |
| `world_recreate` | Удаляет прикладные данные и создает их заново |
Три дага с тегом «модельное время» проигрывают следующие дни. Именно их
называют дагами модельного времени.
Для добавления следующих дней есть три дага с тегом «модельное время»:
| Даг | Что делает |
|---|---|
| `world_next_day` | Играет следующие дни пачкой |
| `world_live_day` | Играет следующий день в темпе модельного времени |
| `world_live` | Запускает живые дни один за другим, пока снят с паузы |
| `world_next_day` | Генерирует данные за следующие дни без пауз между событиями |
| `world_live_day` | Генерирует данные за следующий день постепенно, примерно за 24 минуты |
| `world_live` | Запускает `world_live_day` день за днем, пока снят с паузы |
Сколько дней уже сыграно, стенд помнит в переменной Airflow `world_position`:
в ней лежит номер первого несыгранного дня. `world_next_day` и `world_live_day`
записывают новое значение только после успешно сыгранного дня; оборванный
прогон его не меняет.
В переменной Airflow `world_position` хранится номер следующего дня для
генерации. Например, при значении `8` генератор уже отправил события
за дни с 0 по 7.
Сыграв день D, эти два дага отправляют слепок заказов за день D−1 и ждут, пока
`orders_ingest` его примет. Эти связи разобраны в [справочнике
мира](../architecture/world.md#даги-модельного-времени).
`world_next_day` и `world_live_day` сначала отправляют события дня D,
затем — слепок заказов за предыдущий день D−1. Они ждут, пока `orders_ingest`
загрузит слепок, и только после успешного завершения дня обновляют
`world_position`. Если генерация или прием данных завершились с ошибкой,
номер дня остается прежним. Подробности — в [справочнике генератора
и модельного времени](../architecture/world.md#даги-модельного-времени).
## Дневной конвейер
## Обработка данных в хранилище
У `etl_pipeline` нет своей обработки. Он по очереди запускает `dds_transform`,
`dm_transform` и `dq_check` и ждет каждый даг. Параметров у него нет: сколько
дней обработать, он сам определяет по `world_position` и по тому, что уже лежит
в слоях. Поэтому один и тот же запуск верен и когда его запустил другой даг, и
когда вы нажали кнопку, и когда его повторяют после сбоя. Детали есть в
Даг `etl_pipeline` по очереди запускает `dds_transform`, `dm_transform`
и `dq_check`, дожидаясь завершения каждого. Они рассчитывают данные DDS,
обновляют витрины DM и проверяют качество результата.
При запуске `etl_pipeline` не нужно указывать даты. Дни для обработки
определяются по `world_position` и по уже загруженным данным в слоях.
Детали есть в
[справочнике конвейера](../architecture/etl.md#объём-прогона).
`world_next_day` не запускает конвейер. После него слои хранилища отстают от
мира, пока вы не запустите конвейер сами. `world_live_day`, наоборот,
запускает `etl_pipeline` и ждет его. Поэтому даг остается в работе и после
конца модельного дня.
После `world_next_day` новые события и заказы уже загружены, но данные DDS
и DM еще не пересчитаны: запустите `etl_pipeline` вручную.
`world_live_day` запускает его автоматически и ждет завершения. Поэтому
`world_live_day` может продолжать работу после окончания генерации за день.
## Что значит красный
## Если даг завершился с ошибкой
Красный `etl_pipeline` не отменяет сыгранного дня: `world_position` уже
записана. Достаточно перезапустить конвейер рукой; разбираться, на чем
остановился прошлый прогон, не нужно. Кто запускает конвейер, описано в
Ошибка в `etl_pipeline` не меняет `world_position`: генерация и прием
данных за день уже завершились. После устранения причины ошибки
перезапустите конвейер вручную. Даты для повторной обработки он определит сам.
Кто запускает конвейер, описано в
[разделе о трех путях запуска](../architecture/etl.md#три-пути-запуска).
Качество данных проверяет контур DQ. В дневном конвейере это даг `dq_check`:
упавшая задача печатает в лог ключи строк, не прошедших проверку. Состав
За качество данных отвечают проверки DQ (Data Quality) в даге `dq_check`.
Если проверка находит расхождение, задача завершается с ошибкой и записывает
в журнал ключи строк, которые ее не прошли. Состав
проверок описан в [разделе «Звено DQ
конвейера»](../architecture/dm.md#звено-dq-конвейера).
## Как найти в интерфейсе
## Поиск дагов в Airflow
Даги мира ищите по тегам «модельное время» и «жизненный цикл мира». Остальные
даги ищите по тегам «заказы», `etl`, `dds`, `dm` и `dq`. `homework_check` с
тегами `dm` и `labs` проверяет домашние работы; о нем рассказано в учебных
заданиях.
## Посмотрите сами
## Один день от генерации до витрин
Откройте Airflow по адресу из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли). В Admin → Variables найдите
`world_position`. Затем запустите `world_next_day` на один день по [инструкции
из README](../../README.md#сдвинуть-мир-вперед). Убедитесь, что `orders_ingest`
отработал, а витрины еще ничего не получили. Запустите `etl_pipeline` рукой.
`world_position` и запомните его значение. Затем запустите `world_next_day`
на один день по [инструкции из README](../../README.md#добавить-данные-за-следующие-дни). Убедитесь, что
`orders_ingest` завершился успешно, а в витринах нового дня еще нет.
Значение `world_position` должно увеличиться на один.
Запустите `etl_pipeline` вручную и дождитесь его завершения.
В DBeaver, подключенном по
[инструкции](../../README.md#подключиться-из-dbeaver), выполните два запроса:
@@ -106,12 +118,13 @@ ORDER BY report_date DESC
LIMIT 3;
```
В трафике уже есть сыгранный день, а в выручке только предыдущий: вместе с этим
днем пришел слепок заказов за предыдущий. Выручка сыгранного дня появится после
следующего.
В витрине трафика уже есть новый день, а в выручке последняя дата —
предыдущий день. Причина в выгрузке заказов: вместе с событиями дня D
приходит слепок за день D−1. Выручка дня D появится после генерации
дня D+1 и запуска конвейера.
## Куда дальше
## Следующая глава и справочники
Детали — в справочниках [конвейера](../architecture/etl.md) и
[мира](../architecture/world.md). Следующая глава — [«Мониторинг: куда
смотреть»](monitoring.md).
[мира](../architecture/world.md). Следующая глава —
[«Мониторинг в Grafana»](monitoring.md).
+152 -154
View File
@@ -149,15 +149,14 @@
<script>
const MAP = {
"title": "Карта слоев хранилища",
"lead": "Проследите, как один смысл меняет имя и поверхность между слоями.",
"lead": "Проследите путь событий и заказов от Kafka до отчетов.",
"chapter_url": "https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои",
"intro": [
"Эта карта показывает путь событий и заказов от Kafka до витрин DM.",
"Щелкните по узлу, чтобы увидеть его назначение, устройство и ключи.",
"Щелкните по стрелке, чтобы узнать, кто и каким запросом переносит данные.",
"Когда вы выбираете узел, карта подсвечивает все пути к нему и от него.",
"Переключатель потока приглушает другую часть карты, но не меняет раскладку.",
"Сначала прочитайте главу «Хранилище и его слои», затем вернитесь к карте и рассмотрите объекты."
"Выберите объект, чтобы узнать, что он хранит, как его читать и откуда в него поступают данные.",
"Выберите стрелку, чтобы посмотреть способ обработки и открыть SQL-запрос.",
"Для выбранного объекта подсвечиваются все пути, по которым данные поступают в него и используются дальше.",
"Кнопки «События» и «Заказы» выделяют нужный поток. Остальные объекты остаются на месте, но становятся бледнее.",
"Если вы впервые знакомитесь со слоями хранилища, начните с главы по ссылке ниже."
],
"streams": {
"all": "Все",
@@ -166,39 +165,39 @@
},
"dictionary": {
"title": "Словарь",
"caption": "сбоку цепочки: справочники"
"caption": "справочные данные"
},
"layers": [
{
"id": "kafka",
"title": "Kafka",
"caption": "откуда приходит"
"caption": "сообщения источников"
},
{
"id": "stg",
"title": "STG",
"caption": "как поступило"
"caption": "исходные сообщения"
},
{
"id": "ods",
"title": "ODS",
"caption": "язык источника"
"caption": "данные разобраны по полям"
},
{
"id": "dds",
"title": "DDS",
"caption": "язык бизнеса"
"caption": "события, заказы и сессии"
},
{
"id": "dm",
"title": "DM",
"caption": "данные для дашбордов"
"caption": "витрины для отчетов"
}
],
"mechanisms": [
{
"id": "materialized_view",
"label": "матвью",
"label": "материализованное представление",
"line": "solid",
"rhythm": "непрерывно, по мере прихода сообщений"
},
@@ -206,13 +205,13 @@
"id": "airflow_dag",
"label": "даг Airflow",
"line": "dashed",
"rhythm": "пачкой: раз в модельные сутки (`orders_ingest`) или на каждом прогоне конвейера"
"rhythm": "пакетно: раз в модельные сутки (`orders_ingest`) или при каждом запуске конвейера"
},
{
"id": "view",
"label": "представление",
"line": "dotted",
"rhythm": "без копии, данные читаются на месте"
"rhythm": "при запросе к представлению, без копирования данных"
}
],
"nodes": [
@@ -225,9 +224,9 @@
"events"
],
"what": "Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие.",
"read": "Топик напрямую не читают: таблица `stg.hits_raw_kafka` забирает сообщения из топика. Для данных событий используйте `stg.hits_raw_dist`.",
"read": "Для просмотра загруженных сообщений используйте `stg.hits_raw_dist`. Таблица `stg.hits_raw_kafka` служит для приема из топика.",
"fill": [
"Источник событий пишет сообщения в топик в темпе выбранного режима мира."
"Генератор отправляет события без пауз в пакетном режиме или постепенно в живом режиме."
],
"links": [
{
@@ -248,10 +247,10 @@
"streams": [
"orders"
],
"what": "Топик Kafka со слепками заказов магазина. После модельного дня приходит снимок изменяемого окна.",
"read": "Топик напрямую не читают: таблица `stg.orders_raw_kafka` забирает сообщения из топика. Для заказов используйте `stg.orders_raw_dist`.",
"what": "Топик Kafka с дневными выгрузками заказов — слепками. Каждый слепок содержит состояние заказов за последние семь модельных дней.",
"read": "Для просмотра загруженных сообщений используйте `stg.orders_raw_dist`. Таблица `stg.orders_raw_kafka` служит для приема из топика.",
"fill": [
"Источник заказов публикует слепок пачкой после каждого модельного дня."
"После генерации событий дня D источник отправляет слепок заказов за предыдущий день D−1."
],
"links": [
{
@@ -272,17 +271,17 @@
"streams": [
"events"
],
"what": "Сырая строка события и метаданные доставки. STG сохраняет сообщение без разбора.",
"what": "Исходное сообщение события и сведения о его доставке из Kafka. Содержимое сохраняется целой строкой, без разбора на поля.",
"read": "Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.",
"parts": [
"`stg.hits_raw_kafka` — чтец топика `hits`.",
"`stg.hits_raw_kafka` — таблица для чтения сообщений топика `hits`.",
"`stg.hits_raw_rep` — реплицируемая таблица на шарде.",
"`stg.hits_raw_dist` — распределенная поверхность записи и чтения.",
"`stg.hits_raw_mv` — матвью приема сообщений из чтеца."
"`stg.hits_raw_dist` — распределенная таблица для записи и чтения.",
"`stg.hits_raw_mv` — материализованное представление, которое записывает сообщения из Kafka в STG."
],
"fill": [
"`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.",
"Сырые строки живут трое суток реального времени плюс хвост до суток."
"Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL."
],
"links": [
{
@@ -294,7 +293,7 @@
"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"
}
]
@@ -307,16 +306,16 @@
"streams": [
"orders"
],
"what": "Сырые строки слепка заказов, метаданные доставки и идентификатор пачки `_load_id`.",
"what": "Исходные строки выгрузки заказов, сведения о доставке и идентификатор загруженной партии `_load_id`.",
"read": "Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.",
"parts": [
"`stg.orders_raw_kafka` — чтец топика `orders` на первой ноде.",
"`stg.orders_raw_kafka` — таблица для чтения топика `orders` на первом узле.",
"`stg.orders_raw_rep` — реплицируемая таблица на шарде.",
"`stg.orders_raw_dist` — распределенная поверхность записи и чтения."
"`stg.orders_raw_dist` — распределенная таблица для записи и чтения."
],
"fill": [
"Задача `pull_batch` дага `orders_ingest` забирает слепок пачкой раз в модельные сутки.",
"Сырые строки живут трое суток реального времени плюс хвост до суток."
"Задача `pull_batch` дага `orders_ingest` загружает слепок раз в модельные сутки.",
"Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL."
],
"links": [
{
@@ -337,13 +336,13 @@
"streams": [
"events"
],
"what": "Типизированное широкое событие с именами полей источника. Запрос представления с `FINAL` возвращает актуальную версию события.",
"read": "Читайте `ods.event_v`: запрос представления с `FINAL` возвращает актуальные строки вместо физических версий.",
"what": "События, разобранные на колонки с типами данных. Имена полей сохранены как в источнике.",
"read": "Читайте `ods.event_v`: представление использует `FINAL` и возвращает последнюю версию каждого события.",
"parts": [
"`ods.event_rep` — реплицируемая таблица версий события на шарде.",
"`ods.event_dist` — распределенная поверхность записи и диагностики.",
"`ods.event_dist` — распределенная таблица записи и диагностики.",
"`ods.event_v` — представление актуального состояния с `FINAL`.",
"`ods.event_mv` — матвью разбора годных строк STG."
"`ods.event_mv` — материализованное представление для разбора сообщений STG без ошибок."
],
"keys": [
"`WatchID` — тот же идентификатор, что `event_id` в DDS.",
@@ -377,16 +376,16 @@
"streams": [
"events"
],
"what": "Сырые события, которые не прошли строгий прием, с классом ошибки и метаданными доставки.",
"what": "Сообщения событий, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и сведения о доставке.",
"read": "Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.",
"parts": [
"`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.",
"`ods.event_errors_dist` — распределенная поверхность чтения и записи.",
"`ods.event_errors_mv` — матвью, которое отбирает негодные строки STG."
"`ods.event_errors_dist` — распределенная таблица чтения и записи.",
"`ods.event_errors_mv` — материализованное представление для отбора сообщений STG с ошибками."
],
"fill": [
"`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.",
"Ошибки живут один месяц и не останавливают прием годных событий."
"Ошибки хранятся один месяц. Остальные события продолжают загружаться."
],
"links": [
{
@@ -398,7 +397,7 @@
"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"
}
]
@@ -411,11 +410,11 @@
"streams": [
"orders"
],
"what": "Текущая версия заказа на языке источника. Позиции пока остаются сырым фрагментом JSON.",
"what": "Последняя загруженная версия заказа с именами полей из источника. Состав заказа пока хранится как фрагмент JSON.",
"read": "Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.",
"parts": [
"`ods.order_rep` — реплицируемая таблица версий заказа на шарде.",
"`ods.order_dist` — распределенная поверхность записи и диагностики.",
"`ods.order_dist` — распределенная таблица записи и диагностики.",
"`ods.order_v` — представление текущего состояния с `FINAL`."
],
"keys": [
@@ -423,7 +422,7 @@
"`user_id` — пользователь магазина, который переходит в заказ DDS."
],
"fill": [
"Задача `parse_batch` дага `orders_ingest` разбирает пачку раз в модельные сутки.",
"Задача `parse_batch` дага `orders_ingest` разбирает загруженный слепок раз в модельные сутки.",
"`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка."
],
"links": [
@@ -449,15 +448,15 @@
"streams": [
"orders"
],
"what": "Сырые строки слепка, которые не прошли строгий прием, с классом ошибки и `_load_id`.",
"what": "Строки выгрузки заказов, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и `_load_id`.",
"read": "Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.",
"parts": [
"`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.",
"`ods.order_errors_dist` — распределенная поверхность чтения и записи."
"`ods.order_errors_dist` — распределенная таблица чтения и записи."
],
"fill": [
"Задача `parse_batch` дага `orders_ingest` пишет ошибки из той же пачки, что и годные заказы.",
"Ошибки живут один месяц и не останавливают прием годных заказов."
"Задача `parse_batch` дага `orders_ingest` сохраняет строки с ошибками при разборе той же выгрузки, из которой загружаются заказы.",
"Ошибки хранятся один месяц. Остальные заказы продолжают загружаться."
],
"links": [
{
@@ -478,8 +477,8 @@
"streams": [
"events"
],
"what": "Публичное представление события на языке бизнеса. Его запрос переименовывает поля и расшифровывает категорию устройства.",
"read": "Читайте `dds.event_v`: запрос представления возвращает явный список колонок DDS.",
"what": "События с названиями полей, принятыми в DDS. Например, `WatchID` переименован в `event_id`, а числовой код устройства заменен названием категории.",
"read": "Читайте `dds.event_v`: представление возвращает явно перечисленные колонки DDS.",
"parts": [
"`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет."
],
@@ -490,7 +489,7 @@
"`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM."
],
"fill": [
"Данные для `dds.event_v` читаются из актуальных событий ODS без копии и отдельного расписания."
"При запросе к `dds.event_v` данные читаются из актуальных событий ODS. Отдельная загрузка в DDS не нужна."
],
"links": [
{
@@ -511,22 +510,22 @@
"streams": [
"events"
],
"what": "Сессия, которую запрос дага `dds_transform` собирает из последовательных событий одной куки. В источнике готовой сессии нет.",
"read": "Читайте `dds.session_v`: запрос представления возвращает деловые и служебные колонки.",
"what": "Сессии, рассчитанные дагом `dds_transform` по событиям посетителя. Новая сессия начинается после паузы дольше 30 минут или при смене модельной даты.",
"read": "Читайте `dds.session_v`: здесь есть начало, конец, длительность сессии, показатели событий и служебные поля загрузки.",
"parts": [
"`dds.session_rep` — реплицируемая таблица сессий на шарде.",
"`dds.session_dist` — распределенная поверхность загрузки и диагностики.",
"`dds.session_stage_rep` — донор дневных партиций на шарде.",
"`dds.session_stage_dist` — распределенная поверхность донора.",
"`dds.session_v` — публичное представление."
"`dds.session_dist` — распределенная таблица загрузки и диагностики.",
"`dds.session_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.",
"`dds.session_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
"`dds.session_v` — представление для чтения готовых данных."
],
"keys": [
"`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.",
"`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`."
],
"fill": [
"Даг `dds_transform` пересобирает данные прожитых дней при каждом прогоне конвейера.",
"Задачи `session.rebuild` и `session.replace` собирают день в доноре и заменяют партицию."
"При каждом запуске `dds_transform` выбирает дни от первого пропущенного в DDS до последнего дня с завершенной генерацией.",
"Задача `session.rebuild` рассчитывает данные в промежуточной таблице (доноре). `session.replace` заменяет ими дневные партиции основной таблицы."
],
"links": [
{
@@ -551,14 +550,14 @@
"streams": [
"orders"
],
"what": "Заказ в текущем состоянии на языке бизнеса. Артикулы, количества и цены позиций хранятся в согласованных массивах.",
"read": "Читайте `dds.order_v`: запрос представления возвращает данные без таблиц и донора партиций.",
"what": "Последнее известное состояние заказа, подготовленное для анализа. Артикулы, количества и цены товаров хранятся в массивах; элементы с одинаковым индексом относятся к одной позиции заказа.",
"read": "Читайте `dds.order_v`, чтобы получить готовые заказы из основной таблицы.",
"parts": [
"`dds.order_rep` — реплицируемая таблица заказов на шарде.",
"`dds.order_dist` — распределенная поверхность загрузки и диагностики.",
"`dds.order_stage_rep` — донор дневных партиций на шарде.",
"`dds.order_stage_dist` — распределенная поверхность донора.",
"`dds.order_v` — публичное представление."
"`dds.order_dist` — распределенная таблица загрузки и диагностики.",
"`dds.order_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.",
"`dds.order_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
"`dds.order_v` — представление для чтения готовых данных."
],
"keys": [
"`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.",
@@ -567,8 +566,8 @@
"`order_date` — день, который в витринах называется `report_date` или `order_day`."
],
"fill": [
"Даг `dds_transform` пересобирает изменяемое окно при каждом прогоне конвейера.",
"Задачи `order.rebuild` и `order.replace` собирают дни в доноре и заменяют партиции."
"При каждом запуске `dds_transform` пересчитывает дни заказов из последнего слепка и заполняет пропуски в DDS.",
"Задача `order.rebuild` рассчитывает данные в промежуточной таблице (доноре). `order.replace` заменяет ими дневные партиции основной таблицы."
],
"links": [
{
@@ -594,20 +593,20 @@
"events",
"orders"
],
"what": "Известные пары куки трекера и пользователя магазина. Связь появляется после сопоставленной покупки.",
"read": "Читайте `dds.identity_map_v`: запрос представления с `FINAL` возвращает актуальные строки полного пересчета.",
"what": "Связи между посетителем сайта и пользователем магазина. Они появляются, когда событие покупки сопоставлено с заказом по его номеру.",
"read": "Читайте `dds.identity_map_v`: представление с `FINAL` возвращает последнюю версию каждой пары посетителя и пользователя.",
"parts": [
"`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.",
"`dds.identity_map_dist` — распределенная поверхность записи.",
"`dds.identity_map_v` — точное публичное представление с `FINAL`."
"`dds.identity_map_dist` — распределенная таблица записи.",
"`dds.identity_map_v` — представление последних версий строк с `FINAL`."
],
"keys": [
"`client_id` — кука из события и сессии DDS.",
"`client_id` — идентификатор посетителя из cookie браузера, как в событии и сессии DDS.",
"`user_id` — пользователь магазина из заказа DDS."
],
"fill": [
"Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов.",
"Новая версия полного набора пишется при каждом прогоне; `FINAL` скрывает физические повторы."
"При каждом запуске записывается новая версия набора связей."
],
"links": [
{
@@ -632,19 +631,18 @@
"streams": [
"events"
],
"what": "Дневные счетчики посетителей и известных пользователей после склейки идентичностей.",
"read": "Читайте `dm.daily_traffic_v`: запрос представления с `FINAL` возвращает актуальные строки полного пересчета.",
"what": "Число посетителей и известных пользователей за каждый день. Известными считаются пользователи магазина, связанные с посетителями сайта через карту идентичностей.",
"read": "Читайте `dm.daily_traffic_v`: представление с `FINAL` возвращает последний результат расчета за каждый день.",
"parts": [
"`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.",
"`dm.daily_traffic_dist` — распределенная поверхность записи.",
"`dm.daily_traffic_v` — точное публичное представление с `FINAL`."
"`dm.daily_traffic_dist` — распределенная таблица записи.",
"`dm.daily_traffic_v` — представление последних версий строк с `FINAL`."
],
"keys": [
"`report_date` — тот же день, что `session_date` в сессии DDS."
],
"fill": [
"Задача `daily_traffic` дага `dm_transform` при каждом прогоне пересчитывает всю историю и записывает новую версию.",
"`FINAL` оставляет текущую строку каждого дня."
"Задача `daily_traffic` дага `dm_transform` при каждом запуске пересчитывает всю историю и записывает новую версию."
],
"links": [
{
@@ -669,22 +667,22 @@
"streams": [
"orders"
],
"what": "Дневная выручка, число заказов и единиц товара по категориям.",
"read": "Читайте `dm.revenue_daily_v`: это публичное представление готовой дневной витрины.",
"what": "Выручка, число заказов и единиц товара за день по категориям. В расчет входят оплаченные заказы.",
"read": "Читайте `dm.revenue_daily_v`, чтобы получить готовые показатели по дням и категориям товаров.",
"parts": [
"`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.",
"`dm.revenue_daily_dist` — распределенная поверхность загрузки.",
"`dm.revenue_daily_stage_rep` — донор дневных партиций на шарде.",
"`dm.revenue_daily_stage_dist` — распределенная поверхность донора.",
"`dm.revenue_daily_v` — публичное представление."
"`dm.revenue_daily_dist` — распределенная таблица загрузки.",
"`dm.revenue_daily_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.",
"`dm.revenue_daily_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
"`dm.revenue_daily_v` — представление для чтения готовых данных."
],
"keys": [
"`report_date` — тот же день, что `order_date` в заказе DDS.",
"`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря."
],
"fill": [
"Даг `dm_transform` пересобирает изменяемое окно заказов при каждом прогоне конвейера.",
"Задачи `revenue_daily.rebuild` и `revenue_daily.replace` собирают дни в доноре и заменяют партиции."
"При каждом запуске `dm_transform` пересчитывает дни заказов из последнего слепка и заполняет пропущенные дни витрины.",
"Задача `revenue_daily.rebuild` рассчитывает данные в промежуточной таблице (доноре). `revenue_daily.replace` заменяет ими дневные партиции витрины."
],
"links": [
{
@@ -710,22 +708,22 @@
"events",
"orders"
],
"what": "Сверка события покупки с заказом магазина. Одна строка показывает совпадение или класс расхождения.",
"read": "Читайте `dm.purchase_vs_orders_v`: в результате запроса отсутствующей стороне соответствует `NULL`.",
"what": "Результат сверки покупок из кликстрима с заказами магазина. Для каждого номера заказа указано, совпали ли данные двух источников и какое расхождение найдено.",
"read": "Читайте `dm.purchase_vs_orders_v`: если покупка или заказ отсутствует, поля этой стороны содержат `NULL`.",
"parts": [
"`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.",
"`dm.purchase_vs_orders_dist` — распределенная поверхность загрузки.",
"`dm.purchase_vs_orders_stage_rep` — донор дневных партиций на шарде.",
"`dm.purchase_vs_orders_stage_dist` — распределенная поверхность донора.",
"`dm.purchase_vs_orders_v` — публичное представление."
"`dm.purchase_vs_orders_dist` — распределенная таблица загрузки.",
"`dm.purchase_vs_orders_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.",
"`dm.purchase_vs_orders_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
"`dm.purchase_vs_orders_v` — представление для чтения готовых данных."
],
"keys": [
"`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS.",
"`order_day` — общий день события покупки и заказа."
"`order_day` — дата заказа; если заказа еще нет, берется дата события покупки."
],
"fill": [
"Даг `dm_transform` пересобирает изменяемый хвост обоих источников при каждом прогоне конвейера.",
"Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` собирают дни в доноре и заменяют партиции."
"При каждом запуске `dm_transform` пересчитывает дни последнего слепка, добавляет новые дни покупок, заполняет пропуски и повторяет сверку дней с покупками без заказа.",
"Задача `purchase_vs_orders.rebuild` рассчитывает данные в промежуточной таблице (доноре). `purchase_vs_orders.replace` заменяет ими дневные партиции витрины."
],
"links": [
{
@@ -751,19 +749,19 @@
"events",
"orders"
],
"what": "Текущее состояние пяти атомарных проверок качества по дням. История запусков остается в Airflow.",
"read": "Читайте `dm.dq_summary_v`: запрос к публичному представлению возвращает последнюю опубликованную сводку дня.",
"what": "Последние результаты пяти проверок качества данных за каждый день. Историю запусков можно посмотреть в Airflow.",
"read": "Читайте `dm.dq_summary_v`, чтобы увидеть результат каждой проверки за день.",
"parts": [
"`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.",
"`dm.dq_summary_dist` — распределенная поверхность чтения и записи.",
"`dm.dq_summary_stage_rep` — общий донор дневных партиций.",
"`dm.dq_summary_stage_dist` — распределенная поверхность донора.",
"`dm.dq_summary_v` — публичное представление."
"`dm.dq_summary_dist` — распределенная таблица чтения и записи.",
"`dm.dq_summary_stage_rep` — промежуточная таблица для результатов всех проверок за день.",
"`dm.dq_summary_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.",
"`dm.dq_summary_v` — представление для чтения готовых данных."
],
"fill": [
"Даг `dq_check` при каждом прогоне сначала выполняет все пять проверок, затем заменяет готовые дневные партиции.",
"Даг `dq_check` сначала рассчитывает результаты всех пяти проверок в промежуточной таблице, затем заменяет ими дневные партиции сводки.",
"`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`.",
"`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.",
"`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`."
@@ -791,17 +789,17 @@
"streams": [
"orders"
],
"what": "Словарь товаров вне цепочки слоев. По артикулу доступны название, бренд, категория и цена.",
"read": "Читайте `dic.products`: у словаря одна поверхность без суффикса.",
"what": "Словарь товаров. По артикулу можно получить название, бренд, категорию и цену. Эти данные доступны для расчетов в разных слоях.",
"read": "Читайте `dic.products`: к словарю обращаются напрямую, суффикса `_v` у него нет.",
"parts": [
"`dic.products_file` — чтец CSV-каталога из файла репозитория.",
"`dic.products` — словарь в памяти поверх подложки."
"`dic.products_file` — таблица, которая читает каталог товаров из CSV-файла репозитория.",
"`dic.products` — словарь в памяти, загруженный из этой таблицы."
],
"keys": [
"`sku` — тот же артикул, что `item_sku` в позиции заказа DDS."
],
"fill": [
"ClickHouse перечитывает подложку каждые 60–90 секунд по настройке `LIFETIME`."
"ClickHouse обновляет словарь из `dic.products_file` каждые 60–90 секунд согласно настройке `LIFETIME`."
],
"links": [
{
@@ -825,8 +823,8 @@
"events"
],
"rhythm": "непрерывно, по мере прихода сообщений",
"actor": "Чтец `stg.hits_raw_kafka` и матвью `stg.hits_raw_mv`.",
"join": "Соединения нет: сообщение кладется строкой целиком.",
"actor": "Таблица `stg.hits_raw_kafka` и материализованное представление `stg.hits_raw_mv`.",
"join": "Каждое сообщение Kafka записывается целой строкой, без разбора на поля.",
"links": [
{
"label": "SQL",
@@ -847,8 +845,8 @@
"events"
],
"rhythm": "непрерывно, по мере прихода сообщений",
"actor": "Матвью `ods.event_mv` разбирает годные строки.",
"join": "Соединения нет: матвью разбирает строку в колонки.",
"actor": "Материализованное представление `ods.event_mv` разбирает сообщения без ошибок.",
"join": "Материализованное представление разбирает сообщение из STG на колонки с нужными типами данных.",
"links": [
{
"label": "SQL",
@@ -869,8 +867,8 @@
"events"
],
"rhythm": "непрерывно, по мере прихода сообщений",
"actor": "Матвью `ods.event_errors_mv` отбирает строки, которые не прошли строгий прием.",
"join": "Соединения нет: негодная строка уходит целиком вместе с классом ошибки.",
"actor": "Материализованное представление `ods.event_errors_mv` отбирает сообщения с ошибками.",
"join": "Сообщение, которое не удалось разобрать по правилам ODS, сохраняется целиком вместе с классом ошибки.",
"links": [
{
"label": "SQL",
@@ -890,9 +888,9 @@
"streams": [
"orders"
],
"rhythm": "пачкой, раз в модельные сутки",
"rhythm": "одной выгрузкой, раз в модельные сутки",
"actor": "Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`.",
"join": "Соединения нет: сообщение слепка кладется целиком, пачка помечена `_load_id`.",
"join": "Строки выгрузки сохраняются целиком. Общий `_load_id` позволяет найти все строки одной загрузки.",
"links": [
{
"label": "SQL",
@@ -912,9 +910,9 @@
"streams": [
"orders"
],
"rhythm": "пачкой, раз в модельные сутки",
"actor": "Задача `parse_batch` дага `orders_ingest` пишет годные версии заказа.",
"join": "Соединения нет: строка разбирается в колонки, версия задается `snapshot_date`.",
"rhythm": "одной выгрузкой, раз в модельные сутки",
"actor": "Задача `parse_batch` дага `orders_ingest` записывает версии заказов без ошибок разбора.",
"join": "Строка разбирается на колонки заказа. Версия определяется датой слепка `snapshot_date`.",
"links": [
{
"label": "SQL",
@@ -934,9 +932,9 @@
"streams": [
"orders"
],
"rhythm": "пачкой, раз в модельные сутки",
"actor": "Задача `parse_batch` дага `orders_ingest` пишет негодные строки той же пачки.",
"join": "Соединения нет: негодная строка пачки уходит целиком.",
"rhythm": "одной выгрузкой, раз в модельные сутки",
"actor": "Задача `parse_batch` дага `orders_ingest` записывает строки с ошибками из загруженной выгрузки.",
"join": "Строка с ошибкой сохраняется целиком вместе с классом ошибки и идентификатором загрузки.",
"links": [
{
"label": "SQL",
@@ -956,9 +954,9 @@
"streams": [
"events"
],
"rhythm": "без копии, данные читаются на месте",
"actor": "Запрос `dds.event_v` читает данные из `ods.event_v` и переименовывает поля на языке бизнеса.",
"join": "Соединения нет: представление переименовывает колонки той же строки.",
"rhythm": "при запросе к представлению, без копирования данных",
"actor": "Представление `dds.event_v` читает данные из `ods.event_v`.",
"join": "Колонки события переименовываются по правилам DDS. Данные не копируются в отдельную таблицу.",
"links": [
{
"label": "SQL",
@@ -978,9 +976,9 @@
"streams": [
"events"
],
"rhythm": "пачкой, при каждом прогоне `dds_transform`",
"rhythm": "при каждом запуске `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": [
{
"label": "SQL сборки",
@@ -1004,9 +1002,9 @@
"streams": [
"orders"
],
"rhythm": "пачкой, при каждом прогоне `dds_transform`",
"rhythm": "при каждом запуске `dds_transform`",
"actor": "Задачи `order.rebuild` и `order.replace` дага `dds_transform`.",
"join": "Соединения нет: из заказа берутся поля и позиции, `order_date` выводится из `created_at`.",
"join": "Поля заказа приводятся к типам DDS, а позиции из JSON разбираются в массивы. Дата `order_date` рассчитывается из `created_at`.",
"links": [
{
"label": "SQL сборки",
@@ -1030,9 +1028,9 @@
"streams": [
"events"
],
"rhythm": "пачкой, при каждом прогоне `dds_transform`",
"rhythm": "при каждом запуске `dds_transform`",
"actor": "Задача `identity_map` дага `dds_transform` соединяет покупки с заказами.",
"join": "Строки покупки и заказа соединяются по `purchase_id[1] = order_id`.",
"join": "Событие покупки соединяется с заказом по `purchase_id[1] = order_id`. В результате связываются посетитель сайта и пользователь магазина.",
"links": [
{
"label": "SQL",
@@ -1052,9 +1050,9 @@
"streams": [
"orders"
],
"rhythm": "пачкой, при каждом прогоне `dds_transform`",
"rhythm": "при каждом запуске `dds_transform`",
"actor": "Задача `identity_map` дага `dds_transform` соединяет заказы с покупками.",
"join": "Строки заказа и покупки соединяются по `order_id = purchase_id[1]`; берется `user_id`.",
"join": "Заказ соединяется с событием покупки по `order_id = purchase_id[1]`. Из заказа берется `user_id` пользователя магазина.",
"links": [
{
"label": "SQL",
@@ -1074,9 +1072,9 @@
"streams": [
"events"
],
"rhythm": "пачкой, при каждом прогоне `dm_transform`",
"rhythm": "при каждом запуске `dm_transform`",
"actor": "Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям.",
"join": "Соединения нет: сессии группируются по `session_date`, который становится `report_date`.",
"join": "К сессиям присоединяется карта идентичностей по `client_id`, затем данные группируются по `session_date`. Эта дата записывается в `report_date`.",
"links": [
{
"label": "SQL",
@@ -1096,9 +1094,9 @@
"streams": [
"events"
],
"rhythm": "пачкой, при каждом прогоне `dm_transform`",
"rhythm": "при каждом запуске `dm_transform`",
"actor": "Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`.",
"join": "GLOBAL LEFT JOIN по `client_id`; сессии без пары остаются в витрине.",
"join": "`GLOBAL LEFT JOIN` присоединяет карту идентичностей к сессиям по `client_id`. Сессии без известного пользователя тоже участвуют в расчете посетителей.",
"links": [
{
"label": "SQL",
@@ -1118,9 +1116,9 @@
"streams": [
"orders"
],
"rhythm": "пачкой, при каждом прогоне `dm_transform`",
"rhythm": "при каждом запуске `dm_transform`",
"actor": "Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`.",
"join": "Соединения таблиц нет: ARRAY JOIN раскрывает `item_sku`, `item_quantity`, `item_price`; `order_date` становится `report_date`.",
"join": "`ARRAY JOIN` разворачивает массивы `item_sku`, `item_quantity`, `item_price` в строки позиций заказа. Выручка считается по оплаченным заказам; `order_date` записывается в `report_date`.",
"links": [
{
"label": "SQL сборки",
@@ -1144,9 +1142,9 @@
"streams": [
"orders"
],
"rhythm": "пачкой, при каждом прогоне `dm_transform`",
"actor": "Даг `dm_transform` получает категорию через `dictGet` по `item_sku`; ключ словаря — `sku`.",
"join": "Категория читается через `dictGet` по `item_sku`; ключ справочника - `sku`.",
"rhythm": "при каждом запуске `dm_transform`",
"actor": "Задача `revenue_daily.rebuild` дага `dm_transform`.",
"join": "Функция `dictGet` получает категорию товара по артикулу `item_sku` из заказа. В словаре этому артикулу соответствует ключ `sku`.",
"links": [
{
"label": "SQL",
@@ -1166,9 +1164,9 @@
"streams": [
"events"
],
"rhythm": "пачкой, при каждом прогоне `dm_transform`",
"rhythm": "при каждом запуске `dm_transform`",
"actor": "Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки.",
"join": "Покупки группируются по `purchase_id[1]` как `purchase_order_id`; `event_date` становится `purchase_day`, затем GLOBAL FULL OUTER JOIN `purchase_order_id = backend_order_id`.",
"join": "События покупки группируются по номеру `purchase_id[1]`, который получает имя `purchase_order_id`. Дата `event_date` записывается в `purchase_day`. Затем `GLOBAL FULL OUTER JOIN` сопоставляет покупки с заказами по `purchase_order_id = backend_order_id`.",
"links": [
{
"label": "SQL сборки",
@@ -1192,9 +1190,9 @@
"streams": [
"orders"
],
"rhythm": "пачкой, при каждом прогоне `dm_transform`",
"rhythm": "при каждом запуске `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": [
{
"label": "SQL сборки",
@@ -1308,9 +1306,9 @@
details.append(heading, meta);
addSection("Что это", node.what);
addSection("Как читать", node.read);
addSection("Из чего состоит", node.parts);
addSection("Ключи", node.keys);
addSection("Кто наполняет и как часто", node.fill);
addSection("Связанные таблицы и представления", node.parts);
addSection("Как связать данные", node.keys);
addSection("Как обновляются данные", node.fill);
addLinks(node.links);
}
function renderEdgeCard(edge) {
@@ -1318,10 +1316,10 @@
const heading = document.createElement("h2");
heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`;
details.append(heading);
addSection("Механизм", mechanismById.get(edge.mechanism).label);
addSection("Ритм", edge.rhythm);
addSection("Кто именно", edge.actor);
addSection("Как соединяет", edge.join);
addSection("Способ обработки", mechanismById.get(edge.mechanism).label);
addSection("Когда выполняется", edge.rhythm);
addSection("Что выполняет обработку", edge.actor);
addSection("Что происходит с данными", edge.join);
addLinks(edge.links);
}
function layoutMap() {
+150 -152
View File
@@ -1,13 +1,12 @@
title: Карта слоев хранилища
lead: Проследите, как один смысл меняет имя и поверхность между слоями.
lead: Проследите путь событий и заказов от Kafka до отчетов.
chapter_url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои
intro:
- Эта карта показывает путь событий и заказов от Kafka до витрин DM.
- Щелкните по узлу, чтобы увидеть его назначение, устройство и ключи.
- Щелкните по стрелке, чтобы узнать, кто и каким запросом переносит данные.
- Когда вы выбираете узел, карта подсвечивает все пути к нему и от него.
- Переключатель потока приглушает другую часть карты, но не меняет раскладку.
- Сначала прочитайте главу «Хранилище и его слои», затем вернитесь к карте и рассмотрите объекты.
- Выберите объект, чтобы узнать, что он хранит, как его читать и откуда в него поступают данные.
- Выберите стрелку, чтобы посмотреть способ обработки и открыть SQL-запрос.
- Для выбранного объекта подсвечиваются все пути, по которым данные поступают в него и используются дальше.
- Кнопки «События» и «Заказы» выделяют нужный поток. Остальные объекты остаются на месте, но становятся бледнее.
- Если вы впервые знакомитесь со слоями хранилища, начните с главы по ссылке ниже.
streams:
all: Все
@@ -16,38 +15,38 @@ streams:
dictionary:
title: Словарь
caption: 'сбоку цепочки: справочники'
caption: справочные данные
layers:
- id: kafka
title: Kafka
caption: откуда приходит
caption: сообщения источников
- id: stg
title: STG
caption: как поступило
caption: исходные сообщения
- id: ods
title: ODS
caption: язык источника
caption: данные разобраны по полям
- id: dds
title: DDS
caption: язык бизнеса
caption: события, заказы и сессии
- id: dm
title: DM
caption: данные для дашбордов
caption: витрины для отчетов
mechanisms:
- id: materialized_view
label: матвью
label: материализованное представление
line: solid
rhythm: непрерывно, по мере прихода сообщений
- id: airflow_dag
label: даг Airflow
line: dashed
rhythm: 'пачкой: раз в модельные сутки (`orders_ingest`) или на каждом прогоне конвейера'
rhythm: 'пакетно: раз в модельные сутки (`orders_ingest`) или при каждом запуске конвейера'
- id: view
label: представление
line: dotted
rhythm: без копии, данные читаются на месте
rhythm: при запросе к представлению, без копирования данных
nodes:
- id: kafka_hits
@@ -56,9 +55,9 @@ nodes:
order: 0
streams: [events]
what: Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие.
read: 'Топик напрямую не читают: таблица `stg.hits_raw_kafka` забирает сообщения из топика. Для данных событий используйте `stg.hits_raw_dist`.'
read: "Для просмотра загруженных сообщений используйте `stg.hits_raw_dist`. Таблица `stg.hits_raw_kafka` служит для приема из топика."
fill:
- Источник событий пишет сообщения в топик в темпе выбранного режима мира.
- "Генератор отправляет события без пауз в пакетном режиме или постепенно в живом режиме."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
@@ -70,10 +69,10 @@ nodes:
layer: kafka
order: 1
streams: [orders]
what: Топик Kafka со слепками заказов магазина. После модельного дня приходит снимок изменяемого окна.
read: 'Топик напрямую не читают: таблица `stg.orders_raw_kafka` забирает сообщения из топика. Для заказов используйте `stg.orders_raw_dist`.'
what: "Топик Kafka с дневными выгрузками заказов — слепками. Каждый слепок содержит состояние заказов за последние семь модельных дней."
read: "Для просмотра загруженных сообщений используйте `stg.orders_raw_dist`. Таблица `stg.orders_raw_kafka` служит для приема из топика."
fill:
- Источник заказов публикует слепок пачкой после каждого модельного дня.
- "После генерации событий дня D источник отправляет слепок заказов за предыдущий день D−1."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных
@@ -85,22 +84,22 @@ nodes:
layer: stg
order: 0
streams: [events]
what: Сырая строка события и метаданные доставки. STG сохраняет сообщение без разбора.
what: "Исходное сообщение события и сведения о его доставке из Kafka. Содержимое сохраняется целой строкой, без разбора на поля."
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
parts:
- '`stg.hits_raw_kafka` — чтец топика `hits`.'
- '`stg.hits_raw_rep` — реплицируемая таблица на шарде.'
- '`stg.hits_raw_dist` — распределенная поверхность записи и чтения.'
- '`stg.hits_raw_mv` — матвью приема сообщений из чтеца.'
- "`stg.hits_raw_kafka` — таблица для чтения сообщений топика `hits`."
- "`stg.hits_raw_rep` — реплицируемая таблица на шарде."
- "`stg.hits_raw_dist` — распределенная таблица для записи и чтения."
- "`stg.hits_raw_mv` — материализованное представление, которое записывает сообщения из Kafka в STG."
fill:
- '`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.'
- Сырые строки живут трое суток реального времени плюс хвост до суток.
- Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL.
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql
- label: DDL матвью
- label: DDL материализованного представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql
- id: stg_orders
@@ -108,15 +107,15 @@ nodes:
layer: stg
order: 1
streams: [orders]
what: Сырые строки слепка заказов, метаданные доставки и идентификатор пачки `_load_id`.
what: "Исходные строки выгрузки заказов, сведения о доставке и идентификатор загруженной партии `_load_id`."
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
parts:
- '`stg.orders_raw_kafka` — чтец топика `orders` на первой ноде.'
- '`stg.orders_raw_rep` — реплицируемая таблица на шарде.'
- '`stg.orders_raw_dist` — распределенная поверхность записи и чтения.'
- "`stg.orders_raw_kafka` — таблица для чтения топика `orders` на первом узле."
- "`stg.orders_raw_rep` — реплицируемая таблица на шарде."
- "`stg.orders_raw_dist` — распределенная таблица для записи и чтения."
fill:
- Задача `pull_batch` дага `orders_ingest` забирает слепок пачкой раз в модельные сутки.
- Сырые строки живут трое суток реального времени плюс хвост до суток.
- Задача `pull_batch` дага `orders_ingest` загружает слепок раз в модельные сутки.
- Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL.
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных
@@ -128,13 +127,13 @@ nodes:
layer: ods
order: 0
streams: [events]
what: Типизированное широкое событие с именами полей источника. Запрос представления с `FINAL` возвращает актуальную версию события.
read: 'Читайте `ods.event_v`: запрос представления с `FINAL` возвращает актуальные строки вместо физических версий.'
what: "События, разобранные на колонки с типами данных. Имена полей сохранены как в источнике."
read: "Читайте `ods.event_v`: представление использует `FINAL` и возвращает последнюю версию каждого события."
parts:
- '`ods.event_rep` — реплицируемая таблица версий события на шарде.'
- '`ods.event_dist` — распределенная поверхность записи и диагностики.'
- '`ods.event_dist` — распределенная таблица записи и диагностики.'
- '`ods.event_v` — представление актуального состояния с `FINAL`.'
- '`ods.event_mv` — матвью разбора годных строк STG.'
- '`ods.event_mv` — материализованное представление для разбора сообщений STG без ошибок.'
keys:
- '`WatchID` — тот же идентификатор, что `event_id` в DDS.'
- '`ClientID` — тот же посетитель, что `client_id` в DDS.'
@@ -155,21 +154,21 @@ nodes:
layer: ods
order: 1
streams: [events]
what: Сырые события, которые не прошли строгий прием, с классом ошибки и метаданными доставки.
what: "Сообщения событий, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и сведения о доставке."
read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.'
parts:
- '`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.'
- '`ods.event_errors_dist` — распределенная поверхность чтения и записи.'
- '`ods.event_errors_mv` — матвью, которое отбирает негодные строки STG.'
- '`ods.event_errors_dist` — распределенная таблица чтения и записи.'
- '`ods.event_errors_mv` — материализованное представление для отбора сообщений STG с ошибками.'
fill:
- '`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.'
- Ошибки живут один месяц и не останавливают прием годных событий.
- Ошибки хранятся один месяц. Остальные события продолжают загружаться.
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
- label: DDL таблиц
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
- label: DDL матвью
- label: DDL материализованного представления
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
- id: ods_order
@@ -177,17 +176,17 @@ nodes:
layer: ods
order: 2
streams: [orders]
what: Текущая версия заказа на языке источника. Позиции пока остаются сырым фрагментом JSON.
what: "Последняя загруженная версия заказа с именами полей из источника. Состав заказа пока хранится как фрагмент JSON."
read: 'Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.'
parts:
- '`ods.order_rep` — реплицируемая таблица версий заказа на шарде.'
- '`ods.order_dist` — распределенная поверхность записи и диагностики.'
- '`ods.order_dist` — распределенная таблица записи и диагностики.'
- '`ods.order_v` — представление текущего состояния с `FINAL`.'
keys:
- '`order_id` — тот же заказ в `dds.order_v` и `dm.purchase_vs_orders_v`.'
- '`user_id` — пользователь магазина, который переходит в заказ DDS.'
fill:
- Задача `parse_batch` дага `orders_ingest` разбирает пачку раз в модельные сутки.
- Задача `parse_batch` дага `orders_ingest` разбирает загруженный слепок раз в модельные сутки.
- '`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка.'
links:
- label: Справочник
@@ -202,14 +201,14 @@ nodes:
layer: ods
order: 3
streams: [orders]
what: Сырые строки слепка, которые не прошли строгий прием, с классом ошибки и `_load_id`.
what: "Строки выгрузки заказов, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и `_load_id`."
read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.'
parts:
- '`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.'
- '`ods.order_errors_dist` — распределенная поверхность чтения и записи.'
- '`ods.order_errors_dist` — распределенная таблица чтения и записи.'
fill:
- Задача `parse_batch` дага `orders_ingest` пишет ошибки из той же пачки, что и годные заказы.
- Ошибки живут один месяц и не останавливают прием годных заказов.
- Задача `parse_batch` дага `orders_ingest` сохраняет строки с ошибками при разборе той же выгрузки, из которой загружаются заказы.
- Ошибки хранятся один месяц. Остальные заказы продолжают загружаться.
links:
- label: Справочник
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
order: 0
streams: [events]
what: Публичное представление события на языке бизнеса. Его запрос переименовывает поля и расшифровывает категорию устройства.
read: 'Читайте `dds.event_v`: запрос представления возвращает явный список колонок DDS.'
what: "События с названиями полей, принятыми в DDS. Например, `WatchID` переименован в `event_id`, а числовой код устройства заменен названием категории."
read: "Читайте `dds.event_v`: представление возвращает явно перечисленные колонки DDS."
parts:
- '`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет.'
keys:
@@ -231,7 +230,7 @@ nodes:
- '`event_date` — тот же день, что `EventDate` в ODS.'
- '`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM.'
fill:
- Данные для `dds.event_v` читаются из актуальных событий ODS без копии и отдельного расписания.
- "При запросе к `dds.event_v` данные читаются из актуальных событий ODS. Отдельная загрузка в DDS не нужна."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма
@@ -243,20 +242,20 @@ nodes:
layer: dds
order: 1
streams: [events]
what: Сессия, которую запрос дага `dds_transform` собирает из последовательных событий одной куки. В источнике готовой сессии нет.
read: 'Читайте `dds.session_v`: запрос представления возвращает деловые и служебные колонки.'
what: "Сессии, рассчитанные дагом `dds_transform` по событиям посетителя. Новая сессия начинается после паузы дольше 30 минут или при смене модельной даты."
read: "Читайте `dds.session_v`: здесь есть начало, конец, длительность сессии, показатели событий и служебные поля загрузки."
parts:
- '`dds.session_rep` — реплицируемая таблица сессий на шарде.'
- '`dds.session_dist` — распределенная поверхность загрузки и диагностики.'
- '`dds.session_stage_rep` — донор дневных партиций на шарде.'
- '`dds.session_stage_dist` — распределенная поверхность донора.'
- '`dds.session_v` — публичное представление.'
- '`dds.session_dist` — распределенная таблица загрузки и диагностики.'
- '`dds.session_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
- '`dds.session_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dds.session_v` — представление для чтения готовых данных.'
keys:
- '`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.'
- '`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`.'
fill:
- Даг `dds_transform` пересобирает данные прожитых дней при каждом прогоне конвейера.
- Задачи `session.rebuild` и `session.replace` собирают день в доноре и заменяют партицию.
- "При каждом запуске `dds_transform` выбирает дни от первого пропущенного в DDS до последнего дня с завершенной генерацией."
- "Задача `session.rebuild` рассчитывает данные в промежуточной таблице (доноре). `session.replace` заменяет ими дневные партиции основной таблицы."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение
@@ -270,22 +269,22 @@ nodes:
layer: dds
order: 2
streams: [orders]
what: Заказ в текущем состоянии на языке бизнеса. Артикулы, количества и цены позиций хранятся в согласованных массивах.
read: 'Читайте `dds.order_v`: запрос представления возвращает данные без таблиц и донора партиций.'
what: "Последнее известное состояние заказа, подготовленное для анализа. Артикулы, количества и цены товаров хранятся в массивах; элементы с одинаковым индексом относятся к одной позиции заказа."
read: "Читайте `dds.order_v`, чтобы получить готовые заказы из основной таблицы."
parts:
- '`dds.order_rep` — реплицируемая таблица заказов на шарде.'
- '`dds.order_dist` — распределенная поверхность загрузки и диагностики.'
- '`dds.order_stage_rep` — донор дневных партиций на шарде.'
- '`dds.order_stage_dist` — распределенная поверхность донора.'
- '`dds.order_v` — публичное представление.'
- '`dds.order_dist` — распределенная таблица загрузки и диагностики.'
- '`dds.order_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
- '`dds.order_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dds.order_v` — представление для чтения готовых данных.'
keys:
- '`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.'
- '`user_id` — пользователь магазина, который входит в карту идентичностей.'
- '`item_sku` — тот же артикул, что `sku` в `dic.products`.'
- '`order_date` — день, который в витринах называется `report_date` или `order_day`.'
fill:
- Даг `dds_transform` пересобирает изменяемое окно при каждом прогоне конвейера.
- Задачи `order.rebuild` и `order.replace` собирают дни в доноре и заменяют партиции.
- "При каждом запуске `dds_transform` пересчитывает дни заказов из последнего слепка и заполняет пропуски в DDS."
- "Задача `order.rebuild` рассчитывает данные в промежуточной таблице (доноре). `order.replace` заменяет ими дневные партиции основной таблицы."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение
@@ -299,18 +298,18 @@ nodes:
layer: dds
order: 3
streams: [events, orders]
what: Известные пары куки трекера и пользователя магазина. Связь появляется после сопоставленной покупки.
read: 'Читайте `dds.identity_map_v`: запрос представления с `FINAL` возвращает актуальные строки полного пересчета.'
what: "Связи между посетителем сайта и пользователем магазина. Они появляются, когда событие покупки сопоставлено с заказом по его номеру."
read: "Читайте `dds.identity_map_v`: представление с `FINAL` возвращает последнюю версию каждой пары посетителя и пользователя."
parts:
- '`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.'
- '`dds.identity_map_dist` — распределенная поверхность записи.'
- '`dds.identity_map_v` — точное публичное представление с `FINAL`.'
- '`dds.identity_map_dist` — распределенная таблица записи.'
- '`dds.identity_map_v` — представление последних версий строк с `FINAL`.'
keys:
- '`client_id` — кука из события и сессии DDS.'
- '`user_id` — пользователь магазина из заказа DDS.'
- "`client_id` — идентификатор посетителя из cookie браузера, как в событии и сессии DDS."
- "`user_id` — пользователь магазина из заказа DDS."
fill:
- Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов.
- Новая версия полного набора пишется при каждом прогоне; `FINAL` скрывает физические повторы.
- "Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов."
- "При каждом запуске записывается новая версия набора связей."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка
@@ -324,17 +323,16 @@ nodes:
layer: dm
order: 0
streams: [events]
what: Дневные счетчики посетителей и известных пользователей после склейки идентичностей.
read: 'Читайте `dm.daily_traffic_v`: запрос представления с `FINAL` возвращает актуальные строки полного пересчета.'
what: "Число посетителей и известных пользователей за каждый день. Известными считаются пользователи магазина, связанные с посетителями сайта через карту идентичностей."
read: "Читайте `dm.daily_traffic_v`: представление с `FINAL` возвращает последний результат расчета за каждый день."
parts:
- '`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.'
- '`dm.daily_traffic_dist` — распределенная поверхность записи.'
- '`dm.daily_traffic_v` — точное публичное представление с `FINAL`.'
- '`dm.daily_traffic_dist` — распределенная таблица записи.'
- '`dm.daily_traffic_v` — представление последних версий строк с `FINAL`.'
keys:
- '`report_date` — тот же день, что `session_date` в сессии DDS.'
fill:
- Задача `daily_traffic` дага `dm_transform` при каждом прогоне пересчитывает всю историю и записывает новую версию.
- '`FINAL` оставляет текущую строку каждого дня.'
- "Задача `daily_traffic` дага `dm_transform` при каждом запуске пересчитывает всю историю и записывает новую версию."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v
@@ -348,20 +346,20 @@ nodes:
layer: dm
order: 1
streams: [orders]
what: Дневная выручка, число заказов и единиц товара по категориям.
read: 'Читайте `dm.revenue_daily_v`: это публичное представление готовой дневной витрины.'
what: "Выручка, число заказов и единиц товара за день по категориям. В расчет входят оплаченные заказы."
read: "Читайте `dm.revenue_daily_v`, чтобы получить готовые показатели по дням и категориям товаров."
parts:
- '`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.'
- '`dm.revenue_daily_dist` — распределенная поверхность загрузки.'
- '`dm.revenue_daily_stage_rep` — донор дневных партиций на шарде.'
- '`dm.revenue_daily_stage_dist` — распределенная поверхность донора.'
- '`dm.revenue_daily_v` — публичное представление.'
- '`dm.revenue_daily_dist` — распределенная таблица загрузки.'
- '`dm.revenue_daily_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
- '`dm.revenue_daily_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dm.revenue_daily_v` — представление для чтения готовых данных.'
keys:
- '`report_date` — тот же день, что `order_date` в заказе DDS.'
- '`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря.'
fill:
- Даг `dm_transform` пересобирает изменяемое окно заказов при каждом прогоне конвейера.
- Задачи `revenue_daily.rebuild` и `revenue_daily.replace` собирают дни в доноре и заменяют партиции.
- "При каждом запуске `dm_transform` пересчитывает дни заказов из последнего слепка и заполняет пропущенные дни витрины."
- "Задача `revenue_daily.rebuild` рассчитывает данные в промежуточной таблице (доноре). `revenue_daily.replace` заменяет ими дневные партиции витрины."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v
@@ -375,20 +373,20 @@ nodes:
layer: dm
order: 2
streams: [events, orders]
what: Сверка события покупки с заказом магазина. Одна строка показывает совпадение или класс расхождения.
read: 'Читайте `dm.purchase_vs_orders_v`: в результате запроса отсутствующей стороне соответствует `NULL`.'
what: "Результат сверки покупок из кликстрима с заказами магазина. Для каждого номера заказа указано, совпали ли данные двух источников и какое расхождение найдено."
read: "Читайте `dm.purchase_vs_orders_v`: если покупка или заказ отсутствует, поля этой стороны содержат `NULL`."
parts:
- '`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.'
- '`dm.purchase_vs_orders_dist` — распределенная поверхность загрузки.'
- '`dm.purchase_vs_orders_stage_rep` — донор дневных партиций на шарде.'
- '`dm.purchase_vs_orders_stage_dist` — распределенная поверхность донора.'
- '`dm.purchase_vs_orders_v` — публичное представление.'
- '`dm.purchase_vs_orders_dist` — распределенная таблица загрузки.'
- '`dm.purchase_vs_orders_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
- '`dm.purchase_vs_orders_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dm.purchase_vs_orders_v` — представление для чтения готовых данных.'
keys:
- '`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS.'
- '`order_day` — общий день события покупки и заказа.'
- "`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS."
- "`order_day` — дата заказа; если заказа еще нет, берется дата события покупки."
fill:
- Даг `dm_transform` пересобирает изменяемый хвост обоих источников при каждом прогоне конвейера.
- Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` собирают дни в доноре и заменяют партиции.
- "При каждом запуске `dm_transform` пересчитывает дни последнего слепка, добавляет новые дни покупок, заполняет пропуски и повторяет сверку дней с покупками без заказа."
- "Задача `purchase_vs_orders.rebuild` рассчитывает данные в промежуточной таблице (доноре). `purchase_vs_orders.replace` заменяет ими дневные партиции витрины."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v
@@ -402,18 +400,18 @@ nodes:
layer: dm
order: 3
streams: [events, orders]
what: Текущее состояние пяти атомарных проверок качества по дням. История запусков остается в Airflow.
read: 'Читайте `dm.dq_summary_v`: запрос к публичному представлению возвращает последнюю опубликованную сводку дня.'
what: "Последние результаты пяти проверок качества данных за каждый день. Историю запусков можно посмотреть в Airflow."
read: "Читайте `dm.dq_summary_v`, чтобы увидеть результат каждой проверки за день."
parts:
- '`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.'
- '`dm.dq_summary_dist` — распределенная поверхность чтения и записи.'
- '`dm.dq_summary_stage_rep` — общий донор дневных партиций.'
- '`dm.dq_summary_stage_dist` — распределенная поверхность донора.'
- '`dm.dq_summary_v` — публичное представление.'
- '`dm.dq_summary_dist` — распределенная таблица чтения и записи.'
- '`dm.dq_summary_stage_rep` — промежуточная таблица для результатов всех проверок за день.'
- '`dm.dq_summary_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
- '`dm.dq_summary_v` — представление для чтения готовых данных.'
fill:
- Даг `dq_check` при каждом прогоне сначала выполняет все пять проверок, затем заменяет готовые дневные партиции.
- Даг `dq_check` сначала рассчитывает результаты всех пяти проверок в промежуточной таблице, затем заменяет ими дневные партиции сводки.
- '`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`.'
- '`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.'
- '`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`.'
@@ -430,15 +428,15 @@ nodes:
layer: dic
order: 0
streams: [orders]
what: Словарь товаров вне цепочки слоев. По артикулу доступны название, бренд, категория и цена.
read: 'Читайте `dic.products`: у словаря одна поверхность без суффикса.'
what: "Словарь товаров. По артикулу можно получить название, бренд, категорию и цену. Эти данные доступны для расчетов в разных слоях."
read: "Читайте `dic.products`: к словарю обращаются напрямую, суффикса `_v` у него нет."
parts:
- '`dic.products_file` — чтец CSV-каталога из файла репозитория.'
- '`dic.products` — словарь в памяти поверх подложки.'
- "`dic.products_file` — таблица, которая читает каталог товаров из CSV-файла репозитория."
- "`dic.products` — словарь в памяти, загруженный из этой таблицы."
keys:
- '`sku` — тот же артикул, что `item_sku` в позиции заказа DDS.'
fill:
- ClickHouse перечитывает подложку каждые 60–90 секунд по настройке `LIFETIME`.
- "ClickHouse обновляет словарь из `dic.products_file` каждые 60–90 секунд согласно настройке `LIFETIME`."
links:
- label: Справочник
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-справочники
@@ -452,8 +450,8 @@ edges:
mechanism: materialized_view
streams: [events]
rhythm: непрерывно, по мере прихода сообщений
actor: Чтец `stg.hits_raw_kafka` и матвью `stg.hits_raw_mv`.
join: 'Соединения нет: сообщение кладется строкой целиком.'
actor: "Таблица `stg.hits_raw_kafka` и материализованное представление `stg.hits_raw_mv`."
join: "Каждое сообщение Kafka записывается целой строкой, без разбора на поля."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql
@@ -466,8 +464,8 @@ edges:
mechanism: materialized_view
streams: [events]
rhythm: непрерывно, по мере прихода сообщений
actor: Матвью `ods.event_mv` разбирает годные строки.
join: 'Соединения нет: матвью разбирает строку в колонки.'
actor: Материализованное представление `ods.event_mv` разбирает сообщения без ошибок.
join: "Материализованное представление разбирает сообщение из STG на колонки с нужными типами данных."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
@@ -480,8 +478,8 @@ edges:
mechanism: materialized_view
streams: [events]
rhythm: непрерывно, по мере прихода сообщений
actor: Матвью `ods.event_errors_mv` отбирает строки, которые не прошли строгий прием.
join: 'Соединения нет: негодная строка уходит целиком вместе с классом ошибки.'
actor: "Материализованное представление `ods.event_errors_mv` отбирает сообщения с ошибками."
join: "Сообщение, которое не удалось разобрать по правилам ODS, сохраняется целиком вместе с классом ошибки."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
@@ -493,9 +491,9 @@ edges:
to: stg_orders
mechanism: airflow_dag
streams: [orders]
rhythm: пачкой, раз в модельные сутки
rhythm: одной выгрузкой, раз в модельные сутки
actor: Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`.
join: 'Соединения нет: сообщение слепка кладется целиком, пачка помечена `_load_id`.'
join: "Строки выгрузки сохраняются целиком. Общий `_load_id` позволяет найти все строки одной загрузки."
links:
- label: 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
mechanism: airflow_dag
streams: [orders]
rhythm: пачкой, раз в модельные сутки
actor: Задача `parse_batch` дага `orders_ingest` пишет годные версии заказа.
join: 'Соединения нет: строка разбирается в колонки, версия задается `snapshot_date`.'
rhythm: одной выгрузкой, раз в модельные сутки
actor: Задача `parse_batch` дага `orders_ingest` записывает версии заказов без ошибок разбора.
join: "Строка разбирается на колонки заказа. Версия определяется датой слепка `snapshot_date`."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql
@@ -521,9 +519,9 @@ edges:
to: ods_order_errors
mechanism: airflow_dag
streams: [orders]
rhythm: пачкой, раз в модельные сутки
actor: Задача `parse_batch` дага `orders_ingest` пишет негодные строки той же пачки.
join: 'Соединения нет: негодная строка пачки уходит целиком.'
rhythm: одной выгрузкой, раз в модельные сутки
actor: Задача `parse_batch` дага `orders_ingest` записывает строки с ошибками из загруженной выгрузки.
join: "Строка с ошибкой сохраняется целиком вместе с классом ошибки и идентификатором загрузки."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql
@@ -535,9 +533,9 @@ edges:
to: dds_event
mechanism: view
streams: [events]
rhythm: без копии, данные читаются на месте
actor: Запрос `dds.event_v` читает данные из `ods.event_v` и переименовывает поля на языке бизнеса.
join: 'Соединения нет: представление переименовывает колонки той же строки.'
rhythm: при запросе к представлению, без копирования данных
actor: "Представление `dds.event_v` читает данные из `ods.event_v`."
join: "Колонки события переименовываются по правилам DDS. Данные не копируются в отдельную таблицу."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
@@ -549,9 +547,9 @@ edges:
to: dds_session
mechanism: airflow_dag
streams: [events]
rhythm: пачкой, при каждом прогоне `dds_transform`
rhythm: при каждом запуске `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:
- label: 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
mechanism: airflow_dag
streams: [orders]
rhythm: пачкой, при каждом прогоне `dds_transform`
rhythm: при каждом запуске `dds_transform`
actor: Задачи `order.rebuild` и `order.replace` дага `dds_transform`.
join: 'Соединения нет: из заказа берутся поля и позиции, `order_date` выводится из `created_at`.'
join: "Поля заказа приводятся к типам DDS, а позиции из JSON разбираются в массивы. Дата `order_date` рассчитывается из `created_at`."
links:
- label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_rebuild.sql
@@ -581,9 +579,9 @@ edges:
to: dds_identity
mechanism: airflow_dag
streams: [events]
rhythm: пачкой, при каждом прогоне `dds_transform`
rhythm: при каждом запуске `dds_transform`
actor: Задача `identity_map` дага `dds_transform` соединяет покупки с заказами.
join: 'Строки покупки и заказа соединяются по `purchase_id[1] = order_id`.'
join: "Событие покупки соединяется с заказом по `purchase_id[1] = order_id`. В результате связываются посетитель сайта и пользователь магазина."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql
@@ -595,9 +593,9 @@ edges:
to: dds_identity
mechanism: airflow_dag
streams: [orders]
rhythm: пачкой, при каждом прогоне `dds_transform`
rhythm: при каждом запуске `dds_transform`
actor: Задача `identity_map` дага `dds_transform` соединяет заказы с покупками.
join: 'Строки заказа и покупки соединяются по `order_id = purchase_id[1]`; берется `user_id`.'
join: "Заказ соединяется с событием покупки по `order_id = purchase_id[1]`. Из заказа берется `user_id` пользователя магазина."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql
@@ -609,9 +607,9 @@ edges:
to: dm_daily_traffic
mechanism: airflow_dag
streams: [events]
rhythm: пачкой, при каждом прогоне `dm_transform`
rhythm: при каждом запуске `dm_transform`
actor: Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям.
join: 'Соединения нет: сессии группируются по `session_date`, который становится `report_date`.'
join: "К сессиям присоединяется карта идентичностей по `client_id`, затем данные группируются по `session_date`. Эта дата записывается в `report_date`."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql
@@ -623,9 +621,9 @@ edges:
to: dm_daily_traffic
mechanism: airflow_dag
streams: [events]
rhythm: пачкой, при каждом прогоне `dm_transform`
rhythm: при каждом запуске `dm_transform`
actor: Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`.
join: 'GLOBAL LEFT JOIN по `client_id`; сессии без пары остаются в витрине.'
join: "`GLOBAL LEFT JOIN` присоединяет карту идентичностей к сессиям по `client_id`. Сессии без известного пользователя тоже участвуют в расчете посетителей."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql
@@ -637,9 +635,9 @@ edges:
to: dm_revenue
mechanism: airflow_dag
streams: [orders]
rhythm: пачкой, при каждом прогоне `dm_transform`
rhythm: при каждом запуске `dm_transform`
actor: Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`.
join: 'Соединения таблиц нет: ARRAY JOIN раскрывает `item_sku`, `item_quantity`, `item_price`; `order_date` становится `report_date`.'
join: "`ARRAY JOIN` разворачивает массивы `item_sku`, `item_quantity`, `item_price` в строки позиций заказа. Выручка считается по оплаченным заказам; `order_date` записывается в `report_date`."
links:
- label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql
@@ -653,9 +651,9 @@ edges:
to: dm_revenue
mechanism: airflow_dag
streams: [orders]
rhythm: пачкой, при каждом прогоне `dm_transform`
actor: Даг `dm_transform` получает категорию через `dictGet` по `item_sku`; ключ словаря — `sku`.
join: 'Категория читается через `dictGet` по `item_sku`; ключ справочника - `sku`.'
rhythm: при каждом запуске `dm_transform`
actor: "Задача `revenue_daily.rebuild` дага `dm_transform`."
join: "Функция `dictGet` получает категорию товара по артикулу `item_sku` из заказа. В словаре этому артикулу соответствует ключ `sku`."
links:
- label: SQL
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql
@@ -667,9 +665,9 @@ edges:
to: dm_purchase
mechanism: airflow_dag
streams: [events]
rhythm: пачкой, при каждом прогоне `dm_transform`
rhythm: при каждом запуске `dm_transform`
actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки.
join: 'Покупки группируются по `purchase_id[1]` как `purchase_order_id`; `event_date` становится `purchase_day`, затем GLOBAL FULL OUTER JOIN `purchase_order_id = backend_order_id`.'
join: "События покупки группируются по номеру `purchase_id[1]`, который получает имя `purchase_order_id`. Дата `event_date` записывается в `purchase_day`. Затем `GLOBAL FULL OUTER JOIN` сопоставляет покупки с заказами по `purchase_order_id = backend_order_id`."
links:
- label: SQL сборки
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql
@@ -683,9 +681,9 @@ edges:
to: dm_purchase
mechanism: airflow_dag
streams: [orders]
rhythm: пачкой, при каждом прогоне `dm_transform`
rhythm: при каждом запуске `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:
- label: 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);
addSection("Что это", node.what);
addSection("Как читать", node.read);
addSection("Из чего состоит", node.parts);
addSection("Ключи", node.keys);
addSection("Кто наполняет и как часто", node.fill);
addSection("Связанные таблицы и представления", node.parts);
addSection("Как связать данные", node.keys);
addSection("Как обновляются данные", node.fill);
addLinks(node.links);
}
function renderEdgeCard(edge) {
@@ -253,10 +253,10 @@
const heading = document.createElement("h2");
heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`;
details.append(heading);
addSection("Механизм", mechanismById.get(edge.mechanism).label);
addSection("Ритм", edge.rhythm);
addSection("Кто именно", edge.actor);
addSection("Как соединяет", edge.join);
addSection("Способ обработки", mechanismById.get(edge.mechanism).label);
addSection("Когда выполняется", edge.rhythm);
addSection("Что выполняет обработку", edge.actor);
addSection("Что происходит с данными", edge.join);
addLinks(edge.links);
}
function layoutMap() {
+40 -32
View File
@@ -1,44 +1,51 @@
# Генератор: откуда берутся данные
Генератор изображает две системы интернет-магазина. Первая — счетчик на сайте,
он шлет события в топик `hits`. Вторая — бэкенд магазина, он раз в модельные
сутки выгружает слепок заказов в топик `orders`.
Генератор имитирует работу двух систем интернет-магазина. Счетчик
на сайте отправляет записи о действиях посетителей в топик `hits`. Серверная часть
магазина раз в модельные сутки выгружает состояние заказов в топик `orders`.
Эта дневная выгрузка называется слепком.
## Что он производит
## События сайта и заказы
Событие — широкая строка по образцу выгрузки Яндекс Метрики. Генератор создает
три вида событий: просмотр страницы, корзину и покупку. Все колонки перечислены
Событие содержит сведения о действии посетителя: например, время,
адрес страницы и идентификатор посетителя. Формат соответствует образцу
выгрузки Яндекс Метрики. Генератор создает три вида событий: просмотр
страницы, добавление в корзину и покупку. Все колонки перечислены
в [описании выгрузки](../formats/clickstream-event.md).
Заказ — та же покупка, но записанная бэкендом магазина, с суммами в деньгах.
Семь модельных дней после оформления заказ еще может измениться, поэтому каждый
дневной слепок несет заказы за последние семь дней в их текущем состоянии.
Устройство и форма слепка разобраны в [отдельном
Заказ содержит сведения о покупке со стороны магазина: товары, суммы
и состояние заказа. После оформления он еще может измениться, например
при отмене. Каждый дневной слепок содержит текущее состояние заказов,
оформленных за последние семь модельных дней. Состав и формат слепка
разобраны в [отдельном
справочнике](../architecture/orders/snapshot.md).
## Почему данные всегда одни и те же
## Почему начальные данные повторяются
Генератор не берет случайность извне. Все его случайные решения выводятся из
[зерна](../architecture/generator/seeds.md) — одного числа в настройках — и из
номера дня. То же зерно и тот же номер дня дают ровно те же события, поэтому
стартовый мир одинаков при каждом `make up`.
Случайные решения генератора зависят от заданного числа —
[зерна генерации](../architecture/generator/seeds.md) — и номера дня.
При неизменных коде, каталоге товаров и настройках одинаковые зерно и день
дают одинаковые события. Поэтому при создании стенда вы каждый раз
получаете те же начальные данные. Повторный `make up` на работающем стенде
сохраняет уже загруженные данные.
Рядом лежит паспорт мира, `data/world-inventory.json`: зерно, версия генератора
и по строке на каждый день с числом событий и контрольной суммой. По нему
проверяют, что генератор собрал тот же мир, что и раньше, и что в ODS все
доехало без потерь. Паспорт описывает только восемь дней стартового мира.
В файле `data/world-inventory.json` записаны зерно, версия генератора,
число событий и контрольная сумма за каждый из восьми начальных дней.
В документации этот файл называется паспортом мира. С ним можно сверить
результат генерации и проверить, все ли события загрузились в ODS.
## Кто его запускает
## Запуск генератора
На стенде генератором управляют [даги модельного
времени](airflow-dags.md#даги-мира). Его можно запустить и напрямую. Команды
собраны в [справочнике мира](../architecture/world.md#как-запустить-генератор).
времени](airflow-dags.md#генерация-модельных-дней). Его можно запустить и напрямую. Команды
собраны в [справочнике генератора и модельного
времени](../architecture/world.md#как-запустить-генератор).
У генератора два режима. Пакетный отправляет весь день разом. Живой шлет
события по ходу модельных суток; сутки идут ускоренно и занимают около двадцати
четырех минут.
У генератора два режима. Пакетный отправляет все события дня без пауз.
В живом режиме события поступают постепенно, как в работающем магазине,
но время идет ускоренно: модельные сутки занимают около 24 минут.
## Посмотрите сами
## Сверка с паспортом мира
Подключитесь из DBeaver по [инструкции](../../README.md#подключиться-из-dbeaver)
и выполните запрос:
@@ -50,13 +57,14 @@ GROUP BY EventDate
ORDER BY EventDate;
```
Сравните первые восемь дней с восемью строками в [паспорте
мира](../../data/world-inventory.json): числа совпадут. Дни, сыгранные после
стартового мира, в паспорте не записаны. Так и задумано.
Сравните число событий за каждый из первых восьми дней со значением
`events` за ту же дату в [паспорте мира](../../data/world-inventory.json).
Числа должны совпасть. Если вы уже генерировали следующие дни,
в результате запроса будут дополнительные даты; в паспорте их нет.
## Куда дальше
## Учебные задания и справочники
Подробнее генератор разобран в [README папки
`generator/`](../../generator/README.md) и в [справочнике его
устройства](../architecture/generator/README.md). На этом путеводитель
заканчивается: дальше справочники и [учебные задания](../labs/README.md).
устройства](../architecture/generator/README.md). Теперь можно переходить
к [учебным заданиям](../labs/README.md).
+54 -39
View File
@@ -1,71 +1,86 @@
# Мониторинг: куда смотреть
# Мониторинг в Grafana
В Grafana три дашборда, и у каждого своя тема. «Данные» показывает, все ли в
порядке с потоком данных. «Кластер» — состояние машин и самого ClickHouse.
«Запросы» — какие запросы выполнялись и сколько ресурсов они заняли.
В Grafana настроены три дашборда — экрана с показателями и графиками.
«Данные» показывает загрузку и обработку событий и заказов,
«Кластер» — состояние узлов и самого ClickHouse, а «Запросы» — выполненные
запросы и затраченные на них ресурсы.
Prometheus собирает метрики с двух узлов ClickHouse, keeper и экспортера Kafka.
Grafana читает и Prometheus, и ClickHouse напрямую. На панелях нет порогов и
цветовой подсветки: оценивать картинку придется самому. Автоматические
проверки, которые выносят вердикт, собраны в контуре DQ.
На панелях не заданы пороги для цветовых предупреждений: оценивать показания
вы будете самостоятельно. Качество данных автоматически проверяет
даг `dq_check`.
## Дашборд «Данные»
«Свежесть» показывает, сколько времени прошло с последнего события и с
последнего заказа в ODS. Свежесть заказов растет весь модельный день и
сбрасывается, когда на границе суток приходит слепок. Это нормальное поведение
источника, а не поломка.
«Свежесть» показывает, сколько реального времени прошло с поступления
последних успешно разобранных событий и заказов. Время поступления хранится
в колонке `_load_ts` слоя ODS. Показатель для заказов растет весь модельный день
и снижается, когда поступает очередная дневная выгрузка — слепок.
Это ожидаемо: заказы загружаются раз в день.
«Поток» показывает число сырых сообщений в STG за выбранный интервал. «Брак»
рядом — доля строк, которые не удалось разобрать.
показывает долю строк, которые не удалось разобрать.
«Сходимость» сравнивает число событий в Kafka и в ClickHouse. Тревожный
признак — устойчивый разрыв между линиями. Расхождение в одном столбце ничего
не значит.
признак — устойчивый разрыв между рядами. Разовое расхождение еще не говорит
о потере данных: метрики Kafka и строки ClickHouse могут попадать в соседние
интервалы на графике.
«Отставание» показывает, сколько сообщений в Kafka еще не прочитано
потребителями. «Дневной слепок» перечисляет последние десять слепков заказов в
STG: по нему видно, пришла ли выгрузка вовремя.
STG: по нему видно, поступила ли очередная выгрузка.
Источники и правила чтения всех панелей разобраны в [справочнике дашборда
«Данные»](../architecture/monitoring/data.md).
## Почему у панелей разные источники
## Источники показателей
Свежесть считается по ODS: если разбор остановился, это видно именно там. Поток
и брак считаются по STG, где сохраняется каждое доставленное сообщение. В ODS
повторы события и старые версии заказа схлопываются, поэтому счетчик там был бы
занижен. Подробности — в [разделе о слоях
Grafana получает данные напрямую из ClickHouse и через Prometheus.
Prometheus собирает метрики с двух узлов ClickHouse, ClickHouse Keeper
и экспортера Kafka — службы, которая предоставляет метрики Kafka.
Свежесть считается по ODS: если новые сообщения перестали разбираться,
показатель будет расти. Число поступивших сообщений берется из STG,
где сохраняются все сообщения, включая повторы. Для доли брака это число
сравнивается с числом строк в таблицах ошибок ODS.
В основных таблицах ODS повторы событий и старые версии заказов удаляются
при слияниях. Поэтому число строк в них не подходит для подсчета
всех доставленных сообщений. Подробности — в [разделе о слоях
панелей](../architecture/monitoring/data.md#почему-слои-у-панелей-разные).
## Два других дашборда
## Состояние кластера и запросы
Открывайте «Кластер», когда нужно понять, что происходит с процессором, памятью
и дисками, с кусками данных и их слияниями в ClickHouse, с keeper или очередью
DDL. Устройство панелей описано в [справочнике дашборда
Открывайте «Кластер», чтобы проверить нагрузку на процессор, использование
памяти и дисков, число частей таблиц и ход их слияний в ClickHouse. Здесь же
показаны состояние ClickHouse Keeper и очередь распределенных DDL-команд.
Устройство панелей описано в [справочнике дашборда
«Кластер»](../architecture/monitoring/cluster.md).
«Запросы» пригодится, когда вы ищете тяжелые запросы, ошибки или расход
ресурсов по пользователям. Как читать списки, объясняет [справочник дашборда
«Запросы» поможет найти медленные запросы, ошибки и пользователей,
чьи запросы потребляют много ресурсов. Как читать панели, объясняет
[справочник дашборда
«Запросы»](../architecture/monitoring/queries.md).
## Посмотрите сами
## Показатели за один модельный день
Откройте Grafana по адресу из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли), затем дашборд «Данные». Запустите в
Airflow [`world_live_day`](airflow-dags.md#даги-мира): модельный день займет
Airflow [`world_live_day`](airflow-dags.md#генерация-модельных-дней): модельный день займет
около двадцати четырех минут.
Смотрите свежесть событий и свежесть заказов по отдельности. Пока день идет,
свежесть событий держится на секундах, а свежесть заказов растет. На границе дня
слепок отправляется сразу: свежесть заказов сбрасывается, а свежесть событий
начинает расти, потому что новых событий до следующего дня не будет. Сам даг
остается в работе и после конца дня, потому что ждет дневной конвейер.
со времени последнего события проходят секунды, а время с последней
загрузки заказов растет. После завершения генерации событий отправляется
слепок заказов. Когда он загружен, показатель свежести заказов снижается.
Показатель для событий начинает расти: новые события поступят только
при генерации следующего дня. Даг пока продолжает работу, ожидая завершения
обработки данных в дневном конвейере.
## Куда дальше
## Следующая глава и справочники
Состав мониторинга собран в [справочнике
зоны](../architecture/monitoring/README.md), детали главного экрана — в
[справочнике дашборда «Данные»](../architecture/monitoring/data.md), а границы
решения — в [ADR 0002](../adr/0002-monitoring-scope.md). Следующая глава —
[«Немного Superset»](superset.md).
Устройство мониторинга описано в [техническом
справочнике](../architecture/monitoring/README.md), панели дашборда «Данные» —
в [отдельном разделе](../architecture/monitoring/data.md).
Почему выбраны именно эти показатели, объясняет
[ADR 0002](../adr/0002-monitoring-scope.md). Следующая глава —
[«Отчеты в Superset»](superset.md).
+74 -65
View File
@@ -1,66 +1,70 @@
# Хранилище и его слои
В хранилище приходят два потока из Kafka: в топике `hits` — события
кликстрима, в `orders` — заказы магазина. Оба проходят один путь: STG → ODS →
DDS → DM. Каждый слой отвечает на свой вопрос.
В Kafka поступают события сайта, или кликстрим, и заказы магазина.
Для событий выделен топик `hits`, для заказов — `orders`.
Из Kafka данные проходят четыре слоя хранилища. В STG сообщения сохраняются
как есть, в ODS разбираются по полям, в DDS из них собираются события,
заказы и сессии, а в DM — витрины для отчетов.
```mermaid
flowchart LR
Kafka --> STG --> ODS --> DDS --> DM
```
## Как читать имена
## Имена таблиц и представлений
Для обычного чтения выбирайте объекты с суффиксом `_v`. Это представления:
их устройство может поменяться, а ваш запрос останется прежним. Суффиксы
`_rep`, `_dist`, `_kafka` и `_mv` относятся к внутреннему
устройству хранилища. Они нужны для загрузки, обслуживания и диагностики.
У словарей есть исключение: у `dic.products` нет суффикса, потому что
различать нечего — объект один.
Для обычного чтения выбирайте представления с суффиксом `_v`. Они позволяют
менять устройство таблиц, сохраняя привычные запросы к данным.
Суффиксы `_rep`, `_dist`, `_kafka` и `_mv` относятся к внутреннему устройству
хранилища. Эти объекты нужны для загрузки, обслуживания и диагностики.
У словаря товаров `dic.products` суффикса нет.
Подробный разбор имен есть в [справочнике
хранилища](../architecture/storage.md#имена-объектов).
## STG: как поступило
## STG: исходные сообщения
STG — сырой слой. Он сохраняет сообщение целой строкой, без разбора. Рядом
лежат метаданные доставки: топик, партиция, смещение и время приема. Поэтому в
STG можно увидеть, что именно приехало из Kafka, даже если сообщение
испорчено.
STG — сырой слой. Он сохраняет сообщение целой строкой, без разбора. Вместе
с ним записываются сведения о доставке: топик, партиция, смещение и время
приема. Содержимое сообщения сохраняется в STG, даже если в нем есть ошибки.
Срок хранения — трое суток реального времени. Этого хватает для расследования
и повторного разбора, но старые строки не занимают диск вечно. Точное правило
— в [справочнике
Сырые сообщения хранятся трое суток реального времени. В течение этого срока
их можно использовать для поиска ошибок и повторной обработки. Затем они
удаляются по правилу из [справочника
хранилища](../architecture/storage.md#срок-жизни-сырых-данных).
## ODS: язык источника
## ODS: разбор сообщений
ODS — слой разобранных данных. Строка получает типы: дата становится датой,
число — числом. Имена полей остаются на языке источника. Например, событие
сохраняет колонки `WatchID`, `ClientID` и `EventDate`.
В ODS содержимое сообщения разбирается на колонки с нужными типами данных:
датами, числами, строками. Колонки сохраняют имена из источника. Например,
в таблице событий есть `WatchID`, `ClientID` и `EventDate`.
Испорченное сообщение не останавливает загрузку. Оно попадает в таблицу
`*_errors` вместе с сырой строкой и классом ошибки. Так брак остается виден, а
годные строки продолжают движение.
`*_errors` вместе с исходной строкой и классом ошибки. Правильно записанные
сообщения обрабатываются дальше.
## DDS: язык бизнеса
## DDS: события, заказы и сессии
DDS — детальный слой на языке бизнеса. Здесь есть событие, заказ, сессия и
карта идентичностей. Карта связывает посетителя, то есть куку `ClientID`, с
пользователем магазина из заказа.
DDS — детальный слой: в нем данные подготовлены для анализа событий,
заказов и сессий. Здесь же хранится карта идентичностей — таблица связей
между идентификатором посетителя `ClientID` и пользователем магазина
из заказа. `ClientID` хранится в cookie браузера: по нему можно узнать
браузер посетителя, но не всегда самого человека.
В стенде визитом считают подряд идущие события одной куки без пауз
дольше 30 минут. Визит не пересекает границу модельных суток.
Аналитик называет такой визит сессией.
В стенде визитом, или сессией, считают подряд идущие события одного
`ClientID` без пауз дольше 30 минут. Визит не пересекает границу модельных суток.
Модельные сутки — день в календаре учебного магазина. Этот календарь
не зависит от текущей даты на вашем компьютере.
В бою Метрика нарезает визиты сама и добавляет готовый `VisitID` в
события. Стенд играет ее роль: считает сессии из событий заново, а `VisitID`
оставляет эталоном для проверки. Иначе сборка сессий свелась бы к группировке
по чужому полю, и учиться было бы не на чем. Идентификатор сессии
`session_id` чеканит стенд: в событии его нет. Готовые сессии читайте через
`dds.session_v`. В `dds.event_v` поля `VisitID` нет: так сессии можно собрать
самому и сравнить с эталоном.
Источник передает готовый идентификатор визита `VisitID` по образцу
Яндекс Метрики. В учебном хранилище сессии рассчитываются заново по времени
событий; каждой присваивается `session_id`. Поле `VisitID` остается
эталоном для проверки. Так в заданиях вы сможете самостоятельно определить
границы сессий и сравнить их с результатом источника.
## DM: данные для дашбордов
Готовые сессии читайте через `dds.session_v`. События для самостоятельного
расчета доступны в `dds.event_v`: в этом представлении `VisitID` скрыт.
## DM: витрины для отчетов
DM — слой витрин. Витрина — готовый набор показателей для отчета или
дашборда. Здесь уже посчитаны, например, дневная выручка по категориям и
@@ -70,44 +74,43 @@ DM — слой витрин. Витрина — готовый набор по
путь расчета. Состав готовых витрин смотрите в [карте
таблиц](../architecture/storage.md#карта-таблиц).
## Сбоку цепочки: зона `dic`
## Справочники в базе `dic`
Зона `dic` стоит вне пути STG → ODS → DDS → DM. В ней живут справочные данные,
которые хранилище получает готовыми. Ни один слой их не производит, а читать
может любой.
В базе `dic` хранятся готовые справочные данные. Они загружаются отдельно
от событий и заказов и могут использоваться в расчетах любого слоя.
Первый справочник стенда — каталог товаров `dic.products`. Его ключ `sku` —
Каталог товаров `dic.products` — один из таких справочников. Его ключ `sku` —
артикул товара. По нему можно узнать название, бренд и категорию товара. Как
устроена зона, смотрите в [разделе о
справочниках](../architecture/storage.md#справочники).
## Сквозные ключи
## Связь данных между слоями
Один и тот же смысл проходит через слои под разными именами: имя меняется
вместе с языком слоя. Таблица показывает исходное имя и то, где искать тот же
смысл дальше.
Идентификаторы позволяют связать данные из разных слоев. При переходе в DDS
часть колонок переименовывается: например, `WatchID` становится `event_id`.
В таблице указано, как найти соответствующие колонки и соединить по ним данные.
| Колонка | Что означает | Что соединяет |
|---|---|---|
| `WatchID` | Идентификатор события | Событие ODS с событием DDS, где колонка называется `event_id` |
| `ClientID` | Посетитель, то есть кука | События ODS с событиями, сессиями и картой идентичностей DDS; в DDS колонка называется `client_id` |
| `ClientID` | Идентификатор посетителя из cookie браузера | События ODS с событиями, сессиями и картой идентичностей DDS; в DDS колонка называется `client_id` |
| `order_id` | Идентификатор заказа | Заказ ODS с заказом DDS и витриной сверки `dm.purchase_vs_orders_v` |
| `sku` | Артикул товара в каталоге | Справочник `dic.products` с позициями заказа DDS, где артикулы лежат в `item_sku`; через категорию товара — с витриной выручки DM |
| `EventDate` | День на [оси модельного времени](../../CONTEXT.md), который счетчик сайта проставляет в [своем часовом поясе](../architecture/storage.md#часовые-пояса) | Событие ODS с событием DDS, где колонка называется `event_date`, и дневными расчетами DM |
| `EventDate` | Дата события в [модельном времени](../../CONTEXT.md), указанная счетчиком сайта в [его часовом поясе](../architecture/storage.md#часовые-пояса) | Событие ODS с событием DDS, где колонка называется `event_date`, и дневными расчетами DM |
Что здесь значит «посетитель» и чем кука отличается от человека, объясняет
Почему идентификатор посетителя не равнозначен человеку, объясняет
[словарь понятий](../../CONTEXT.md#язык). Точные имена объектов и полей сверяйте с
[картой таблиц](../architecture/storage.md#карта-таблиц).
## Посмотрите сами
## Примеры запросов
Подключитесь к ClickHouse по инструкции из разделов [«Адреса и
пароли»](../../README.md#адреса-и-пароли) и [«Подключиться из
DBeaver»](../../README.md#подключиться-из-dbeaver). Во всех трех примерах
`2026-06-01` — это D0, первый день [стартового
мира](../../CONTEXT.md). Чтобы посмотреть другой день, замените
эту дату в условии. Первый запрос покажет
события одного модельного дня:
`2026-06-01` — первый день работы учебного магазина. В документации он
обозначается D0; подробнее — в [словаре понятий](../../CONTEXT.md).
Чтобы посмотреть другой день, замените дату в условии.
Первый запрос покажет 20 событий за выбранный день:
```sql
SELECT
@@ -122,8 +125,10 @@ ORDER BY UTCEventTime
LIMIT 20;
```
Второй запрос покажет сессии того же дня. Они рассчитаны из событий
первого запроса:
Каждая строка здесь — отдельное действие посетителя.
Второй запрос покажет 20 сессий того же дня с наибольшим числом событий.
Сессии рассчитаны по всем событиям дня:
```sql
SELECT
@@ -140,7 +145,12 @@ ORDER BY events_total DESC
LIMIT 20;
```
Третий запрос покажет готовую выручку по категориям за тот же день:
Теперь одна строка представляет целый визит. `events_total` показывает,
сколько событий в него вошло; начало и конец визита записаны в
`started_at` и `finished_at`.
Третий запрос покажет выручку магазина по категориям за тот же день.
Эта витрина рассчитана по заказам:
```sql
SELECT
@@ -153,14 +163,13 @@ WHERE report_date = '2026-06-01'
ORDER BY revenue DESC;
```
Все три запроса читают поверхности `_v`. Первый показывает данные на языке
источника, второй — расчетную сущность, третий — готовый ответ витрины.
Здесь одна строка содержит итоги дня для категории товаров.
Все три запроса читают представления `_v`.
## Куда дальше
## Следующая глава и справочники
Откройте [интерактивную карту](architecture-map.html) из своей копии
репозитория и проследите путь данных.
Список объектов, сроки хранения и устройство таблиц — в [справочнике
хранилища](../architecture/storage.md). Как данные переносят между слоями — в
следующей главе: [потоки Airflow и даги модельного
времени](airflow-dags.md).
хранилища](../architecture/storage.md). Как данные обрабатываются между слоями — в
следующей главе: [«Генерация и обработка данных в Airflow»](airflow-dags.md).
+37 -26
View File
@@ -1,4 +1,4 @@
# Немного Superset
# Отчеты в Superset
Superset показывает витрины слоя DM в виде графиков. К ClickHouse он
подключается пользователем `bi`. Дашборд стенда «Бизнес-сюжеты» собран из трех
@@ -6,45 +6,51 @@ Superset показывает витрины слоя DM в виде графи
| Витрина | Что показывает |
|---|---|
| `dm.revenue_daily_v` | выручку, заказы и средний чек крупными числами, выручку по дням и категориям столбиками, доли категорий кольцом |
| `dm.purchase_vs_orders_v` | сводную сверки: сколько заказов дня попало в каждый класс расхождения |
| `dm.daily_traffic_v` | число посетителей крупно, посетителей и известных пользователей по дням, отдельно — долю известных |
| `dm.revenue_daily_v` | Выручку, число заказов и средний чек; выручку по дням и категориям товаров, долю каждой категории |
| `dm.purchase_vs_orders_v` | Результат сверки покупок из кликстрима с заказами: сколько заказов за день относится к каждому виду расхождения |
| `dm.daily_traffic_v` | Число посетителей и известных пользователей по дням, долю известных пользователей |
Известные пользователи — те, кого удалось связать с посетителями сайта
по заказам. Эти связи хранятся в карте идентичностей DDS.
Слева от панелей стоит фильтр «Период». Он задает даты сразу для всех панелей.
## Как витрина становится графиком
От витрины до экрана три объекта Superset: датасет → график → дашборд. Датасет
описывает витрину: задает русские имена колонок, формат чисел и показатели —
например, средний чек. График ссылается на эти имена и задает способ показа.
Дашборд собирает готовые графики на одном экране.
В Superset сначала создается датасет — описание таблицы или представления,
из которого будут браться данные. В нем задаются подписи колонок, формат
чисел и показатели, например средний чек.
## Стендовые объекты и ваши
Затем на основе датасета создается график: выбираются показатели и способ
их отображения. Несколько графиков можно разместить на одном дашборде.
Датасеты, графики и дашборд из поставки стенда лежат файлами в репозитории и
заново импортируются при каждом `make up`. Если поправить такой объект в
интерфейсе, правка проживет до следующего `make up`, а потом без предупреждения
заменится версией из файла.
## Готовые и собственные отчеты
Ваши собственные объекты хранятся в той же базе метаданных Superset, но импорт
их не трогает: они переживают и `make up`, и `make rebuild-storage`. Стирает их
только `make clean` вместе с остальным постоянным состоянием стенда. Поэтому
стендовые объекты оставляйте как есть, а свои отчеты стройте рядом. Устройство
Готовые датасеты, графики и дашборд сохранены в файлах репозитория
и импортируются при каждом `make up`. Если изменить их через интерфейс,
следующий `make up` заменит изменения версией из файлов без предупреждения.
Созданные вами датасеты, графики и дашборды хранятся в базе метаданных
Superset. Импорт их не меняет: они сохраняются после `make up`
и `make rebuild-storage`. Команда `make clean` удалит их вместе со всеми
данными стенда. Для учебных отчетов создавайте собственные объекты,
чтобы повторный запуск не перезаписал вашу работу. Устройство
импорта описано в [справочнике
Superset](../architecture/superset.md#импорт-при-каждой-инициализации).
## Почему края графиков не совпадают
## Почему выручка отстает на день
Правый край графика выручки всегда на день позади графиков кликстрима. Слепок
заказов за день приходит только со следующим модельным днем. Так устроен
источник, а не витрина. Вы уже видели этот
сдвиг в [главе об Airflow](airflow-dags.md#посмотрите-сами). Начальное состояние
Последняя дата с выручкой на день раньше последней даты с событиями
кликстрима. Выгрузка заказов за день D поступает при генерации следующего
дня D+1, а затем обрабатывается конвейером. Вы уже видели эту разницу
в [главе об Airflow](airflow-dags.md#один-день-от-генерации-до-витрин). Начальное состояние
показано в разделе [«Стартовый мир»](../architecture/world.md#стартовый-мир).
## Посмотрите сами
## Сверка графика с витриной
Откройте Superset по адресу из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли) и выберите дашборд «Бизнес-сюжеты».
Найдите график «Посетители и известные пользователи».
Затем откройте SQL Lab и выполните запрос:
```sql
@@ -54,11 +60,16 @@ ORDER BY report_date DESC
LIMIT 7;
```
Как построить свой отчет, рассказывают [учебные задания](../labs/README.md).
Выберите одну дату из результата. На дашборде задайте «Период», в который
она входит, и обновите график. Наведите указатель на точку этой даты:
числа посетителей и известных пользователей должны совпасть с `visitors`
и `known_users` из SQL.
## Куда дальше
Как построить собственный отчет, рассказывают [учебные задания](../labs/README.md).
Устройство BI-зоны описано в [справочнике
## Следующая глава и справочники
Подключение и настройка Superset описаны в [справочнике
Superset](../architecture/superset.md), а состав витрин — в [справочнике
DM](../architecture/dm.md). Следующая глава — [«Генератор: откуда берутся
данные»](generator.md).
+24 -26
View File
@@ -1,37 +1,38 @@
# Задание 1: `dm.events_enriched_v`
# Задание 1: события для анализа, `dm.events_enriched_v`
**Чему учит:** подготовить удобный для анализа набор полей события
и обойтись представлением, сохранив одну строку на событие.
Вы подготовите представление с полями для анализа событий. Оно будет
добавлять вычисляемые поля при чтении, сохраняя одну строку на событие.
## Сначала прочитать
## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md).
- [Как применить свой DDL](README.md#как-применить-свой-ddl): файл для решения
- [Выполнение SQL в DBeaver](README.md#выполнение-sql-в-dbeaver): файл для решения
и подключение с правами на создание витрины.
## Для чего нужна витрина
## Данные для анализа событий
В `dds.event_v` 44 колонки, включая технические поля источника и массивы
товаров. Для анализа событий в Superset нужны деловые поля и три готовых
значения: час события, путь страницы и домен перехода. Тогда вам не придется
товаров. Для анализа событий в Superset нужны сведения о посетителе и его
действиях, а также три вычисляемых поля: час события, путь страницы и домен,
с которого пришел посетитель. Тогда вам не придется
повторять эти выражения в каждом запросе.
Витрины следующих заданий читают DDS напрямую. Использовать
`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`. У события с тремя
позициями получится три строки. Зерно изменится. Витрину с новым зерном на
стенде хранят готовым агрегатом — причины описаны в разделе [«Как принято на
ступени 1»](README.md#как-принято-на-ступени-1).
позициями получится три строки вместо одной.
Главная опасность — потеря событий с пустым массивом товаров: `ARRAY JOIN`
исключает их из результата. Товары есть только в событиях корзины и покупки.
@@ -68,9 +67,9 @@ DDS читают из ODS без отдельной загрузки. Эти с
по-прежнему будет выглядеть таблицей событий.
Сохраните массивы как в источнике. Для анализа отдельных товарных позиций
нужна другая витрина с другим зерном и именем.
нужна отдельная витрина, где одна строка соответствует товарной позиции.
## Как проверить себя
## Проверка результата
После применения DDL откройте Airflow по [адресу из README](../../README.md#адреса-и-пароли),
найдите даг `homework_check` и запустите его вручную. В новом запуске откройте
@@ -79,16 +78,15 @@ DDS читают из ODS без отдельной загрузки. Эти с
расхождениях с источником — дни и примеры несовпадений. Задачи еще не
созданных витрин будут красными; это ожидаемо и не мешает проверке первой.
Деловой ключ — `event_id` внутри дня. Получите результат прямо из
`dds.event_v` за тот же день. Соедините его с витриной полным внешним
соединением по `event_id`. Посчитайте строки, где ключ отсутствует с одной
из сторон или различаются значения деловых колонок. Расхождений должно быть
ноль.
Выберите день и прочитайте его события из `dds.event_v` и своей витрины.
Сопоставьте строки по `event_id` через `FULL OUTER JOIN`.
Найдите события, которые есть только с одной стороны, и события, у которых
различаются перенесенные из источника поля. Расхождений должно быть ноль.
Отдельно сверьте `event_hour`, `page_path` и `referer_host` с теми же
выражениями, рассчитанными прямо по `dds.event_v`.
## Что должно получиться
## Результат работы
- [ ] у вас есть `dm.events_enriched_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);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
- [«Генерация и обработка данных в Airflow»](../handbook/airflow-dags.md).
## Для чего нужна витрина
## Дневные показатели страниц
По дневным показателям страниц аналитик сравнивает просмотры, посетителей и
действия с товарами. Добавление в корзину происходит на карточке товара,
@@ -19,19 +20,23 @@
действия происходят на разных страницах, а связи между ними в агрегате нет.
Путь человека исследуют по событиям или сессиям.
## Что сделать
## Создание витрины
Источник — `dds.event_v`. Зерно витрины — **день события × страница**.
Читайте DDS напрямую: так выполнение задания не зависит от витрины
задания 1.
Читайте события из `dds.event_v`. В результате нужна одна строка на страницу
за день. Например, все просмотры одной карточки товара за один день попадут
в одну строку. Витрина задания 1 для этого расчета не нужна.
На стенде витрину с новым зерном хранят как готовый агрегат, чтобы не
рассчитывать ее заново для каждого запроса. Причины описаны в разделе
[«Как принято на ступени 1»](README.md#как-принято-на-ступени-1). Создайте
пять объектов:
`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
публичное представление `dm.top_pages_daily_v`. Ключ партиции — день, он же
единица пересборки.
Сохраните рассчитанные показатели в таблице, чтобы отчеты читали готовые
итоги. Создайте пять объектов:
- основные таблицы `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`.
Их назначение и порядок обновления разобраны во вводной:
[«Создание и обновление витрин»](README.md#создание-и-обновление-витрин).
Разбейте данные на партиции по дням, чтобы можно было заменить результат
одного дня целиком.
### Состав колонок
@@ -50,25 +55,26 @@
адрес разделил бы показатели одной страницы на сотни строк. Учитывайте все
три типа событий, а их счетчики вычисляйте через `countIf`.
### Ритм пересборки
### Дни для пересчета
События прожитого дня больше не меняются. Определяйте объем прогона по
состоянию витрины: от первого несобранного прожитого дня до последнего
прожитого. Отсчет от последнего собранного дня оставит пропуски после
частичного сбоя.
События завершившегося модельного дня больше не меняются. Найдите первый
такой день, которого еще нет в витрине, и рассчитайте все дни от него до
последнего завершившегося. Если начать после последнего дня, который уже
есть в витрине, можно пропустить более ранний день, не загруженный из-за сбоя.
Как у `revenue_daily`, заполните донор за весь диапазон одной вставкой,
затем замените дневные партиции по одной.
Как у `revenue_daily`, запишите расчет за все выбранные дни в промежуточную
таблицу одной вставкой. Затем замените дневные партиции основной таблицы по
одной. Добавьте запуск этих запросов в `dm_transform`.
### Ключ шардирования
Выберите ключ самостоятельно. У `revenue_daily` кроме дня одно измерение с
шестью близкими по объему значениями. Здесь страниц много, и число событий
на них сильно различается.
Выберите ключ самостоятельно. У `revenue_daily` строки сгруппированы по дню
и категории товара; категорий всего шесть. Здесь страниц много, и число
событий на них сильно различается.
У цели и донора используйте одинаковый ключ: иначе при замене партиции
строка потеряется на одном шарде и удвоится на другом. О том, [как устроен
донор](../architecture/storage.md), читайте в разделе «Раскладка по шардам».
У основной и промежуточной таблиц используйте одинаковый ключ шардирования.
Замена партиции не перераспределяет строки между шардами. Подробнее об этом
в разделе [«Раскладка по шардам»](../architecture/storage.md#раскладка-по-шардам).
## Типичная ошибка
@@ -82,13 +88,13 @@
Выбирайте проверку под возможную ошибку. Этот подход описан в
[справочнике проверок](../architecture/testing.md).
## Как проверить себя
## Проверка результата
Деловой ключ — `page_path` внутри дня. Получите прямой агрегат из
`dds.event_v` за день и соедините его с `dm.top_pages_daily_v` полным
внешним соединением по ключу. Посчитайте строки, где ключ отсутствует с
одной из сторон или различается любой из четырех счетчиков. Расхождений
должно быть ноль.
Рассчитайте те же показатели по `dds.event_v` за выбранный день.
Сопоставьте результат со строками `dm.top_pages_daily_v` за этот день
через `FULL OUTER JOIN` по `page_path`. Найдите страницы, которые есть
только с одной стороны, и строки, где различается хотя бы один из четырех
счетчиков. Расхождений должно быть ноль.
Перед полным сравнением проверьте дневные суммы:
@@ -98,10 +104,10 @@
Сумма `visitors` по страницам повторно учитывает посетителя, открывшего
несколько страниц. Число посетителей за день берите из `dm.daily_traffic_v`.
## Что должно получиться
## Результат работы
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
использует тот же ключ шардирования, что и целевая таблица;
- [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
`AS` и использует тот же ключ шардирования, что и основная;
- [ ] в `dm.top_pages_daily_v` явно перечислены колонки, включая
`_load_id` и `_load_ts`;
- [ ] ваш запрос сам определяет дни пересборки по состоянию витрины;
+47 -38
View File
@@ -1,14 +1,15 @@
# Задание 3: `dm.session_overview`
# Задание 3: визиты по устройствам, `dm.session_overview`
**Чему учит:** хранить слагаемые для расчета средних и долей и понимать,
Вы соберете дневные показатели визитов по устройствам. В таблице будут
храниться суммы и счетчики для расчета средних и долей. Заодно проверите,
когда сложение числа посетителей по устройствам завышает аудиторию.
## Сначала прочитать
## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
- [«Генерация и обработка данных в Airflow»](../handbook/airflow-dags.md).
## Для чего нужна витрина
## Показатели визитов
Витрина нужна, чтобы по дням сравнивать визиты с телефона и компьютера: их
число, длительность, просмотры страниц и частоту покупок.
@@ -17,13 +18,19 @@
устройствам. Почему такие итоги хранят в отдельной таблице, объясняет
[справочник DM](../architecture/dm.md).
## Что сделать
## Создание витрины
Источник — `dds.session_v`. Зерно — **день сессии × категория устройства**.
Зерно новое, поэтому витрина хранится готовым агрегатом — см. [«Как принято
на ступени 1»](README.md#как-принято-на-ступени-1). Создайте пять объектов:
`dm.session_overview_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
публичное представление `dm.session_overview_v`. Ключ партиции — день.
Читайте готовые сессии из `dds.session_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`
создавайте только при появлении таких визитов.
Вместо `avg_duration_seconds` храните слагаемые. Среднее из средних по трем
устройствам даст неверный ответ за день: число сессий в категориях
различается, поэтому средние нужно взвешивать. Потребитель делит сумму
`duration_seconds_total` на сумму `sessions`. Долю отказов считают так же:
сумму `bounce_sessions` делят на сумму `sessions`, а не усредняют готовые
доли.
Для расчета средней длительности храните общую длительность и число сессий.
Готовую среднюю `avg_duration_seconds` сохранять не нужно. Число сессий
в категориях различается, поэтому среднее из трех средних даст неверный
ответ за день. Чтобы получить его правильно, разделите сумму
`duration_seconds_total` на сумму `sessions`. Для доли отказов разделите
сумму `bounce_sessions` на сумму `sessions`. Усреднение готовых долей тоже
даст неверный результат.
### Ритм пересборки
### Дни для пересчета
Сессии прожитого дня больше не меняются. Как в задании 2, определяйте
диапазон от первого несобранного прожитого дня до последнего прожитого.
Соберите его в донор и замените дневные партиции. Добавьте задачу в
`dm_transform`.
Сессии завершившегося модельного дня больше не меняются. Как в задании 2,
найдите первый завершившийся день, которого еще нет в витрине. Рассчитайте
все дни от него до последнего завершившегося, запишите их в промежуточную
таблицу и замените дневные партиции основной. Добавьте задачу в `dm_transform`.
### Ключ шардирования
@@ -68,18 +76,18 @@
`device_category`, сначала посчитайте строки на каждом шарде. Затем
объясните, почему такое распределение вас устраивает.
Ключ донора — тот же, что у цели (см. [«Как принято на ступени
1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
[справочника хранилища](../architecture/storage.md)).
У основной и промежуточной таблиц должен быть одинаковый ключ шардирования:
см. [общие правила ступени 1](README.md#создание-и-обновление-витрин).
## Почему сумма `visitors` сходится с `dm.daily_traffic_v`
## Число посетителей за день
Сравните сумму `visitors` по устройствам за день с `dm.daily_traffic_v`. На
стенде значения совпадут: устройство приписано куке на всю жизнь, поэтому за
день она учитывается только в одной строке. Это свойство мира стенда; см.
стенде значения совпадут: одному `client_id` всегда соответствует одно
устройство, поэтому за день он учитывается только в одной категории.
Это свойство мира стенда; см.
раздел «Паспорт куки» в [словаре понятий](../../CONTEXT.md).
Если появятся визиты одной куки с двух устройств, сумма начнет завышать
Если появятся визиты одного `client_id` с двух устройств, сумма начнет завышать
аудиторию. Проверки счетчиков по категориям этого не обнаружат. Число
посетителей дня берите из `dm.daily_traffic_v`, где оно рассчитано для дня
целиком.
@@ -91,12 +99,13 @@
событий совпало с источником, а число сессий завышено, вероятная причина —
сборка витрины из событий вместо сессий.
## Как проверить себя
## Проверка результата
Деловой ключ — `device_category` внутри дня. Получите прямой агрегат из
`dds.session_v` за день. Соедините его с `dm.session_overview_v` полным
внешним соединением по ключу. Посчитайте строки, где ключ отсутствует с
одной из сторон или различается любой счетчик. Расхождений должно быть ноль.
Рассчитайте те же показатели по `dds.session_v` за выбранный день.
Сопоставьте результат со строками `dm.session_overview_v` за этот день
через `FULL OUTER JOIN` по `device_category`. Найдите категории, которые
есть только с одной стороны, и строки, где различается хотя бы один
счетчик. Расхождений должно быть ноль.
Еще две проверки дневных итогов:
@@ -104,12 +113,12 @@
повторного учета визитов;
- сумма `events_total` равна сумме `events_total` по сессиям того же дня.
## Что должно получиться
## Результат работы
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
использует ключ шардирования целевой таблицы;
- [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
`AS` и использует ключ шардирования основной;
- [ ] вы явно перечислили колонки в `dm.session_overview_v` и собрали
показатели из готовых сессий `dds.session_v`;
- [ ] в таблице хранятся слагаемые для средних и долей;
- [ ] в таблице хранятся суммы и счетчики для расчета средних и долей;
- [ ] проверка `session_overview` в `homework_check` прошла успешно;
- [ ] витрина обновляется при запуске `dm_transform`.
+52 -50
View File
@@ -1,28 +1,42 @@
# Задание 4: `dm.dq_errors_daily`
# Задание 4: ошибки загрузки по дням, `dm.dq_errors_daily`
**Чему учит:** считать брак по дню загрузки и сохранять сводку дольше
исходных строк.
Вы соберете дневную сводку ошибок загрузки. Ее итоги должны сохраниться
и после того, как исходные сообщения будут удалены из хранилища.
## Сначала прочитать
## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
- [«Генерация и обработка данных в Airflow»](../handbook/airflow-dags.md).
## Для чего нужна витрина
## Ошибки загрузки и их источники
Сводку открывают при росте брака в потоке: смотрят, каких ошибок стало
больше и сколько строк забраковано. Причину конкретной ошибки исследуют
в `ods.event_errors_dist` и `ods.order_errors_dist`, где есть сырой текст и
координаты доставки.
Если сообщение не удалось разобрать, стенд записывает его в таблицу ошибок.
Такие строки дальше называются браком. Вы соберете сводку, по которой можно
увидеть, каких ошибок стало больше и сколько строк забраковано.
## Что сделать
Источники сводки:
Зерно — **день загрузки × источник × класс брака**. В источнике зерно —
строка брака. Зерно новое, поэтому витрина хранится готовым агрегатом — см.
[«Как принято на ступени 1»](README.md#как-принято-на-ступени-1). Создайте
пять объектов: `dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` /
`_stage_dist` и публичное представление `dm.dq_errors_daily_v`. Ключ
партиции — день загрузки.
- `ods.event_errors_dist`: ошибки разбора событий;
- `ods.order_errors_dist`: ошибки разбора выгрузки заказов.
В этих таблицах есть исходный текст сообщения и сведения о его доставке:
они пригодятся при поиске причины ошибки. Представлений `_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_date`.
У событий и заказов свои классы брака. `source` входит в зерно, чтобы
одинаковые имена классов с разным смыслом не объединялись в одну строку.
У событий и заказов свои классы брака. Группируйте строки также по `source`,
чтобы одноименные классы разных источников не объединялись.
### Ритм пересборки
### Пересчет и хранение итогов
Брак хранится месяц по `TTL`, сырые данные — трое суток. Сводка хранится
бессрочно: итоги нужны и после удаления исходных строк.
Правило `TTL` удаляет строки брака через месяц, сырые данные через трое суток.
Сводку храните бессрочно: итоги нужны и после удаления исходных строк.
Получите список дней загрузки, еще имеющихся в источниках. Соберите их в
донор и замените только эти партиции, сохранив остальные.
Получите список дней загрузки, еще имеющихся в источниках. Рассчитайте их
итоги в промежуточной таблице и замените только эти партиции основной таблицы.
Более старые итоги сохраните.
В течение текущих суток ошибки еще поступают: пересчитайте этот день на
следующем прогоне.
@@ -60,36 +75,23 @@
Выберите ключ самостоятельно. Классов брака у событий и заказов вместе
меньше десятка. Объясните, насколько существенен перекос при таком объеме.
Ключ донора — тот же, что у цели (см. [«Как принято на ступени
1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
[справочника хранилища](../architecture/storage.md)).
## Источники: таблицы `_dist`
Только это задание читает данные не через `_v`: брак нужен для диагностики
приема и не входит в бизнес-модель. Публичных представлений у таблиц ошибок
нет, создавать их для задания не нужно. Источники:
- `ods.event_errors_dist` — ошибки разбора событий;
- `ods.order_errors_dist` — ошибки разбора слепка заказов.
Чтение локальной `_rep` даст строки одного шарда из двух: часть брака не
попадет в сводку, хотя запрос выполнится без ошибки.
У основной и промежуточной таблиц должен быть одинаковый ключ шардирования:
см. [общие правила ступени 1](README.md#создание-и-обновление-витрин).
## Типичная ошибка
Если заменить всю таблицу данными из нового донора, исчезнут итоги дней,
истекших по `TTL` источника. Восстановить их неоткуда.
Если заменить всю сводку новым расчетом, исчезнут итоги дней, за которые
исходные строки уже удалены по `TTL`. Восстановить эти итоги неоткуда.
## Как проверить себя
## Проверка результата
Деловой ключ — `source` и `error_class` внутри `load_date`. Проверяйте дни,
оставшиеся хотя бы в одной таблице ошибок. Если дня за пределами `TTL` уже
нет в источнике, это не ошибка: сверять его уже не с чем.
Сопоставляйте строки по `load_date`, `source` и `error_class`. Проверяйте дни,
оставшиеся хотя бы в одной таблице ошибок. Старые итоги, для которых исходные
строки уже удалены по `TTL`, сверять не с чем.
Посчитайте строки прямо в обеих таблицах ошибок и соедините результат с
`dm.dq_errors_daily_v` полным внешним соединением по ключу. Затем посчитайте
строки, где ключ отсутствует с одной из сторон или различается `error_rows`.
`dm.dq_errors_daily_v` через `FULL OUTER JOIN` по этим трем полям. Посчитайте
строки, которые есть только с одной стороны или у которых различается `error_rows`.
Расхождений должно быть ноль.
Сумма `error_rows` по проверяемым дням равна общему числу строк в
@@ -99,10 +101,10 @@
Если в источниках нет брака, витрина должна быть пустой. В этом случае
расхождений тоже ноль и проверка успешна.
## Что должно получиться
## Результат работы
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
использует ключ шардирования целевой таблицы;
- [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
`AS` и использует ключ шардирования основной;
- [ ] сводка включает ошибки событий и заказов из таблиц `_dist`;
- [ ] ваш запрос пересобирает только дни, еще доступные в источниках,
сохраняя более старые итоги;
+45 -36
View File
@@ -1,30 +1,38 @@
# Задание 5: `dm.utm_effectiveness`
# Задание 5: показатели рекламы, `dm.utm_effectiveness`
**Чему учит:** различать сумму покупки со слов клиента (`declared_`) и
серверную выручку, находить причины их расхождения.
Вы подготовите показатели рекламных каналов по событиям сайта. Суммы покупок
в этой витрине будут отличаться от выручки по заказам магазина. В конце
работы вы сравните их и объясните причины расхождения.
## Сначала прочитать
## Подготовка
- [«Хранилище и его слои»](../handbook/storage-layers.md);
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
- [«Генерация и обработка данных в Airflow»](../handbook/airflow-dags.md).
## Для чего нужна витрина
## Покупки по рекламным каналам
Аналитик сравнивает рекламные каналы по дням: трафик, добавления в корзину,
покупки и заявленные клиентом суммы.
покупки и их суммы из событий сайта.
На стенде поведение и атрибуцию считают по трекеру, деньги — по бэкенду.
Здесь сделано исключение: UTM-метки есть только в кликстриме, поэтому для
оценки покупок по меткам используют клиентскую сумму.
События сайта показывают действия посетителя и рекламные метки его переходов.
Заказы магазина служат источником выручки. Но в заказах нет UTM-меток, поэтому
в этой витрине вы возьмете суммы из событий покупки. Их передает браузер
посетителя; дальше они называются клиентскими суммами. Приставка `declared_`
в имени показателя напоминает об этом источнике.
## Что сделать
## Создание витрины
Читайте `dds.event_v` напрямую, без соседних витрин DM. Зерно — **день
события × `utm_source` × `utm_medium` × `utm_campaign`**. Зерно новое,
поэтому витрина хранится готовым агрегатом — см. [«Как принято на ступени
1»](README.md#как-принято-на-ступени-1). Создайте пять объектов:
`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
публичное представление `dm.utm_effectiveness_v`. Ключ партиции — день.
Читайте события из `dds.event_v`, без других витрин DM. В результате нужна
одна строка на день и сочетание трех меток: `utm_source`, `utm_medium`,
`utm_campaign`. События одного дня с одинаковыми метками попадают в одну группу.
Создайте пять объектов по [общим правилам ступени 1](README.md#создание-и-обновление-витрин):
- основные таблицы `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`: имен кампаний со временем становится
много, и затраты на словарь `LowCardinality` перестают окупаться.
### Ритм пересборки
### Дни для пересчета
События прожитого дня больше не меняются. Как в задании 2, соберите в донор
дни от первого несобранного до последнего прожитого и замените дневные
партиции. Добавьте задачу в `dm_transform`.
События завершившегося модельного дня больше не меняются. Как в задании 2,
найдите первый завершившийся день, которого еще нет в витрине. Рассчитайте
все дни от него до последнего завершившегося, запишите их в промежуточную
таблицу и замените дневные партиции основной. Добавьте задачу в `dm_transform`.
### Ключ шардирования
Выберите ключ самостоятельно. Если берете хеш одной из трех UTM-колонок,
посчитайте долю строк с ее самым частым значением и обоснуйте выбор.
Ключ донора — тот же, что у цели (см. [«Как принято на ступени
1»](README.md#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
[справочника хранилища](../architecture/storage.md)).
У основной и промежуточной таблиц должен быть одинаковый ключ шардирования:
см. [общие правила ступени 1](README.md#создание-и-обновление-витрин).
## Атрибуция по событию, а не по визиту
## Рекламные метки событий
Группируйте события по их собственным меткам. Модели «первое касание» и
«последнее касание» здесь не рассчитываются.
В `dds.session_v` записаны UTM первого события визита. Распределение по
каналам может отличаться от этой витрины, и это не означает ошибку в
расчете. В отчете различайте атрибуцию визитов и событий.
расчете. Укажите в отчете, что распределяете по рекламным каналам отдельные
события. Такое распределение называют атрибуцией.
## Прямой трафик остается в витрине
## События без рекламных меток
Сохраните события без меток: прямой трафик обычно крупнейший канал, и без
него доля рекламы будет завышена. Если отфильтровать такие строки, дневные
@@ -108,13 +117,13 @@
причины можно посмотреть в `dm.purchase_vs_orders_v`, где выделены классы
расхождений.
## Как проверить себя
## Проверка результата
Деловой ключ — `utm_source`, `utm_medium`, `utm_campaign` внутри дня.
Получите прямой агрегат из `dds.event_v` за день и соедините его с
`dm.utm_effectiveness_v` полным внешним соединением по ключу. Посчитайте
строки, где ключ отсутствует с одной из сторон или различается любая из пяти
величин. Расхождений должно быть ноль.
Рассчитайте те же показатели по `dds.event_v` за выбранный день.
Сопоставьте результат со строками `dm.utm_effectiveness_v` за этот день
через `FULL OUTER JOIN` по `utm_source`, `utm_medium` и `utm_campaign`.
Найдите сочетания меток, которые есть только с одной стороны, и строки,
где различается хотя бы один из пяти показателей. Расхождений должно быть ноль.
Дополнительно сверьте дневные итоги:
@@ -126,10 +135,10 @@
Сумма `visitors` по строкам повторно учитывает одного посетителя,
приходящего по разным меткам. Для числа посетителей дня она не подходит.
## Что должно получиться
## Результат работы
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
использует ключ шардирования целевой таблицы;
- [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
`AS` и использует ключ шардирования основной;
- [ ] строки без UTM-меток сохранены, а `declared_revenue` хранится
в `Decimal(18, 2)`;
- [ ] вы записали, почему сумма отличается от `dm.revenue_daily_v`;
+22 -19
View File
@@ -1,24 +1,27 @@
# Свой график в Superset
**Чему учит:** увидеть, как собственный результат из хранилища становится
отчетом в BI.
В этой работе вы построите график по своей витрине и проверите числа
на нем SQL-запросом.
## Сначала прочитать
## Подготовка
- [«Немного Superset»](../handbook/superset.md);
- [«Отчеты в Superset»](../handbook/superset.md);
- [задание 3: `dm.session_overview`](1-3-session-overview.md).
## На какой вопрос ответит график
## График отказов по устройствам
В задании 3 вы собрали витрину `dm.session_overview_v`. Теперь покажите, с
каких устройств чаще уходят после первой страницы. Постройте столбиковый
график доли отказов по `device_category` за весь период витрины. Измерение
одно — `device_category`, метрика тоже одна.
В задании 3 первой ступени вы собрали витрину `dm.session_overview_v`.
Теперь покажите, с каких устройств чаще уходят после единственного события.
Такие визиты в стенде считаются отказами. Постройте столбиковый график доли
отказов по `device_category` за весь период витрины. Каждый столбик должен
показывать долю для одной категории устройств.
## Что сделать
## Настройка графика
Заведите физический датасет в подключении к `ClickHouse`: схема `dm`, объект
`session_overview_v`. Назовите по-русски хотя бы две колонки:
Добавьте представление как источник данных для графиков. В Superset такой
источник называется физическим датасетом. Выберите подключение `ClickHouse`,
схему `dm` и объект `session_overview_v`. Задайте русские подписи хотя бы двум
колонкам:
- `device_category` — «Категория устройства»;
- `report_date` — «Дата отчета».
@@ -32,10 +35,10 @@
| `SQL expression` | `SUM(bounce_sessions) / SUM(sessions)` |
| `D3 format` | `.1%` |
Доля считается из сумм слагаемых: среднее готовых долей соврет. Поэтому
метрике нужно это выражение. Сохраните ее в датасете, а не в графике:
подписи и формат задаются в датасете, а график их использует. Так во всех
будущих графиках у метрики будут одинаковые подписи. Это правило описано в
Выражение делит общее число отказов на общее число сессий. Усреднение дневных
долей может дать неверный результат: число сессий в разные дни различается.
Сохраните метрику в датасете, чтобы все будущие графики использовали одно
выражение, подпись и формат. Это правило описано в
[справочнике Superset](../architecture/superset.md#дерево-импорта).
Не используйте вкладку «Custom SQL» конструктора графика. Созданная там
@@ -56,9 +59,9 @@ FROM dm.session_overview_v;
```
Стендовые объекты не меняйте. Что переживает `make up`, а что стирает
`make clean`, описано в главе [«Немного Superset»](../handbook/superset.md#стендовые-объекты-и-ваши).
`make clean`, описано в главе [«Отчеты в Superset»](../handbook/superset.md#готовые-и-собственные-отчеты).
## Как проверить себя
## Проверка результата
Автоматической проверки этого задания нет. Сверьте числа на графике
с результатом запроса в SQL Lab:
@@ -76,7 +79,7 @@ ORDER BY device_category;
проценты с одним знаком, как формат `.1%`, поэтому числа должны совпасть
один в один.
## Что должно получиться
## Результат работы
- [ ] у вас есть физический датасет `dm.session_overview_v` в подключении
`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#идентификаторы-и-время).
## Для чего нужны сессии
## События и визиты
Визит, или сессия, объединяет действия посетителя за один заход на сайт.
По визитам аналитик считает длительность и глубину просмотра.
В хранилище сессии собираются из отдельных событий.
Сверьте самостоятельный расчет с `VisitID`, чтобы увидеть, как из событий
получается сессия.
## Что сделать
## Расчет сессий
### Нарезка
### Границы визитов
В SQL Lab или DBeaver под `analyst` напишите запрос к `ods.event_v`.
Для своей нарезки и эталона возьмите одни и те же строки за все доступные
Для своего расчета и эталона возьмите одни и те же строки за все доступные
дни: для расследования понадобятся события соседних дней.
Выберите `ClientID`, `UTCEventTime`, `EventDate`, `EventType` и `VisitID`.
В `dds.event_v` эталонного `VisitID` нет.
Для начала считайте визитом последовательные события одной куки (`ClientID`);
пауза дольше 30 минут открывает новый визит.
`ClientID` хранится в cookie браузера и обозначает посетителя в событиях
сайта. Для начала считайте визитом последовательные события одного
`ClientID`: пауза дольше 30 минут открывает новый визит.
Учитывайте события всех типов. Упорядочите их по `UTCEventTime`.
Присвойте каждому событию номер своей сессии внутри куки.
Присвойте каждому событию номер своей сессии внутри `ClientID`.
Подсказка: оконные функции.
Днем сессии считайте `EventDate` ее первого события: дата из `UTCEventTime`
@@ -38,7 +38,8 @@
### Сверка с эталоном
В эталоне посчитайте число визитов каждой куки за модельный день:
Посчитайте по эталонному `VisitID` число визитов каждого `ClientID`
за модельный день:
```sql
SELECT ClientID, EventDate, uniqExact(VisitID) AS reference_sessions
@@ -47,46 +48,48 @@ GROUP BY ClientID, EventDate
ORDER BY EventDate, ClientID;
```
Посчитайте свои сессии по куке и дню сессии.
Соедините два готовых агрегата через `FULL OUTER JOIN` по куке и дню.
В результате полного соединения останутся ключи обеих сторон: при расхождении
на вашей стороне ключа может не быть совсем. Учтите такие строки при сравнении.
Выведите сводку по дням с числом разошедшихся кук и список этих кук
с днем и обоими значениями счета. Общая сумма за день могла бы скрыть
взаимную компенсацию ошибок разных кук. По списку видно, у кого искать причину.
Посчитайте свои сессии по `ClientID` и дню сессии. Соедините результаты двух
расчетов через `FULL OUTER JOIN` по этим полям. Учтите строки, которые есть
только с одной стороны: полное соединение сохранит их в результате.
Выведите сводку по дням с числом `ClientID`, для которых расчеты разошлись,
и список этих идентификаторов с днем и обоими значениями. Общая сумма за день
могла бы скрыть ошибки: для одного посетителя вы посчитали лишнюю сессию,
для другого пропустили. По списку видно, чьи события нужно исследовать.
Расхождений будет всего несколько. Если их нет, мир прожил мало дней.
Прогоните несколько дней через [даги модельного времени](../handbook/airflow-dags.md#даги-мира)
Прогоните несколько дней через [даги модельного времени](../handbook/airflow-dags.md#генерация-модельных-дней)
и повторите сверку.
### Расследование
### Поиск причины расхождения
Выберите одну разошедшуюся куку. Выведите ее события за два соседних дня
Выберите один `ClientID` с расхождением. Выведите его события за два соседних дня
с `UTCEventTime`, `EventDate`, `VisitID` и номером своей сессии.
Сопоставьте сессии через события: общего ключа у двух нарезок нет,
Сопоставьте сессии через события: общего ключа у двух расчетов нет,
зато у каждого события есть и `VisitID`, и присвоенный вами номер.
Найдите два соседних события, между которыми границы визитов разошлись.
Запишите куку, день, время обоих событий и причину одним предложением.
Запишите `ClientID`, день, время обоих событий и причину одним предложением.
Уточните определение визита и добейтесь нуля расхождений во всех днях.
## Как проверить себя
## Проверка результата
Сохраните исходный и уточненный расчеты, результаты сверки и найденные строки.
Таблицы и представления создавать не нужно. Задачи в `homework_check`
для этого задания нет: вы проверяете расчет своим запросом.
В `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` за все
доступные дни;
- [ ] у вас есть сводка расхождений по дням и список кук, полученные через
- [ ] у вас есть сводка расхождений по дням и список `ClientID`, полученные через
`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);
- [дневной конвейер трансформаций](../architecture/etl.md#объём-прогона);
- [раскладка по шардам](../architecture/storage.md#раскладка-по-шардам).
## Что предстоит сделать
## Файлы для изменения
Даг, таблицы сессий и проверка их числа уже созданы.
Перепишите только три файла:
@@ -19,56 +20,57 @@
- `sql/dds/session_rebuild.sql`;
- `sql/dds/session_replace.sql`.
Удалите из них готовый SQL и соберите свою реализацию. Параметры и порядок
запросов заданы в `dds_transform`. DDL задает целевые таблицы и донор. Донор —
служебная таблица, где день собирается перед заменой. В
`sessions_vs_reference.sql` число сессий сравнивается с `VisitID` из ODS.
Удалите из них готовый SQL и напишите свой. Даг `dds_transform` передаст
параметры и выполнит запросы по порядку. Таблицы уже созданы: основные хранят
готовые сессии, промежуточные нужны для нового расчета перед заменой данных.
Запрос `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_replace.sql` | День в `{{ params.day }}` | Партиция дня в `dds.session_rep` |
Первый прожитый день, которого еще нет в DDS, становится `first_day`.
`last_day` — последний прожитый день. Начало шкалы нужно взять из событий ODS:
`position` считает дни от него. Живой модельный день еще не закончен, поэтому
сессии за него не собираются.
`first_day` должен быть первым завершившимся модельным днем, которого еще
нет в DDS, а `last_day` последним завершившимся днем. Начальную дату возьмите
из событий ODS: `position` считает дни от нее. Текущий модельный день еще
не закончен, поэтому сессии за него не рассчитываются.
Если в ODS нет ни одного события, запрос не возвращает строк. Если DDS уже
догнал мир, запрос возвращает строку с `first_day = NULL`. В этом случае группа
сессий в Airflow получает состояние `skipped`.
Если в ODS нет ни одного события, запрос не возвращает строк. Если все
завершившиеся дни уже есть в DDS, запрос возвращает строку с `first_day = NULL`.
Тогда Airflow пропускает группу задач расчета сессий: ее состояние будет `skipped`.
`session_rebuild.sql` сначала очищает `dds.session_stage_rep` на всем кластере.
Затем он собирает в `dds.session_stage_dist` все дни отрезка. Добавьте к
Затем он записывает в `dds.session_stage_dist` расчет за все выбранные дни. Добавьте к
вставке `SETTINGS distributed_foreground_insert = 1`. Так строки успеют попасть
на шарды до замены партиций.
Цель `session_replace.sql` — локальная таблица `dds.session_rep`, источник —
локальный донор `dds.session_stage_rep`. Операция выполняется на всем кластере.
Запрос `session_replace.sql` заменяет партицию в основной `dds.session_rep`
готовой партицией из промежуточной `dds.session_stage_rep`.
Операция выполняется на всем кластере, на локальных таблицах каждого шарда.
Не ставьте точку с запятой в конце `session_scope.sql`. Драйвер добавляет формат
ответа, и точка с запятой превратит строку в мультизапрос, который ClickHouse
ответа. С точкой с запятой получится несколько команд, которые ClickHouse
отклонит. День для замены подставляет шаблон Jinja в Airflow. Параметр
ClickHouse в `ALTER ... PARTITION` здесь не работает.
## Нарежьте сессии
## Правила расчета сессий
Источник сборки — `dds.event_v`. Одна строка результата представляет один
визит. На третьей ступени вы уже нарезали сессии:
визит. На третьей ступени вы уже получили правила разделения событий на сессии:
- сессия не пересекает модельные сутки;
- пауза больше 30 минут открывает новую сессию;
- в нарезке участвуют события всех типов.
- в расчете участвуют события всех типов.
Пересборка добавляет требования к повторяемости. При одинаковом времени
порядок событий задает `event_id`, поэтому полный порядок окон —
Повторный расчет должен давать тот же результат. При одинаковом времени
упорядочивайте события по `event_id`: порядок в оконных функциях должен быть
`(event_time, event_id)`. Один визит должен получать одинаковый `session_id`
при каждом запуске. Тогда его можно сравнить с сохраненным результатом.
при каждом запуске.
Вычислите идентификатор как `cityHash64(client_id, min(event_time))`.
Состав и типы строки заданы в `dds.session_rep`. Заполните начало, конец и
@@ -76,11 +78,11 @@ ClickHouse в `ALTER ... PARTITION` здесь не работает.
покупки. Возьмите входную и выходную страницы, UTM первого события, устройство
и город. Заполните также служебные колонки из схемы таблицы.
Пишите через `dds.session_stage_dist`: место строки определит ключ
`client_id`. При записи прямо в локальную таблицу оно зависело бы от ноды,
которая выполнила запрос.
Пишите через `dds.session_stage_dist`: шард для строки определяется по
`client_id`. При записи прямо в локальную таблицу он зависел бы от узла,
который выполнил запрос.
## Запустите свою реализацию
## Запуск нового расчета
Готовые дни повторно не собираются. Чтобы новый SQL точно выполнился, выберите
последний собранный день:
@@ -90,8 +92,8 @@ SELECT max(session_date)
FROM dds.session_v;
```
Берите последний день: более ранняя дыра расширит объем до всех последующих
прожитых дней.
Берите последний день: если удалить более ранний, расчет придется повторить
и за все последующие завершившиеся дни.
Сохраните десять строк с идентификатором, границами, длительностью и счетчиками:
@@ -113,7 +115,7 @@ ORDER BY client_id, started_at, session_id
LIMIT 10;
```
Создайте в DBeaver подключение к первой ноде по [инструкции из
Создайте в DBeaver подключение к первому узлу по [инструкции из
README](../../README.md#подключиться-из-dbeaver). Укажите пользователя `etl` и
пароль `CLICKHOUSE_ETL_PASSWORD` из `.env`. У этой учетной записи есть право
менять таблицы DDS на кластере. Подставьте найденную дату и удалите только ее
@@ -128,19 +130,18 @@ DROP PARTITION 'YYYY-MM-DD';
группу `session`: в ней должны выполниться задачи `scope` и `rebuild`, а также
`replace` для выбранного дня. Если после третьей ступени мир ушел вперед,
в группе появятся задачи `replace` и для последующих прожитых дней. Следом
выполнятся витрины и `dq_check`.
обновятся витрины и выполнится `dq_check`.
Если прогон дошел до замены, но упал на проверке, ошибочная партиция уже
находится в DDS. После правки снова удалите тот же день перед следующим
запуском. Иначе день останется заполненным, а новая сборка для него не
выполнится.
## Как проверить себя
## Проверка результата
Сначала повторите запрос с десятью строками. `session_id`, границы,
длительность и счетчики должны совпасть с сохраненным результатом. Так вы
завершите отложенное с третьей ступени сопоставление со стендовой нарезкой по
`session_id`.
длительность и счетчики должны совпасть с сохраненным результатом.
Это проверит, что ваш расчет восстановил те же сессии с теми же идентификаторами.
Задача `dq_check.sessions_vs_reference` должна стать зеленой. Та же проверка
видна в хранилище:
@@ -152,33 +153,34 @@ WHERE check_name = 'sessions_vs_reference'
ORDER BY data_date;
```
Эта сверка сравнивает число сессий каждой куки за день с `VisitID`. Нулевой
`failed_rows` не подтверждает границы, страницы и UTM: две ошибки могут
оставить тот же счет. Возьмите поштучную сверку из третьей ступени. Для одной
куки выбранного дня сопоставьте границы `VisitID` по событиям с границами
строк `dds.session_v`. По исходным событиям проверьте страницы, UTM, устройство
Эта сверка сравнивает число сессий каждого `client_id` за день с `VisitID`.
При `failed_rows = 0` число сессий совпало, но их границы, страницы и UTM
еще могут различаться. Как на третьей ступени, проверьте отдельные сессии.
Для одного `client_id` выбранного дня найдите начало и конец каждого
`VisitID` по событиям и сравните с границами строк `dds.session_v`.
По исходным событиям проверьте страницы, UTM, устройство
и город. Страницы и UTM не входят в сохраненные десять строк: готовый SQL
стенда выбирал их через `argMin` и `argMax` только по `event_time`. При равном
времени ожидаемое значение определяйте по исходным событиям и полному порядку
`(event_time, event_id)`.
Осталась физическая раскладка. Напишите отдельный запрос к
`dds.session_dist`. Для каждой строки он должен:
Проверьте также, на каком шарде лежит каждая строка. Напишите отдельный
запрос к `dds.session_dist`. Он должен:
1. получить фактическую ноду через `hostName()`;
2. пересчитать ожидаемую ноду по `cityHash64(client_id)`;
3. посчитать строки, у которых ноды не совпали.
1. получить фактический узел строки через `hostName()`;
2. определить ожидаемый узел по `cityHash64(client_id)`;
3. посчитать строки, у которых фактический и ожидаемый узлы не совпали.
У кластера два равных шарда: остаток 0 соответствует `clickhouse-01`, остаток
1 — `clickhouse-02`. Сгруппируйте ответ по фактической ноде. На каждой ноде
1 — `clickhouse-02`. Сгруппируйте ответ по фактическому узлу. На каждом узле
число строк на чужом шарде должно быть нулевым. Проверка числа сессий читает
Distributed-таблицу и такую ошибку не заметит.
После зеленого результата запустите `etl_pipeline` еще раз, не удаляя
партицию. Расчет объема должен пропустить группу сессий: новых прожитых дней и
дыр в слое нет.
партицию. Группа сессий должна быть пропущена: все завершившиеся дни уже
рассчитаны, новых дней и пропусков нет.
## Что должно получиться
## Результат работы
- [ ] вы переписали три файла `sql/dds/session_*.sql`, сохранив даг,
таблицы и проверку стенда;
@@ -186,6 +188,6 @@ Distributed-таблицу и такую ошибку не заметит.
- [ ] вы сверили десять сохраненных строк: `session_id`, границы,
длительность и счетчики совпали после пересборки;
- [ ] вы получили ноль расхождений в `sessions_vs_reference` за все
прожитые дни и вручную сверили границы и атрибуты сессии одной куки;
прожитые дни и вручную сверили границы и поля сессии одного `client_id`;
- [ ] ваш запрос с `hostName()` не нашел строк на чужом шарде;
- [ ] повторный прогон без удаления данных пропустил группу сессий.
+38 -36
View File
@@ -1,9 +1,10 @@
# Научите проверки замечать ошибки в заказах
# Проверки качества заказов
**Чему учит:** написать проверки заказов до изменения их обработки
и убедиться на намеренной ошибке, что проверки ее замечают.
Перед изменением обработки заказов вы добавите две проверки качества данных.
Затем намеренно испортите один заказ и убедитесь, что проверки обнаружили
ошибки.
## Сначала прочитать
## Подготовка
- [модель заказа DDS](../architecture/dds/order.md);
- [дневной конвейер](../architecture/etl.md#объём-прогона);
@@ -13,30 +14,29 @@
и на ее включение в [`dq_check`](../../dags/dq_check.py). Готового ответа для
двух проверок из этого задания в репозитории нет.
## Что предстоит сделать
## Новые проверки
Добавьте две проверки и включите их в даг:
- `sql/dq/order_items_total.sql` проверяет сумму позиций заказа;
- `sql/dq/orders_vs_ods.sql` сверяет DDS с его источником;
- `dags/dq_check.py` запускает обе проверки, публикует их строки в
- `dags/dq_check.py` запускает обе проверки, записывает их результаты в
`dm.dq_summary` и создает для них отдельные задачи.
Разбор ODS и сборку DDS вы перепишете в следующих заданиях. Здесь сохраните
их, даги загрузки, DDL, таблицы-доноры, Kafka и службы как есть.
их, даги загрузки, DDL, промежуточные таблицы, Kafka и службы как есть.
Перед опытами остановите автоматическое движение мира: поставьте `world_live`
на паузу и дождитесь завершения активных прогонов. До завершения опытов не
запускайте руками `world_next_day`, `world_live_day`, `world_initialize` и
`world_recreate`. Это условие стабильного модельного времени: одной паузы
`world_live` для него мало.
`world_recreate`, чтобы новые данные не меняли результат между проверками.
## Что будут проверять ваши запросы
## Требования к запросам
Чтобы `dq_check` мог запустить проверку, ее запрос возвращает по одной строке
на день с колонками `data_date`, `reference_rows`, `actual_rows`,
`failed_rows`, `diagnostics`. Даг сохраняет в `dm.dq_summary` только счетчики,
статус и координаты запуска. Примеры из `diagnostics` ищите в
статус и сведения о запуске. Примеры из `diagnostics` ищите в
журнале упавшей задачи Airflow. Ограничьте их тем же малым числом, что и
существующие запросы в `sql/dq/`.
@@ -44,46 +44,48 @@
Имя проверки - `order_items_total`. Для каждого заказа из `dds.order_v`
посчитайте сумму `item_price * item_quantity` и сравните ее с `items_total`.
`reference_rows` считает ключи со стороны `items_total`, `actual_rows` - ключи
со стороны вычисленной суммы, а `failed_rows` - несовпавшие значения. В
исправном наборе первые два счетчика равны; несовпадения сумм считает
`failed_rows`. Для диагностики достаточно `order_id`, ожидаемой суммы и
суммы позиций.
В `reference_rows` запишите число заказов со стороны `items_total`,
в `actual_rows` число заказов со стороны вычисленной суммы, в `failed_rows`
число заказов, у которых суммы не совпали. На исправных данных первые два
счетчика равны, а `failed_rows` равен нулю. Для примеров расхождений
достаточно `order_id`, значения `items_total` и вычисленной суммы позиций.
Проверка относится к типизированному разбору массива `items` на границе
ODS -> DDS. Скидку, доставку и равенство `total` здесь проверять не нужно.
Проверка поможет заметить ошибки разбора `items` при переносе заказа
из ODS в DDS. Скидку, доставку и равенство `total` здесь проверять не нужно.
### DDS против ODS
### Полнота заказов в DDS
Имя проверки - `orders_vs_ods`. Источник модели DDS здесь - текущее состояние
`ods.order_v`. С ним вы и сравниваете DDS; обращаться к внутреннему состоянию
генератора для этой проверки не нужно.
Одним запросом соберите обе стороны на зерне дня заказа и сравните:
Одним запросом рассчитайте итоги каждого дня заказа в обоих слоях и сравните:
- число заказов;
- максимальный `updated_at`.
День ODS вычисляется в `Europe/Samara`, как `order_date` в DDS. Область
проверки - объединение дней обеих сторон, включая день, который полностью
пропал из одного слоя. Перед сравнением приведите времена к одному часовому
поясу. `reference_rows` - число заказов ODS, `actual_rows` - число заказов DDS,
а `failed_rows` равен единице для дня, где расходится хотя бы одно значение.
День заказа в ODS вычисляйте в `Europe/Samara`, как `order_date` в DDS.
Проверьте все дни, которые есть хотя бы в одном слое: так вы заметите день,
целиком пропавший из другого. Перед сравнением приведите времена к одному
часовому поясу. `reference_rows` содержит число заказов ODS, `actual_rows`
число заказов DDS. В `failed_rows` запишите 1, если за день расходится
хотя бы одно значение, и 0, если оба совпали.
Обе стороны распределены по `order_id`. Дневные агрегаты соедините через
`GLOBAL FULL OUTER JOIN` с `join_use_nulls = 1`, как в существующих сверках.
Если разбор ODS и сборка DDS одинаково потеряют строку, слои останутся равны
по этой проверке. Общая потеря при разборе проявится в отдельном балансе по
`_load_id` из [следующего задания](5-2-order-parsing.md).
Если строка потерялась при разборе в ODS, она не попадет и в DDS. Эта проверка
не заметит потерю: данные слоев совпадут. В [следующем задании](5-2-order-parsing.md)
вы отдельно сравните число исходных строк с числом разобранных заказов
и ошибок за один запуск, обозначенный `_load_id`.
## Получите красный и зеленый результаты
## Проверка на намеренных ошибках
Сначала запустите `dq_check` на нетронутых данных. Обе новые задачи должны
завершиться успешно, а в `dm.dq_summary_v` у обоих имен не должно быть
нарушенных дней.
дней с расхождениями.
Выберите оплаченный заказ из последнего слепка с непустым массивом позиций и
сохраните его `order_id` и `order_date`. Подключитесь к первой ноде из DBeaver
сохраните его `order_id` и `order_date`. Подключитесь к первому узлу из DBeaver
по [инструкции из README](../../README.md#подключиться-из-dbeaver) под
пользователем `etl`. Прибавьте одну копейку к `items_total` этого заказа в
локальной таблице `dds.order_rep` на всем кластере:
@@ -102,7 +104,7 @@ SETTINGS mutations_sync = 2;
Запустите `etl_pipeline`. Текущее окно заказов будет заново собрано из ODS, и
исходная строка вернется в DDS. Повторный `dq_check` должен дать зеленый
результат для нового сторожа.
результат для новой проверки.
Теперь удалите тот же заказ из `dds.order_rep` на всем кластере:
@@ -118,9 +120,9 @@ SETTINGS mutations_sync = 2;
Если `orders_vs_ods` не обнаружил удаление, исправьте запрос до следующего
задания.
## Как проверить себя
## Проверка результата
В `dm.dq_summary_v` должны быть зелены обе новые и пять прежних проверок:
В `dm.dq_summary_v` обе новые и пять прежних проверок должны быть успешны:
```sql
SELECT check_name, sum(failed_rows) AS failed_rows, groupUniqArray(status)
@@ -132,12 +134,12 @@ ORDER BY check_name;
Проверка суммы должна охватывать каждый день `dds.order_v`. Сверка слоев
должна охватывать объединение дней ODS и DDS, включая дни только одной стороны.
## Завершите опыт
## Завершение работы
После восстановления заказа и успешного `dq_check` можно закончить работу или
перейти к разбору заказов, оставив модельное время на паузе.
## Что должно получиться
## Результат работы
- [ ] две новые проверки запускаются в `dq_check`, а их дневные результаты
появляются в `dm.dq_summary`;
+42 -40
View File
@@ -1,9 +1,10 @@
# Перепишите разбор заказов в ODS
# Разбор заказов в ODS
**Чему учит:** переписать разбор заказов и на пробных сообщениях проверить,
что годные строки и брак попадают в свои таблицы без потерь.
Вы перепишете разбор заказов в ODS. На четырех пробных сообщениях проверите,
что правильно заполненный заказ и сообщения с ошибками попадают
в соответствующие таблицы без потерь.
## Сначала прочитать
## Подготовка
- [проверки заказов](5-1-order-checks.md);
- [прием заказов в ODS](../architecture/orders/ingestion.md);
@@ -11,24 +12,22 @@
- [`orders_ingest`](../../dags/orders_ingest.py);
- [`order_parse.sql`](../../sql/ods/order_parse.sql).
Запрос для проверки пробных строк вы напишете сами.
## Что предстоит сделать
## Порядок работы
Сначала проведите опыт на готовом `order_parse.sql`. Затем удалите его SQL,
соберите разбор заново и повторите опыт с другим маркером.
Не меняйте устройство `orders_ingest`, DDL, Kafka, таблиц и преобразований
DDS. Оба опыта должны закончиться адресной очисткой только своих строк.
Сохраните устройство `orders_ingest`, DDL, Kafka, таблиц и преобразований
DDS. После каждого опыта удалите только добавленные вами строки.
Перед началом убедитесь, что после [предыдущего задания](5-1-order-checks.md)
все семь DQ остались зелеными. Поставьте `world_live` на паузу, дождитесь
все семь проверок качества прошли успешно. Поставьте `world_live` на паузу, дождитесь
активных прогонов и до завершения опыта не запускайте `world_next_day`,
`world_live_day`, `world_initialize` и `world_recreate`. Не запускайте
`etl_pipeline` между
отправкой пробной пачки и ее очисткой.
## Четыре строки для двух ветвей
## Пробные сообщения
Сделайте четыре сообщения с общим уникальным маркером:
@@ -39,9 +38,9 @@ DDS. Оба опыта должны закончиться адресной оч
`field_invalid`.
`not_an_object` означает, что корень JSON не объект. `keyset_mismatch` означает,
что у объекта отличается точный набор верхнеуровневых ключей. `field_invalid`
означает, что ключи на месте, но значение не прошло проверку типа, формата или
вместимости.
что у объекта есть лишние верхнеуровневые ключи или не хватает обязательных.
`field_invalid` означает, что ключи на месте, но значение имеет неверный тип,
формат или не помещается в заданный тип данных.
Соберите строки в `/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"
```
Запустите `orders_ingest` и найдите `_load_id` по маркеру в
`stg.orders_raw_dist`. У четырех строк должен быть один идентификатор прогона.
Запустите `orders_ingest`. Найдите свои строки по маркеру в
`stg.orders_raw_dist` и сохраните их `_load_id`: это идентификатор запуска,
который принял сообщения. Он должен быть одинаковым у всех четырех строк.
## Зафиксируйте исходный результат
## Проверка готового разбора
До любого запуска `etl_pipeline` проверьте срез по найденному `_load_id`:
До запуска `etl_pipeline` проверьте строки с найденным `_load_id`:
- в STG четыре строки;
- в физической `ods.order_dist` одна годная строка;
- в таблице `ods.order_dist` одна годная строка;
- в `ods.order_errors_dist` три строки, по одной каждого класса;
- `STG = годные + брак`;
- текущая строка `ods.order_v` совпадает по деловым полям с результатом
`argMax` по `snapshot_date` из годного сырья среза.
- поля заказа в текущей строке `ods.order_v` совпадают с полями из годного
исходного сообщения; последнюю версию для сравнения выберите через
`argMax` по `snapshot_date`.
Счет физической таблицы нужен именно по `_load_id`: представление
`ods.order_v` оставляет текущую версию сущности и не показывает, сколько строк
разобрал запуск. Запрос сверки с `argMax` напишите сами. Эталонные деловые поля
стройте только из `raw`; отдельно сравните различающиеся `created_at` и
`updated_at`.
Считайте строки `ods.order_dist` именно по `_load_id`: представление
`ods.order_v` оставляет только текущую версию каждого заказа и не показывает,
сколько строк разобрал запуск. Запрос сверки с `argMax` напишите сами.
Значения для сравнения извлеките из `raw` пробных сообщений. Отдельно
сравните `created_at` и `updated_at`: в заготовке они намеренно различаются.
Проверьте раскладку годной строки через `hostName()` на `ods.order_dist`.
Фактическая нода должна совпасть с остатком `cityHash64(order_id) % 2`:
Узел, на котором лежит строка, должен соответствовать остатку `cityHash64(order_id) % 2`:
0 - `clickhouse-01`, 1 - `clickhouse-02`.
## Уберите пробную пачку
## Удаление пробных сообщений
Удалите только строки маркера из локальных таблиц:
@@ -135,40 +136,41 @@ SETTINGS mutations_sync = 2;
запустите `etl_pipeline`: пересборка текущего окна удалит заказ из DDS и
обновит витрины.
## Перепишите разбор
## Собственный разбор заказов
Удалите готовый SQL из `sql/ods/order_parse.sql` и напишите свой. Чтобы
`orders_ingest` мог его использовать, сохраните следующие условия:
- вход - строки `stg.orders_raw_dist` одного `{load_id:String}`;
- каждая строка среза попадает ровно в один результат: годный заказ или брак;
- произвольный `raw` не останавливает весь разбор исключением;
- запрос читает строки `stg.orders_raw_dist` с переданным `{load_id:String}`;
- каждая прочитанная строка попадает ровно в один результат: годный заказ или брак;
- ошибка в `raw` одной строки не прерывает разбор остальных;
- годные строки записываются в `ods.order_dist`, остальные - в
`ods.order_errors_dist`;
- классы брака и их порядок - `not_an_object`, `keyset_mismatch`,
`field_invalid`;
- метки доставки и `_load_id` переходят из STG без пересчета;
- задача завершается после доставки обоих результатов через Distributed, а
строки распределяются по ключу целевой таблицы.
- задача завершается после записи и заказов, и ошибок на шарды через
Distributed; строки распределяются по ключу соответствующей целевой таблицы.
Точный набор полей, типы и граница строгого приема описаны в
Точный набор полей, типы и условия проверки значений описаны в
[документе приема](../architecture/orders/ingestion.md#граница-строгого-приёма),
а целевые колонки - в [`20-ods-tables.sql`](../../sql/ddl/20-ods-tables.sql).
Внутренность `items` остается сырым JSON: ее разбирает DDS.
Содержимое `items` сохраните как JSON: его разбирает DDS.
Повторите опыт с новым маркером. Получите тот же баланс 4 = 1 + 3, те же три
класса, верную текущую строку по `snapshot_date`, разные времена и правильный
шард. Затем очистите второй срез теми же адресными командами и запустите
класса, верную текущую версию заказа по `snapshot_date`, разные значения
`created_at` и `updated_at` и правильный шард. Затем удалите строки второго
опыта теми же командами и запустите
`dq_check`.
## Завершите опыт
## Завершение работы
Убедитесь, что оба маркера отсутствуют в STG, ODS, таблице брака и DDS,
временные файлы удалены, а семь проверок успешны. Теперь можно перейти к
сборке DDS, не снимая паузу с модельного времени. Если вы прерываетесь и до
опыта `world_live` работал, снимите его с паузы.
## Что должно получиться
## Результат работы
- [ ] у вас сохранены результаты до переписывания: четыре строки дали
один заказ и три класса брака; вы сверили поля текущей версии и шард;
+49 -40
View File
@@ -1,9 +1,10 @@
# Перепишите сборку заказов в DDS
# Сборка заказов в DDS
**Чему учит:** настроить пересборку заказов по состоянию ODS и DDS
и проверить, что она восстанавливает пропущенный день за пределами текущего окна.
Вы перепишете загрузку заказов из ODS в DDS и выбор дней для пересчета.
Затем удалите данные за один из прошлых дней и проверите, что ваша
загрузка восстановила их.
## Сначала прочитать
## Подготовка
- [проверки заказов](5-1-order-checks.md);
- [разбор заказов в ODS](5-2-order-parsing.md);
@@ -11,7 +12,7 @@
- [дневной конвейер](../architecture/etl.md#объём-прогона);
- [раскладку по шардам](../architecture/storage.md#раскладка-по-шардам).
## Что предстоит сделать
## Файлы для изменения
Удалите готовый SQL и перепишите только три файла:
@@ -19,18 +20,18 @@
- `sql/dds/order_rebuild.sql`;
- `sql/dds/order_replace.sql`.
Даг `dds_transform`, DDL, донор, ODS и обе проверки из первого задания
Даг `dds_transform`, DDL, промежуточную таблицу, ODS и обе проверки из первого задания
оставьте как есть. Перед началом убедитесь, что пробные строки предыдущего
задания удалены, а семь DQ дают зеленый результат.
задания удалены, а семь проверок качества дают зеленый результат.
Поставьте `world_live` на паузу и дождитесь завершения активных прогонов. До
завершения опыта не запускайте `world_live_day`, `world_initialize` и
`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_replace.sql` | день в `{{ params.day }}` | партиция дня в `dds.order_rep` |
Объем состоит из текущего окна источника и хвоста от первого дня, который есть
в ODS, но отсутствует в DDS. Текущее окно дают строки последнего
`snapshot_date`: их минимальный и максимальный день заказа. Если источник
пуст, `order_scope.sql` не возвращает строку. Если дыр нет, `first_day` равен
началу окна. Если дыра старше, непрерывный отрезок начинается с нее.
Сначала определите дни, за которые заказы еще могут измениться. Возьмите
строки с последним `snapshot_date` и найдите минимальную и максимальную
дату заказа. Этот диапазон далее называется текущим окном источника.
Затем найдите первый день, который есть в ODS, но отсутствует в DDS.
Если он раньше начала текущего окна, начните расчет с него. Иначе начните
с начала окна. Полученная дата станет `first_day`, а конец окна `last_day`.
Один запуск пересчитывает все дни между ними. Если источник пуст,
`order_scope.sql` не возвращает строку.
`order_rebuild.sql` сначала очищает все партиции `dds.order_stage_rep` на
кластере, затем записывает в `dds.order_stage_dist` весь выбранный отрезок. Он
читает текущее состояние `ods.order_v`, вычисляет `order_date`, переводит
времена в `Europe/Samara`, разбирает `items` в три параллельных массива и
ставит координаты запуска.
заполняет служебные поля запуска.
Запись через Distributed должна завершиться до замены и использовать ключ
целевой таблицы для раскладки строк. Способ собрать весь отрезок внутри файла
выберите сами.
Дождитесь записи всех строк на шарды через Distributed перед заменой партиций.
Строки должны распределяться по ключу целевой таблицы. Способ рассчитать
выбранные дни внутри файла выберите сами.
`order_replace.sql` работает с локальными `dds.order_rep` и
`dds.order_stage_rep` на всем кластере. `REPLACE PARTITION` переносит готовую
дневную партицию из донора и оставляет донор на месте. Структура, ключи и
политика хранения совпадают: DDL объявляет донор через `AS dds.order_rep`.
`dds.order_stage_rep` на всем кластере. `REPLACE PARTITION` заменяет дневную
партицию основной таблицы готовой партицией из промежуточной. Промежуточная
партиция при этом сохраняется. Структура, ключи и политика хранения таблиц
совпадают: DDL создает промежуточную через `AS dds.order_rep`.
## Восстановите ближайший закрытый день
## Восстановление пропущенного дня
Найдите последний по дате день DDS, который лежит левее начала текущего окна
ODS. Это ближайший к окну закрытый день: его удаление показывает расширение
объема на дыру без пересборки всей истории.
Найдите самый поздний день DDS перед началом текущего окна ODS. Заказы за этот
день уже не меняются, поэтому он считается закрытым. Удалив его, вы проверите,
что ваш запрос заметил пропуск и включил этот день в расчет.
Если такого дня еще нет, один раз продвиньте мир через `world_next_day`,
дождитесь `orders_ingest`, затем запустите `etl_pipeline`. После этого снова
зафиксируйте модельное время до конца опыта и выберите ближайший закрытый день.
оставьте модельное время неизменным до конца опыта и выберите этот день.
Сохраните для него число заказов и контрольную сумму деловых полей без
`_load_id` и `_load_ts`. Удалите только его партицию:
Сохраните для него число заказов и контрольную сумму их полей, исключив
служебные `_load_id` и `_load_ts`. Контрольная сумма позволит проверить,
что после восстановления значения остались прежними. Удалите только
партицию выбранного дня:
```sql
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
@@ -90,29 +98,30 @@ DROP PARTITION 'YYYY-MM-DD';
прогона число заказов и контрольная сумма выбранного дня должны совпасть с
сохраненными.
## Проверьте раскладку и повтор
## Распределение по шардам и повторный запуск
Напишите отдельный запрос к `dds.order_dist`, который получает фактическую
ноду через `hostName()`, вычисляет ожидаемую по `cityHash64(order_id) % 2` и
считает несовпадения по каждой ноде. Остаток 0 соответствует
`clickhouse-01`, остаток 1 - `clickhouse-02`. На обеих нодах число строк на
чужом шарде должно быть нулевым. Счетные DQ читают Distributed-таблицы и такую
ошибку раскладки не обнаруживают.
Напишите отдельный запрос к `dds.order_dist`, который получает фактический
узел каждой строки через `hostName()`, вычисляет ожидаемый по
`cityHash64(order_id) % 2` и считает несовпадения по каждому узлу.
Остаток 0 соответствует `clickhouse-01`, остаток 1 - `clickhouse-02`.
На обоих узлах число строк на чужом шарде должно быть нулевым.
Проверки количества строк читают данные со всех шардов через Distributed
и такую ошибку не обнаруживают.
Запустите `etl_pipeline` еще раз при неизменном модельном времени. Группа
заказов снова должна пересобрать текущее изменяемое окно. Контрольные суммы
деловых полей остаются прежними, а все семь проверок дают зеленый результат.
полей заказов остаются прежними, а все семь проверок дают зеленый результат.
## Завершите опыт
## Завершение работы
После восстановления партиции и успешных проверок снимите `world_live`
с паузы, если он работал до упражнения.
## Что должно получиться
## Результат работы
- [ ] вы переписали три файла `sql/dds/order_*.sql`, сохранив остальные
части стенда;
- [ ] вы удалили ближайший к окну закрытый день и увидели ошибку DQ до
- [ ] вы удалили ближайший к окну закрытый день и увидели ошибку проверки до
восстановления;
- [ ] ваш расчет выбрал `first_day` с удаленного дня и заменил партиции
всего отрезка до конца окна;
@@ -120,4 +129,4 @@ DROP PARTITION 'YYYY-MM-DD';
с сохраненными значениями; они совпали;
- [ ] `order_items_total`, `orders_vs_ods` и пять прежних проверок успешны;
- [ ] ваш запрос с `hostName()` не нашел заказов на чужом шарде;
- [ ] повторный `etl_pipeline` сохранил деловые данные, проверки прошли.
- [ ] после повторного `etl_pipeline` поля заказов остались прежними, проверки прошли.
+60 -54
View File
@@ -1,9 +1,9 @@
# Разберите сырые события рядом со стендом
# Разбор сырых событий
**Чему учит:** пакетно разобрать сообщения отдельно от живого приема и
построчно проверить свой результат по нетронутому ODS.
В этой работе вы разберете сохраненные сообщения о событиях в свою таблицу.
Каждую полученную строку сравните с результатом обработки в стенде.
## Сначала прочитать
## Подготовка
- [формат события](../formats/clickstream-event.md);
- [справочник хранилища](../architecture/storage.md), разделы про имена
@@ -15,26 +15,26 @@
Готовый стендовый разбор находится в `sql/ddl/30-ods-views.sql`. Откройте его
после собственной сверки: в нем лежит ответ на основную часть задания.
## Что предстоит сделать
## Порядок работы
Создайте в базе `default` одну свою таблицу годных событий и наполните ее одним
пакетным `INSERT SELECT` из `stg.hits_raw_dist`. Работайте с первой нодой под
пользователем `etl`.
Создайте в базе `default` свою таблицу для правильно заполненных событий.
Выберите небольшой набор сообщений из `stg.hits_raw_dist`, разберите их
и загрузите в таблицу одним `INSERT SELECT`. Работайте с первым узлом под
пользователем `etl`. Затем сравните результат с событиями в ODS.
Матвью `ods.event_mv` и `ods.event_errors_mv` продолжают принимать живой
поток. Оставьте их, STG, ODS, Kafka и даги без изменений. Пересоздание матвью
на работающем стенде оставило бы зазор в приеме, а для пакетного опыта оно не
нужно.
Материализованные представления `ods.event_mv` и `ods.event_errors_mv`
продолжают обрабатывать поступающие сообщения. Оставьте их, STG, ODS, Kafka
и даги без изменений. Если пересоздать представления на работающем стенде,
часть сообщений может остаться без обработки.
Дайте своей таблице имя, которое не пересечется с чужой работой, и запишите
его вместе с запросами. В конце вы удалите только эту таблицу.
его вместе с запросами.
## Выберите сырой срез
## Выбор исходных сообщений
STG хранит строки примерно три дня по `_load_ts`. Эта колонка отмечает время
поступления в хранилище. Модельный день события лежит в `EventDate`. Сначала
найдите среди недавних данных завершившийся интервал со строками. Посмотрите
счет по минутам:
поступления в хранилище. Модельный день события лежит в `EventDate`.
Посмотрите, сколько строк поступило за каждую из последних минут:
```sql
SELECT
@@ -48,7 +48,8 @@ ORDER BY minute DESC;
```
Выберите короткий интервал `[slice_from, slice_to)`, который уже закончился и
дает удобное для построчной проверки число строк. Если одна минута велика,
дает удобное для построчной проверки число строк. Этот набор строк дальше
называется срезом. Если за одну минуту пришло слишком много строк,
сузьте ее точными границами `_load_ts`. Если недавних строк нет, откройте
Airflow. При включенном `world_live` посмотрите его текущий запуск и дочерний
`world_live_day`: дождитесь свежих строк от текущего или следующего планового
@@ -64,9 +65,9 @@ Airflow. При включенном `world_live` посмотрите его т
Во всех дальнейших запросах читайте `stg.hits_raw_dist` только с этими
границами. Новые строки с более поздним `_load_ts` в срез не попадут. Повторите
опыт до очистки по TTL; после нее прежний срез уже не восстановить из ODS.
опыт до удаления исходных строк по TTL; после него прежний срез уже не восстановить из ODS.
## Соберите собственную таблицу
## Таблица для результата
Таблица содержит все 47 полей источника с именами и типами
`ods.event_rep`, а также `_load_ts`. Тип `purchaseRevenue` возьмите из ODS:
@@ -75,14 +76,15 @@ Airflow. При включенном `world_live` посмотрите его т
Обработайте повторные доставки так же, как ODS. Возьмите
`ReplacingMergeTree(_load_ts)`, партицию `EventDate` и полный ключ замены:
`(CounterID, EventDate, intHash32(ClientID), WatchID)`. Достаточно одной
локальной таблицы: загрузка и сверка в этом опыте выполняются на одной ноде.
локальной таблицы: загрузка и сверка в этом опыте выполняются на одном узле.
`WatchID` — логический идентификатор события, уникальный по
[договору генератора](../specs/2026-08-01-generator.md). Физический ключ ODS
шире: в `ReplacingMergeTree` версиями считаются строки с одинаковым полным
ключом сортировки. Используйте его при загрузке и сверке.
`WatchID` обозначает событие и уникален по
[правилам генератора](../specs/2026-08-01-generator.md). Но `ReplacingMergeTree`
считает версиями строки, у которых совпадает весь ключ сортировки.
Поэтому для загрузки и сверки используйте все четыре части ключа ODS,
перечисленные выше.
## Разберите годные строки
## Разбор сообщений
Для каждой строки среза проверьте те же условия приема, что и на стенде:
@@ -92,8 +94,10 @@ Airflow. При включенном `world_live` посмотрите его т
в ожидаемые типы.
Напишите разбор, который не прерывает пакет из-за одной грязной строки. Сами
выберите способ безопасного извлечения пяти опорных полей, форму общих выражений
и место фильтра. Остальные поля извлеките по описанию формата.
выберите, как извлечь пять перечисленных полей без ошибки запроса, какие
выражения вынести в общую часть и где отфильтровать неподходящие строки.
Эти пять полей дальше называются опорными. Остальные поля извлеките
по описанию формата.
Перенесите `_load_ts` из STG без пересчета: это время исходной доставки и
колонка версии. Время запуска вашего запроса сюда не входит. Вставьте в свою
@@ -114,25 +118,25 @@ Airflow. При включенном `world_live` посмотрите его т
условия отбора на этом срезе, но не классификатор ошибок. Подкладывать грязные
сообщения ради задания не требуется.
## Приведите стороны к одному зерну
## Подготовка к сравнению
### Учтите повторные доставки
### Повторные доставки
В STG одна строка представляет доставку. Повтор сообщения остается второй
строкой. В `ods.event_v` одна строка представляет актуальное событие, потому
что представление читает физическую таблицу с `FINAL`.
Свою сторону тоже читайте с `FINAL`. Ожидаемый счет равен числу различных
полных ключей среди годных доставок. Разница между числом доставок и этим
счетом показывает повторы в исходном срезе.
Свою таблицу тоже читайте с `FINAL`. Число строк должно совпасть с числом
различных полных ключей среди годных исходных сообщений. Разница между числом
годных доставок и этим числом показывает повторы в исходном срезе.
### Выберите события ODS для сравнения
### События ODS для сравнения
Теперь нужно найти в ODS события из выбранного среза. Список стройте по
исходным доставкам: если взять его из своей таблицы, ошибочно отвергнутое
событие исчезнет с обеих сторон сверки и потеря останется незамеченной.
Снова возьмите весь исходный STG-срез. По координатам Kafka исключите
Снова возьмите все выбранные строки STG. По координатам Kafka исключите
доставки, которые есть в `ods.event_errors_dist`. У оставшихся извлеките
`WatchID` без риска прервать запрос и выберите эти события из свежего
`ods.event_v`. Свое условие отбора здесь не применяйте: решение о годности
@@ -141,36 +145,38 @@ Airflow. При включенном `world_live` посмотрите его т
`WatchID` связывает доставку с событием в ODS. Для удаления повторов и
сравнения полей дальше используйте полный физический ключ из схемы таблицы.
## Сверьте 47 полей
## Сверка полей
Соедините свою таблицу и выбранные строки `ods.event_v` через `FULL OUTER
JOIN` по полному ключу замены. Полное соединение сохраняет строки,
которых нет на одной из сторон. Разделите результат на три исхода:
которых нет на одной из сторон. Найдите три вида расхождений:
- строка есть только в ODS — вы ее потеряли;
- строка есть только у вас — вы приняли лишнее или иначе разобрали часть
ключа;
- ключ есть с обеих сторон, но различается хотя бы одно из 47 полей.
ClickHouse по умолчанию заполняет отсутствующую сторону значениями типов.
ClickHouse по умолчанию заполняет отсутствующую сторону значениями по умолчанию
для соответствующих типов, например нулем или пустой строкой.
Включите для сверки `join_use_nulls = 1` или добавьте явный признак присутствия
в каждую сторону. Не определяйте отсутствие по значениям полей события.
Сравните все поля явно, включая массивы, `ecommerce` и точное десятичное
`purchaseRevenue`. Удобный вид диагностики выберите сами: важно, чтобы по
результату можно было назвать отличающиеся колонки одной строки. Выведите число
строк каждого из трех исходов. В верном разборе все три числа равны нулю.
`purchaseRevenue`. Выберите, как показать расхождения, чтобы было видно,
какие колонки различаются в конкретной строке. Выведите число строк каждого
из трех видов расхождений. В верном разборе все три числа равны нулю.
`_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
## Ограничения проверки
`ods.event_v` — образец для сравнения. Оба разбора могли повторить одну
ошибку. Для пяти опорных полей у вас есть проверка преобразования в нужный тип
и исходный JSON. Остальные 42 поля можно вручную сопоставить с `raw`, однако
второго независимого источника для всех значений на стенде нет.
## Уберите свой объект
## Сохранение результата и удаление таблицы
Сохраните DDL таблицы, запрос разбора, границы среза, число исходных, годных
и отвергнутых строк и итоговую сверку без расхождений. Запишите также, чего
не подтверждает сравнение с ODS и как TTL ограничивает время для повторения
опыта. Затем удалите свою таблицу командой `DROP TABLE` с ее точным именем.
Убедитесь через `system.tables`, что объекта больше нет.
Прикладные строки стенда вы не меняли, поэтому удалять данные из STG, ODS или
Данные стенда вы не меняли, поэтому удалять строки из STG, ODS или
Kafka после опыта не нужно.
## Что должно получиться
## Результат работы
- [ ] у вас сохранены границы выбранного среза, число строк и координаты
доставки по партициям Kafka;
+32 -28
View File
@@ -1,9 +1,10 @@
# Мониторинг: куда смотреть
**Чему учит:** начать расследование с подходящего экрана, найти источник
нагрузки и объяснить свежесть данных через путь доставки.
Вы познакомитесь с дашбордами мониторинга и проведете два расследования:
найдете запросы, которые создают нагрузку, и выясните, почему события сайта
и заказы обновляются с разной частотой.
## Перед началом
## Знакомство с дашбордами
Откройте Grafana и Airflow по адресам из раздела [«Адреса и
пароли»](../../README.md#адреса-и-пароли). Для первого знакомства в Grafana
@@ -11,7 +12,7 @@
- «Данные» — путь сообщений от источника до хранилища;
- «Кластер» — процессор, память, диски и внутренняя работа ClickHouse;
- «Запросы» — выполненные запросы, их стоимость и ошибки.
- «Запросы» — выполненные запросы, затраченные на них ресурсы и ошибки.
Откройте все три дашборда и запишите по одному вопросу, на который каждый из
них помогает ответить. «Данные» оставьте открытым в соседней вкладке.
@@ -19,7 +20,7 @@
Проведите два расследования на работающем стенде. Службы останавливать и данные
портить не понадобится.
## Расследование 1. Кто читает больше всех
## Расследование 1. Какие запросы читают больше всего данных
Начните в спокойный момент: не запускайте одновременно свои тяжелые запросы.
Откройте дашборд «Запросы». На панели «Тяжёлые запросы: что прочитало больше
@@ -27,8 +28,8 @@
Сохраните:
- границы окна и время запроса;
- место запроса в списке, пользователя и ноду;
- начало и конец выбранного периода, время запроса;
- место запроса в списке, пользователя и узел;
- текст запроса;
- длительность, память, число прочитанных и отданных строк.
@@ -44,22 +45,25 @@
В меню панели «Кто спрашивает: сколько запросов и какой у них аппетит»
откройте Inspect, затем Query. Ответьте на два вопроса:
1. Почему журнал читают сразу с обеих нод?
2. Почему из счета исключены строки начала запроса и работа шардов?
1. Почему журнал читают сразу с обоих узлов?
2. Почему при подсчете запросов исключают записи о начале выполнения и
записи о выполнении частей запроса на шардах?
Проверьте второе объяснение по панели ошибок: ее фильтр не исключает записи
шардов. Объясните, почему различаются вопросы панелей, а не только их SQL.
шардов. Объясните, на какие разные вопросы отвечают эти две панели
и зачем им разные условия отбора.
## Расследование 2. Как меняется свежесть заказов
Откройте дашборд «Данные». Панель «Свежесть: сколько прошло с последней
доехавшей строки» показывает текущее число, а не историю. До наблюдения
нарисуйте ожидаемую форму свежести событий и заказов в течение одного живого
дня. Отдельно отметьте момент доставки слепка заказов.
доехавшей строки» показывает, сколько времени прошло с последней загрузки.
Истории значений на ней нет. До наблюдения нарисуйте, как, по вашему ожиданию,
будут меняться эти числа для событий и заказов в течение одного запуска
`world_live_day`. Отдельно отметьте ожидаемый момент доставки слепка заказов.
Если `world_live` снят с паузы, наблюдайте его текущий или следующий
`world_live_day`: новый ручной запуск встанет в очередь. Если выключатель на
паузе и активного живого дня нет, запустите один `world_live_day` вручную.
`world_live_day`: новый ручной запуск встанет в очередь. Если `world_live` на
паузе и активного `world_live_day` нет, запустите один `world_live_day` вручную.
Модельные сутки занимают около 24 реальных минут.
Запишите несколько наблюдений до конца дня и сразу после него:
@@ -69,29 +73,29 @@
| | | | |
Дашборд обновляется раз в минуту, поэтому точного совпадения момента
доставки со строкой таблицы ждать не нужно. Рост и сброс свежести заказов
перенесите на свой набросок. Объясните форму линии: какую метку времени
доставки с записью наблюдения ждать не нужно. Перенесите рост и сброс
показателя заказов на свой рисунок. Объясните форму линии: какую метку времени
вычитает панель из текущего времени, почему число растет и какое событие его
сбрасывает. Модельную дату сыгранного дня подпишите отдельно от реального
сбрасывает. Модельную дату наблюдаемого дня подпишите отдельно от реального
времени наблюдения.
Подтвердите сброс двумя следами:
Подтвердите, что сброс совпал с загрузкой заказов:
- в Airflow завершился прием слепка `orders_ingest`;
- на панели «Дневной слепок заказов: какой доехал, когда и после какой паузы»
появилась новая верхняя строка.
Если вы начали после доставки, дождитесь следующего дня либо честно запишите,
какую часть формы увидели. Одно текущее значение не доказывает ни рост, ни
Если вы начали после доставки, дождитесь следующего дня либо запишите,
какую часть изменений успели увидеть. Одно текущее значение не доказывает ни рост, ни
сброс.
## Объясните путь данных
## Источники показателей
Не открывая справочник, объясните своими словами:
- почему свежесть считают в ODS, а поток — в STG;
- почему время с последней загрузки считают по ODS, а число поступивших сообщений по STG;
- почему знаменатель доли брака берут из STG;
- почему у числителя и знаменателя доли брака одно окно.
- почему числитель и знаменатель доли брака берут за один период.
Свяжите ответы с тем, что вы видели на панелях. Затем сверьте объяснение с
[главой о мониторинге](../handbook/monitoring.md) и справочниками дашбордов
@@ -99,20 +103,20 @@
[«Данные»](../architecture/monitoring/data.md). Устройство живого дня и
доставки слепка описано в [главе об Airflow](../handbook/airflow-dags.md).
## Что должно получиться
## Результат работы
- [ ] для каждого из трех дашбордов записан вопрос, на который он отвечает;
- [ ] у вас сохранены окно, текст и показатели верхнего запроса;
- [ ] у вас сохранены выбранный период, текст и показатели верхнего запроса;
вы выяснили, кто его отправил и зачем;
- [ ] вы нашли запрос открытых дашбордов, который прочитал больше всего
строк, и панель, которая его отправила;
- [ ] вы можете объяснить, зачем журнал собирают с двух нод и какие записи
- [ ] вы можете объяснить, зачем журнал собирают с двух узлов и какие записи
учитывают при подсчете запросов;
- [ ] у вас есть прогноз свежести, сделанный до наблюдения, и несколько
замеров; рост и сброс на рисунке объяснены через время последней доставки;
- [ ] вы подтвердили доставку заказов в Airflow и на панели слепков,
подписали модельную дату отдельно от реального времени наблюдений;
- [ ] вы можете объяснить выбор слоев для свежести, потока и доли брака
и почему числитель и знаменатель берут за одно окно;
и почему числитель и знаменатель берут за один период;
- [ ] после наблюдений службы продолжают работать, данные и стендовые
дашборды остались без изменений.
+142 -96
View File
@@ -1,84 +1,121 @@
# Учебные задания
Программа состоит из двух частей: основная включает семь ступеней, которые вы
проходите по порядку, а факультативы выбираете по желанию. Причины такого
порядка и формы заданий описаны в [программе учебных лаб](../specs/2026-09-01-labs-program.md#2-устройство-программы-основная-программа-и-факультативы).
Вы будете достраивать хранилище небольшого интернет-магазина. Стенд уже
загружает события сайта и заказы, обрабатывает их и строит несколько отчетов.
Сначала вы подготовите данные для новых отчетов, затем разберетесь, как
работают расчеты и загрузка, и перепишете часть из них.
## Основная программа
Основная программа состоит из семи ступеней. Проходите их по порядку,
факультативы выбирайте по желанию. Перед первым заданием прочитайте главу
[«Хранилище и его слои»](../handbook/storage-layers.md) и выполните запросы
из нее: они покажут данные, с которыми предстоит работать.
| Ступень | Задание | Чему учит |
## Порядок заданий
| Ступень | Задания | Содержание |
|---|---|---|
| 1 | Пять витрин слоя DM | построить результат для потребителя хранилища; уроки каждой витрины — в таблице ниже |
| 2 | [Свой график в Superset](2-1-superset-chart.md) | превратить свой результат из хранилища в отчет в BI |
| 3 | [Нарежьте сессии сами](3-1-sessions-by-hand.md) | сверить число сессий в своей нарезке с эталоном и найти события, из-за которых оно расходится |
| 4 | [Перепишите нарезку стенда](4-1-session-pipeline.md) | переписать расчет сессий в готовом конвейере и проверить по исходным событиям |
| 5 | [Научите проверки замечать ошибки в заказах](5-1-order-checks.md)<br>[Перепишите разбор заказов в ODS](5-2-order-parsing.md)<br>[Перепишите сборку заказов в DDS](5-3-order-pipeline.md) | написать проверки качества до рискованного изменения, затем переписать разбор и сборку заказов |
| 6 | [Разберите сырые события рядом со стендом](6-1-event-parsing.md) | изучить разбор потока отдельно от живого приема и сверить его со стендовым результатом |
| 7 | [Мониторинг: куда смотреть](7-1-monitoring.md) | выбрать, куда и на что смотреть в живой системе |
| 1 | Пять витрин для отчетов | подготовить данные о событиях, страницах, визитах, ошибках загрузки и рекламе; задания перечислены ниже |
| 2 | [Свой график в Superset](2-1-superset-chart.md) | построить график по своей витрине и проверить числа на нем |
| 3 | [Сессии из событий](3-1-sessions-by-hand.md) | объединить действия посетителя в визиты и сравнить их с визитами источника |
| 4 | [Расчет сессий в стенде](4-1-session-pipeline.md) | включить свой расчет в готовую обработку данных и проверить результат |
| 5 | [Проверки качества заказов](5-1-order-checks.md)<br>[Разбор заказов в ODS](5-2-order-parsing.md)<br>[Сборка заказов в DDS](5-3-order-pipeline.md) | сначала написать проверки качества, затем с их помощью проверить свою обработку заказов |
| 6 | [Разбор сырых событий](6-1-event-parsing.md) | извлечь поля из сообщений и сравнить результат с готовым разбором стенда |
| 7 | [Мониторинг: куда смотреть](7-1-monitoring.md) | найти тяжелые запросы и разобраться, почему данные обновляются с разной частотой |
## Ступень 1: пять витрин слоя DM
## Ступень 1: витрины для отчетов
Слой DM в стенде построен наполовину: витрины трех сюжетов дашборда и контроль
качества уже построены, остальные пять витрин строите вы. Каждая из них —
самостоятельное задание: в нем есть состав витрины, зерно, источники и способ
проверки.
Витрина готовит данные для конкретного отчета. В стенде они находятся в базе
`dm`: это слой DM. Витрины выручки, трафика, сверки покупок с заказами и
сводка качества уже готовы. Вы добавите еще пять.
| № | Результат | Чему учит |
| № | Витрина | Содержание задания |
|---|---|---|
| 1 | [`events_enriched_v`](1-1-events-enriched.md) | подготовить удобный для анализа набор полей, сохранив одну строку на событие |
| 2 | [`top_pages_daily`](1-2-top-pages-daily.md) | создавать материализованную витрину с дневной пересборкой в конвейере |
| 3 | [`session_overview`](1-3-session-overview.md) | хранить слагаемые для расчета средних и долей |
| 4 | [`dq_errors_daily`](1-4-dq-errors-daily.md) | считать брак по дню загрузки и хранить итоги дольше источника |
| 5 | [`utm_effectiveness`](1-5-utm-effectiveness.md) | читать `declared_` как предупреждение о сумме со слов клиента |
| 1 | [События для анализа: `events_enriched_v`](1-1-events-enriched.md) | выбрать нужные поля и добавить вычисляемые, сохранив одну строку на событие |
| 2 | [Показатели страниц по дням: `top_pages_daily`](1-2-top-pages-daily.md) | сохранить итоги в таблице и настроить их обновление через Airflow |
| 3 | [Визиты по устройствам: `session_overview`](1-3-session-overview.md) | хранить суммы и счетчики для расчета средних и долей |
| 4 | [Ошибки загрузки по дням: `dq_errors_daily`](1-4-dq-errors-daily.md) | сохранить число ошибок даже после удаления исходных строк |
| 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`.** Так строки распределяются по ключу шардирования. При
записи в локальную таблицу распределение зависит от узла, выполнившего
запрос, и при следующем запуске может измениться. Подробности — раздел
«Раскладка по шардам» [справочника хранилища](../architecture/storage.md).
События читайте из `dds.event_v`, сессии из `dds.session_v`. Суффикс `_v`
обозначает обычное представление: оно выполняет сохраненный запрос при
чтении. Такие представления сохраняют привычные имена и состав колонок,
когда внутреннее устройство таблиц меняется.
**Форма — по зерну.** На стенде агрегат с новым зерном принято хранить
готовым: потребитель читает его много раз без повторного расчета по
источнику. Создайте `dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` /
`_stage_dist` и публичное `dm.<имя>_v` с явным списком колонок. При прежнем
зерне достаточно представления: таблица дублировала бы данные. Доводы —
раздел «Таблица или представление» [справочника
DM](../architecture/dm.md).
Каждая учебная витрина читает готовый источник и не зависит от других
учебных витрин. В задании 4 источниками служат таблицы ошибок ODS:
представлений для них нет.
**Ключ шардирования — ваше решение.** Объясните, почему выбранный ключ не
дает существенного перекоса и почему более сложный ничего не добавит.
Объявите донор через `AS <цель>_rep` с ключом цели: иначе замена партиции
потеряет строку на одном шарде и удвоит на другом. Детали — раздел
«Раскладка по шардам» [справочника хранилища](../architecture/storage.md).
Пример обоснования — `dm.revenue_daily_dist` в
### Таблицы и представления
В заданиях 2–5 вы группируете исходные строки. Например, в `top_pages_daily`
одна строка содержит итоги для одной страницы за день. То, чему соответствует
одна строка, называют **зерном**. На стенде итоги с новым зерном сохраняют
в таблицах. Отчеты читают готовые числа.
Новый расчет сначала записывают в промежуточную таблицу. Пока он готовится,
отчеты читают прежние данные из основной. Затем готовыми разделами
промежуточной таблицы заменяют разделы основной. Раздел называется
**партицией**; в этих заданиях он содержит данные одного дня.
Промежуточную таблицу также называют **донором**.
На примере `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).
**Где лежит код.** DDL заданий — в `sql/ddl/70-labs.sql`.
При создании мира он применяется после стендовых файлов.
В уже работающем стенде сохранение файла и повторный `make up` новый DDL
не применят — выполните его сами, как описано ниже. В
`sql/dm/<витрина>_scope.sql` определяются дни прогона, в `_rebuild.sql` они
собираются в донор, в `_replace.sql` заменяется партиция. Задачу сборки
добавьте в [`dags/dm_transform.py`](../../dags/dm_transform.py): без нее
конвейер не обновит витрину. Представлению достаточно `CREATE VIEW`.
Создайте промежуточную таблицу через `AS <цель>_rep`, сохранив структуру и
ключи основной. У обеих таблиц `_dist` должен быть одинаковый ключ
шардирования: замена партиции выполняется внутри каждого шарда и не
перераспределяет строки. Подробности есть в разделе
[«Раскладка по шардам»](../architecture/storage.md#раскладка-по-шардам).
**Образец — витрина `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),
но укажите пользователя `etl` и пароль из `CLICKHOUSE_ETL_PASSWORD` в `.env`.
@@ -88,51 +125,62 @@ DM](../architecture/dm.md).
Создайте файл `sql/ddl/70-labs.sql` и сохраняйте в нем DDL своих витрин.
Откройте его в SQL-редакторе этого подключения и выполните DDL текущего
задания. Создавайте объекты с
`ON CLUSTER clickstream_cluster`, чтобы они появились на обеих нодах;
`ON CLUSTER clickstream_cluster`, чтобы они появились на обоих узлах;
образец для представления — [`sql/ddl/60-dm-views.sql`](../../sql/ddl/60-dm-views.sql).
Сохраненный файл позволит воссоздать ваши объекты при пересборке мира.
## Самопроверка ступени 1
## Проверка витрин
Даг `homework_check` запускается руками, расписания у него нет. Задача
витрины красная, пока витрины нет или счет не сходится, и зеленая, когда
собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет: это
инструмент лабы, а не история качества стенда.
В Airflow есть даг `homework_check` с отдельной задачей для каждой учебной
витрины. Запускайте его вручную: расписания у него нет. Задача завершится
ошибкой, если витрина еще не создана или ее данные расходятся с ожидаемыми.
Причину и примеры расхождений ищите в журнале задачи. Проверки еще не
выполненных заданий тоже будут красными.
Для каждой витрины проверка независимо рассчитывает ожидаемый результат
прямо по ее источнику. У четырех заданий источник
эталона тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания
4 это таблицы брака ODS, потому что в DDS нет сущности брака. Обе стороны
соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число
строк, где стороны разошлись или одной стороны нет. Ноль — зеленый. Полное
соединение сохраняет ключи обеих сторон: при внутреннем пропавший день или
ключ не попал бы в результат, и потеря осталась бы незамеченной.
Проверка заново рассчитывает показатели по источнику и сравнивает их с вашей
витриной. Строки сопоставляются по полям, которые их определяют: например,
по дню и странице. `FULL OUTER JOIN` позволяет найти и разные значения, и
строки, которых нет с одной стороны. `INNER JOIN` пропустил бы отсутствующие
строки. Когда расхождений нет, задача становится зеленой.
Раздел «Как проверить себя» каждого задания по витрине называет деловой ключ и
сравниваемые величины. Тот же прием в стендовом исполнении — [даг
`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
`homework_check` не сохраняет результаты в сводку качества `dm.dq_summary`.
Запросы проверок лежат в `sql/dq/`. Откройте их после собственного решения:
Запросы проверок лежат в [`sql/dq/`](../../sql/dq/). Откройте их после собственного решения:
они заново рассчитывают показатели витрины, поэтому содержат ответ на
существенную часть задания. Сравните свой способ расчета с готовым.
## Факультативы
## Задания по желанию
Эти задания вы берете по желанию. Полный список сюжетов — в разделе
Полный список сюжетов — в разделе
[«Факультативы — пять разделов»](../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. Два самостоятельных задания:
@@ -141,17 +189,15 @@ DM](../architecture/dm.md).
- [В DBeaver работает, в Superset нет](optional/superset-missing-object.md):
выяснить, почему клиенты по-разному видят один объект хранилища.
### Инженерия стенда
### Загрузка и обновление данных
Открывается после ступени 1. Прием и хранение данных, раскладка по шардам,
повторная обработка дня и обновление витрин.
Открывается после ступени 1.
### Что делать, когда стенд сломался
- [Как обновлять витрину без полной пересборки](optional/incremental-daily-traffic.md):
продолжить работу о связях пользователей, пересчитать затронутые дни
и проверить повтор незавершенной загрузки. Повышенная сложность.
Открывается после ступени 7. Поломки стенда и инструкции по восстановлению
по итогам расследований; весь раздел — повышенная сложность.
## Уборка
## Удаление временных объектов
После опытов удаляйте свои временные объекты. Если удалили учебную витрину,
ее задача в `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 и BI». Его можно пройти после ступени 2,
Это задание раздела «Отчеты в Superset». Его можно пройти после ступени 2,
когда готовы витрины ступени 1.
## На какие вопросы ответит дашборд
## Отчет о поведении посетителей
Соберите обзор трафика и поведения посетителей. Он должен отвечать на три
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
@@ -17,25 +18,23 @@
стендового дашборда не нужно: выберите форму, в которой ответы читаются без
пояснений рядом с экраном.
## Свои объекты — отдельно
## Отдельный дашборд для задания
Не добавляйте графики в стендовый дашборд и не правьте его датасеты. При
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
таких объектов описан в [доке Superset](../../architecture/superset.md), «Импорт
таких объектов описан в [справочнике Superset](../../architecture/superset.md), «Импорт
при каждой инициализации».
Используйте свои датасеты и графики, соберите из них свой дашборд.
Назовите его, например, «Мои витрины DM». Это то же правило, по которому DDL
домашек лежит в `sql/ddl/70-labs.sql`: стендовые объекты уже есть, свои
создаете вы.
Назовите его, например, «Мои витрины DM».
## Датасеты
Датасет `session_overview_v`, его метрика доли отказов и график уже готовы
после [ступени 2](../2-1-superset-chart.md). Используйте их здесь. Добавьте
недостающие датасеты, чтобы в подключении `ClickHouse` были представлены
пять публичных представлений схемы `dm`:
пять представлений ваших витрин из схемы `dm`:
- `events_enriched_v`;
- `top_pages_daily_v`;
@@ -56,7 +55,7 @@
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
Для долей используйте слагаемые из витрин. Например, доля отказов считается
Для долей используйте суммы счетчиков из витрин. Например, доля отказов считается
как сумма `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. Затем найдете причину и исправите ее.
Факультатив доступен после [ступени 2](../2-1-superset-chart.md).
Свой дашборд для опыта не нужен. Используйте готовую витрину стенда
`dm.daily_traffic_v`.
## Подготовьте опыт
## Подготовка
В DBeaver подключитесь к первой ноде пользователем `etl`, как описано
в разделе [«Как применить свой DDL»](../README.md#как-применить-свой-ddl).
В DBeaver подключитесь к первому узлу пользователем `etl`, как описано
в разделе [«Выполнение SQL в DBeaver»](../README.md#выполнение-sql-в-dbeaver).
Для опыта создайте отдельное представление:
```sql
@@ -28,19 +28,19 @@ SELECT count() FROM dm.bi_node_probe_v;
Повторите запрос в SQL Lab Superset, выбрав подключение `ClickHouse`.
Сохраните текст ошибки.
## Найдите причину
## Поиск причины
Выясните, к каким серверам обращаются DBeaver и Superset. Адреса подключения
показывают настройки клиентов, а имя ответившего сервера можно спросить
запросом `SELECT hostName()`.
Проверьте наличие `dm.bi_node_probe_v` на обеих нодах. Для второй ноды
Проверьте наличие `dm.bi_node_probe_v` на обоих узлах. Для второго узла
заведите подключение DBeaver с портом из
[README](../../../README.md#подключиться-из-dbeaver). Запишите, где объект
есть, и объясните, почему успешного создания в первом подключении оказалось
недостаточно.
Исправьте DDL так, чтобы представление появилось на обеих нодах. Подключение
Исправьте DDL так, чтобы представление появилось на обоих узлах. Подключение
Superset должно остаться прежним. Сохраните исправленный DDL и повторите
исходный запрос в обоих клиентах.
@@ -48,23 +48,23 @@ Superset должно остаться прежним. Сохраните исп
[справочнике Superset](../../architecture/superset.md#устройство-зоны) и
[описание распределенного DDL ClickHouse](https://clickhouse.com/docs/reference/statements/distributed-ddl).
## Как проверить себя
## Проверка результата
В обоих клиентах запрос к вашему представлению должен вернуть столько же
строк, сколько `SELECT count() FROM dm.daily_traffic_v`. Сравнивайте результаты
при завершенном прогоне конвейера, пока мир не продвигается.
Объясните причину ошибки через три факта: куда пришел DDL, куда пришел
запрос Superset и на каких нодах существовало представление.
запрос Superset и на каких узлах существовало представление.
После опыта удалите только `dm.bi_node_probe_v` на обеих нодах. Не включайте
После опыта удалите только `dm.bi_node_probe_v` на обоих узлах. Не включайте
его в постоянный DDL домашних витрин: это временный объект расследования.
## Что должно получиться
## Результат работы
- [ ] у вас сохранены успешный запрос из DBeaver и ошибка из SQL Lab;
- [ ] вы можете объяснить, к каким серверам подключены клиенты, где было
создано представление и почему Superset его не видел;
- [ ] у вас есть исправленный DDL; оба клиента возвращают столько же строк,
сколько исходная витрина;
- [ ] после опыта вы удалили учебное представление с обеих нод.
- [ ] после опыта вы удалили учебное представление с обоих узлов.
+96 -26
View File
@@ -45,12 +45,16 @@
Пишите для учащегося: объясняйте, чему он научится, зачем нужны действия
и как понять результат. Историю разработки оставляйте за пределами задания.
«Как проверить себя» описывает действия для проверки и поиска ошибок.
«Что должно получиться» — результат работы и то, что учащийся теперь может
Начинайте задание коротким описанием предстоящей работы. Заголовки должны
называть содержание разделов: «Подготовка», «Создание витрины», «Проверка
результата». Шаблонные вопросы «Чему учит» и «Что сделать» не нужны.
Раздел проверки описывает действия для проверки и поиска ошибок.
Раздел результата перечисляет итог работы и то, что учащийся теперь может
объяснить. Если проверка уже встроена в шаги упражнения, повторять ее
отдельным разделом не нужно.
Остальные заголовки и структуру выбирайте по содержанию. Образцы — задания
Структуру выбирайте по содержанию. Образцы — задания
про [дашборд Superset](../labs/optional/superset-dashboard.md) и
[расследование подключения](../labs/optional/superset-missing-object.md).
@@ -149,38 +153,85 @@
## 4. Факультативы — пять разделов
Ниже перечислены только названия сюжетов и их уроки. Материал, формы и способы
проверки остаются в [инвентаризации](../research/2026-08-31-labs-inventory.md).
Ниже перечислены сюжеты, их уроки и принятые решения по форме заданий. Исходный
материал и способы проверки — в
[инвентаризации](../research/2026-08-31-labs-inventory.md).
### Почему выручка не сходится
Этот раздел рекомендован первым из факультативов. Он открывается после ступени
1 основной программы: материал живет на стенде с этапа 6.
Этот раздел рекомендован первым из факультативов и открывается после ступени
1 основной программы. Шесть сюжетов собраны в четыре лабораторные: три
расследования готовых данных и одно наблюдение во времени. Опоздания,
изменение выручки и история отмен используют одну историю заказов, поэтому
для них выбрана общая работа вместо трех отдельных.
- **Почему трекер недосчитывает покупки (#186).** Разрыв воронки подтверждений
показывает потерянные события и границу доверия к двум источникам.
- **Почему повтор покупки завышает выручку (#187).** Движок убирает технический
дубль строки, а повтор одной покупки приходится находить по деловому ключу.
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
разложить до конкретной вычеркнутой позиции и объяснить до копейки.
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
некоторые различия после истечения срока хранения сырых данных уже не
восстановить.
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день
становится окончательным лишь после того, как источник больше не может
изменить этот день.
- **Почему выручка за день еще меняется.** Внутри окна изменяемости выручка
движется, а после закрытия дня фиксируется.
Результат расследования — запрос сверки и инженерное решение, подтвержденное
данными: выбор источника, ключа, полей для точного расчета или правила
пересчета и хранения истории. Объяснение суммы служит проверкой решения.
Вариант закончить работы только объяснением показателей отклонен: он
смещал акцент в анализ отчетов. Постройка новых DAG и подключение проверок
к конвейеру оставлены основной программе, чтобы факультативы не повторяли
ее задания.
- **[Куда пропали покупки](../labs/optional/missing-purchases.md) (#186).**
Сверка по номерам заказов обосновывает источник витрины и условие
окончательной проверки полноты. Счетчики событий помогают обнаружить
расхождение, но не устанавливают место потери.
- **[Почему повтор покупки завышает выручку](../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 основной программы.
- **Что мы знали о пользователе в тот день (#145).** Текущее знание и знание на
дату дают разные отчеты; сведения из будущего меняют прошлое.
- **[Как новые заказы меняют отчет о посетителях](../labs/optional/identity-over-time.md) (#145).**
Учащийся собирает связи с подтверждающими покупками и сохраняет три
ежедневных наблюдения. На одном наборе прежних сессий сравнивает расчет
с прежними и новыми связями, выбирает историю для воспроизведения расчета.
- **Добавь названия регионов в отчет (#143).** Менти собирает свой словарь по
образцу каталога и обогащает отчет через `dictGet`.
Работа о связях продолжается в инженерном разделе лабораторной про
инкрементальный `daily_traffic`. В обеих учащийся продвигает мир через
`world_next_day` и `etl_pipeline`, сохраняет наблюдения под `student` в
свои локальные таблицы `default` и сравнивает их SQL-запросами.
Время первой подтверждающей покупки и время обнаружения связи разделены.
Минимум времени `purchase` не принят за границу появления новых связей:
заказ может поступить позже события. Нужные изменения определяются по
разности сохраненных наборов пар. Ежедневное наблюдение ограничивает момент
появления связи интервалом; для пар начального наблюдения более ранняя
история неизвестна. История сессий в этом опыте растет без исправлений
прошлых дней, а связи не отзываются — поэтому сохраненных наборов связей
достаточно для сравнения расчетов на прежних сессиях.
### Superset и BI
Открывается после ступени 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 @@
# Генератор кликстрима
# Генератор событий и заказов
Клиентская сторона стенда: отсюда берётся поток событий — широкое событие по
образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения —
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
Генератор создает данные двух источников интернет-магазина. Счетчик сайта
отправляет в топик Kafka `hits` события: просмотр страницы, добавление товара
в корзину и покупку. Серверная часть магазина выгружает в `orders` дневной
слепок — состояние заказов, оформленных за последние семь модельных дней.
Оба источника работают; хранилище принимает события непрерывно, заказы —
по команде Airflow.
События уже есть и уже уезжают: день-функция отдаёт по паре (зерно, D)
упорядоченный поток трёх видов — просмотр страницы, корзина, покупка, —
канонический сериализатор превращает его в JSON, а проигрыватель гонит в файл
или в Kafka. Клиентская сторона на этом целая. Второй источник строится:
день отдаёт и заказы бэкенда — проекцию своих покупок с деньгами магазина, —
а судьба заказа и отправка слепка идут следующими тикетами.
Событие описывает отдельное действие посетителя. Заказ после оформления
может измениться, например перейти в статус `paid` или `cancelled`.
Поэтому один `order_id` встречается в нескольких слепках. Часть заказов
впервые появляется с задержкой в один или два дня; конец семидневного окна
от этого не сдвигается. Правила изменений и задержек — в
[описании судьбы заказа](../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/seeds.py` — иерархия зёрен: кто из какого
подпотока берёт случайность. На ней держится весь детерминизм.
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в
день D. Когорты, приток, двухкуковые пары, паспорта кук, человек за кукой
и счётчики — до генерации событий.
- `src/clickstream_generator/weights.py` — выбор по целым весам: один приём
на весь генератор, чтобы дисциплина целочисленной случайности не жила
копиями.
- `src/clickstream_generator/reference.py` — справочники: устройства,
города, источники трафика, карта сайта. Таблицы-литералы: доля живёт в
строке, которой принадлежит.
- `src/clickstream_generator/catalog.py` — каталог товаров из
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
- `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/` — инварианты контракта, свежесть описания и обещания мира:
чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
и сборка визитов по задокументированным правилам. Там же побайтовое
обещание, доведённое до диска: два прогона дня в файл дают тот же файл, а
строк в нём ровно столько, сколько событий.
Все перечисленные модули находятся в `src/clickstream_generator/`.
| Модули | Назначение |
|---|---|
| `world.py`, `seeds.py`, `plan.py` | Настройки мира, случайные выборы и план посещений |
| `reference.py`, `catalog.py` | Устройства, города, источники трафика и каталог товаров |
| `day.py`, `commerce.py` | Визиты, события сайта и покупки |
| `orders.py` | Состояние заказов на дату слепка, изменения и задержки |
| `serialize.py`, `player.py`, `sinks.py` | Запись JSON и отправка в файл или Kafka |
| `cli.py` | Команды и параметры запуска |
| `schema.py`, `schema_doc.py` | Описания колонок события и сборка документа выгрузки |
| `inventory.py` | Сборка паспорта мира с контрольными суммами и счетчиками |
Тесты в `tests/` проверяют формат, правила модели и повторяемость данных.
Описание выгрузки и [паспорт мира](../data/world-inventory.json) собираются
из кода; вручную их не редактируют.
## Команды
Проиграть день — раздел [«Как запустить
генератор»](../docs/architecture/world.md#как-запустить-генератор)
справочника мира и `--help`.
Для запуска из каталога `generator/` используйте
`uv run python -m clickstream_generator <команда>`. Справку можно открыть
через `--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 lint` — ruff: проверка и формат;
- `make typecheck` — ty: проверка типов;
- `make demo` — пройти стадии канонического D0 на одном экране;
- `make lint` — проверка кода и форматирования через ruff;
- `make typecheck` — проверка типов через ty;
- `make demo` — показать стадии расчета первого дня, D0;
- `make docs` — пересобрать описание выгрузки;
- `make inventory` — пересобрать паспорт мира.
В корне репозитория `make lint` тоже есть, но он про код стенда — даги и
Superset. Одноимённые цели за разными дверями охватывают разное:
[карта проверок](../docs/architecture/testing.md).
Python и зависимости — через `uv`, версии закреплены в `uv.lock`: на этом
держится обещание побайтовой воспроизводимости (спека, раздел 2).
В корне `make lint` проверяет даги, код Superset и сборку карты архитектуры.
Корневые `make generate-batch` и `make generate-live` запускают события
через контейнер генератора. Состав проверок — в
[карте проверок](../docs/architecture/testing.md).
Python и зависимости запускаются через `uv`; версии закреплены в `uv.lock`.
@@ -420,7 +420,7 @@ COLUMNS: tuple[Column, ...] = (
numpy_dtype="int64",
normalized_name="product_price",
group=ColumnGroup.ECOMMERCE,
comment="цена за штуку целым числом: деньги генератор считает целыми",
comment="цена за штуку, округленная до целых рублей",
),
Column(
name="productQuantity",
+13
View File
@@ -61,6 +61,11 @@
<query>GRANT SELECT ON system.settings</query>
</grants>
</analyst_reader>
<student_writer>
<grants>
<query>GRANT SELECT, INSERT, CREATE TABLE, DROP TABLE ON default.*</query>
</grants>
</student_writer>
<monitoring_reader>
<grants>
<query>GRANT SELECT ON stg.*</query>
@@ -113,6 +118,14 @@
<query>GRANT analyst_reader</query>
</grants>
</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>
<password from_env="CLICKHOUSE_GRAFANA_PASSWORD"/>
<quota>default</quota>
@@ -11,7 +11,7 @@
"panels": [
{
"type": "timeseries",
"title": "Процессор: сколько ядер занимает нода",
"title": "Процессор: сколько ядер использует узел",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -51,7 +51,7 @@
},
{
"type": "timeseries",
"title": "Память: сколько нода занимает и сколько ей отведено",
"title": "Память: использование и лимит узла",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -114,7 +114,7 @@
},
{
"type": "timeseries",
"title": "Диски: сколько нода читает и пишет",
"title": "Диски: скорость чтения и записи узла",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -154,7 +154,7 @@
},
{
"type": "table",
"title": "Части: сколько их у таблицы и по сколько строк в каждой",
"title": "Части таблиц: количество и среднее число строк в части",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -199,7 +199,7 @@
},
{
"type": "timeseries",
"title": "Слияния: сколько частей они съедают",
"title": "Слияния: число объединенных частей",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -280,11 +280,12 @@
"min": 0
},
"overrides": []
}
},
"description": "Узлы дерева Keeper (znode) — записи со служебными данными координации ClickHouse."
},
{
"type": "table",
"title": "Распределённый DDL: что прошло по кластеру и чем кончилось",
"title": "Распределенный DDL: результаты команд на узлах кластера",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -11,7 +11,7 @@
"panels": [
{
"type": "stat",
"title": "Свежесть: сколько прошло с последней доехавшей строки",
"title": "Свежесть: время с последней загрузки в ODS",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -48,7 +48,7 @@
},
{
"type": "table",
"title": "Брак: сколько приехавшего не разобралось",
"title": "Брак: доля строк с ошибками разбора",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -72,11 +72,28 @@
"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"
}
],
"fieldConfig": {
"defaults": {},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Приехало"
},
"properties": [
{
"id": "displayName",
"value": "Загружено в STG"
}
]
}
]
}
},
{
"type": "table",
"title": "Дневной слепок заказов: какой доехал, когда и после какой паузы",
"title": "Дневной слепок заказов: дата и время загрузки",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -100,11 +117,28 @@
"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"
}
],
"fieldConfig": {
"defaults": {},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Доехал"
},
"properties": [
{
"id": "displayName",
"value": "Время загрузки"
}
]
}
]
}
},
{
"type": "timeseries",
"title": "Поток: сколько строк приезжает в сырой слой",
"title": "Поток: число сообщений, загруженных в STG",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -136,7 +170,7 @@
},
{
"type": "timeseries",
"title": "Сходимость: произведено в Kafka против приземлившегося в ClickHouse",
"title": "Сходимость: число событий в Kafka и ClickHouse",
"datasource": {
"type": "datasource",
"uid": "-- Mixed --"
@@ -161,7 +195,7 @@
},
"editorMode": "code",
"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
},
{
@@ -187,7 +221,7 @@
"properties": [
{
"id": "displayName",
"value": "приземлилось в ClickHouse"
"value": "загружено в ClickHouse"
}
]
}
@@ -197,7 +231,7 @@
},
{
"type": "timeseries",
"title": "Отставание чтения из Kafka: непрочитанных сообщений",
"title": "Отставание чтения из Kafka: число непрочитанных сообщений",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
@@ -11,7 +11,7 @@
"panels": [
{
"type": "table",
"title": "Тяжёлые запросы: что прочитало больше всех",
"title": "Запросы с наибольшим числом прочитанных строк",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -56,7 +56,7 @@
},
{
"type": "table",
"title": "Кто спрашивает: сколько запросов и какой у них аппетит",
"title": "Пользователи: число запросов и потребление ресурсов",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -93,6 +93,10 @@
{
"id": "unit",
"value": "bytes"
},
{
"id": "displayName",
"value": "Максимальная память запроса"
}
]
}
@@ -101,7 +105,7 @@
},
{
"type": "table",
"title": "Ошибки: на чём запросы споткнулись",
"title": "Ошибки выполнения запросов",
"datasource": {
"type": "grafana-clickhouse-datasource",
"uid": "clickhouse"
@@ -128,7 +132,20 @@
],
"fieldConfig": {
"defaults": {},
"overrides": []
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Чем кончилось"
},
"properties": [
{
"id": "displayName",
"value": "Ошибка"
}
]
}
]
}
}
]
@@ -1,4 +1,4 @@
slice_name: Сверка purchase и заказов
slice_name: Сверка событий покупки и заказов
description: null
certified_by: null
certification_details: null
@@ -153,7 +153,7 @@ position:
chartId: 2
height: 44
width: 12
sliceName: Сверка purchase и заказов
sliceName: Сверка событий покупки и заказов
uuid: 19600000-0000-4000-8000-000000000102
ROW-traffic:
id: ROW-traffic
@@ -37,7 +37,7 @@ metrics:
extra: null
warning_text: null
- metric_name: known_share
verbose_name: Доля известных
verbose_name: Доля известных пользователей
metric_type: null
expression: SUM(known_users) / nullIf(SUM(visitors), 0)
description: null
@@ -55,7 +55,7 @@ columns:
datetime_format: null
extra: null
- column_name: declared_revenue
verbose_name: Выручка purchase
verbose_name: Заявленная сумма покупки из события
is_dttm: false
is_active: true
type: NULLABLE(DECIMAL(18, 2))
@@ -120,7 +120,7 @@ columns:
datetime_format: null
extra: null
- column_name: order_id
verbose_name: Заказ
verbose_name: Номер заказа
is_dttm: false
is_active: true
type: STRING
@@ -125,7 +125,7 @@ columns:
datetime_format: null
extra: null
- column_name: units
verbose_name: Товары
verbose_name: Продано единиц товара
is_dttm: false
is_active: true
type: UINT64
+6 -6
View File
@@ -24,10 +24,10 @@ compose() {
next_step() {
case "$DAG_ID" in
world_initialize)
printf 'посмотрите журнал; повторите make up, а для недействительного мира — make rebuild-storage'
printf 'проверьте состояние запуска в Airflow и журнал задачи с ошибкой; после устранения причины повторите make up'
;;
world_recreate) printf 'устраните причину и повторите make rebuild-storage' ;;
*) printf 'проверьте журнал дага и повторите команду' ;;
world_recreate) printf 'проверьте состояние запуска в Airflow и журнал задачи с ошибкой; после устранения причины повторите make rebuild-storage' ;;
*) printf 'проверьте состояние запуска в Airflow и журнал задачи с ошибкой; после устранения причины повторите команду' ;;
esac
}
@@ -67,7 +67,7 @@ for ((attempt = 1; attempt <= ATTEMPTS; attempt++)); do
done
jq -e --arg dag_id "$DAG_ID" '.dag_id == $dag_id' \
>/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 запуска принимает только запись
# с `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)"
done
((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")"
state="$(jq -r '.state // "queued"' <<<"$response")"
[[ -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)"
case "$state" in
success)
printf 'ЗЕЛЁНО: даг %s, запуск %s, состояние success.\n' "$DAG_ID" "$run_id"
printf 'УСПЕХ: даг %s, запуск %s, состояние success.\n' "$DAG_ID" "$run_id"
exit 0
;;
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. Работа у версии ровно
-- одна: схлопнуть повтор доставки. Метка не ставится здесь заново, а
-- переносится из stg.hits_raw как есть — колонка отвечает на вопрос «когда
-- строка приехала в хранилище», а не «когда её разобрали». У повтора
-- содержимое то же самое, отличается только метка, поэтому какая из двух
-- строк переживёт мерж, безразлично.
-- Партиция EventDate хранит модельный день события. CounterID на стенде
-- постоянен, EventDate постоянна внутри партиции; дальнейшая сортировка —
-- по intHash32(ClientID) и WatchID. WatchID отличает события посетителя:
-- без него они заменяли бы друг друга.
--
-- Колонка версии не входит ни в ключ партиции, ни в ключ сортировки, и это
-- не случайность: попади она туда — версии одной строки лягут в разные куски
-- или в разные места ключа и не встретятся при мерже, то есть дедупликация
-- перестанет работать молча.
--
-- 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; наш генератор исправлений не шлёт, поэтому колонка
-- есть, а механики за ней нет.
-- SAMPLE BY использует то же выражение, что ключ сортировки, и отбирает
-- посетителей целиком. Sign всегда равен 1: поле сохранено по формату
-- источника, но исправлений парой −1/+1 генератор не отправляет.
CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
(
WatchID UInt64,
@@ -100,36 +71,24 @@ PARTITION BY EventDate
ORDER BY (CounterID, EventDate, intHash32(ClientID), WatchID)
SAMPLE BY intHash32(ClientID);
-- Лицо слоя: пишем и читаем через него. Ключ шардирования —
-- cityHash64(ClientID), а не сырой ClientID: структурированный числовой
-- идентификатор перекашивает остаток по модулю числа шардов, хеш — нет.
-- События одной куки при этом остаются на одном шарде, и сессионизация со
-- склейкой идентичностей ниже по течению живут локально.
--
-- У STG ключ другой (хеш строки), поэтому сырая строка и разобранное из неё
-- событие почти всегда лежат на разных шардах. Пошардовые счётчики слоёв
-- сходиться не должны — сверять слои можно только через _dist.
-- Запись и диагностика через Distributed; актуальные события — в ods.event_v.
-- cityHash64(ClientID) распределяет посетителей равномернее, чем остаток
-- самого структурированного идентификатора. События посетителя лежат вместе.
-- STG распределен по хешу raw, поэтому сообщение и разобранное событие могут
-- лежать на разных шардах. Общие счетчики сравнивают через _dist.
CREATE TABLE IF NOT EXISTS ods.event_dist ON CLUSTER clickstream_cluster
AS ods.event_rep
ENGINE = Distributed('clickstream_cluster', 'ods', 'event_rep', cityHash64(ClientID));
-- Локальная таблица ошибок разбора.
-- Ошибки разбора: исходный текст, класс и сведения о доставке.
-- У сообщения может не быть разобранного ClientID, поэтому шардирование —
-- по cityHash64(raw), партиция — по реальному дню загрузки.
-- ORDER BY группирует ошибки по классу и координатам Kafka.
--
-- Сырой текст сообщения, метаданные доставки и класс брака. Разобранных полей
-- у брака нет по определению: строка сюда попала как раз потому, что не
-- разобралась.
--
-- Движок — обычный ReplicatedMergeTree, без замены версий: у брака нет ключа
-- сущности, схлопывать его не по чему.
--
-- Ключи собственные. Шардирование — cityHash64(raw): ClientID у неразобранной
-- строки взять неоткуда. ORDER BY (error_class, kafka_partition,
-- kafka_offset): такую таблицу смотрят от класса брака, а внутри класса — по
-- координатам доставки. Нарезка по дню загрузки, как у STG: модельного дня
-- у брака тоже нет.
--
-- Срок жизни — месяц, вдесятеро дольше сырых данных. Истеки брак вместе с ними —
-- к моменту разбирательства не осталось бы ни того, ни другого.
-- Замены версий нет: надежного ключа события у брака может не быть.
-- TTL сохраняет ошибки месяц, дольше трехсуточного срока STG.
CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster
(
raw String,
+26 -84
View File
@@ -1,8 +1,6 @@
-- ODS: каноническое чтение актуальных событий.
--
-- Представление сохраняет поля и зерно ods.event_dist, но скрывает FINAL от
-- потребителя. Поэтому физическая таблица остаётся местом диагностики
-- доставленных версий, а точное чтение получает одно имя.
-- Актуальные события: FINAL выбирает последнюю доставку по _load_ts,
-- не дожидаясь фоновых слияний. Поля и типы остаются как в ods.event_dist.
CREATE VIEW IF NOT EXISTS ods.event_v ON CLUSTER clickstream_cluster
AS
SELECT *
@@ -25,69 +23,24 @@ AS
SELECT *
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. Трёхзначная логика дала бы
-- строку, которую не берёт ни «условие», ни «NOT условие», — и потерялась бы
-- она бесшумно. Поэтому сравнения здесь дают 0 или 1, а разбор в Nullable
-- заканчивается IS NOT NULL.
--
-- Второй: ни одна его часть не бросает исключений. Упавшая матвью роняет
-- вставку, офсеты не коммитятся, и потребление топика встаёт до починки —
-- это единственное, на чём сейчас держится правило репозитория «грязные
-- записи не валят пайплайн». Поэтому приведение 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 её берёт.
-- UTCEventTime приходит строкой вида "2026-06-01T12:34:56Z". Ее разбирает
-- parseDateTimeOrNull с явной маской: JSONExtract в DateTime такую форму
-- не принимает, а BestEffort допускает неполные даты. Пояс UTC указан
-- отдельно, потому что Z в маске проверяется как буква. EventDate приходит
-- как "2026-06-01" и разбирается через JSONExtract.
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_mv ON CLUSTER clickstream_cluster
TO ods.event_dist
@@ -154,10 +107,8 @@ SELECT
JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached,
JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1,
JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID,
-- Выгрузка несет JSON-числа типа Float64, но деньги хранятся в Decimal.
-- JSONExtract разбирает их сразу, без промежуточного двоичного числа:
-- синтаксис подтвержден документацией ClickHouse через Context7 и
-- запросом к стендовому ClickHouse 26.3 при исполнении #155.
-- purchaseRevenue приходит числом рублей и разбирается сразу в Decimal.
-- Прямой разбор сверяли через Context7 и на ClickHouse 26.3 (#155).
JSONExtract(raw, 'purchaseRevenue', 'Array(Decimal(18, 2))') AS purchaseRevenue,
JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency,
JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon,
@@ -168,25 +119,16 @@ SELECT
JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity,
JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType,
JSONExtract(raw, 'ecommerce', 'String') AS ecommerce,
-- Метка загрузки переносится из STG как есть, а не ставится заново:
-- она отвечает на вопрос «когда строка приехала в хранилище». Поставь
-- здесь now64(3) — и колонка версии молча ответила бы на другой вопрос.
-- _load_ts остается временем исходной доставки и версией события.
_load_ts
FROM stg.hits_raw_dist
WHERE is_object AND keys_match AND key_fields_parsed;
-- Брак: буквальное отрицание того же предиката.
--
-- Классы брака пересекаются, поэтому проверяются по порядку, а в error_class
-- пишется первый совпавший. Скаляр проваливает и проверку на объект, и сверку
-- ключей — JSONExtractKeys от него даёт пустой массив (измерено на стенде
-- 7 августа 2026 года), — и без объявленного порядка попал бы то в один
-- класс, то в другой.
--
-- Предикат повторён здесь дословно, и это выбор, а не безвыходность: назвать
-- его один раз на две матвью позволил бы CREATE FUNCTION. Отвергнуто — условие
-- разбора ушло бы за имя, в отдельный объект со своей жизнью, и слой перестал
-- бы читаться по своему же DDL.
-- Ошибки: отрицание того же условия годности.
-- Класс определяется первым нарушенным условием. Например, JSON-строка
-- не проходит и проверку объекта, и проверку ключей, но получает not_an_object.
-- Условие повторено рядом с запросом, без отдельной функции ClickHouse.
CREATE MATERIALIZED VIEW IF NOT EXISTS ods.event_errors_mv ON CLUSTER clickstream_cluster
TO ods.event_errors_dist
AS
+11 -21
View File
@@ -1,26 +1,16 @@
-- STG: матвью приёма — из чтеца в таблицу STG.
-- Прием событий из Kafka в STG.
-- Подключение этого представления запускает чтение топика. Поэтому файл 40
-- применяется после таблиц и представлений разбора из файлов 20 и 30:
-- иначе часть сообщений сохранится в STG, но не попадет в ODS.
--
-- Номер 40, а не 11, и пропуск в нумерации намеренный. Файлы 20-ods-tables.sql
-- и 30-ods-views.sql приносит #43, и матвью приёма обязана создаваться после
-- матвью разбора: Kafka-движок начинает читать топик ровно тогда, когда к нему
-- привязывают первую матвью. Создай её раньше разбора — и всё, что доедет в
-- зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в ошибки.
-- На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет:
-- проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, то есть на
-- обычной отладке. Нумерацию здесь не «приводить в порядок».
-- Запись через stg.hits_raw_dist распределяет сообщения по ключу таблицы.
-- Она фоновая; возможную потерю при сбое описывает раздел «Приём»
-- в docs/architecture/storage.md.
--
-- Пишем в stg.hits_raw_dist, а не в локальную таблицу: раскладку по шардам
-- обязан определять ключ шардирования, а не то, какая нода случайно читала
-- топик. Вставка при этом фоновая — окно потери принято осознанно, довод
-- целиком в docs/architecture/storage.md, раздел «Приём».
--
-- Служебные колонки заполняются выражением здесь, а не DEFAULT в таблице.
-- Для consumer_host это обязательно: проверено на стенде 5 августа 2026 года —
-- DEFAULT hostName() вычисляется на шарде-получателе и назвал бы не ту ноду,
-- которая читала топик. hostName() в SELECT снимается на вставляющей ноде,
-- то есть отвечает ровно на нужный вопрос.
--
-- Порядок колонок в SELECT совпадает с порядком в целевой таблице.
-- Сведения о доставке вычисляются здесь, на ноде чтения Kafka.
-- DEFAULT hostName() только в локальной таблице назвал бы шард-получатель.
-- Порядок SELECT совпадает с порядком колонок цели.
CREATE MATERIALIZED VIEW IF NOT EXISTS stg.hits_raw_mv ON CLUSTER clickstream_cluster
TO stg.hits_raw_dist
AS
+19 -42
View File
@@ -1,21 +1,9 @@
-- DDS: сборка дней заказа в таблицу-двойник перед заменой партиций.
-- Сборка выбранных дней в донор перед заменой партиций DDS.
-- Донор очищается в начале: иначе новые строки добавились бы к прежним.
-- После сборки он остается для диагностики до следующего запуска.
--
-- Двойник очищается целиком в начале сборки, а не в конце: собранное остаётся
-- лежать до следующего запуска, и разбор происшествия видит ровно то, чем
-- подменили день. Очистка обязана быть первой ещё и по делу — вставка поверх
-- прежних строк удвоила бы день в доноре, а REPLACE PARTITION перенёс бы
-- удвоение в цель.
--
-- Дни собираются одной вставкой, а не по дню за раз: источник читается один
-- раз на весь отрезок. Кто раскладывает строки по шардам — вставка через
-- Distributed, ключ cityHash64(order_id) — разобрано в DDL двойника.
--
-- Чистит двойник DROP PARTITION ALL, а не TRUNCATE: последнее — отдельное
-- право, роли etl оно не выдано, а снятие всех партиций
-- входит в ALTER, который у неё есть (ADR 0007). Замерено: TRUNCATE отвечает
-- ролью etl кодом 497, DROP PARTITION ALL проходит. Расширять права ради
-- операции, для которой уже есть разрешённая форма, значило бы платить
-- доступом за синоним.
-- DROP PARTITION ALL доступен роли etl через ALTER; отдельного права
-- TRUNCATE у нее нет (ADR 0007). Весь отрезок дней собирается одной вставкой.
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_ts
)
-- Позиции разбираются здесь один раз, на границе ODS → DDS. Цена на проводе —
-- строка с двумя знаками, и JSONExtract читает её прямо в Decimal: замерено на
-- живом слепке, промежуточный toDecimal64 не нужен.
--
-- Разбор по типу молчалив, и знать об этом надо заранее. Строгий приём ODS
-- проверил только, что items — массив, внутрь он не смотрел; здесь тоже никто
-- не смотрит. Замерено: qty вида "oops" даёт 0, пропущенная цена даёт 0,
-- скаляр вместо объекта выпадает из массива целиком. На каноническом слепке
-- генератора такого не бывает, и сумма «цена × количество» сходится с
-- items_total у всех заказов, но сходится она по свойству источника, а не
-- потому, что её здесь кто-то проверяет. Постоянное место такой проверки —
-- контур DQ этапа 4; пока она делается руками при приёмке.
-- Разбор позиций из JSON: цена в строке сразу превращается в Decimal.
-- ODS проверил только тип массива items. Неверное содержимое здесь может
-- дать значения по умолчанию: qty вида "oops" и пропущенная цена дают 0,
-- скаляр вместо объекта выпадает из массива. Наблюдения —
-- docs/architecture/storage.md, «Что проверено».
-- Проверку суммы позиций участник добавляет в docs/labs/5-1-order-checks.md.
WITH JSONExtract(
items, 'Array(Tuple(sku String, qty UInt32, price Decimal(18, 2)))'
) AS lines
@@ -56,8 +39,7 @@ SELECT
order_id,
user_id,
status,
-- День заказа и оба времени переезжают в пояс счётчика: DDS обслуживает
-- человека с дашбордом, и пояс называется здесь в последний раз.
-- День и время в Europe/Samara: витрины используют готовые колонки.
toDate(toTimeZone(created_at, 'Europe/Samara')),
toTimeZone(created_at, 'Europe/Samara'),
toTimeZone(updated_at, 'Europe/Samara'),
@@ -68,22 +50,17 @@ SELECT
arrayMap(line -> line.sku, lines),
arrayMap(line -> line.qty, lines),
arrayMap(line -> line.price, lines),
-- Координаты запуска, собравшего день: у всех строк дня они одни.
-- Метки этой сборки DDS, общие для всех вставляемых строк.
{load_id:String},
now64(3, 'UTC')
FROM ods.order_v
WHERE toDate(toTimeZone(created_at, 'Europe/Samara'))
BETWEEN {first_day:Date} AND {last_day:Date}
-- Вторая граница не меняет отбор, а открывает источнику отсечение
-- партиций: ODS нарезан по toDate(created_at) в UTC, и условие на
-- вычисленный день заказа для этого ключа непрозрачно — сервер прочёл бы
-- все партиции. Сутки пояса счётчика ложатся ровно на две даты UTC,
-- поэтому день слева берётся с запасом.
-- Дополнительный фильтр сокращает чтение партиций ODS по UTC.
-- Модельные сутки Europe/Samara пересекают две даты UTC, поэтому
-- левая граница берется на день раньше. Точный отбор — условием выше.
AND toDate(created_at) BETWEEN {first_day:Date} - 1 AND {last_day:Date}
-- Тот же ноль, что в приёме заказов: заводская двойка исполнила бы вставку
-- локально на каждом шарде, минуя ключ шардирования цели, и строки унаследовали
-- бы раскладку источника. Ключи ODS и DDS здесь совпадают, и раскладка вышла бы
-- верной — но по совпадению, а не по контракту (docs/architecture/storage.md,
-- «Раскладка по шардам», капкан #137).
-- Ждем доставки на шарды и распределяем строки по ключу цели
-- (docs/architecture/storage.md, «Раскладка по шардам»).
SETTINGS distributed_foreground_insert = 1,
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 рассылает её
-- на обе ноды, а по Distributed то же самое не работает — операции с
-- партициями отвечают кодом 48 (docs/architecture/storage.md, «Что
-- проверено»). Правильность пошардовой замены держится на том, что двойник
-- шардирован тем же ключом, что цель, — см. sql/ddl/45-dds-tables.sql.
--
-- Отсюда граница обещания: атомарность здесь пошардовая. Внутри шарда подмена
-- мгновенна — читатель видит либо прежний день целиком, либо новый; но две
-- локальные замены независимы, и в зазоре между ними чтение через Distributed
-- соберёт ответ из нового дня одного шарда и прежнего дня другого. Зазор
-- короткий, а отказ одной из двух замен растягивает его до повторного прогона.
--
-- Пустая партиция двойника — законный случай, а не сбой: день, в котором
-- заказов нет, снимает день из цели, и слой перестаёт показывать то, чего в
-- источнике больше нет.
--
-- День подставляет Jinja, а не параметр ClickHouse: в ALTER … PARTITION
-- сервер параметров не подставляет — замерено, отвечает синтаксической
-- ошибкой. Общее правило репозитория «параметры передаются отдельно, а не
-- строкой» здесь не нарушено по существу: значение пришло из ответа самого
-- ClickHouse на запрос объёма, а не снаружи стенда.
-- День подставляет Jinja: параметры ClickHouse в ALTER ... PARTITION
-- не поддерживаются в закрепленной версии 26.3. Значение получено
-- из результата order_scope.sql; замер — docs/architecture/storage.md.
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
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 одинаков у всех версий,
-- а строки последнего слепка сохраняются при слияниях.
--
-- Файл кончается без точки с запятой: даг читает его целиком и шлёт одним
-- запросом, дописывая формат ответа, — точка с запятой сделала бы из этого
-- мультизапрос, который ClickHouse не принимает.
--
-- Объём складывается из двух частей. Первая — дышащее окно: дни, которые нёс
-- последний слепок, то есть те, чьё состояние источник ещё может изменить.
-- Вторая — хвост от первого дня, которого в слое нет: первая сборка застаёт
-- слой пустым и собирает весь стартовый мир, а после разгона мира пачкой слой
-- догоняет уехавший вперёд источник.
--
-- **Хвост считается от первого недостающего дня, а не от последнего
-- собранного.** Разница не косметическая: замены партиций идут отдельными
-- задачами и падают порознь, поэтому упавший день оставляет дыру, пока
-- соседние дни собираются дальше. Считай мы прогресс по максимуму собранного
-- дня, дыра позади окна не заросла бы никогда, а прогоны продолжали бы
-- зеленеть над щербатым слоем. Цена честного ответа — проход по дню заказа
-- в ODS.
--
-- Окно читается из ярлыка слепка, а не вычисляется вычитанием окна
-- изменяемости: у выжившей строки snapshot_date — последний слепок, который
-- нёс этот заказ (ADR 0014). Поэтому оба края окна знает сам источник, а
-- копии его константы на стороне стенда больше нет.
--
-- FINAL не нужен ни здесь, ни ниже: время рождения заказа у всех его версий
-- одно, а строки последнего слепка несут максимум колонки версии — мерж
-- оставляет как раз их.
-- Пустой ODS не дает строки результата; даг завершится ошибкой.
-- Точка с запятой в конце не нужна: клиент дописывает FORMAT ответа.
-- Порядок пересборки — docs/architecture/etl.md, «Объём прогона».
WITH
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
(
@@ -44,10 +24,9 @@ WITH
FROM ods.order_dist
WHERE snapshot_date = last_snapshot
) AS window_end,
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
-- бы дни источника с локальной частью слоя на каждом шарде — день, чьи
-- заказы легли на соседний шард, показался бы недостающим.
-- minOrNull отличает «дыр нет» от даты по умолчанию.
-- GLOBAL NOT IN сравнивает дни со всем DDS. Локальное сравнение могло бы
-- счесть день пропущенным, если его заказы находятся на соседнем шарде.
-- minOrNull возвращает NULL, когда пропущенных дней нет.
(
SELECT minOrNull(day)
FROM (
@@ -57,7 +36,7 @@ WITH
WHERE day GLOBAL NOT IN (SELECT order_date FROM dds.order_dist)
) AS first_missing_day
SELECT
-- Дыр нет — начинаем с окна; есть — с первой из них, если она старше окна.
-- Начало окна или первый пропущенный день, если он раньше.
least(window_start, coalesce(first_missing_day, window_start)) AS first_day,
window_end AS last_day
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) считает каждый модельный день отдельно:
-- события разных дней не попадут в одну сессию даже при короткой паузе.
-- Учитываются все типы событий.
--
-- Нарезка — центральный расчёт слоя, и стоит она на двух окнах подряд.
-- Первое спрашивает у каждого события, сколько прошло с предыдущего события
-- той же куки в тот же день, и ставит единицу там, где пауза длиннее
-- таймаута: это шов между визитами. Второе считает нарастающий итог этих
-- единиц — номер визита внутри дня куки. Дальше визит становится обычной
-- группировкой.
--
-- Границу суток окна не проверяют: она уже в разбиении PARTITION BY
-- (client_id, event_date), поэтому визит через полночь не склеится и при
-- короткой паузе. Так правило визита ложится в запрос целиком: одна кука,
-- один день, паузы не длиннее таймаута (CONTEXT.md, «Визит»).
--
-- Первое событие куки за день сравнивается само с собой — третий аргумент
-- lagInFrame. Паузы у него нет, шов ему не нужен, и номер первого визита
-- получается нулём: номера различают визиты внутри дня куки, а не считают их
-- от единицы.
--
-- Порядок в окнах полный — метка времени и event_id. Окна считаются двумя
-- разными сортировками (видно в EXPLAIN PIPELINE), и при равных метках они
-- вправе разойтись: шов достался бы одной строке, а нарастающий итог другой,
-- и сессия разъехалась бы надвое.
--
-- Тридцать минут написаны числом, а не приехали параметром: это правило
-- аналитики, а не константа мира. Генератор режет визиты тем же порогом;
-- разойдись эти два числа — увидит счётная сверка с VisitID
-- (docs/architecture/dds/session.md, «Сверка с эталоном»).
-- У первого события lagInFrame возвращает его собственное время: разница
-- равна нулю, opens_visit = 0, и первый visit_no тоже равен нулю.
-- Оба окна используют (event_time, event_id), чтобы при равном времени
-- отметка начала и номер визита относились к одним событиям.
-- Порог 1800 секунд — правило визита; он не передается параметром.
ALTER TABLE dds.session_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL;
INSERT INTO dds.session_stage_dist
@@ -93,8 +78,8 @@ WITH
FROM seams
)
SELECT
-- Чекан визита: кука и его начало. Время входит в хеш секундой эпохи,
-- поэтому id держится за содержание сессии, а не за порядок сборки.
-- Идентификатор зависит от посетителя и начала визита.
-- Повторная сборка тех же событий дает тот же session_id.
cityHash64(client_id, min(event_time)),
client_id,
event_date,
@@ -107,8 +92,10 @@ SELECT
countIf(event_type = 'purchase'),
argMin(url, event_time),
argMax(url, event_time),
-- Атрибуция и паспорт куки берутся с первого события визита. У паспорта
-- любое событие дало бы то же: устройство и город у куки постоянны.
-- UTM, устройство и город берутся по минимальному event_time.
-- Как и у страниц выше, при равном времени выбор между событиями
-- не определен: event_id в эти argMin/argMax не входит.
-- Устройство и город на стенде постоянны для одной куки.
argMin(utm_source, event_time),
argMin(utm_medium, event_time),
argMin(utm_campaign, event_time),
@@ -120,12 +107,7 @@ SELECT
now64(3, 'UTC')
FROM numbered
GROUP BY client_id, event_date, visit_no
-- Капкана parallel_distributed_insert_select здесь нет, и это выигрыш
-- ко-локации: ключ шардирования у источника и цели один — cityHash64 от
-- куки, — поэтому визит ляжет туда же, где лежали его события, исполнись
-- вставка хоть на инициаторе, хоть локально на шардах. Заказу приходится
-- называть ноль вслух: совпадение ключей у него случайное
-- (sql/dds/order_rebuild.sql). Здесь выбор сделан обратный и он тоже
-- осознанный: настройку не называть, чтобы не притворяться, будто её значение
-- что-то решает.
-- Источник и цель распределены по cityHash64(client_id): локальная вставка
-- сохраняет нужную раскладку, parallel_distributed_insert_select менять
-- не требуется. Перед заменой партиций ждем доставки строк на шарды.
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: замена идёт по
-- локальным таблицам, шард за шардом, атомарность пошардовая, день
-- подставляет Jinja, потому что параметров ALTER … PARTITION не берёт.
--
-- Разница одна, и она про отказ. Несобранный день заказа вернуло бы в работу
-- следующее окно; сессиям возвращать нечем, и упавшая замена оставляет дыру
-- до тех пор, пока её не найдёт счёт хвоста (sql/dds/session_scope.sql).
-- Дату из session_scope.sql подставляет Jinja: параметр ClickHouse в
-- ALTER ... PARTITION здесь не поддерживается. Поиск дней следующего
-- запуска не обнаружит неполный день, если часть его строк уже есть в DDS.
ALTER TABLE dds.session_rep ON CLUSTER clickstream_cluster
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.
-- Позиция мира сама по себе не подтверждает доставку событий.
--
-- Правый край — последний прожитый день, и знает его только позиция мира.
-- События живой день от прожитого не отличают: и посреди дня 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, «Готовность источника»).
-- Точки с запятой в конце нет: драйвер добавляет FORMAT к этому запросу.
WITH
(SELECT min(EventDate) FROM ods.event_dist) AS first_source_day,
first_source_day + ({position:UInt16} - 1) AS last_lived_day,
-- Хвост считается от первой дыры, а не от последнего собранного дня:
-- замены партиций падают порознь, и счёт по максимуму собранного дня
-- дыру позади себя не заметил бы (доводы целиком — sql/dds/order_scope.sql).
-- У сессий это единственный способ дыру найти: дышащего окна, которое
-- пересобрало бы день заодно, здесь нет.
--
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
-- бы дни источника с локальной частью слоя на каждом шарде.
-- minOrNull отличает «дыр нет» от даты по умолчанию.
-- Ищем первую дату событий ODS, за которую еще нет сессий DDS.
-- Поиск после max(session_date) пропустил бы более раннюю потерянную дату.
-- Если за день есть хотя бы одна сессия, он уже не считается пропущенным.
-- GLOBAL берет даты сессий со всех шардов.
-- minOrNull возвращает NULL, если пропусков нет.
(
SELECT minOrNull(day)
FROM (SELECT DISTINCT EventDate AS day FROM ods.event_dist)
WHERE day GLOBAL NOT IN (SELECT session_date FROM dds.session_dist)
) AS first_missing_day
SELECT
-- Живой день в слое отсутствует всегда, и работой это не считается:
-- дырой он станет, только когда мир его проживёт.
-- Отсутствующий день попадет в сборку только после его завершения.
if(first_missing_day <= last_lived_day, first_missing_day, NULL) AS first_day,
last_lived_day AS last_day
-- FINAL не нужен: повтор доставки не двигает ни край оси, ни набор дней.
-- FINAL не нужен: повтор доставки не меняет начало календаря и набор дат.
WHERE (SELECT count() FROM ods.event_dist) > 0
+12 -8
View File
@@ -1,7 +1,10 @@
-- DM: сборка выручки в донор перед заменой дневных партиций.
-- DROP PARTITION ALL очищает локальные таблицы на каждом узле; причина не
-- применять TRUNCATE разобрана в соседнем sql/dds/order_rebuild.sql.
-- DM: расчет выручки за дни от first_day до last_day включительно.
-- DROP PARTITION ALL очищает промежуточную revenue_daily_stage_rep.
-- Затем 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
DROP PARTITION ALL;
@@ -22,8 +25,8 @@ SELECT
orders,
units,
CAST(revenue, 'Decimal(18, 2)'),
-- Обычное деление Decimal усекает доли копейки вместо округления;
-- запас масштаба сохраняет их до денежного результата.
-- Делим с шестью знаками после запятой, затем округляем до копеек.
-- Так дробная часть сохраняется до округления среднего чека.
CAST(
round(divideDecimal(revenue, toDecimal128(orders, 0), 6), 2),
'Decimal(18, 2)'
@@ -49,7 +52,8 @@ FROM
report_date,
product_category
)
-- Источник лежит по заказу, цель — по категории. Ноль заставляет инициатор
-- слить частичные агрегаты и разложить готовые строки по ключу цели.
-- Источник распределен по заказу, цель — по категории. Значение 0 оставляет
-- итоговую агрегацию и распределение по ключу цели на ноде, принявшей запрос.
-- distributed_foreground_insert = 1 ждет доставки строк перед заменой.
SETTINGS distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0;
+7 -6
View File
@@ -1,8 +1,9 @@
-- DM: подмена одного дня выручки готовой партицией донора.
--
-- День приходит из результата revenue_daily_scope.sql, а не от пользователя.
-- Jinja нужна потому, что ClickHouse не подставляет параметр в PARTITION.
-- Замена идет на локальной таблице ON CLUSTER: граница атомарности и повтор
-- разобраны на таком же шаге в sql/dds/order_replace.sql.
-- DM: заменяем раздел revenue_daily_rep за выбранный день готовым разделом
-- промежуточной revenue_daily_stage_rep. Раздел таблицы называется партицией.
-- Дату из revenue_daily_scope.sql подставляет Jinja: параметр ClickHouse
-- в ALTER ... PARTITION здесь не поддерживается.
-- ON CLUSTER выполняет замену на локальных таблицах каждого шарда.
-- На одном шарде раздел меняется целиком; общей транзакции для кластера нет.
-- Повтор с теми же строками промежуточной таблицы не добавляет копий в основной.
ALTER TABLE dm.revenue_daily_rep ON CLUSTER clickstream_cluster
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 называет дни, состояние которых источник еще может
-- изменить. Сам агрегат читает только DDS; ODS здесь нужен лишь для границ.
-- Первый запуск и повтор после частичного отказа расширяют левую границу до
-- первого дня оплаченных заказов, которого еще нет в витрине.
-- Почему хвост начинается с первого пропуска и чем за это платит ODS,
-- разобрано в sql/dds/order_scope.sql.
--
-- Файл кончается без точки с запятой: даг читает его одним запросом и ждет
-- одну строку с границами.
-- Точки с запятой в конце нет: драйвер добавляет FORMAT к этому запросу.
WITH
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
(
@@ -24,8 +21,7 @@ WITH
(
SELECT minOrNull(report_date)
FROM (SELECT DISTINCT order_date AS report_date FROM dds.order_v WHERE status = 'paid')
-- Правая сторона тоже распределенная: GLOBAL собирает множество дней
-- на инициаторе и не превращает запрос в запрещенное двойное чтение.
-- GLOBAL собирает список дней витрины со всех шардов.
WHERE report_date GLOBAL NOT IN (
SELECT report_date FROM dm.revenue_daily_dist
)
@@ -33,7 +29,7 @@ WITH
SELECT
least(window_start, coalesce(first_missing_day, window_start)) AS first_day,
window_end AS last_day
-- Без ODS неизвестно окно. Без готового DDS донор останется пустым, а замена
-- сотрет уже собранные дни выручки. Поэтому для прогона нужны оба источника.
-- Для выбора диапазона нужны заказы в ODS, для расчета — в DDS.
-- Эта проверка наличия строк не подтверждает полноту источников.
WHERE (SELECT count() FROM dds.order_v) > 0
AND (SELECT count() FROM ods.order_dist) > 0
+27 -46
View File
@@ -1,24 +1,15 @@
-- Разбор одного среза заказов: сначала годные версии, затем брак.
-- Разбор сохраненной порции заказов: сначала годные версии, затем ошибки.
-- Макрос Jinja подставляет одно условие годности в обе вставки.
-- Вторая вставка берет его отрицание, чтобы каждая строка получила результат.
-- Условие должно давать только 0 или 1 и не вызывать исключений на неверном raw.
--
-- Обе вставки должны делить один предикат. Локальный макрос Jinja
-- разворачивает его в каждое выражение до того, как оператор разделит файл по
-- точкам с запятой. Так контракт остаётся рядом с запросами и не становится
-- третьим SQL-файлом, который нельзя исполнить самостоятельно.
--
-- Строгий приём проверяет форму провода и ничего сверх неё. Ключей одиннадцать,
-- и проверяются все: десять скалярных значений прямо образуют типизированную
-- строку заказа. Внутрь items приём не смотрит — содержимое позиций, переходы
-- статуса и равенства сумм остаются ниже этой границы
-- (docs/architecture/orders/ingestion.md, «Граница строгого приёма»).
--
-- У общего предиката два обязательных свойства. Он всегда даёт 0 или 1, не
-- NULL: иначе строку не возьмут ни условие, ни его отрицание. И он не бросает
-- исключений на произвольном raw: одна грязная строка не должна ронять весь
-- переход, ради этого и существует таблица брака.
-- Проверяются одиннадцать полей верхнего уровня. Внутри items поля не проверяются;
-- позиции разбирает DDS, а равенства сумм проверяются отдельно.
-- Точные правила — docs/architecture/orders/ingestion.md, «Граница строгого приёма».
{% macro order_wire_contract() %}
WITH
-- Каноническое время провода: UTC, ровно три знака долей секунды.
-- Время источника: UTC, ровно три знака долей секунды.
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'' AS ts_mask,
JSONType(raw) = 'Object' AS is_object,
arraySort(JSONExtractKeys(raw)) = arraySort([
@@ -28,15 +19,14 @@ WITH
]) AS keys_match,
JSONType(raw, 'order_id') = 'String'
AND JSONType(raw, 'status') = 'String'
-- Целое у JSONType зовётся двумя именами, и нужны оба: с одним
-- Int64 законный идентификатор за 2^63 уехал бы в брак.
-- JSONType различает Int64 и UInt64; принимаются оба, если значение
-- разбирается в UInt64, в том числе идентификаторы больше 2^63−1.
AND JSONType(raw, 'user_id') IN ('Int64', 'UInt64')
AND JSONExtract(raw, 'user_id', 'Nullable(UInt64)') IS NOT NULL
AND JSONType(raw, 'items') = 'Array'
-- Форму держит регулярка, реальность — разбор, и порознь они дырявы:
-- маска берёт «2026-6-3T…» без ведущих нулей, а регулярка пропускает
-- 30 февраля. Обе половины измерены — docs/architecture/storage.md,
-- «Что проверено».
-- Регулярное выражение проверяет точную форму, включая ведущие нули.
-- Разбор проверяет само время: например, отвергает 30 февраля.
-- Замеры функций — docs/architecture/storage.md, «Что проверено».
AND arrayAll(k ->
JSONType(raw, k) = 'String'
AND match(JSONExtractString(raw, k),
@@ -44,9 +34,8 @@ WITH
AND parseDateTime64InJodaSyntaxOrNull(
JSONExtractString(raw, k), ts_mask, 'UTC') IS NOT NULL,
['created_at', 'updated_at'])
-- Деньги — строка с ровно двумя знаками после точки. Регулярка держит
-- форму, разбор — вместимость: у строки из двадцати девяток форма
-- хороша, а в Decimal(18, 2) она не влезает.
-- Сумма в рублях: строка с двумя знаками после точки, без минуса.
-- После проверки формы проверяется возможность разбора в Decimal.
AND arrayAll(k ->
JSONType(raw, k) = 'String'
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
{% endmacro %}
-- Годные версии заказа. Срез читается по _load_id — тому же, что проставил
-- забор: разбор идёт по неизменной порции, а не по «всему, что появилось».
--
-- Разобранные значения берёт assumeNotNull: обнуляемый разбор стоит за
-- предикатом, который NULL уже отсёк, и приведение здесь не может упасть.
-- Годные строки сохраненного среза по _load_id запуска приема.
-- WHERE исключает строки с NULL в разобранных полях; assumeNotNull
-- приводит оставшиеся значения к типам целевой таблицы.
INSERT INTO ods.order_dist
{{ order_wire_contract() }}
@@ -81,30 +68,25 @@ SELECT
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'discount'), 2)) AS discount,
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'delivery'), 2)) AS delivery,
assumeNotNull(toDecimal64OrNull(JSONExtractString(raw, 'total'), 2)) AS total,
-- items кладётся сырым фрагментом JSON, а не разобранной структурой.
-- items сохраняется строкой JSON для разбора позиций в DDS.
JSONExtractRaw(raw, 'items') AS items,
toDate(assumeNotNull(parseDateTimeInJodaSyntaxOrNull(
JSONExtractString(raw, 'snapshot_date'),
'yyyy-MM-dd', 'UTC'))) AS snapshot_date,
-- Метки запуска и прибытия переносятся как есть. Поставь здесь now64(3) —
-- и _load_ts молча ответила бы на другой вопрос: «когда разобрали».
-- _load_ts остается временем приема в STG; _load_id связывает с исходной порцией.
_load_id,
_load_ts
FROM stg.orders_raw_dist
WHERE _load_id = {load_id:String} AND row_is_valid
-- Заводская двойка parallel_distributed_insert_select исполняет вставку из
-- Distributed в Distributed локально на каждом шарде, минуя ключ шардирования
-- цели, — так заказы и расползлись по обоим шардам. Ноль отдаёт раскладку
-- инициатору, по ключу цели. Капкан целиком — docs/architecture/storage.md,
-- «Раскладка по шардам».
-- Ноль заставляет распределить результат по ключу цели. При значении 2
-- вставка может сохранить раскладку STG, где ключ — raw, вместо order_id.
-- Подробнее — docs/architecture/storage.md, «Раскладка по шардам».
SETTINGS distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0;
-- Брак: тот же срез и буквальное отрицание того же предиката.
--
-- Классы перекрываются, поэтому проверяются по порядку, а в error_class идёт
-- первый совпавший: скаляр проваливает и проверку на объект, и сверку ключей —
-- без объявленного порядка он попал бы то в один класс, то в другой.
-- Ошибки: тот же срез и отрицание условия годности.
-- Класс определяется первым нарушенным условием: например, JSON-строка
-- не проходит и проверку объекта, и проверку ключей, но получает not_an_object.
INSERT INTO ods.order_errors_dist
{{ order_wire_contract() }}
@@ -124,7 +106,6 @@ SELECT
_load_ts
FROM stg.orders_raw_dist
WHERE _load_id = {load_id:String} AND NOT row_is_valid
-- Тот же ноль: ключ брака сегодня совпадает с ключом STG, и локальная
-- запись легла бы верно — но по совпадению, а не по контракту.
-- Та же настройка: распределяем строки по ключу целевой таблицы ошибок.
SETTINGS distributed_foreground_insert = 1,
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
-- берёт десятки тысяч сообщений, а слепок дня — порядка полутора тысяч строк.
-- Короткая порция оставит хвост до следующего прогона, а отказ после чтения
-- унесёт прочитанное с собой: офсеты коммитятся в момент чтения. Граница
-- целиком — ADR 0008, «Следствия».
--
-- stream_like_engine_allow_direct_select разрешает читать чтеца запросом; вторая
-- половина пары объявлена на самой таблице (sql/ddl/10-stg-tables.sql).
-- distributed_foreground_insert = 1 — конвенция ETL-вставок стенда: задача не
-- должна зеленеть раньше, чем строки легли на шарды.
-- stream_like_engine_allow_direct_select разрешает прямое чтение Kafka;
-- kafka_commit_on_select включен в sql/ddl/10-stg-tables.sql.
-- distributed_foreground_insert = 1 ждет записи на шарды до завершения задачи.
INSERT INTO stg.orders_raw_dist
SELECT