# Слой 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/optional/superset-dashboard.md), доступная после ступени 2. Устройство стендового дашборда — в [доке зоны 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.