- Зачем:
- менти строит витрины DM сам, от DDL по правилу зерна до задачи в
конвейере; стенду нужны ТЗ с образцом и обратная связь по каждой
витрине (#159, резолюция #135).
- Что:
- docs/labs/: README и пять ТЗ — events_enriched_v, top_pages_daily,
session_overview, dq_errors_daily, utm_effectiveness; каждое ТЗ
называет урок, состав колонок, форму по зерну и самопроверку.
- dags/homework_check.py: ручной даг, задача на витрину, красная без
витрины или при расхождении; проверки — шесть файлов sql/dq/ с
инвариантами против DDS и ODS, без золотых констант; в dq_summary
не пишет.
- docs: dm.md называет homework_check и снимает «Остается нарезке»,
§7 и §10 мастер-спеки согласованы с лабами, dds/session.md указывает
на ТЗ, AGENTS.md пополнен правилом имен docs/labs/.
- Проверка:
- make lint; make smoke 21/21, make check-clickhouse 10/10,
make check-services 7/7.
- на свежем стенде все пять задач homework_check красные и называют
ТЗ; черновик каждой витрины по тексту ТЗ зеленит свою задачу,
после сноса — снова красная; строк от самопроверки в dq_summary нет.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
22 KiB
Слой DM: витрины
Как модель DDS превращается в витрины для потребителя BI: какую форму принимает витрина, кто какие витрины строит и как слой проверяется. Состав колонок каждой витрины задаёт раздел 7 мастер-спеки; здесь — форма, наполнение и контроль.
Форма: по зерну, не по цене запроса
Витрина, меняющая зерно источника — дневной агрегат, сверка по заказам, —
материализуется: dm.<имя>_rep / _dist и публичное dm.<имя>_v с явным
списком колонок, по общим конвенциям хранилища. Наполняет её
dm_transform; ритм и механика пересборки — ниже. Витрина, сохраняющая зерно
источника — переименование, обогащение строки, — остаётся обычным
представлением: копию событий ради других имён колонок не держит ни один
боевой склад.
Правило выбрано учебной целью, а не ценой запроса. На объёмах стенда представлениями обошлось бы всё: условия сквозь цепочку представлений проходят (исследование). Но пересборка агрегатов конвейером — производственный паттерн, ради которого стенд и строится, а пара «таблица рядом с представлением» показывает: форма — решение по роли витрины, у одного слоя законны обе.
Кто строит: стенд и менти
Стенд привозит готовыми витрины трёх сюжетов дашборда этапа 8 и инфраструктуру контроля:
revenue_daily— выручка по дням и категориям; образец материализованной витрины, на него ссылаются учебные задания;purchase_vs_orders— сверка A+C с классами расхождений; зерно — заказ, шардирование сохраняет ко-локацию заказов поcityHash64(order_id);daily_traffic— пара «посетители» / «известные пользователи», обогащение картой идентичностей;dq_summary— сводка проверок качества (ниже).
Остальные витрины спеки — учебные задания: менти строит их сам по ТЗ с
образцом. Разминка — представление events_enriched_v (зерно события),
затем агрегаты top_pages_daily, session_overview и dq_errors_daily,
последней — самая содержательная utm_effectiveness с уроком declared_*.
Построенная витрина встаёт в конвейер руками менти: финальный пункт ТЗ
каждого агрегата — добавить задачу своей витрины в dm_transform. До этого
конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается
наравне со стендовыми. У разминочного представления такого пункта нет:
пересобирать нечего. Тексты заданий — в docs/labs/.
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит от выполненных домашек. Расширение дашборда визуализациями по своим витринам — отдельная домашка после этапа 8.
Ритм пересборки
День витрины пересобирается, пока может измениться его источник, — поэтому ритмов три, по числу источников:
- от событий и сессий — день замерзает сразу; пересборка догоняет хвост несобранных дней заменой дневных партиций;
- от заказов — день дышит, пока входит в последний слепок; пересборка заменяет партиции этого отрезка, дни за ним закрыты по построению;
- от карты идентичностей — не замерзает никогда: склейка ретроспективна, покупка сегодня делает куку известной во всех её прошлых днях, а карта пересчитывается целиком.
Поэтому 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-таблица вернула бы каждый непарный заказ по разу с каждого шарда
(исследование).
Непарная сторона остается 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и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 — отдельный даг без расписания: менти
запускает его рукой. По задаче на каждое из пяти заданий.
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
золотые константы не прибиваются. В dq_summary даг не пишет: он инструмент
лабы, а не история качества конвейера.
Приём проверен на соседнем учебном стенде Airflow + Greenplum: валидационный даг с группами проверок по слоям давал менти обратную связь по каждому шагу домашки, не трогая рабочий конвейер.
Отклонённые варианты
- Весь слой представлениями. Дёшево и всегда свежо, но главный
производственный паттерн слоя — пересборка агрегатов — остался бы
невидим, а
dm_transform— без работы. - Материализовать всё. Копия событий ради переименования колонок — антипаттерн, который менти увёз бы в бой.
- Гибрид по цене запроса. Выбор формы по стоимости, которой на объёмах стенда нет: сторож без настоящей опасности.
- Проверки домашек в конвейерном звене DQ. Конвейер стоял бы красным до выполнения лаб, а проверка, которая всегда красная, приучает себя игнорировать.
- «Жёлтый» статус недостроенной витрины. У задачи Airflow нет такого состояния; изображать его логами — семантика, которую пришлось бы расшифровывать. Красное «ещё не построено» честнее.
- Самопроверка пишет в
dq_summary. Попытки менти смешались бы с историей качества конвейера. - Окно заказов для
daily_traffic— прошлое замерзает. Дни за окном хранили бы число известных на момент закрытия — витрина тихо расходится с картой и протаскивает обратно «знание на дату», отвергнутое развилкой карты (#133). - Инкрементальный пересчёт затронутых дней по
first_matched_at. Производственный ход, но на объёмах стенда его экономия неизмерима, а след новых связок требует вернуть колонку, выкинутую в #133. Стал лабой повышенной сложности — issue #149. - Superset поверх DDS без слоя DM. Терялся бы урок «витрина — контракт под потребителя», против раздела 7 спеки.
Связанные решения
- Мастер-спека, разделы 4 и 7, — состав колонок витрин и классы сверки.
- Дневной конвейер — запуск
dm_transform, объём прогона, замена партиций на кластере. - Дока хранилища — имена объектов, конвенция чтения через
_v, партиция-донор. - Карта модели DDS — источники витрин.
- Резолюция — тикет #135 карты #127.