diff --git a/docs/architecture/dds/README.md b/docs/architecture/dds/README.md index d0d7ab9..52d4b62 100644 --- a/docs/architecture/dds/README.md +++ b/docs/architecture/dds/README.md @@ -18,16 +18,18 @@ Целостность модели — связи и ко-локация — живёт в этом разделе; файлы сущностей держат глубину и на карту ссылаются. -- **Заказ** (`dds.order`) — физическая сущность: одна строка — заказ в - текущем состоянии, позиции массивами в строке, наполняется заменой - партиций — [модель заказа](order.md). -- **Сессия** (`dds.session`) — расчётная сущность: одна строка — визит, +- **Заказ** (`dds.order_v`, хранение `dds.order_rep` / `_dist`) — физическая + сущность: одна строка — заказ в текущем состоянии, позиции массивами в + строке, наполняется заменой партиций — [модель заказа](order.md). +- **Сессия** (`dds.session_v`, хранение `dds.session_rep` / `_dist`) — + расчётная сущность: одна строка — визит, нарезку по правилу визита стенд делает сам, `VisitID` генератора — эталон сверки; наполнение заменой дневной партиции — [модель сессии](session.md). - **Событие** (`dds.event_v`) — контракт над ODS, а не копия: представление, переводящее событие на язык бизнес-модели, — [контракт события](event.md). -- **Карта идентичностей** (`dds.identity_map`) — кука ↔ пользователь - магазина; сборку решает развилка #133. +- **Карта идентичностей** (`dds.identity_map_v`, хранение + `dds.identity_map_rep` / `_dist`) — кука ↔ пользователь магазина — + [модель карты](identity.md). Связи держатся двумя ключами моста (спека, разделы 4 и 5): `purchaseID[1]` события равен `order_id` заказа — по нему идут сверка и сборка склейки; @@ -43,13 +45,15 @@ - DDS обслуживает человека с дашбордом: время лежит в поясе счётчика, пересчёт — один раз при наполнении слоя ([дока хранилища](../storage.md), «Часовые пояса»). +- Публичный договор каждой сущности — `_v`; роли объектов и проверяемая + граница доступа заданы [конвенциями хранилища](../storage.md), «Имена + объектов». - Служебные колонки — координаты собственной загрузки слоя; чужие ярлыки не переносятся, а спрашиваются у их слоя по ключу сущности. Вывод правила и цепочка расследования — [модель заказа](order.md). ## Открытые решения -- Сборка `dds.identity_map` — развилка #133 карты #127. - Запуск трансформаций и механика замены партиций на кластере — #134 и этап 5. - Витрины DM — #135. diff --git a/docs/architecture/dds/event.md b/docs/architecture/dds/event.md index 069a25f..42f07d6 100644 --- a/docs/architecture/dds/event.md +++ b/docs/architecture/dds/event.md @@ -21,6 +21,9 @@ индекс перестал бы её анализировать. Цена `FINAL` — отключённый автоматический `PREWHERE` — свойство ODS, представление её не усугубляет. +Как публичный договор DDS, представление перечисляет колонки явно и следует +общей [границе доступа](../storage.md), «Имена объектов». + ## Язык модели Имена атрибутов — имена модели, а не нормализованные имена источника @@ -38,7 +41,7 @@ выбор линзы слой сделал за читателя, UTC остаётся языком ODS. `ClientID` остаётся `client_id`: это устоявшееся имя куки во всей -экосистеме Метрики, по нему шардирование и мост к `dds.identity_map`; +экосистеме Метрики, по нему шардирование и мост к `dds.identity_map_v`; своё имя здесь создало бы третий синоним. `client_timezone` тоже остаётся — это атрибут посетителя, а не линза времени. @@ -79,12 +82,13 @@ issue #143. ## Читатели -- Сборка `dds.session`: ключи нарезки (`client_id`, `event_time`, +- Сборка сессий: ключи нарезки (`client_id`, `event_time`, `event_date`), тип события, страница, UTM, паспорт куки — категория устройства приходит уже расшифрованной. - Витрины `dm.events_enriched_v`, `dm.top_pages_daily_v`, `dm.utm_effectiveness_v` (`declared_revenue` — из `purchase_revenue`), - `dm.daily_traffic_v` (пара «посетители / известные» через `identity_map`). + `dm.daily_traffic_v` (пара «посетители / известные» через + `dds.identity_map_v`). - Событийная сторона сверки `dm.purchase_vs_orders_v`: `purchase_id`, `purchase_revenue`, `event_date`. - DQ-сверка сессий с эталоном читает не его, а `ods.event_v` — diff --git a/docs/architecture/dds/identity.md b/docs/architecture/dds/identity.md new file mode 100644 index 0000000..f883eb3 --- /dev/null +++ b/docs/architecture/dds/identity.md @@ -0,0 +1,122 @@ +# Карта идентичностей в DDS + +Учебный результат: менти строит физический мост между кукой трекера и +пользователем магазина, раскладывает его по ключу будущего соединения и видит, +как пакетный полный пересчёт сочетается с точным публичным представлением. + +## Назначение + +Карта — адресная книга: «эта кука уже покупала как этот пользователь +магазина». Связь становится известна только после покупки, потому что в +кликстриме есть `client_id`, а `user_id` живёт в заказе. Две куки одного +покупателя дают две строки с одним `user_id`; поэтому витрина может показать +рядом число посетителей по кукам и число известных людей после склейки. + +Это не полная клиентская база. Куки без покупки остаются анонимными, а карта не +пытается угадать их владельца. + +## Зерно и состав строки + +Одна строка — известная пара `client_id` × `user_id`. Деловых колонок две: + +- `client_id UInt64` — кука трекера; +- `user_id UInt64` — пользователь магазина. + +Собственные служебные колонки `_load_id String` и +`_load_ts DateTime64(3, 'UTC')` показывают, каким запуском и когда пара записана +в DDS. Они имеют читателя при расследовании повтора и потому входят в публичный +договор вместе с деловой парой. + +`first_matched_at` нет. Запланированные витрины его не читают, а два возможных +смысла — «первая покупка вообще» и «момент, с которого связь была известна +хранилищу» — требуют разных исторических моделей. Этот вопрос оставлен +[самостоятельной работе](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/145), +а текущий стенд не выдаёт один смысл за другой. + +Статус заказа связь не меняет: любой заказ, сопоставленный с событием покупки, +показывает владельца куки. Отмена меняет состояние заказа, но не личность +покупателя. + +## Физическая модель и публичный интерфейс + +- `dds.identity_map_rep` — локальная + `ReplicatedReplacingMergeTree(_load_ts)`, `ORDER BY (client_id, user_id)`, + без партиционирования; +- `dds.identity_map_dist` — распределённая поверхность записи, шардирование + по `cityHash64(client_id)`; +- `dds.identity_map_v` — публичное обычное представление над `_dist`, которое + перечисляет четыре колонки явно и применяет `FINAL`. + +Повторы полного запуска физически могут лежать рядом до фонового слияния. +`FINAL` оставляет одну последнюю версию пары, поэтому пользователь всегда читает +точную карту через `_v`. Роли объектов и доступ следуют общей +[конвенции](../storage.md), «Имена объектов». + +Шардирование совпадает с событиями и сессиями. Благодаря этому витрина может +обогащать их картой локально по `client_id`; точную форму распределённого +запроса решает проектирование витрин #135. + +## Сборка + +Карта собирается один раз в суточной пакетной цепочке после обновления модели +заказов и до зависимых витрин. При догоняющем запуске за несколько дней хватает +одной сборки после всей пачки заказов. + +Источники — вся доступная история `dds.event_v` и текущее состояние заказов +`dds.order_v`. Трансформация: + +1. оставляет события `purchase` и разворачивает единственный `purchase_id`; +2. соединяет его с `order_id` заказа через `GLOBAL INNER JOIN`; +3. выбирает различные пары `client_id`, `user_id`; +4. добавляет координаты текущего запуска и пишет полный набор в + `dds.identity_map_dist`. + +`GLOBAL` нужен потому, что события разложены по `client_id`, а заказы — по +`order_id`: строки соединения не ко-локированы. Правая сторона мала, поэтому +рассылка заказов приемлема. Материализованное представление здесь не подходит: +заказ может появиться в пакетном слепке позже уже принятого события покупки. + +## Повтор запуска + +Каждый запуск заново получает все известные пары из источников. Он не читает +предыдущую карту и не делает anti-join «вставить только отсутствующее». +`_load_ts` служит версией `ReplacingMergeTree`, а `dds.identity_map_v` прячет +физические повторы через `FINAL`. + +Полный пересчёт выбран потому, что карта стенда мала, источники уже содержат +нужную историю, а повтор задачи не зависит от того, на каком месте оборвался +предыдущий. Мир стенда не отзывает связь куки с пользователем, поэтому удалять +исчезнувшие пары не требуется. + +## Читатели + +- `dm.daily_traffic_v` — посетители по `client_id` и известные пользователи по + `user_id` рядом; +- учебные запросы — показать, как несколько кук одного человека меняют + `uniq`; +- расследование пакетного запуска — `_load_id` и `_load_ts` текущей версии + пары. + +## Отклонённые варианты + +- **`first_matched_at` в текущей модели.** Нет запланированного читателя и нет + однозначной временной семантики; идея передана в issue #145. +- **Словарь ClickHouse.** Для малой карты мог бы ускорить точечное обогащение, + но спрятал бы физическую модель и не добавил нового урока рядом с уже + существующим словарём товаров. +- **Одно динамическое представление без физической карты.** Повторяло бы + соединение всей истории при каждом чтении, не давало ко-локации результата и + не показывало устройство реального слоя хранения. +- **Инкремент через anti-join с самой картой.** Делал бы результат предыдущего + запуска обязательным входом и усложнял повтор без пользы на объёме стенда. +- **Материализованное представление от событий.** Не увидело бы заказ, который + приехал позже события покупки. + +## Связанные решения + +- [Карта DDS](README.md) — связи сущностей и публичная поверхность `_v`. +- [Модель заказа](order.md) — текущее состояние заказа и пакетное наполнение. +- [Контракт события](event.md) — поля событий на языке DDS. +- [Исследование распределённых соединений](../../research/2026-08-22-distributed-join.md) + — форма `GLOBAL JOIN` и капкан `INSERT SELECT` между разными ключами + шардирования. diff --git a/docs/architecture/dds/order.md b/docs/architecture/dds/order.md index 3d65bc9..44463f6 100644 --- a/docs/architecture/dds/order.md +++ b/docs/architecture/dds/order.md @@ -38,18 +38,22 @@ JSON происходит один раз, на границе ODS → DDS, ку ## Движок и наполнение -`MergeTree` без колонки версии: партиция — `order_date` (день заказа -неизменен), ключ сортировки — `order_id`, шардирование распределённой -таблицы — `cityHash64(order_id)`, тем же выражением, что у ODS: это -единственный устойчивый ключ сущности. +`dds.order_rep` — `ReplicatedMergeTree` без колонки версии: партиция — +`order_date` (день заказа неизменен), ключ сортировки — `order_id`. +`dds.order_dist` шардирует по `cityHash64(order_id)`, тем же выражением, что +ODS: это единственный устойчивый ключ сущности. Идемпотентность держит замена партиции, а не версии. Сбор дня — батч после прихода слепка: трансформация собирает партицию заново из `ods.order_v` и -атомарно подменяет. Поэтому каждая строка таблицы — ровно один заказ, и -таблица сама служит поверхностью чтения слоя: голый `SELECT`, без `FINAL` и -без обёртки-представления. Пока окно K живо, день «дышит» — статусы заказов -меняются, и партиция дня пересобирается очередным запуском; на выходе из -окна день замерзает. +атомарно подменяет. Поэтому каждая строка физической таблицы — ровно один +заказ, а чтению не нужен `FINAL`. Пока окно K живо, день «дышит» — статусы +заказов меняются, и партиция дня пересобирается очередным запуском; на выходе +из окна день замерзает. + +Публичная поверхность — `dds.order_v`: обычное представление с явным списком +деловых и собственных служебных колонок над `dds.order_dist`, без `FINAL`. +Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена +объектов». Механика запуска — где собирается партиция и как подменяется на кластере — сюда не входит: это устройство трансформаций (#134, этап 5). @@ -86,7 +90,8 @@ JSON происходит один раз, на границе ODS → DDS, ку - Сверка `dm.purchase_vs_orders_v`: `order_id`, `order_date`, `items_total`, `status`; соединение — GLOBAL JOIN с обеими сторонами-подзапросами ([исследование соединений](../../research/2026-08-22-distributed-join.md)). -- Сборка `dds.identity_map`: пара `order_id` ↔ `user_id`. +- Сборка карты идентичностей: пара `order_id` ↔ `user_id`, источник — + `dds.order_v`. ## Не входит @@ -98,11 +103,12 @@ JSON происходит один раз, на границе ODS → DDS, ку ## Отклонённые варианты -- **Представление-контракт вместо физической таблицы.** Рамка стенда — +- **Одно динамическое представление вместо физической таблицы.** Рамка стенда — показать модель данных, которую хранилище готовит потребителям; урок «слой — контракт, а не копия» уже закреплён за `dds.event_v`, а разбор позиций и пересчёт времён при каждом чтении собирали бы модель заново на - лету. + лету. Публичное `dds.order_v` поверх готовой физической модели этому доводу + не противоречит: оно ничего заново не вычисляет. - **`ReplacingMergeTree(updated_at)` ещё раз.** Повторил бы механику ODS вторым разом: физически хранил бы устаревшие состояния, вернул бы `FINAL` и обёртку-представление — а урок версий менти уже отыграл. diff --git a/docs/architecture/dds/session.md b/docs/architecture/dds/session.md index d20211f..70cd96f 100644 --- a/docs/architecture/dds/session.md +++ b/docs/architecture/dds/session.md @@ -5,8 +5,8 @@ свою нарезку с эталоном. Стенд здесь играет роль сессионизатора: в бою её играет Метрика, которая режет визиты у себя и с октября 2025 отдаёт готовый `VisitID` прямо в хитах. Поэтому стенд считает сессии сам, а `VisitID` -генератора использует только как эталон самопроверки — иначе сборка -`dds.session` выродилась бы в `GROUP BY` по чужому полю и центральный урок +генератора использует только как эталон самопроверки — иначе сборка сессий +выродилась бы в `GROUP BY` по чужому полю и центральный урок слоя пропал бы. Без этого абзаца действия стенда выглядят странно: «зачем считать то, что уже дано колонкой». @@ -35,10 +35,10 @@ ломают. `session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка: -витрины сессий читают таблицу напрямую, их зерно совпадает с зерном модели, -и в соединениях id не участвует — события его не несут, мост к заказам живёт -на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со -стендовой и назвать расхождение одним числом) и разбор DQ-расхождений. +витрины сессий читают `dds.session_v`, их зерно совпадает с зерном модели, и в +соединениях id не участвует — события его не несут, мост к заказам живёт на +событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со стендовой и +назвать расхождение одним числом) и разбор DQ-расхождений. ## Состав строки @@ -64,10 +64,11 @@ ## Движок и наполнение -`MergeTree` без колонки версии, по образцу заказа: партиция — день сессии, -ключ сортировки — кука и начало сессии, шардирование распределённой таблицы — -`cityHash64(ClientID)`, тем же выражением, что у событий, — ко-локация с -`dds.identity_map` ([дока хранилища](../storage.md), «Раскладка по шардам»). +`dds.session_rep` — `ReplicatedMergeTree` без колонки версии, по образцу +заказа: партиция — день сессии, ключ сортировки — кука и начало сессии. +`dds.session_dist` шардирует по `cityHash64(client_id)`, тем же выражением, +что события, — это ко-локация с `dds.identity_map_rep` ([дока +хранилища](../storage.md), «Раскладка по шардам»). Совпадение ключа с событиями не случайно: все события куки лежат на одном шарде, поэтому сессионизация локальна по природе — капкан смены ключа при вставке, который дока хранилища обещает сборкам DDS, у сессий не стреляет. @@ -78,10 +79,15 @@ целиком; идемпотентность держит замена партиции. В отличие от заказов, где батч пересобирает до K «дышащих» дней, у сессий пересобирается только одна партиция: день самодостаточен и замерзает сразу (правило спеки, раздел 6). -Внутри живого дня `dds.session` про сегодня честно молчит — «сегодня» +Внутри живого дня модель сессии про сегодня честно молчит — «сегодня» смотрят через `dds.event_v`; сессия и по смыслу не финальна, пока день не прожит. +Публичная поверхность — `dds.session_v`: обычное представление с явным +списком деловых и собственных служебных колонок над `dds.session_dist`. +Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена +объектов». + Источник сборки — `dds.event_v` ([контракт события](event.md)): сессии нужны от него ключи нарезки, тип события, страница, UTM и паспорт куки. Механика запуска — где собирается партиция и как подменяется на кластере — @@ -102,7 +108,7 @@ ## Читатели - Витрина обзора сессий `session_overview_v`: длительности, глубина, - вход/выход; читает таблицу напрямую, зерно совпадает (#135). + вход/выход; читает `dds.session_v`, зерно совпадает (#135). - DQ-сверка с эталоном (см. выше). - Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда, потом сравнивает — с эталоном и со стендовой. @@ -124,9 +130,11 @@ - **Гибрид: сборка по `VisitID`, DQ пересчитывает нарезку.** Сторож на нарезку, которой сборка не делает; та же работа, что в варианте «считать сам», но урок спрятан в проверку. -- **Представление вместо таблицы.** Оконная сессионизация на каждое чтение; +- **Одно динамическое представление вместо физической таблицы.** Оконная + сессионизация на каждое чтение; урок «слой — контракт, а не копия» уже закреплён за `dds.event_v` — доводы - модели заказа, здесь они только сильнее. + модели заказа, здесь они только сильнее. Публичное `dds.session_v` поверх + готовой физической модели ничего заново не считает. - **Счётчик или UUID вместо детерминированного хеша.** Пересборка партиции выдала бы другие id — замена перестала бы быть идемпотентной. - **`VisitID` колонкой в строке сессии.** Чужой чекан в своей модели: наша diff --git a/docs/architecture/storage.md b/docs/architecture/storage.md index c11c480..5c7e6ca 100644 --- a/docs/architecture/storage.md +++ b/docs/architecture/storage.md @@ -36,7 +36,7 @@ CSV-файла. Живёт он вне цепочки слоёв, в зоне `d | `_kafka` | таблица на движке `Kafka` | | `_file` | таблица на движке `File` — чтец файла | | `_mv` | материализованное представление | -| `_v` | обычное представление | +| `_v` | обычное представление, публичная поверхность чтения | Суффикс носит каждый физический объект. Голого имени у таблицы не существует: запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым @@ -63,6 +63,23 @@ CSV-файла. Живёт он вне цепочки слоёв, в зоне `d `order`, `session`. Граница между «как привезли» и «что это такое» проходит по STG, и имена её показывают. +В DDS публичный договор сущности заканчивается на `_v`. Потребительские запросы +и витрины называют только представление; `_rep` и `_dist` остаются ролям +загрузки, диагностики и обслуживания. Представление перечисляет колонки явно, +даже если его запрос — простая проекция над `_dist`: договор не выдаёт +устройство кластера и случайно добавленные физической таблице колонки. У +события физической копии в DDS нет, но правило то же: `dds.event_v` — его +публичный договор. + +Граница доступа — часть договора, а не следствие имени. Обычное представление +создаётся с явными `DEFINER = CURRENT_USER SQL SECURITY DEFINER`: определивший +его пользователь читает источники, потребительская роль получает `SELECT` на +`_v`, физические объекты — роль загрузки или диагностики. При стандартном +`INVOKER` потребителю нужны были бы права и на источник ([документация +ClickHouse](https://clickhouse.com/docs/sql-reference/statements/create/view#sql-security)). +Значит реализация публичной поверхности закончена, когда запрос +потребительской ролью проходит через `_v` и отклоняется для `_rep` и `_dist`. + ## Раскладка по шардам Пишем только в `_dist`. Локальные таблицы остаются для чтения и обслуживания — @@ -98,10 +115,10 @@ FROM источник_dist`. Заводское значение `parallel_distr Ключи ко-локации названы заранее, потому что на них стоит политика соединений из раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё -прочее — через `GLOBAL`. Значит `dds.session` и `dds.identity_map` шардируются по -`cityHash64(ClientID)`. Объекты DDS с зерном заказа должны сохранять ко-локацию -по `cityHash64(order_id)`; ключи остальных частей будущей модели и витрин -появятся вместе с ними. +прочее — через `GLOBAL`. Значит `dds.session_dist` и `dds.identity_map_dist` +шардируются по `cityHash64(client_id)`. Объекты DDS с зерном заказа сохраняют +ко-локацию по `cityHash64(order_id)`; ключи остальных частей модели и витрин +появляются вместе с ними. Замена партиций не используется для `ods.order`: версии прошлых дней доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR diff --git a/docs/specs/2026-07-30-stand-v2-realism.md b/docs/specs/2026-07-30-stand-v2-realism.md index 1b34496..89277ad 100644 --- a/docs/specs/2026-07-30-stand-v2-realism.md +++ b/docs/specs/2026-07-30-stand-v2-realism.md @@ -224,14 +224,15 @@ Ecommerce (заполнены только у торговых событий): неизменного `created_at`, шардирование по `cityHash64(order_id)`. `snapshot_date` остаётся датой наблюдения источника, но не задаёт публикацию или идемпотентность. Физическое чтение может видеть несколько версий; - `ods.order_v` возвращает точное текущее состояние. Устройство модели DDS и - способ её материализации решаются отдельно. + `ods.order_v` возвращает точное текущее состояние. В DDS одна строка хранит + один заказ с позициями в типизированных массивах; физическая модель и + пакетное наполнение описаны в [доке заказа](../architecture/dds/order.md). Пропущенный день ничего не ломает, следующий слепок самовосстанавливает. -- `items` остаётся сырой строкой JSON в ODS. Разбирать позиции следует на - границе ODS → DDS, но их представление определяется вместе с будущей моделью - заказов. Это остаётся носителем навыка «вложенный JSON в ClickHouse», не - превращая приём в преждевременную модель данных. +- `items` остаётся сырой строкой JSON в ODS. Позиции разбираются на границе + ODS → DDS в типизированные массивы строки заказа. Это остаётся носителем + навыка «вложенный JSON в ClickHouse», не превращая приём в преждевременную + модель данных. - Статусы держим все три: смена `created` → `paid` и есть причина «дыхания» выручки внутри окна; сужение до двух — резервный срез 1. На выходе из окна заказ либо `paid`, либо `cancelled`: неоплаченного отменяют, «навсегда @@ -292,12 +293,19 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate - **Карта соответствий кука↔пользователь** строится трансформацией из уже существующего моста: `purchase`-событие (`ClientID`, `purchaseID`) ↔ заказ (`order_id`, `user_id`). Ни новых полей, ни нового транспорта. -- Форма и место карты: таблица `dds.identity_map` - (`client_id` UInt64, `user_id` UInt64, `first_matched_at` DateTime), - ReplacingMergeTree, `ORDER BY (client_id, user_id)`, шардирование по - `cityHash64(client_id)` — тем же выражением, что события (иначе ко-локации - нет); ко-локация делает обогащение витрин локальным; - заказы при сборке карты подтягиваются через GLOBAL JOIN. +- Форма и место карты: `dds.identity_map_rep` хранит пару `client_id` UInt64, + `user_id` UInt64 и координаты собственной загрузки `_load_id`, `_load_ts` в + `ReplicatedReplacingMergeTree(_load_ts)` с `ORDER BY (client_id, user_id)`. + `dds.identity_map_dist` шардирует по `cityHash64(client_id)` — тем же + выражением, что события; ко-локация позволяет обогащать витрины локально. + Публичный интерфейс `dds.identity_map_v` перечисляет колонки явно и прячет + `FINAL`. `first_matched_at` в стенде нет: у него нет читателя; история + склейки оставлена самостоятельной работе в issue #145. +- Карта пересобирается пакетно после модели заказов: все `purchase` из + `dds.event_v` соединяются с текущими заказами `dds.order_v` через + `GLOBAL JOIN`, статус заказа связь покупателя с кукой не отменяет. Каждый + запуск пишет полный текущий набор различных пар; предыдущий результат не + служит источником следующего. - **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») — параметр мира; значение фиксирует эта спека: 15%, детерминировано от seed. Ядро лабы: @@ -422,16 +430,20 @@ README. | STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг | | ODS | `ods.event` (+`_errors`), `ods.event_v` | типизированные версии событий, брак и точное актуальное состояние | | ODS | `ods.order` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние | -| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) | +| DDS | `dds.session_rep` / `_dist`, `dds.session_v` | физическая сборка сессий и её публичный интерфейс | | DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую | -| DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS | -| DDS | `dds.identity_map` | карта кука↔пользователь | +| DDS | `dds.order_rep` / `_dist`, `dds.order_v` | текущая модель заказа и её публичный интерфейс | +| DDS | `dds.identity_map_rep` / `_dist`, `dds.identity_map_v` | физическая карта кука↔пользователь и её точный публичный интерфейс | | DIC | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) | | DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже | У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья — см. [доку хранилища](../architecture/storage.md). +Потребительские роли и витрины читают сущности DDS через `_v` с явным списком +колонок; прав на `_rep` и `_dist` у них нет. Физические объекты остаются ролям +загрузки, диагностики и обслуживания. Механизм и проверяемая граница доступа — +в [доке хранилища](../architecture/storage.md), «Имена объектов». Заказы принимаются **пакетным забором** ([ADR 0008](../adr/0008-order-ingestion.md), [ADR @@ -494,7 +506,7 @@ Kafka день переигрывается генератором заново: счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется `declared_revenue` по UTM. - **`daily_traffic_v`** — расширяется парой «посетители» / «известные - пользователи» (обогащение через `dds.identity_map`, локальное соединение + пользователи» (обогащение через `dds.identity_map_v`, локальное соединение по ключу ко-локации). - `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`, `dq_errors_daily_v` — переезжают на новую модель без смены роли: источник — @@ -670,8 +682,10 @@ Kafka Engine на двух нодах снят с этого списка при `ReplacingMergeTree`. Проверять то, что заведомо случается и заведомо обезврежено, — работа без ответа на конце. -- Поведение соединения двух Distributed-таблиц и `distributed_product_mode` — - эмпирически на стенде (хвост #14). +- Точную форму локального обогащения `daily_traffic_v` через публичные `_v` — + эмпирически при проектировании витрин #135. Общее поведение соединения двух + Distributed-таблиц уже проверено в + [исследовании](../research/2026-08-22-distributed-join.md). - Размер артефакта эталонного мира после пересборки. - Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом сверять через MCP Context7 (правило AGENTS.md).