- Зачем:
- справочники читает человек со стороны, а «пульт мира», «опись мира»,
«работник» и «сырьё» звучали шуткой или калькой времён разработки
(тикет #214, выбор владельца).
- Что:
- CONTEXT.md: статьи «Паспорт мира», «Даги модельного времени»,
«Шаговый даг», «Сырые данные», старые слова в строках «Избегать»;
пометки в ADR 0005, 0008, 0009 без переписывания решений.
- замены по README, справочникам, путеводителю, лабам, спекам,
комментариям дагов и SQL, тегу дага и панели Grafana; имена в коде
не тронуты.
- world.md и stand.md: 35 находок вычитки внесены, регистр выровнен.
- Проверка:
- make config-test; make lint в корне и в generator/; make test в
generator/ (437 passed); скрипт ссылок из #212 пуст; grep старых
слов вне ADR, research и CONTEXT.md пуст.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QBCtU38qSHg5DvgtnKXcW
268 lines
22 KiB
Markdown
268 lines
22 KiB
Markdown
# Слой DM: витрины
|
||
|
||
Как модель DDS превращается в витрины для потребителя BI: какую форму
|
||
принимает витрина, кто какие витрины строит и как слой проверяется. Состав
|
||
колонок каждой витрины задаёт раздел 7 мастер-спеки; здесь — форма,
|
||
наполнение и контроль.
|
||
|
||
## Форма: по зерну, не по цене запроса
|
||
|
||
Витрина, меняющая зерно источника — дневной агрегат, сверка по заказам, —
|
||
материализуется: `dm.<имя>_rep` / `_dist` и публичное `dm.<имя>_v` с явным
|
||
списком колонок, по общим [конвенциям хранилища](storage.md). Наполняет её
|
||
`dm_transform`; ритм и механика пересборки — ниже. Витрина, сохраняющая зерно
|
||
источника — переименование, обогащение строки, — остаётся обычным
|
||
представлением: копию событий ради других имён колонок не держит ни один
|
||
боевой склад.
|
||
|
||
Правило выбрано учебной целью, а не ценой запроса. На объёмах стенда
|
||
представлениями обошлось бы всё: условия сквозь цепочку представлений
|
||
проходят ([исследование](../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` — сводка проверок качества (ниже).
|
||
|
||
Остальные витрины спеки — учебные задания: менти строит их сам по ТЗ с
|
||
образцом. Разминка — представление `events_enriched_v` (зерно события),
|
||
затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`,
|
||
последней — самая содержательная `utm_effectiveness` с уроком `declared_*`.
|
||
Построенная витрина встаёт в конвейер руками менти: финальный пункт ТЗ
|
||
каждого агрегата — добавить задачу своей витрины в `dm_transform`. До этого
|
||
конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается
|
||
наравне со стендовыми. У разминочного представления такого пункта нет:
|
||
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
|
||
|
||
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит
|
||
от выполненных домашек. Свой дашборд по витринам менти —
|
||
[необязательная лаба](../labs/superset-dashboard.md) после этапа 8. Устройство
|
||
стендового дашборда — в [доке зоны Superset](superset.md).
|
||
|
||
## Ритм пересборки
|
||
|
||
День витрины пересобирается, пока может измениться его источник, —
|
||
поэтому ритмов три, по числу источников:
|
||
|
||
- **от событий и сессий** — день замерзает сразу; пересборка догоняет
|
||
хвост несобранных дней заменой дневных партиций;
|
||
- **от заказов** — день дышит, пока входит в последний слепок; пересборка
|
||
заменяет партиции этого отрезка, дни за ним закрыты по построению;
|
||
- **от карты идентичностей** — не замерзает никогда: склейка
|
||
ретроспективна, покупка сегодня делает куку известной во всех её прошлых
|
||
днях, а карта пересчитывается целиком.
|
||
|
||
Поэтому `daily_traffic` наследует у своего источника и ритм, и механику:
|
||
полный пересчёт всех дней каждый прогон в `ReplacingMergeTree(_load_ts)`,
|
||
как у самой карты. Дневных партиций у неё нет: партиция — единица замены,
|
||
а эта витрина заменяется только целиком. На стенде такая работа остается
|
||
быстрой; витрина читает сессии и карту через их публичные договоры.
|
||
Пересчитывать точечно только затронутые дни не из чего: для этого надо
|
||
знать, какие связки новые, а полный пересчёт карты этот след затирает —
|
||
восстановление следа оставлено лабе повышенной сложности (issue #149).
|
||
|
||
У `purchase_vs_orders` два источника и одна непрерывная пересборка. Заказная
|
||
сторона задает изменяемое окно последним принятым слепком ODS, событийная —
|
||
последний прожитый день с `purchase`. Левая граница возвращает первую дыру и
|
||
самый ранний день с `awaiting_order`: предварительная строка уже есть в
|
||
витрине, поэтому поиск отсутствующего дня ее не заметит. Правая граница
|
||
доходит до более позднего из двух хвостов. Так один прогон догоняет пакетный
|
||
разгон длиннее K и не оставляет старое ожидание замороженным.
|
||
|
||
## Витрины стенда
|
||
|
||
Публичные представления перечисляют деловые колонки и координаты запуска
|
||
`_load_id`, `_load_ts` явно. Новая колонка физической таблицы сама в договор не
|
||
попадет.
|
||
|
||
### `dm.revenue_daily_v`
|
||
|
||
Зерно: день заказа × категория товара. Витрина читает только оплаченные
|
||
заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает
|
||
категорию артикула через `dic.products`. Один заказ с несколькими позициями
|
||
категории считается в `orders` один раз. `units` складывает количество единиц,
|
||
`revenue` складывает цену позиции × количество. Скидка, доставка и итог заказа
|
||
между категориями не делятся. `aov` равен `revenue / orders` в том же срезе и
|
||
хранит два знака после запятой.
|
||
|
||
Первый запуск строит всю доступную историю. Следующие берут границы дней из
|
||
последнего слепка ODS: только источник знает, какие даты еще может изменить.
|
||
Пропущенный оплаченный день расширяет левую границу, чтобы частичный отказ
|
||
не оставил дыру после следующего прогона. Агрегат при этом читает DDS; ODS
|
||
отвечает только на вопрос об объеме.
|
||
|
||
`dm.revenue_daily_rep` нарезана по `report_date`; день собирается в
|
||
таблице-доноре и заменяется через `REPLACE PARTITION`. Цель и донор шардируются
|
||
по `cityHash64(product_category)`. Кроме дня, у витрины только категория, а
|
||
шесть категорий стартового мира близки по объему. Более сложный составной ключ
|
||
распределил бы эту маленькую таблицу без нового урока.
|
||
|
||
### `dm.purchase_vs_orders_v`
|
||
|
||
Зерно — заказ. Покупки из `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)`. Классы проверяются по порядку:
|
||
`awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`,
|
||
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
|
||
события остается отмененным, а не потерянным.
|
||
|
||
Витрина нарезана по `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`.
|
||
|
||
Сессии и карта лежат по `client_id`, но публичные `_v` скрывают
|
||
`Distributed`-таблицы от локальной подстановки. На ClickHouse 26.3
|
||
`EXPLAIN PLAN` показал чтение обоих шардов на инициаторе: настройка
|
||
`distributed_product_mode = 'local'` локального соединения не дала. Поэтому
|
||
витрина использует явный `GLOBAL LEFT JOIN` через публичные договоры, а не
|
||
подменяет их физическими таблицами. Это дороже по сети, зато граница слоев
|
||
остается явной. Новая связь куки меняет прошлые дни, поэтому каждый запуск
|
||
пишет полную историю новой версией в `ReplacingMergeTree(_load_ts)`, а
|
||
публичное представление выбирает текущие строки через `FINAL`.
|
||
|
||
## `dm.dq_summary`
|
||
|
||
Зерно публичного `dm.dq_summary_v` — «день данных × атомарная проверка».
|
||
Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
|
||
Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
|
||
|
||
Деловые колонки сводки:
|
||
|
||
- `data_date` — проверяемый день;
|
||
- `check_name` — устойчивое имя инварианта;
|
||
- `reference_rows` — число деловых ключей на эталонной стороне;
|
||
- `actual_rows` — число деловых ключей в проверяемом объекте;
|
||
- `failed_rows` — число отсутствующих или различающихся ключей;
|
||
- `status` — `pass` при нуле нарушений, иначе `fail`.
|
||
|
||
Служебные `_load_id` и `_load_ts` называют собравший запуск. Представление
|
||
перечисляет колонки явно. Порогов и промежуточного состояния нет.
|
||
|
||
## Звено DQ конвейера
|
||
|
||
Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять
|
||
атомарных договоров:
|
||
|
||
- `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон —
|
||
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк
|
||
`dds.session_v`;
|
||
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
|
||
`orders` в паспорте мира, объект — число заказов класса в
|
||
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
|
||
`duplicate_event` и `amount_delta` только для покрытых паспортом закрытых
|
||
дней;
|
||
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
|
||
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
|
||
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
|
||
поля сверки сравниваются с независимой сборкой из `dds.event_v` и
|
||
`dds.order_v`;
|
||
- `daily_traffic_vs_dds`: ключ — день. `visitors` и `known_users`
|
||
сравниваются с прямыми агрегатами `dds.session_v` и
|
||
`dds.identity_map_v`.
|
||
|
||
Область каждой проверки выводится из объединения сторон. Полностью потерянный
|
||
день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут
|
||
скомпенсировать друг друга общим итогом: `failed_rows` считается после
|
||
сопоставления деловых ключей.
|
||
|
||
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
|
||
подтверждает, что до витрины доехал записанный в паспорте мир, но не проверяет
|
||
сам генератор: у настоящего магазина такого оракула нет.
|
||
|
||
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
|
||
сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой
|
||
файл JSON в Python. Все пять сборок пишут в общий донор. После их успеха даг
|
||
заменяет проверенные дневные партиции. Затем именованные задачи читают
|
||
опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
|
||
сохраниться до красного завершения, а ошибка сборки не затирает прежний
|
||
результат. Красная задача печатает не больше двадцати нарушенных ключей за
|
||
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
|
||
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
|
||
business_key`.
|
||
|
||
Если после починки день исчез с обеих сторон, его больше нет и в новом доноре.
|
||
Такой день удаляется из сводки отдельно: старая красная строка не должна
|
||
превращаться в историю попыток.
|
||
|
||
## Даг самопроверки
|
||
|
||
Домашки контролирует `homework_check` — отдельный даг без расписания: менти
|
||
запускает его рукой. По задаче на каждое из
|
||
[пяти заданий по витринам](../labs/README.md).
|
||
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
|
||
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
|
||
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
|
||
лабы, а не история качества конвейера.
|
||
|
||
Приём проверен на соседнем учебном стенде Airflow + Greenplum:
|
||
валидационный даг с группами проверок по слоям давал менти обратную связь
|
||
по каждому шагу домашки, не трогая рабочий конвейер.
|
||
|
||
## Отклонённые варианты
|
||
|
||
- **Весь слой представлениями.** Дёшево и всегда свежо, но главный
|
||
производственный паттерн слоя — пересборка агрегатов — остался бы
|
||
невидим, а `dm_transform` — без работы.
|
||
- **Материализовать всё.** Копия событий ради переименования колонок —
|
||
антипаттерн, который менти увёз бы в бой.
|
||
- **Гибрид по цене запроса.** Выбор формы по стоимости, которой на объёмах
|
||
стенда нет: сторож без настоящей опасности.
|
||
- **Проверки домашек в конвейерном звене DQ.** Конвейер стоял бы красным до
|
||
выполнения лаб, а проверка, которая всегда красная, приучает себя
|
||
игнорировать.
|
||
- **«Жёлтый» статус недостроенной витрины.** У задачи Airflow нет такого
|
||
состояния; изображать его логами — семантика, которую пришлось бы
|
||
расшифровывать. Красное «ещё не построено» честнее.
|
||
- **Самопроверка пишет в `dq_summary`.** Попытки менти смешались бы с
|
||
историей качества конвейера.
|
||
- **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили
|
||
бы число известных на момент закрытия — витрина тихо расходится с картой
|
||
и протаскивает обратно «знание на дату», отвергнутое развилкой карты
|
||
(#133).
|
||
- **Инкрементальный пересчёт затронутых дней по `first_matched_at`.**
|
||
Производственный ход, но на объёмах стенда его экономия неизмерима, а
|
||
след новых связок требует вернуть колонку, выкинутую в #133. Стал лабой
|
||
повышенной сложности — issue #149.
|
||
- **Superset поверх DDS без слоя DM.** Терялся бы урок «витрина — контракт
|
||
под потребителя», против раздела 7 спеки.
|
||
|
||
## Связанные решения
|
||
|
||
- [Мастер-спека](../specs/2026-07-30-stand-v2-realism.md), разделы 4 и 7, —
|
||
состав колонок витрин и классы сверки.
|
||
- [Дневной конвейер](etl.md) — запуск `dm_transform`, объём прогона,
|
||
замена партиций на кластере.
|
||
- [Дока хранилища](storage.md) — имена объектов, конвенция чтения через `_v`,
|
||
партиция-донор.
|
||
- [Карта модели DDS](dds/README.md) — источники витрин.
|
||
- Резолюция — тикет #135 карты #127.
|