docs(warehouse): упрощены объяснения витрин, сессий и пересчета

Зачем: читателю ступеней 1–4 нужно проследить выбор дней, расчет строки
и публикацию результата без расшифровки метафор.

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

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

Refs: #249
This commit is contained in:
ddadmin committed 2026-09-13 14:33:39 -04:00
1 parent 84dc718f62
commit 5f317ba8d2
11 files changed
+440 -490

No files matched your search

+9 -14
View File
@@ -1,18 +1,13 @@
"""Трансформации слоя DDS: сборка моделей. """Трансформации DDS: заказы, сессии и карта идентичностей.
Заказ и сессия собираются заменой дневных партиций. Карта идентичностей Заказы и сессии собираются в донор, затем публикуются заменой дневных партиций.
пересчитывается целиком после заказа, потому что читает его текущую модель. Карта идентичностей пересчитывается целиком после заказов и записывается новой
версией; публичное представление выбирает текущие строки через FINAL.
Расписания у дага нет и параметров тоже: объём каждого прогона он выводит сам Даг запускается без расписания и параметров. Даты он выбирает по состоянию
из состояния слоёв и позиции мира, поэтому один и тот же запуск одинаково таблиц и позиции мира. Новый запуск заново определяет диапазон: наличие даты
верен из триггера дневного конвейера, с кнопки менти и после падения не подтверждает полноту дня и не гарантирует повтор его сборки после сбоя.
(docs/architecture/etl.md, «Объём прогона»). Подробности — docs/architecture/etl.md, «Объём прогона» и «Идемпотентность».
Заказ и сессия гасят повтор заменой партиции целиком: день собирается в
таблицу-двойник и подменяет собой партицию цели. Карта пишет полный набор пар
новой версией, а точное чтение даёт FINAL. Замена партиции разобрана в
sql/dds/order_replace.sql; точное чтение карты описано в
sql/ddl/50-dds-views.sql.
""" """
from __future__ import annotations from __future__ import annotations
@@ -298,7 +293,7 @@ def dds_transform():
@task_group(group_id="order") @task_group(group_id="order")
def order(): def order():
"""Заказ: дышащее окно плюс дни, которых в слое ещё нет.""" """Заказ: изменяемые дни последнего слепка и пропущенные дни."""
@task @task
def scope() -> dict[str, object]: def scope() -> dict[str, object]:
+86 -84
View File
@@ -1,111 +1,113 @@
# Модель сессии в DDS # Модель сессии в DDS
Учебный результат: менти строит расчётную сущность, которой нет в потоке, — Сессия объединяет события одного визита. На стенде вы учитесь определять
режет события на сессии по правилу визита, чеканит идентификатор и сверяет ее границы по событиям, вычислять устойчивый идентификатор и сверять
свою нарезку с эталоном. Стенд здесь играет роль сессионизатора: в бою её результат с эталоном. Поэтому сборка читает `dds.event_v`, где нет готового
играет Метрика, которая режет визиты у себя и с октября 2025 отдаёт готовый `VisitID`. Этот идентификатор источника остается в ODS для проверки.
`VisitID` прямо в хитах. Поэтому стенд считает сессии сам, а `VisitID`
генератора использует только как эталон самопроверки — иначе сборка сессий
выродилась бы в `GROUP BY` по чужому полю и центральный урок
слоя пропал бы. Без этого абзаца действия стенда выглядят странно: «зачем
считать то, что уже дано колонкой».
## Зерно ## Зерно
Одна строка — один визит ([CONTEXT.md](../../../CONTEXT.md)): подряд идущие Зерно описывает, чему соответствует одна строка: здесь это один
события одной куки без пауз длиннее 30 минут, не пересекающие границу суток. [визит](../../../CONTEXT.md#люди-и-события). В него входят подряд идущие
В нарезку входят все три типа событий — торговые садятся на свои страницы события одного `client_id` без пауз длиннее 30 минут и без перехода через
и держат сессию наравне с просмотрами. События внутри сессии модельную полночь. Пауза ровно в 30 минут новую сессию не открывает.
упорядочиваются по метке времени события (`UTCEventTime` — опорная колонка Учитываются все три типа событий: `pageview`, `add_to_cart`, `purchase`.
приёма).
Граница суток проходит по модельному дню счётчика, и он у события уже есть: День сессии `session_date` берется из `event_date` событий DDS — это
`EventDate` — свойство содержимого. Раз сессия сутки не пересекает, у всех `EventDate` источника, дата по часовому поясу счетчика. У всех событий
её событий `EventDate` один; он и становится **днём сессии** — колонкой одной сессии она одинакова. Пересчитывать ее из времени UTC не нужно.
партиции, без пересчёта.
Оба окна нарезки используют порядок `(event_time, event_id)`: при равном
времени события идут по идентификатору. Одинаковый порядок нужен, чтобы
признак начала визита и накопленный номер визита относились к одним
и тем же событиям.
## Идентификатор ## Идентификатор
Идентификатор сессии в природе не существует: сессия — расчётная сущность, `session_id` вычисляется как `cityHash64(client_id, min(event_time))`.
и id ей чеканит тот, кто сессионизирует. `VisitID` — такой же суррогат, При повторной сборке тех же событий посетитель и начало визита остаются
отчеканенный генератором в роли Метрики; наш — `session_id`, прежними, поэтому идентификатор не меняется. Счетчик или случайный UUID
детерминированный хеш от пары «кука + начало сессии» (`cityHash64`). выдали бы другие значения.
Детерминированность обязательна: партиция дня пересобирается заменой, и
повторный прогон обязан отчеканить те же id — счётчик или UUID это правило
ломают.
`session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка: По `session_id` можно сопоставить сессии до и после пересборки и назвать
в соединениях он не участвует — события его не несут, мост к заказам живёт конкретную строку при разборе расхождения. Он не используется для сортировки
на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со или шардирования. У событий этого поля нет; связь с заказами строится
стендовой и назвать расхождение одним числом) и разбор DQ-расхождений. по событиям покупки.
## Состав строки ## Состав строки
Боевой образец — таблица визитов Метрики, парная к нашему формату хита; по - `client_id`, `session_id`, `session_date` — посетитель, сессия и ее день.
ней состав и собран: - `started_at`, `finished_at`, `duration_seconds` — время первого и последнего
события и разница между ними в секундах. Времена хранятся в часовом поясе
счетчика.
- `events_total`, `pageviews`, `cart_adds`, `purchases` — число всех событий
и счетчики трех типов.
- `entry_url`, `exit_url` — страницы первого и последнего события.
- `utm_*` — метки первого события: по ним визит относят к рекламной кампании.
- `device_category`, `region_city` — устройство и город посетителя.
На стенде они постоянны для одной куки.
- `_load_id`, `_load_ts` — идентификатор запуска и время сборки строки.
- **ключи и времена**: кука, `session_id`, начало и конец сессии, В готовом SQL страницы выбираются через `argMin` и `argMax`, а UTM,
длительность, день сессии. Времена — в поясе счётчика по правилу слоя; устройство и город — через `argMin`, только по `event_time`. При одинаковом
- **счётчики**: событий всего, просмотров, корзин, покупок; времени выбор между событиями не определен. Полный порядок окон нарезки
- **страницы**: входная и выходная — как `StartURL`/`EndURL` визита; полного сам по себе эту неоднозначность не устраняет. Это ограничение учитывается
списка страниц нет и у Метрики, топы считаются по событиям; в [задании ступени 4](../../labs/4-1-session-pipeline.md).
- **атрибуция**: UTM-метки первого события сессии — так визит атрибутирует
Метрика. В v1 витрина брала последнее касание — заменено осознанно;
- **паспорт куки**: категория устройства (расшифрованная, язык бизнес-модели)
и город — у куки они постоянны, берутся с первого события;
- **служебные**: `_load_id` и `_load_ts` собравшего запуска — правило слоя
([модель заказа](order.md), «Служебные колонки»).
Осознанные расхождения с образцом: **денег в сессии нет** — визит Метрики В сессии нет суммы выручки: ее считают по заказам магазина. Клиентская
несёт клиентскую выручку, у нас деньги считает только бэкенд (спека, раздел сумма покупки используется в сверке с заказами и в учебной UTM-витрине.
7), а клиентская сумма живёт в сверке; **целей нет** — их место заняли
счётчики торговых событий.
## Движок и наполнение ## Движок и наполнение
`dds.session_rep` — `ReplicatedMergeTree` без колонки версии, по образцу `dds.session_rep` хранит готовые сессии в `ReplicatedMergeTree` без колонки
заказа: партиция — день сессии, ключ сортировки — кука и начало сессии. версии. При чтении не нужно заново выполнять оконный расчет. Партиция —
`dds.session_dist` шардирует по `cityHash64(client_id)`, тем же выражением, раздел таблицы за один `session_date`; ключ сортировки — посетитель
что события, — это ко-локация с `dds.identity_map_rep` ([дока и начало сессии. Читайте результат через `dds.session_v`: представление
хранилища](../storage.md), «Раскладка по шардам»). над `dds.session_dist` явно перечисляет деловые и служебные колонки.
Совпадение ключа с событиями не случайно: все события куки лежат на одном
шарде, поэтому сессионизация локальна по природе — капкан смены ключа при
вставке, который дока хранилища обещает сборкам DDS, у сессий не стреляет.
Сборка — дневной батч: хранилище выше ODS по смыслу батчевое, раз второй Источник сборки — `dds.event_v`. События, сессии и карта идентичностей
источник приходит раз в сутки. Собирается день, который мир уже прожил, и распределены по одному ключу `cityHash64(client_id)`: данные посетителя
собирается один раз и целиком; какой день прожит, слой спрашивает у позиции находятся на одном шарде. Роли объектов и правило записи через `_dist`
мира ([дневной конвейер](../etl.md), «Объём прогона»). Идемпотентность держит описаны в [справочнике хранилища](../storage.md#раскладка-по-шардам).
замена партиции. В отличие от заказов, где батч пересобирает до K «дышащих»
дней, у сессии дышащего окна нет: прожитый день самодостаточен (правило
спеки, раздел 6). Второй раз он соберётся только
вынужденно — когда упавшая замена оставила позади дыру и хвост от неё накрыл
соседние дни. Дней в одном прогоне при этом бывает несколько — столько,
сколько мир успел прожить без слоя. Внутри живого дня
модель сессии про сегодня честно молчит — «сегодня» смотрят через
`dds.event_v`; сессия и по смыслу не финальна, пока день не прожит.
Публичная поверхность — `dds.session_v`: обычное представление с явным Три SQL-файла выполняются последовательно в группе `session` дага
списком деловых и собственных служебных колонок над `dds.session_dist`. `dds_transform`:
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
объектов».
Источник сборки — `dds.event_v` ([контракт события](event.md)): сессии 1. `session_scope.sql` находит первый день событий, которого еще нет
нужны от него ключи нарезки, тип события, страница, UTM и паспорт куки. в таблице сессий. Последний день для сборки — первый день событий ODS
Механика запуска — где собирается партиция и как подменяется на кластере — плюс `world_position` минус один. Так в расчет попадают только полностью
не входит: это устройство [дневного конвейера](../etl.md). прожитые дни. При пустом ODS запрос не возвращает строк и задача падает;
если пропущенных прожитых дней нет, `first_day` равен `NULL` и группа
пропускается.
2. `session_rebuild.sql` очищает `dds.session_stage_rep` и собирает весь
выбранный диапазон в `dds.session_stage_dist`. Это донор — служебная
таблица для подготовки результата до публикации. Вставка ждет доставки
строк на шарды благодаря `distributed_foreground_insert = 1`.
3. `session_replace.sql` заменяет каждый день в `dds.session_rep` партицией
донора. Для каждого дня Airflow создает отдельную задачу `replace`.
В обычном ходе сессии прожитого дня больше не меняются. Если пропущен ранний
день, диапазон включает и все последующие прожитые дни, даже уже собранные.
Поиск по наличию даты не обнаружит неполный день, у которого часть строк
сохранилась. Условия повторного запуска и границы атомарности замены
описаны в [справочнике конвейера](../etl.md#идемпотентность).
Сессий еще идущего дня в модели нет; его события доступны через
`dds.event_v`. Перед выбором дней даг ждет завершения доставки событий
в ODS — см. [готовность источника](../etl.md#готовность-источника).
## Сверка с эталоном ## Сверка с эталоном
Счётная DQ-проверка с зерном «кука × день»: число сессий нашей нарезки Проверка `sessions_vs_reference` в `dq_check` сравнивает для каждого
равно `uniqExact(VisitID)` по тем же событиям. Счёт по Distributed ловит и посетителя и дня число строк `dds.session_v` с `uniqExact(VisitID)`
склеенную, и разрезанную сессию — мимо него проходит лишь взаимная из `ods.event_v`. Эталон читается из ODS, потому что `dds.event_v`
компенсация ошибок внутри одной куки за день; эталон в модель не тащим — скрывает `VisitID`.
строка сессии остаётся нашей, а не генераторной. Поштучное сопоставление
границ — материал лабы, где менти разбирает найденное расхождение руками. Дом проверки — Нулевое число расхождений подтверждает совпадение счета, но не всех
контур DQ этапа 4. Сверке нужен `VisitID`, а сборке сессий — нет; контракт границ и атрибутов сессий. Две ошибки внутри одного посетителя и дня
`dds.event_v` его скрывает, и за эталоном проверка идёт в `ods.event_v` могут сохранить число визитов. Поэтому в заданиях вы дополнительно
([контракт события](event.md)). сопоставляете границы сессий по исходным событиям и проверяете страницы,
UTM, устройство и город.
## Читатели ## Читатели
+117 -105
View File
@@ -1,87 +1,83 @@
# Слой DM: витрины # Слой DM: витрины
Как модель DDS превращается в витрины для потребителя BI: какую форму Витрины готовят данные DDS для отчетов и дашбордов. Здесь описаны форма
принимает витрина, кто какие витрины строит и как слой проверяется. Состав хранения, правила обновления и проверки. Состав колонок задан в
колонок каждой витрины задаёт раздел 7 мастер-спеки; здесь — форма, [спецификации](../specs/2026-07-30-stand-v2-realism.md#витрины-dm)
наполнение и контроль. и [учебных заданиях](../labs/README.md).
## Таблица или представление ## Таблица или представление
Дневной агрегат и сверка по заказам меняют зерно источника. На этом стенде такие Зерно — то, чему соответствует одна строка. Например, в заказах это заказ,
результаты хранятся в `dm.<имя>_rep` / `_dist` и читаются через публичное а в дневной выручке — день и категория товара. На стенде результат
представление `dm.<имя>_v` с явным списком колонок. Имена и устройство объектов с новым зерном хранится в таблице: отчет читает готовые итоги без
следуют общим [конвенциям хранилища](storage.md). Таблицы наполняет повторного расчета. Таблицу наполняет `dm_transform`.
`dm_transform`; ритм и механика пересборки описаны ниже. В задании этот путь
приходится пройти целиком: рассчитать агрегат, загрузить его и включить
обновление в конвейер.
Переименование колонок и обогащение строки зерно не меняют. Такие витрины на У такой витрины есть локальная `_rep`, распределенная `_dist` и публичное
стенде остаются обычными представлениями и не хранят вторую копию событий. представление `_v` с явным списком колонок. Для замены дневных партиций
служат еще две таблицы: `_stage_rep` и `_stage_dist`. Это донор — место
сборки результата перед публикацией. Подробности — в
[справочнике хранилища](storage.md#имена-объектов).
На объёмах стенда все витрины могли бы остаться представлениями: условия Если запрос только переименовывает или дополняет поля и сохраняет одну
проходят сквозь их цепочку строку на событие, достаточно обычного представления. Оно выполняет
запрос при чтении, не хранит вторую копию событий и не требует пересборки.
На небольших данных стенда и агрегаты могли бы вычисляться при чтении
([исследование](../research/2026-08-22-view-predicate-pushdown.md)). ([исследование](../research/2026-08-22-view-predicate-pushdown.md)).
Мы все равно оставили материализованные агрегаты ради практики: без них в Готовые таблицы нужны в программе для практики загрузки, обновления
программе не было бы загрузки и пересборки витрин. Представления дают другой и включения витрин в конвейер.
случай: преобразование строк на прежнем зерне.
## Что готово в стенде, а что остаётся заданием ## Что готово в стенде, а что остаётся заданием
Стенд привозит готовыми витрины трёх сюжетов дашборда этапа 8 и Готовы три витрины дашборда и сводка качества:
инфраструктуру контроля:
- `revenue_daily` — выручка по дням и категориям; образец материализованной - `revenue_daily` — одна строка на день заказа и категорию товара;
витрины, на него ссылаются учебные задания; образец дневной пересборки для учебных заданий.
- `purchase_vs_orders` — сверка A+C с классами расхождений; зерно — заказ, - `purchase_vs_orders` — одна строка на заказ: сопоставление покупки
шардирование сохраняет ко-локацию заказов по `cityHash64(order_id)`; из кликстрима с заказом магазина и класс расхождения.
- `daily_traffic` — пара «посетители» / «известные пользователи», обогащение - `daily_traffic` — одна строка на день: число посетителей и известных
[картой идентичностей](dds/identity.md); пользователей с учетом [карты идентичностей](dds/identity.md).
- `dq_summary` — сводка проверок качества (ниже). - `dq_summary` — одна строка на день данных и проверку качества.
Остальные витрины спеки нужно построить в учебных заданиях по готовому ТЗ и В [заданиях ступени 1](../labs/README.md) вы строите `events_enriched_v`,
образцу. Разминка — представление `events_enriched_v` (зерно события), затем `top_pages_daily`, `session_overview`, `dq_errors_daily` и `utm_effectiveness`.
агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`, последней — Для каждой физической витрины нужно добавить сборку в `dm_transform`.
самая содержательная `utm_effectiveness` с уроком `declared_*`. В финале Представление `events_enriched_v` отдельной задачи не требует.
каждого задания с агрегатом его задачу нужно добавить в `dm_transform`. До
этого конвейер о витрине не знает, после — пересобирает её вместе со
стендовыми. Представление из разминки добавлять некуда: его не нужно
пересобирать. Тексты заданий лежат в [`docs/labs/`](../labs/README.md).
Дашборд этапа 8 читает только готовые витрины стенда, поэтому его приёмка не Стендовый дашборд читает готовые витрины и работает до выполнения заданий.
зависит от домашних заданий. Отдельный дашборд по учебным витринам можно Дашборд по своим витринам можно собрать в
собрать в [факультативном задании](../labs/optional/superset-dashboard.md), [факультативе](../labs/optional/superset-dashboard.md) после ступени 2.
доступном после ступени 2. Устройство стендового дашборда описано в Устройство стендового дашборда — в [справочнике Superset](superset.md).
[справочнике Superset](superset.md).
## Ритм пересборки ## Ритм пересборки
День витрины пересобирается, пока может измениться его источник, — Дни для пересчета выбирают по тому, как меняется источник:
поэтому ритмов три, по числу источников:
- **от событий и сессий** — день замерзает сразу; пересборка догоняет - События и сессии прожитого дня на стенде больше не меняются. Витрины
хвост несобранных дней заменой дневных партиций; по ним собирают диапазон от первого пропущенного дня до последнего
- **от заказов** — день дышит, пока входит в последний слепок; пересборка прожитого и заменяют дневные партиции.
заменяет партиции этого отрезка, дни за ним закрыты по построению; - Заказы могут меняться в течение семи модельных дней. Последний слепок ODS
- **от карты идентичностей** — не замерзает никогда: склейка задает диапазон дат для пересборки; пропущенные дни могут расширить его.
ретроспективна, покупка сегодня делает куку известной во всех её прошлых - Новые связи карты идентичностей меняют трактовку прошлых визитов:
днях, а карта пересчитывается целиком. сегодняшняя покупка позволяет узнать пользователя и в ранних днях.
Поэтому `daily_traffic`, как и карта, пересчитывается целиком.
Поэтому `daily_traffic` наследует у своего источника и ритм, и механику: `daily_traffic` каждый раз добавляет всю историю новой версией
полный пересчёт всех дней каждый прогон в `ReplacingMergeTree(_load_ts)`, в `ReplacingMergeTree(_load_ts)`. Дневных партиций у нее нет, а текущие
как у самой карты. Дневных партиций у неё нет: партиция — единица замены, строки выбирает представление через `FINAL`.
а эта витрина заменяется только целиком. На стенде такая работа остается
быстрой; витрина читает сессии и карту через их публичные договоры.
Пересчитывать точечно только затронутые дни не из чего: для этого надо
знать, какие связки новые, а полный пересчёт карты этот след затирает —
восстановление следа оставлено лабе повышенной сложности (issue #149).
У `purchase_vs_orders` два источника и одна непрерывная пересборка. Заказная У `purchase_vs_orders` два источника. Левая граница — самая ранняя
сторона задает изменяемое окно последним принятым слепком ODS, событийная — из трех дат: начало окна последнего слепка, первый пропущенный день
последний прожитый день с `purchase`. Левая граница возвращает первую дыру и и первый день с `awaiting_order`. Последний нужен потому, что строка
самый ранний день с `awaiting_order`: предварительная строка уже есть в ожидания уже есть в витрине и поиском пропусков не обнаружится.
витрине, поэтому поиск отсутствующего дня ее не заметит. Правая граница Правая граница — более поздняя из конца окна заказов и последнего
доходит до более позднего из двух хвостов. Так один прогон догоняет пакетный прожитого дня с покупкой. Один запуск пересобирает весь отрезок,
разгон длиннее K и не оставляет старое ожидание замороженным. даже если с предыдущего запуска прошло больше семи модельных дней.
Для учебной сводки брака действует отдельное правило: дни выбираются
по времени загрузки, а прежние итоги сохраняются после удаления
источника по TTL. Оно разобрано в
[задании `dq_errors_daily`](../labs/1-4-dq-errors-daily.md).
## Витрины стенда ## Витрины стенда
@@ -91,34 +87,49 @@
### `dm.revenue_daily_v` ### `dm.revenue_daily_v`
Зерно: день заказа × категория товара. Витрина читает только оплаченные Одна строка — день заказа и категория товара. Витрина читает только оплаченные
заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает
категорию артикула через `dic.products`. Один заказ с несколькими позициями категорию артикула через `dic.products`. Один заказ с несколькими позициями
категории считается в `orders` один раз. `units` складывает количество единиц, категории считается в `orders` один раз. `units` складывает количество единиц,
`revenue` складывает цену позиции × количество. Скидка, доставка и итог заказа `revenue` складывает цену позиции × количество. Скидка и доставка
между категориями не делятся. `aov` равен `revenue / orders` в том же срезе и в выручку не входят; итог заказа между категориями не делится. `aov` равен `revenue / orders` в том же срезе и
хранит два знака после запятой. хранит два знака после запятой.
Первый запуск строит всю доступную историю. Следующие берут границы дней из Первый запуск строит всю доступную историю. Следующие берут границы дней из
последнего слепка ODS: только источник знает, какие даты еще может изменить. последнего слепка ODS: только источник знает, какие даты еще может изменить.
Пропущенный оплаченный день расширяет левую границу, чтобы частичный отказ Если в DDS есть оплаченные заказы за день, отсутствующий в витрине,
не оставил дыру после следующего прогона. Агрегат при этом читает DDS; ODS левая граница расширяется до него. Агрегат читает DDS; ODS отвечает
отвечает только на вопрос об объеме. только на вопрос об объеме.
`dm.revenue_daily_rep` нарезана по `report_date`; день собирается в В группе `revenue_daily` дага `dm_transform` три шага:
таблице-доноре и заменяется через `REPLACE PARTITION`. Цель и донор шардируются
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_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)`. Кроме дня, у витрины только категория, а по `cityHash64(product_category)`. Кроме дня, у витрины только категория, а
шесть категорий стартового мира близки по объему. Более сложный составной ключ шесть категорий стартового мира близки по объему. Более сложный составной ключ
распределил бы эту маленькую таблицу без нового урока. распределил бы эту маленькую таблицу без нового урока.
### `dm.purchase_vs_orders_v` ### `dm.purchase_vs_orders_v`
Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по Одна строка — один заказ. Покупки из `dds.event_v` сначала сворачиваются по
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную `purchase_id[1]`: контракт источника обещает один номер и одну объявленную
сумму, а отдельный счет событий оставляет класс `duplicate_event` сумму. Отдельный счет событий позволяет обнаружить повтор покупки
наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны и присвоить класс `duplicate_event`. Заказы приходят из `dds.order_v`. Обе стороны записаны
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: левая
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда Distributed-таблица без подзапроса вернула бы каждый непарный заказ
по разу с каждого шарда
([исследование](../research/2026-08-22-distributed-join.md)). ([исследование](../research/2026-08-22-distributed-join.md)).
Непарная сторона остается `NULL` благодаря `join_use_nulls = 1`. При Непарная сторона остается `NULL` благодаря `join_use_nulls = 1`. При
@@ -129,10 +140,10 @@ Distributed-таблица вернула бы каждый непарный з
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без `duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
события остается отмененным, а не потерянным. события остается отмененным, а не потерянным.
Витрина нарезана по `order_day`, цель и донор шардируются по Партиции витрины разделены по `order_day`, цель и донор шардируются по
`cityHash64(order_id)`. Диапазон собирается в доноре одной вставкой и `cityHash64(order_id)`. Диапазон собирается в доноре одной вставкой и
заменяется по дням. Первичная сборка охватывает всю историю обоих источников; заменяется по дням. Первичная сборка охватывает всю историю обоих источников;
следующие прогоны идут по двустороннему ритму, описанному выше. следующие прогоны выбирают дни по правилам обоих источников, описанным выше.
### `dm.daily_traffic_v` ### `dm.daily_traffic_v`
@@ -140,28 +151,26 @@ Distributed-таблица вернула бы каждый непарный з
`dds.session_v`; `known_users` считает разные `user_id` после левого соединения `dds.session_v`; `known_users` считает разные `user_id` после левого соединения
с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине. с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине.
Несколько кук одного человека увеличивают `visitors`, но дают одного Несколько кук одного человека увеличивают `visitors`, но дают одного
`known_user`, поэтому `known_users <= visitors`. известного пользователя, поэтому `known_users <= visitors`.
Сессии и карта лежат по `client_id`, но публичные `_v` скрывают Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3
`Distributed`-таблицы от локальной подстановки. На ClickHouse 26.3 чтение через публичные `_v` с `distributed_product_mode = 'local'`
`EXPLAIN PLAN` показал чтение обоих шардов на инициаторе: настройка не дало локального соединения. Поэтому в запросе явно указан
`distributed_product_mode = 'local'` локального соединения не дала. Поэтому `GLOBAL LEFT JOIN`. Он сохраняет сессии без известного пользователя;
витрина использует явный `GLOBAL LEFT JOIN` через публичные договоры, а не в `known_users` учитываются только ненулевые `user_id`.
подменяет их физическими таблицами. Это дороже по сети, зато граница слоев Обновление всей истории новой версией описано в разделе
остается явной. Новая связь куки меняет прошлые дни, поэтому каждый запуск [«Ритм пересборки»](#ритм-пересборки).
пишет полную историю новой версией в `ReplacingMergeTree(_load_ts)`, а
публичное представление выбирает текущие строки через `FINAL`.
## `dm.dq_summary` ## `dm.dq_summary`
Зерно публичного `dm.dq_summary_v` — «день данных × атомарная проверка». Одна строка `dm.dq_summary_v` — день данных и одна проверка.
Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow. Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`. Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
Деловые колонки сводки: Деловые колонки сводки:
- `data_date` — проверяемый день; - `data_date` — проверяемый день;
- `check_name` — устойчивое имя инварианта; - `check_name` — имя проверяемого правила;
- `reference_rows` — число деловых ключей на эталонной стороне; - `reference_rows` — число деловых ключей на эталонной стороне;
- `actual_rows` — число деловых ключей в проверяемом объекте; - `actual_rows` — число деловых ключей в проверяемом объекте;
- `failed_rows` — число отсутствующих или различающихся ключей; - `failed_rows` — число отсутствующих или различающихся ключей;
@@ -173,7 +182,7 @@ Distributed-таблица вернула бы каждый непарный з
## Звено DQ конвейера ## Звено DQ конвейера
Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять
атомарных договоров: правил:
- `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон — - `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон —
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк `uniqExact(VisitID)` из `ods.event_v`, объект — число строк
@@ -197,9 +206,9 @@ Distributed-таблица вернула бы каждый непарный з
скомпенсировать друг друга общим итогом: `failed_rows` считается после скомпенсировать друг друга общим итогом: `failed_rows` считается после
сопоставления деловых ключей. сопоставления деловых ключей.
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она `classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая
подтверждает, что до витрины доехал записанный в паспорте мир, но не проверяет проверка доставки и обработки эталонных данных стенда. Она не проверяет
сам генератор: у настоящего магазина такого оракула нет. сам генератор; у настоящего магазина такого эталона нет.
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой
@@ -218,17 +227,20 @@ business_key`.
## Даг самопроверки ## Даг самопроверки
Домашние задания проверяет `homework_check` — отдельный даг без расписания, `homework_check` запускается вручную и содержит задачу для каждого из
который запускают вручную. В нём есть задача для каждого из [пяти заданий по витринам](../labs/README.md). Если витрины нет или значения
[пяти заданий по витринам](../labs/README.md). расходятся с источником, задача завершается ошибкой. Журнал показывает
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда причину и примеры несовпадений. Успех означает, что проверяемые значения
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины, совпали; остальные требования задания вы проверяете сами.
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
лабы, а не история качества конвейера.
Приём проверен на соседнем учебном стенде Airflow + Greenplum: Ожидаемые значения рассчитываются по исходным данным текущего стенда.
валидационный даг с группами проверок по слоям давал обратную связь по каждому Для четырех заданий это события или сессии DDS, для сводки брака —
шагу задания, не трогая рабочий конвейер. таблицы ошибок ODS. Сравнение по деловому ключу учитывает отсутствующие
строки с обеих сторон. Результаты не пишутся в `dq_summary`:
самопроверка учебных витрин отделена от качества готового конвейера.
Готовые SQL-проверки открывайте после собственного решения: они содержат
расчет существенной части задания.
## Отклонённые варианты ## Отклонённые варианты
+90 -82
View File
@@ -1,28 +1,21 @@
# Дневной конвейер трансформаций # Дневной конвейер трансформаций
Как принятые источники превращаются в модель DDS и витрины DM: кто запускает Даг `etl_pipeline` собирает модель DDS, обновляет витрины DM и проверяет
трансформации, когда и в каком объёме. Правила самих сущностей — зерно, результат. Здесь описаны запуск, выбор дней и публикация данных.
движки, что именно пересобирается — живут в доках слоя Состав моделей — в [справочниках DDS](dds/README.md) и [DM](dm.md).
([карта модели DDS](dds/README.md)); здесь — только конвейер.
Дневная сборка вперёд появляется на этапе 4 и остаётся основным режимом
конвейера. На этапе 5 к ней добавляется партиционная переобработка: запуск
произвольного прошлого дня по команде.
## Форма ## Форма
Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы `etl_pipeline` последовательно запускает `dds_transform`, `dm_transform`
у него нет: он дёргает слойные даги последовательно и ждёт каждый — и `dq_check`. Каждый следующий даг начинается после успеха предыдущего.
`dds_transform`, затем `dm_transform` и `dq_check`. Состав проверок описан в Для этого `TriggerDagRunOperator` ждет завершения созданного им запуска;
[доке слоя DM](dm.md). его ошибка завершает ошибкой и соответствующую задачу конвейера.
Поведение проверено через Context7 по документации Airflow 3.x (3.1.6).
Ждущие вызовы сделаны `TriggerDagRunOperator`. Проверка через Context7 по У этих дагов нет расписания. Их вызывает конвейер; для отдельного разбора
ближайшей доступной документации Airflow 3.x (версия 3.1.6) подтвердила, что можно запустить даг слоя вручную. У каждого разрешен один активный запуск:
оператор опрашивает точный созданный им прогон и краснеет вслед за потомком. два запуска одного дага не должны одновременно заполнять общий донор.
Внутри дага модели собраны в отдельные задачи и группы задач.
Слойные даги устроены по образцу `orders_ingest`: без расписания, один
активный прогон, запускаются триггером конвейера или рукой при разборе
происшествий. Витрины внутри `dm_transform` — отдельные задачи.
## Три пути запуска ## Три пути запуска
@@ -34,83 +27,98 @@ world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transfor
world_next_day ─────> источники без ETL world_next_day ─────> источники без ETL
``` ```
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним - `world_live_day` запускает конвейер после успешной генерации, приема
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт, заказов и обновления `world_position`. Если трансформация упала, день
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но уже прожит и позиция не откатывается. После исправления причины
зеленеть, не узнав, разложился ли день, шаговый даг не должен ([ADR запустите `etl_pipeline` вручную.
0003](../adr/0003-dag-run-state.md)). Такой отказ чинится перезапуском - `world_next_day` отправляет дни пачкой и ждет приема заказов, но конвейер
`etl_pipeline` рукой — перезапуску ничего не нужно помнить, см. «Объём не запускает. После него запустите `etl_pipeline` сами.
прогона». - `world_initialize` запускает конвейер последним шагом: при успешном
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: даги модельного первом `make up` начальные данные уже собраны в DDS и DM.
времени управляют генератором и только им. После разгона менти запускает
конвейер сам — и видит, как слои догоняют уехавший вперёд мир.
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
ручного запуска начинаются со следующих дней.
## Объём прогона ## Объём прогона
Параметров у конвейера нет: объём он выводит сам из `world_position` и Объем прогона — дни, которые обрабатывает один запуск. Даты передавать
состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по не нужно: даги определяют их по состоянию таблиц и `world_position`.
последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта Например, после генерации семи дней без ETL один запуск соберет все семь.
идентичностей пересчитывается целиком; правила витрин — у
[слоя DM](dm.md).
Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и
после падения, а разгону на семь дней хватает одного прогона.
Окно заказов задаёт последний слепок, наблюдаемый в ODS. У каждого заказа Сессии собираются от первого отсутствующего дня до последнего прожитого.
`snapshot_date` равен последнему слепку, в котором источник его показал. `world_position` хранит номер следующего дня для генерации, поэтому
Трансформация выбирает строки с максимальной `snapshot_date` и берёт среди них последний прожитый день равен `min(EventDate) + world_position - 1`.
минимальный и максимальный `order_date`. Это границы дней, состояние которых Начало календаря берется из ODS; события ранних дней там не удаляются по TTL.
источник ещё может изменить Один `max(EventDate)` не годится: события за день есть и во время его
([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Кроме живого окна, генерации, когда последние визиты еще не закончились.
трансформация закрывает пропуски: находит самый ранний день, представленный в Точный запрос — [`session_scope.sql`](../../sql/dds/session_scope.sql).
ODS и ещё не собранный в DDS, затем при необходимости расширяет до него левую
границу.
Позиция мира заказам не отвечает: она говорит, какой день прожит, а не какой Для заказов диапазон задает последний принятый слепок ODS. Среди строк
доехал. Прогон дня D везёт слепок дня D − 1, да и ставится позиция раньше с максимальной `snapshot_date` выбираются самая ранняя и самая поздняя
трансформации, так что после неудачного приёма обгоняет хранилище. Слой обязан даты оформления заказа. Эти дни еще могут измениться. Если в DDS
считать объём по тому, что у него есть, а не по тому, что мир о себе заявляет. отсутствует более ранний день, который есть в ODS, левая граница
расширяется до него. Все дни отрезка пересобираются.
Для сессий границу задаёт `world_position`: она отличает полностью прожитый Позиция мира для выбора дней заказов не подходит: с событиями дня D
день от ещё идущего. Сами события этой границы не показывают: и посреди дня D, приходит слепок за D−1. Объем рассчитывается по принятым строкам ODS;
и сразу после его конца их позднейший день равен D. Если собрать сессии посреди их наличие еще не доказывает полноту слепка.
дня, визит, начатый в 23:50, останется обрезанным: завершённый день слой
повторно не собирает. Поэтому последний прожитый день равен первому дню Карта идентичностей пересчитывается целиком. Витрины выбирают дни
событий плюс позиция минус один по своим источникам — см. [ритм пересборки DM](dm.md#ритм-пересборки).
([`sql/dds/session_scope.sql`](../../sql/dds/session_scope.sql)). Поиск отсутствующих дней проверяет только наличие даты: частично
Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не заполненный день требует отдельной проверки.
позиция, а «Готовность источника».
## Готовность источника ## Готовность источника
Первый шаг `dds_transform` проходит два барьера за общий срок в 300 секунд. Перед сборкой DDS задача `wait_for_source` последовательно ждет двух
Сначала клиент `confluent-kafka` сверяет подтвержденные смещения группы условий:
`clickstream_hits` с верхними смещениями обоих разделов `hits`. Группа, оба
раздела и все смещения должны существовать. Суммарное отставание должно стать
нулевым. Каждый опрос пишет в журнал оба смещения и отставание по разделу.
Только после этого шаг проверяет обе ноды ClickHouse через 1. Группа `clickstream_hits` дочитала оба раздела топика `hits`.
`clusterAllReplicas`: у `ods.event_dist` в `system.distribution_queue` не Подтвержденные смещения сравниваются с верхними смещениями Kafka.
должно остаться файлов. Каждый опрос пишет в журнал ноды, число файлов и Группа и смещения должны существовать, суммарное отставание — равняться нулю.
`last_exception` очереди. Оба барьера опрашиваются раз в три секунды и делят 2. В `system.distribution_queue` у `ods.event_dist` не осталось файлов
один крайний срок. После Kafka срок не отсчитывается заново. доставки на шарды. Запрос через `clusterAllReplicas` читает обе ноды.
Зелёный приём заказов о событиях не говорит ничего — топики разные и Опросы идут с паузой в три секунды; на обе стадии задан общий срок
потребители разные. Одного нулевого отставания Kafka тоже мало: чтец мог 300 секунд. Журнал показывает смещения и отставание Kafka, затем число
подтвердить сообщение раньше, чем Distributed-таблица доставила файл на шард. файлов и `last_exception` очереди на каждой ноде. Если готовность не
Два барьера закрывают обе части пути до того, как сессии выберут свой объем. подтверждена за срок ожидания, задача завершается ошибкой.
Одного нулевого отставания Kafka мало: сообщение может быть подтверждено,
пока Distributed-таблица еще доставляет его на шард. Успех `orders_ingest`
тоже не заменяет эти проверки: у заказов другой топик и потребитель.
Эти условия проверяют доставку на момент опроса. Они не останавливают
поступление новых событий и не сверяют содержимое таблиц. Поэтому
`world_position` отдельно ограничивает сборку сессий прожитыми днями,
а качество результата проверяет `dq_check`.
## Идемпотентность ## Идемпотентность
Партиция не заменяется простой вставкой. День собирается в таблицу-двойник — Идемпотентность означает, что повторная обработка тех же исходных данных
вставка через Distributed раскладывает строки по шардам, — затем не добавляет копии деловых строк. Служебные `_load_id` и `_load_ts`
`REPLACE PARTITION` на кластере атомарно подменяет кусок каждого шарда. Эта при новой сборке меняются.
механика — часть самих трансформаций и строится этапом 4: без неё дневной
прогон не повторяем. Сущности, пересчитываемые целиком, идемпотентны и без Для дневных таблиц порядок такой: `scope` выбирает даты, `rebuild` очищает
замены: полная перезапись в `ReplacingMergeTree` — так живут карта донор и собирает в него весь диапазон, затем отдельные задачи `replace`
идентичностей и витрина `daily_traffic` ([слой DM](dm.md)). публикуют дни через `REPLACE PARTITION`. Донор — служебная таблица с той же
структурой и раскладкой по шардам, что у цели. Запись в `_stage_dist`
с `distributed_foreground_insert = 1` дожидается доставки строк перед заменой.
Замена выполняется на локальных `_rep` с `ON CLUSTER`. Она атомарна
на каждом шарде: читатель видит старую или новую партицию. Единой
транзакции для двух шардов и всех дней нет. Если замена прошла только
на одном шарде, другой может сохранить прежние данные. Повтор задачи
`replace` с тем же готовым донором повторит замену без добавления копий.
Новый запуск всего конвейера заново выбирает дни. Он не обязан повторить
все дни неудачного запуска: если дата уже есть в целевой таблице, поиск
пропусков ее не выберет. В частности, изменение SQL сессий само по себе
не заставляет пересчитать заполненные дни. Повторная сборка выбранного
дня разобрана в [задании ступени 4](../labs/4-1-session-pipeline.md).
Карта идентичностей и `daily_traffic` обновляются иначе: каждый запуск
добавляет полный результат новой версией в `ReplacingMergeTree(_load_ts)`.
Публичные представления читают текущие строки через `FINAL`. Старые версии
могут физически оставаться до фонового слияния; повторяемость относится
к результату чтения, а не к числу строк на диске.
## Чего конвейер о себе не знает ## Чего конвейер о себе не знает
+69 -92
View File
@@ -30,115 +30,92 @@ keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/d
## Имена объектов ## Имена объектов
Имя объекта заканчивается тем, что это за объект: Суффикс показывает назначение объекта:
| Суффикс | Что это | | Суффикс | Что это |
|---|---| |---|---|
| `_rep` | локальная таблица шарда, движок семейства `Replicated*` | | `_rep` | локальная таблица: хранит данные своего шарда, движок семейства `Replicated*` |
| `_dist` | `Distributed` поверх одноимённой локальной | | `_dist` | таблица `Distributed`: читает данные шардов и распределяет запись между ними |
| `_kafka` | таблица на движке `Kafka` | | `_kafka` | таблица на движке `Kafka`: читает сообщения топика |
| `_file` | таблица на движке `File` — чтец файла | | `_file` | таблица на движке `File`: читает файл |
| `_mv` | материализованное представление | | `_mv` | материализованное представление: обрабатывает вставляемые данные |
| `_v` | обычное представление, публичная поверхность чтения | | `_v` | обычное представление: выполняет сохраненный запрос при чтении |
Суффикс носит каждый физический объект. Голого имени у таблицы не существует: Для запросов к моделям DDS и витринам DM используйте `_v`. Представления
запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым явно перечисляют колонки, поэтому добавление поля в физическую таблицу
именем в документах и разговоре понимается сущность, у которой этих объектов не меняет набор полей для читателя. У `dds.event_v` нет отдельной таблицы
несколько. Единственное исключение — словари: у них воплощение одно, шардировать DDS: представление читает события ODS.
нечего, и суффикс ничего не различал бы. В зоне `dic` обе формы стоят рядом и
правило видно целиком: `dic.products_file` — чтец файла, `dic.products` — сам
словарь.
Распространённая конвенция, где голое имя означает локальную таблицу, а В документации имя без суффикса, например `stg.hits_raw`, обозначает
распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо сущность с несколькими объектами. Таблицы с таким именем нет: в запросе
возвращает данные одного шарда. Правило спеки «проверки и контрольные суммы — нужно указать суффикс. Исключение — словари ClickHouse: `dic.products`
только по `Distributed`» такую тишину переживает плохо. обращается к каталогу товаров, а `dic.products_file` читает его файл.
Второе свойство — в списке по алфавиту объекты группируются по сущности, а не по В STG имя берется из топика: `hits_raw`, `orders_raw`. В типизированных
технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются слоях оно называет сущность в единственном числе: `event`, `order`,
хвостом, а не началом имени. Речь про дерево в клиенте и про `ORDER BY name`: `session`. Объекты одной сущности различаются суффиксом и стоят рядом
порядок выдачи `SHOW TABLES` документация не оговаривает. при сортировке имен по алфавиту.
Начало имени — сущность, и берётся она в разных слоях из разных мест. В STG имя `_rep` и `_dist` нужны для загрузки, диагностики и обслуживания. Роль
приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем `bi_reader` может читать их в ODS, DDS и DM, но для обычного анализа
топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях принято использовать `_v`. Это соглашение о чтении, а не ограничение прав;
имя приходит от предметной области и стоит в единственном числе: `event`, причина широкого доступа описана в
`order`, `session`. Граница между «как привезли» и «что это такое» [ADR 0007](../adr/0007-clickhouse-access.md) и
проходит по STG, и имена её показывают.
В DDS публичный договор сущности заканчивается на `_v`. Потребительские запросы
и витрины называют только представление; `_rep` и `_dist` служат загрузке,
диагностике и обслуживанию. Представление перечисляет колонки явно, даже если
его запрос — простая проекция над `_dist`: договор не выдаёт устройство
кластера и случайно добавленные в физическую таблицу колонки. У события
физической копии в DDS нет, но правило то же: `dds.event_v` — его публичный
договор.
Право доступа здесь намеренно шире договора. Роль `bi_reader` может читать
любые объекты ODS, DDS и DM, но физические объекты от этого не становятся
публичной поверхностью. Конвенцию держат явный список колонок, документация и
ревью, а не запрет на физические таблицы.
Широкое право принято в [ADR 0007](../adr/0007-clickhouse-access.md): узкая
граница поверх `Distributed` потребовала бы отдельной механики. Механизм
ClickHouse и отклонённые варианты записаны в
[исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md). [исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md).
## Раскладка по шардам ## Раскладка по шардам
Пишем только в `_dist`. Локальные таблицы остаются для чтения и обслуживания — Шард хранит часть строк. При записи через `_dist` место строки выбирает
операций с партициями, ручной переобработки. Правило не про удобство: при записи ключ шардирования. При прямой записи в `_rep` она остается на той ноде,
через распределённую таблицу раскладку определяет ключ шардирования, то есть где выполнена вставка. Поэтому данные записывают через `_dist`,
свойство данных, а при записи в локальную — то, какая нода случайно выполняла а операции с партициями выполняют на локальных `_rep`.
код. Отсюда урок стенда: какая нода читала топик, меняется между прогонами
(видно в колонке `consumer_host`), а куда легли данные — нет.
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у У событий ключ — `cityHash64(ClientID)`, у заказов —
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш `cityHash64(order_id)`, у STG и таблиц ошибок — хеш сырой строки.
выбран против перекоса: структурированный числовой идентификатор распределяется Хеш распределяет числовые идентификаторы по шардам и направляет одинаковые
по остатку от деления неравномерно. У сырого слоя выбора нет — строку иначе не сообщения на один шард. У сырой строки и разобранного события разные
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один ключи: они могут оказаться на разных шардах. Счетчики STG и ODS сравнивают
шард. по всему кластеру через `_dist`, а не отдельно по каждой ноде.
Ключи у слоёв разные, и это имеет наблюдаемое следствие: сырая строка и При `INSERT INTO цель_dist SELECT … FROM источник_dist` важна настройка
разобранное из неё событие почти всегда оказываются на разных шардах. `parallel_distributed_insert_select`. При значении `2` и одном кластере
Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны — вставка может выполняться локально на шардах, сохраняя раскладку источника
сверять слои можно только через `_dist`. вместо ключа цели. Если ключи различаются, укажите
`parallel_distributed_insert_select = 0`: нода, принявшая запрос,
соберет результат и распределит его по ключу цели. Так сделано в
[`sql/ods/order_parse.sql`](../../sql/ods/order_parse.sql) и при сборке
выручки. Неверную раскладку проверяют по `hostName()` и ожидаемому шарду
строки; общий счет по `_dist` ее не показывает.
Разные ключи у слоёв ставят капкан на вставке `INSERT INTO цель_dist SELECT … События, `dds.session_dist` и `dds.identity_map_dist` распределены
FROM источник_dist`. Заводское значение `parallel_distributed_insert_select` — по `cityHash64(client_id)` (в ODS поле называется `ClientID`). Данные
2: если обе таблицы на одном кластере, вставка исполняется локально на каждом одного посетителя лежат вместе — это ко-локация. Заказы DDS и их сверка
шарде, минуя ключ шардирования цели. Строки наследуют раскладку источника, и сохраняют раскладку по `cityHash64(order_id)`. Обычное соединение допускается
ошибки при этом нет — расхождение видно только пересчётом ключей по по ключу ко-локации; для остальных соединений применяется `GLOBAL`.
`hostName()`. Так половина заказов и легла на оба шарда, где Само совпадение ключей еще не гарантирует локального выполнения запроса
`ReplacingMergeTree` через границу шарда дубли не схлопывает (замер — «Что через представления — пример есть в [описании `daily_traffic`](dm.md#dmdaily_traffic_v).
проверено», #137). Лекарство — явное `parallel_distributed_insert_select = 0`
в `SETTINGS` такой вставки: раскладку делает инициатор, по ключу цели
(`sql/ods/order_parse.sql`). Капкан ждёт и сборки DDS: они тоже будут читать
`_dist` и писать в `_dist` с другим ключом.
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из Для дневной пересборки DDS и DM используют донор — служебную таблицу,
раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё в которой готовят результат перед заменой партиций. Например,
прочее — через `GLOBAL`. Значит `dds.session_dist` и `dds.identity_map_dist` `dds.order_stage_rep` объявлена через `AS dds.order_rep`, а запись
шардируются по `cityHash64(client_id)`. Объекты DDS с зерном заказа сохраняют в нее идет через `dds.order_stage_dist` с тем же `cityHash64(order_id)`.
ко-локацию по `cityHash64(order_id)`; ключи остальных частей модели и витрин У цели и донора должны совпадать структура, ключи партиции и сортировки,
появляются вместе с ними. первичный ключ и политика хранения. Эти требования сверены через Context7
с [документацией ClickHouse](https://clickhouse.com/docs/sql-reference/statements/alter/partition#replace-partition)
Замена партиций не используется для `ods.order`: версии прошлых дней для используемой схемы на ClickHouse 26.3. Поэтому донор объявлен через
доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR `AS` цели с совпадающими ключами. DDL показан в
0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка
понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее
разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким
объектом, а не переносить на ODS заранее.
Первым такой донор понадобился заказу DDS, и правило на нём видно целиком:
`dds.order_stage_rep` объявлен через `AS dds.order_rep` и шардируется тем же
`cityHash64(order_id)`. Совпадение структуры требует сама операция, а совпадение
ключа — устройство замены: она идёт по локальным таблицам, шард за шардом, и
строка, попавшая в доноре не на свой шард, при подмене пропала бы с одного шарда
и задвоилась на другом. Доводы целиком — в
[`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql). [`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql).
`REPLACE PARTITION` копирует готовую партицию между локальными таблицами
на каждом шарде; заново распределять строки по ключу цели эта операция
не будет. Поэтому донор должен сразу иметь ту же раскладку, что и цель.
Перед заменой вставка с `distributed_foreground_insert = 1` дожидается
доставки данных на шарды.
В `ods.order` этот способ не применяется: туда добавляют версии заказов
из слепков, а актуальные строки выбираются при чтении
([ADR 0010](../adr/0010-order-versions-in-ods.md)).
## Служебные колонки ## Служебные колонки
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок: Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
+21 -42
View File
@@ -1,35 +1,17 @@
-- DDS: нарезка событий на визиты и сборка дней в таблицу-двойник. -- DDS: сборка сессий выбранных дней в донор dds.session_stage_dist.
-- Донор очищается целиком; одна вставка собирает весь диапазон.
-- --
-- Двойник очищается целиком в начале, дни собираются одной вставкой на весь -- Первое окно помечает событие единицей, если с предыдущего события того же
-- отрезок — по правилу заказа (sql/dds/order_rebuild.sql). -- посетителя за тот же день прошло больше 1800 секунд. Второе окно суммирует
-- эти единицы: получается номер визита внутри посетителя и дня.
-- PARTITION BY (client_id, event_date) разделяет визиты по модельной полночи.
-- Учитываются все типы событий.
-- --
-- Нарезка — центральный расчёт слоя, и стоит она на двух окнах подряд. -- У первого события lagInFrame возвращает его собственное время: разница
-- Первое спрашивает у каждого события, сколько прошло с предыдущего события -- равна нулю, opens_visit = 0, и первый visit_no тоже равен нулю.
-- той же куки в тот же день, и ставит единицу там, где пауза длиннее -- Оба окна используют (event_time, event_id), чтобы при равном времени
-- таймаута: это шов между визитами. Второе считает нарастающий итог этих -- признак начала и накопленный номер визита относились к одним событиям.
-- единиц — номер визита внутри дня куки. Дальше визит становится обычной -- Порог 1800 секунд — правило визита; он не передается параметром.
-- группировкой.
--
-- Границу суток окна не проверяют: она уже в разбиении PARTITION BY
-- (client_id, event_date), поэтому визит через полночь не склеится и при
-- короткой паузе. Так правило визита ложится в запрос целиком: одна кука,
-- один день, паузы не длиннее таймаута (CONTEXT.md, «Визит»).
--
-- Первое событие куки за день сравнивается само с собой — третий аргумент
-- lagInFrame. Паузы у него нет, шов ему не нужен, и номер первого визита
-- получается нулём: номера различают визиты внутри дня куки, а не считают их
-- от единицы.
--
-- Порядок в окнах полный — метка времени и event_id. Окна считаются двумя
-- разными сортировками (видно в EXPLAIN PIPELINE), и при равных метках они
-- вправе разойтись: шов достался бы одной строке, а нарастающий итог другой,
-- и сессия разъехалась бы надвое.
--
-- Тридцать минут написаны числом, а не приехали параметром: это правило
-- аналитики, а не константа мира. Генератор режет визиты тем же порогом;
-- разойдись эти два числа — увидит счётная сверка с VisitID
-- (docs/architecture/dds/session.md, «Сверка с эталоном»).
ALTER TABLE dds.session_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL; ALTER TABLE dds.session_stage_rep ON CLUSTER clickstream_cluster DROP PARTITION ALL;
INSERT INTO dds.session_stage_dist INSERT INTO dds.session_stage_dist
@@ -93,8 +75,8 @@ WITH
FROM seams FROM seams
) )
SELECT SELECT
-- Чекан визита: кука и его начало. Время входит в хеш секундой эпохи, -- Идентификатор зависит от посетителя и начала визита.
-- поэтому id держится за содержание сессии, а не за порядок сборки. -- Повторная сборка тех же событий дает тот же session_id.
cityHash64(client_id, min(event_time)), cityHash64(client_id, min(event_time)),
client_id, client_id,
event_date, event_date,
@@ -107,8 +89,10 @@ SELECT
countIf(event_type = 'purchase'), countIf(event_type = 'purchase'),
argMin(url, event_time), argMin(url, event_time),
argMax(url, event_time), argMax(url, event_time),
-- Атрибуция и паспорт куки берутся с первого события визита. У паспорта -- UTM, устройство и город берутся по минимальному event_time.
-- любое событие дало бы то же: устройство и город у куки постоянны. -- Как и у страниц выше, при равном времени выбор между событиями
-- не определен: event_id в эти argMin/argMax не входит.
-- Устройство и город на стенде постоянны для одной куки.
argMin(utm_source, event_time), argMin(utm_source, event_time),
argMin(utm_medium, event_time), argMin(utm_medium, event_time),
argMin(utm_campaign, event_time), argMin(utm_campaign, event_time),
@@ -120,12 +104,7 @@ SELECT
now64(3, 'UTC') now64(3, 'UTC')
FROM numbered FROM numbered
GROUP BY client_id, event_date, visit_no GROUP BY client_id, event_date, visit_no
-- Капкана parallel_distributed_insert_select здесь нет, и это выигрыш -- Источник и цель распределены по cityHash64(client_id): локальная вставка
-- ко-локации: ключ шардирования у источника и цели один — cityHash64 от -- сохраняет нужную раскладку, parallel_distributed_insert_select менять
-- куки, — поэтому визит ляжет туда же, где лежали его события, исполнись -- не требуется. Перед заменой партиций ждем доставки строк на шарды.
-- вставка хоть на инициаторе, хоть локально на шардах. Заказу приходится
-- называть ноль вслух: совпадение ключей у него случайное
-- (sql/dds/order_rebuild.sql). Здесь выбор сделан обратный и он тоже
-- осознанный: настройку не называть, чтобы не притворяться, будто её значение
-- что-то решает.
SETTINGS distributed_foreground_insert = 1; SETTINGS distributed_foreground_insert = 1;
+7 -8
View File
@@ -1,11 +1,10 @@
-- DDS: подмена одного дня сессий собранной партицией двойника. -- DDS: замена одного дня в session_rep готовой партицией session_stage_rep.
-- ON CLUSTER выполняет замену на локальных таблицах каждого шарда.
-- Замена атомарна на одном шарде, общей транзакции для кластера нет.
-- Повтор с тем же донором не добавляет копий строк.
-- --
-- Механика и доводы целиком — sql/dds/order_replace.sql: замена идёт по -- Дату из session_scope.sql подставляет Jinja: параметр ClickHouse в
-- локальным таблицам, шард за шардом, атомарность пошардовая, день -- ALTER ... PARTITION здесь не поддерживается. Поиск дней следующего
-- подставляет Jinja, потому что параметров ALTER … PARTITION не берёт. -- запуска не обнаружит неполный день, если часть его строк уже есть в DDS.
--
-- Разница одна, и она про отказ. Несобранный день заказа вернуло бы в работу
-- следующее окно; сессиям возвращать нечем, и упавшая замена оставляет дыру
-- до тех пор, пока её не найдёт счёт хвоста (sql/dds/session_scope.sql).
ALTER TABLE dds.session_rep ON CLUSTER clickstream_cluster ALTER TABLE dds.session_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dds.session_stage_rep; REPLACE PARTITION '{{ params.day }}' FROM dds.session_stage_rep;
+15 -35
View File
@@ -1,50 +1,30 @@
-- DDS: объём пересборки сессий — какие дни собирает этот запуск. -- DDS: дни для сборки сессий, от first_day до last_day включительно.
-- Пустой ответ означает пустой ODS; NULL в first_day — отсутствие работы.
-- --
-- Ответ — две даты, границы непрерывного отрезка; список дней между ними -- position — номер следующего дня для генерации. Последний прожитый день
-- разворачивает даг. Пустой ответ означает пустой источник. Пустой первый -- равен min(EventDate) + position - 1. В отличие от max(EventDate), эта
-- день — что собирать нечего: все прожитые дни уже в слое, и прогон честно -- граница исключает еще идущий день. Ранние события ODS не удаляются по TTL,
-- пропускает сборку. -- поэтому min(EventDate) сохраняет начало модельного календаря.
-- --
-- Файл кончается без точки с запятой: почему — sql/dds/order_scope.sql. -- До этого запроса даг ждет чтения hits и опустошения очереди ods.event_dist.
-- Позиция мира сама по себе не подтверждает доставку событий.
-- --
-- Правый край — последний прожитый день, и знает его только позиция мира. -- Точки с запятой в конце нет: драйвер добавляет FORMAT к этому запросу.
-- События живой день от прожитого не отличают: и посреди дня D, и сразу после
-- его конца max(EventDate) = D. А разница несущая — визит, начатый в 23:50,
-- посреди дня ещё идёт, и собранная сессия оказалась бы обрезанной навсегда:
-- пересобирать замёрзший день слой не станет
-- (docs/architecture/dds/session.md, «Движок и наполнение»).
--
-- Позиция — номер первого несыгранного дня, счётчик прожитого. Своей копии
-- начала оси стенд из-за неё не заводит: начало он спрашивает у данных, и
-- дата дня N в них — константа, начало оси плюс N. Держится якорь на том, что
-- ранние дни из ods.event не исчезают: срок жизни назначен сырым данным и таблицам
-- ошибок, самому событию — нет (sql/ddl/20-ods-tables.sql).
--
-- За «доехал» позиция не отвечает — только за «прожит». Перед расчётом объёма
-- первый шаг дага дожидается хвоста потока и очереди доставки: иначе
-- недожеванный хвост заморозил бы недособранный день
-- (docs/architecture/etl.md, «Готовность источника»).
WITH WITH
(SELECT min(EventDate) FROM ods.event_dist) AS first_source_day, (SELECT min(EventDate) FROM ods.event_dist) AS first_source_day,
first_source_day + ({position:UInt16} - 1) AS last_lived_day, first_source_day + ({position:UInt16} - 1) AS last_lived_day,
-- Хвост считается от первой дыры, а не от последнего собранного дня: -- Ищем первый день ODS, которого нет в DDS: максимум собранного дня
-- замены партиций падают порознь, и счёт по максимуму собранного дня -- пропустил бы более ранний пробел. Частично заполненный день так не найти.
-- дыру позади себя не заметил бы (доводы целиком — sql/dds/order_scope.sql). -- GLOBAL сравнивает со списком дней всего DDS, а не одного шарда.
-- У сессий это единственный способ дыру найти: дышащего окна, которое -- minOrNull возвращает NULL, если пропусков нет.
-- пересобрало бы день заодно, здесь нет.
--
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
-- бы дни источника с локальной частью слоя на каждом шарде.
-- minOrNull отличает «дыр нет» от даты по умолчанию.
( (
SELECT minOrNull(day) SELECT minOrNull(day)
FROM (SELECT DISTINCT EventDate AS day FROM ods.event_dist) FROM (SELECT DISTINCT EventDate AS day FROM ods.event_dist)
WHERE day GLOBAL NOT IN (SELECT session_date FROM dds.session_dist) WHERE day GLOBAL NOT IN (SELECT session_date FROM dds.session_dist)
) AS first_missing_day ) AS first_missing_day
SELECT SELECT
-- Живой день в слое отсутствует всегда, и работой это не считается: -- Отсутствующий день попадет в сборку только после его завершения.
-- дырой он станет, только когда мир его проживёт.
if(first_missing_day <= last_lived_day, first_missing_day, NULL) AS first_day, if(first_missing_day <= last_lived_day, first_missing_day, NULL) AS first_day,
last_lived_day AS last_day last_lived_day AS last_day
-- FINAL не нужен: повтор доставки не двигает ни край оси, ни набор дней. -- FINAL не нужен: повтор доставки не меняет начало календаря и набор дат.
WHERE (SELECT count() FROM ods.event_dist) > 0 WHERE (SELECT count() FROM ods.event_dist) > 0
+10 -8
View File
@@ -1,7 +1,8 @@
-- DM: сборка выручки в донор перед заменой дневных партиций. -- DM: сборка выручки за выбранный диапазон в донор.
-- DROP PARTITION ALL очищает локальные таблицы на каждом узле; причина не -- DROP PARTITION ALL очищает донор на каждом узле. Вставка ниже дает
-- применять TRUNCATE разобрана в соседнем sql/dds/order_rebuild.sql. -- одну строку на день заказа и категорию товара: только оплаченные заказы.
-- ARRAY JOIN разворачивает согласованные позиции трех массивов вместе.
-- Причина выбора DROP вместо TRUNCATE — в sql/dds/order_rebuild.sql.
ALTER TABLE dm.revenue_daily_stage_rep ON CLUSTER clickstream_cluster ALTER TABLE dm.revenue_daily_stage_rep ON CLUSTER clickstream_cluster
DROP PARTITION ALL; DROP PARTITION ALL;
@@ -22,8 +23,8 @@ SELECT
orders, orders,
units, units,
CAST(revenue, 'Decimal(18, 2)'), CAST(revenue, 'Decimal(18, 2)'),
-- Обычное деление Decimal усекает доли копейки вместо округления; -- Делим с шестью знаками после запятой, затем округляем до копеек.
-- запас масштаба сохраняет их до денежного результата. -- Так дробная часть сохраняется до округления среднего чека.
CAST( CAST(
round(divideDecimal(revenue, toDecimal128(orders, 0), 6), 2), round(divideDecimal(revenue, toDecimal128(orders, 0), 6), 2),
'Decimal(18, 2)' 'Decimal(18, 2)'
@@ -49,7 +50,8 @@ FROM
report_date, report_date,
product_category product_category
) )
-- Источник лежит по заказу, цель — по категории. Ноль заставляет инициатор -- Источник распределен по заказу, цель — по категории. Значение 0 оставляет
-- слить частичные агрегаты и разложить готовые строки по ключу цели. -- итоговую агрегацию и распределение по ключу цели на ноде, принявшей запрос.
-- distributed_foreground_insert = 1 ждет доставки строк перед заменой.
SETTINGS distributed_foreground_insert = 1, SETTINGS distributed_foreground_insert = 1,
parallel_distributed_insert_select = 0; parallel_distributed_insert_select = 0;
+6 -6
View File
@@ -1,8 +1,8 @@
-- DM: подмена одного дня выручки готовой партицией донора. -- DM: замена дня выручки готовой партицией донора.
-- -- Дату из revenue_daily_scope.sql подставляет Jinja: параметр ClickHouse
-- День приходит из результата revenue_daily_scope.sql, а не от пользователя. -- в ALTER ... PARTITION здесь не поддерживается.
-- Jinja нужна потому, что ClickHouse не подставляет параметр в PARTITION. -- ON CLUSTER выполняет замену на локальных таблицах каждого шарда.
-- Замена идет на локальной таблице ON CLUSTER: граница атомарности и повтор -- Замена атомарна на одном шарде, общей транзакции для кластера нет.
-- разобраны на таком же шаге в sql/dds/order_replace.sql. -- Повтор с тем же донором не добавляет копий строк.
ALTER TABLE dm.revenue_daily_rep ON CLUSTER clickstream_cluster ALTER TABLE dm.revenue_daily_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dm.revenue_daily_stage_rep; REPLACE PARTITION '{{ params.day }}' FROM dm.revenue_daily_stage_rep;
+10 -14
View File
@@ -1,14 +1,11 @@
-- DM: объем пересборки выручки. -- DM: диапазон пересборки выручки, first_day и last_day включительно.
-- Последний слепок ODS задает даты оформления еще изменяемых заказов.
-- Агрегат читает DDS; ODS нужен только для границ диапазона.
-- Левая граница расширяется до первого дня с оплаченными заказами DDS,
-- которого нет в витрине. Так первый запуск охватывает историю выручки.
-- По наличию даты нельзя обнаружить частично заполненный день.
-- --
-- Последний слепок ODS называет дни, состояние которых источник еще может -- Точки с запятой в конце нет: драйвер добавляет FORMAT к этому запросу.
-- изменить. Сам агрегат читает только DDS; ODS здесь нужен лишь для границ.
-- Первый запуск и повтор после частичного отказа расширяют левую границу до
-- первого дня оплаченных заказов, которого еще нет в витрине.
-- Почему хвост начинается с первого пропуска и чем за это платит ODS,
-- разобрано в sql/dds/order_scope.sql.
--
-- Файл кончается без точки с запятой: даг читает его одним запросом и ждет
-- одну строку с границами.
WITH WITH
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot, (SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
( (
@@ -24,8 +21,7 @@ WITH
( (
SELECT minOrNull(report_date) SELECT minOrNull(report_date)
FROM (SELECT DISTINCT order_date AS report_date FROM dds.order_v WHERE status = 'paid') FROM (SELECT DISTINCT order_date AS report_date FROM dds.order_v WHERE status = 'paid')
-- Правая сторона тоже распределенная: GLOBAL собирает множество дней -- GLOBAL собирает список дней витрины со всех шардов.
-- на инициаторе и не превращает запрос в запрещенное двойное чтение.
WHERE report_date GLOBAL NOT IN ( WHERE report_date GLOBAL NOT IN (
SELECT report_date FROM dm.revenue_daily_dist SELECT report_date FROM dm.revenue_daily_dist
) )
@@ -33,7 +29,7 @@ WITH
SELECT SELECT
least(window_start, coalesce(first_missing_day, window_start)) AS first_day, least(window_start, coalesce(first_missing_day, window_start)) AS first_day,
window_end AS last_day window_end AS last_day
-- Без ODS неизвестно окно. Без готового DDS донор останется пустым, а замена -- Для выбора диапазона нужны заказы в ODS, для расчета — в DDS.
-- сотрет уже собранные дни выручки. Поэтому для прогона нужны оба источника. -- Эта проверка наличия строк не подтверждает полноту источников.
WHERE (SELECT count() FROM dds.order_v) > 0 WHERE (SELECT count() FROM dds.order_v) > 0
AND (SELECT count() FROM ods.order_dist) > 0 AND (SELECT count() FROM ods.order_dist) > 0