Сессии: dds.session и нарезка визитов #152

Closed
opened 2026-08-25 15:00:58 +03:00 by ddmitry · 1 comment
Owner

Part of #6.

Цель

Расчётная сущность слоя: нарезка событий на визиты правилом стенда,
детерминированный session_id, дневной батч хвоста несобранных дней
заменой партиций. Учебный результат (session.md): менти строит сущность,
которой нет в потоке, — и её можно сверить с эталоном.

Что войдёт

  • DDL по session.md и конвенциям storage.md: dds.session_rep — партиция
    «день сессии», ключ сортировки — кука и начало сессии;
    dds.session_dist — шардирование cityHash64(client_id) (ко-локация с
    событиями и картой); dds.session_v; грант bi_reader на _v.
  • Сессионизация из dds.event_v по правилу визита (CONTEXT.md); состав
    строки — по session.md; session_id — детерминированный cityHash64
    от пары «кука + начало сессии»: пересборка партиции чеканит те же id.
  • Задача сессий в dds_transform рядом с заказами (зависимости данных
    между ними нет): хвост от последнего собранного дня до последнего
    прожитого, сессии живого дня не собираются; двойник и
    REPLACE PARTITION — по образцу заказа.

Границы

  • DQ-сверка с эталоном VisitID — тикет контура DQ; эталон в модель не
    тащить.
  • Витрина обзора сессий — учебное задание (тикет ТЗ).

Кому что

DDL и SQL нарезки — Opus: оконная сессионизация — центральный урок слоя.
Codex — опыты по брифу: повтор прогона, счётные свойства, уборка за
опытами.

Сначала прочитать

  • docs/architecture/dds/session.md — целиком, это спека тикета;
  • docs/architecture/dds/event.md — что отдаёт контракт;
  • CONTEXT.md — «Визит», «Граница суток», «День активности»;
  • docs/architecture/storage.md — «Раскладка по шардам»;
  • docs/architecture/etl.md — «Объём прогона».

Проверка

  • make lint; на стенде make smoke, make check-clickhouse и
    make check-services (в даг пришла новая задача) зелёные.
  • Свойства нарезки запросом dds.session_v против dds.event_v: у
    сессии одна кука и один день; паузы внутри ≤ 30 минут; соседние сессии
    куки разделены паузой > 30 минут или границей суток.
  • Счётная сверка на честном стартовом мире: число сессий по паре
    «кука × день» равно uniqExact(VisitID) из ods.event_v —
    расхождение нулевое.
  • Повтор прогона: те же session_id.
Part of #6. ## Цель Расчётная сущность слоя: нарезка событий на визиты правилом стенда, детерминированный `session_id`, дневной батч хвоста несобранных дней заменой партиций. Учебный результат (session.md): менти строит сущность, которой нет в потоке, — и её можно сверить с эталоном. ## Что войдёт - DDL по session.md и конвенциям storage.md: `dds.session_rep` — партиция «день сессии», ключ сортировки — кука и начало сессии; `dds.session_dist` — шардирование `cityHash64(client_id)` (ко-локация с событиями и картой); `dds.session_v`; грант `bi_reader` на `_v`. - Сессионизация из `dds.event_v` по правилу визита (CONTEXT.md); состав строки — по session.md; `session_id` — детерминированный `cityHash64` от пары «кука + начало сессии»: пересборка партиции чеканит те же id. - Задача сессий в `dds_transform` рядом с заказами (зависимости данных между ними нет): хвост от последнего собранного дня до последнего прожитого, сессии живого дня не собираются; двойник и `REPLACE PARTITION` — по образцу заказа. ## Границы - DQ-сверка с эталоном `VisitID` — тикет контура DQ; эталон в модель не тащить. - Витрина обзора сессий — учебное задание (тикет ТЗ). ## Кому что DDL и SQL нарезки — Opus: оконная сессионизация — центральный урок слоя. Codex — опыты по брифу: повтор прогона, счётные свойства, уборка за опытами. ## Сначала прочитать - docs/architecture/dds/session.md — целиком, это спека тикета; - docs/architecture/dds/event.md — что отдаёт контракт; - CONTEXT.md — «Визит», «Граница суток», «День активности»; - docs/architecture/storage.md — «Раскладка по шардам»; - docs/architecture/etl.md — «Объём прогона». ## Проверка - [x] `make lint`; на стенде `make smoke`, `make check-clickhouse` и `make check-services` (в даг пришла новая задача) зелёные. - [x] Свойства нарезки запросом `dds.session_v` против `dds.event_v`: у сессии одна кука и один день; паузы внутри ≤ 30 минут; соседние сессии куки разделены паузой > 30 минут или границей суток. - [x] Счётная сверка на честном стартовом мире: число сессий по паре «кука × день» равно `uniqExact(VisitID)` из `ods.event_v` — расхождение нулевое. - [x] Повтор прогона: те же `session_id`.
Author
Owner

Уточнения к постановке по итогам разбора с владельцем. Тело тикета не правим — читать вместе с ним.

1. Объём прогона: чем определяется «последний прожитый день»

События не отличают живой день от прожитого: и посреди дня D, и сразу после него max(event_date) = D. А спека требует различать — «сессии только что прожитого дня собираются один раз и целиком», но «внутри живого дня модель про сегодня честно молчит».

Два признака проверены и отклонены:

  • «пошли события следующего дня» — точен (визиты полночь не пересекают по построению), но опаздывает на день: только что прожитый день собрался бы лишь следующим прогоном;
  • приход слепка заказов — отстаёт ещё на день, и это устройство, а не случайность: snapshot --day D отправляет слепок дня D−1 (player.play_snapshots, cli.py), в описи мира это 7 слепков на 8 дней старта. После проигрыша дня D в ODS max(event_date) = D, а max(snapshot_date) = D−1. Сверх того признак завёл бы сессиям зависимость от заказов, которой тикет обещает не иметь.

Решение — позиция мира плюс якорь в данных:

последний прожитый день = min(event_date) + (world_position − 1)

Позиция ставится ровно в нужный момент: последним шагом работника и только по успеху (world_live_day, Variable.set(WORLD_POSITION, str(first_day + 1))). Читать её — как в world_control.first_unplayed_day: Variable.get и внятная ошибка на отсутствие или мусор.

Якорь не требует второй копии физики мира. EventDate дня N — константа ORIGIN + N на весь день (generator/.../day.py), у ods.event нет TTL (он только у *_errors), а PARTITION BY EventDate отдаёт минимум по метаданным партиций.

Сходится на трёх положениях:

положение позиция формула ожидание
после world_initialize (дни 0…7) 8 2026-06-08 собрать всё, край — последний день старта
посреди живого дня 8 8 2026-06-08 сегодня не собирается
после конца живого дня 8 9 2026-06-09 день собран тем же прогоном

Это и есть формулировка резолюции карты в etl.md: «объём выводится из world_position и состояния слоёв». Довод etl.md против позиции целил в другой вопрос — «какой день доехал», и для окна заказов он в силе. Здесь спрашивается «какой день прожит», и отвечает на это только позиция.

За «доехал» позиция не отвечает. Если хвост hits не дожёван, формула назовёт день, чьи события ещё в пути, и день замёрзнет недособранным — окна пересборки у сессий нет. Сторож (нулевое отставание потребителей) приезжает с #156; на приёмке #152 отставание проверяется руками перед счётной сверкой.

Форма передачи. В запрос объёма позиция едет параметром ClickHouse ({position:UInt16}), как window_days у заказа. Jinja нужна только в ALTER … PARTITION: туда сервер параметры не подставляет (замерено, sql/dds/order_replace.sql).

2. Левый край хвоста — первый недостающий день

Формулировка «хвост от последнего собранного дня» приехала из etl.md и написана до заказа. sql/dds/order_scope.sql решает наоборот и объясняет почему: замены партиций размножены по дням и падают порознь, поэтому упавший день оставляет дыру, а счёт по максимуму собранного дня её не заметил бы — прогоны продолжали бы зеленеть над щербатым слоем. У сессий устройство то же, а цена выше: дышащего окна, которое затянуло бы дыру само, здесь нет.

Значит, GLOBAL NOT IN по дням (обе таблицы распределённые) — как у заказа. Запрос объёма читает ods.event_dist без FINAL: дубли доставки минимум и максимум даты не двигают. Формулировку в etl.md поправить тем же PR.

3. Двойник — в список DDL

В «Что войдёт» названы session_rep, session_dist, session_v, а двойник только подразумевается следующим пунктом. Нужны dds.session_stage_rep и dds.session_stage_dist: REPLACE PARTITION берёт готовый кусок из другой таблицы, результат запроса ей не скормишь, а сборка прямо в цель удвоила бы день при повторе. Операция требует совпадения структуры, ключа партиции, сортировки, первичного ключа и политики хранения (отсюда AS dds.session_rep вместо повторённого списка колонок), а пошардовая замена — ещё и совпадения ключа шардирования. Связка DROP PARTITION + INSERT двойник не заменяет: между шагами день пуст для читателя.

4. Грант bi_reader — делать нечего

У роли уже GRANT SELECT ON dds.* (infra/clickhouse/users.d/access.xml), а по ADR 0007 представление — договор чтения, не граница прав. Объектных грантов в репозитории нет ни у одного объекта. Пункт читать как «ничего не требуется», новый GRANT не добавлять.

5. Мелочь для решения в коде

parallel_distributed_insert_select: заказ ставит 0 явно и объясняет, что совпадение ключей у него случайное. У сессий ключ источника и цели совпадает по контракту ко-локации, то есть заводская 2 тоже верна. Выбор сделать вслух и записать комментарием, а не оставить умолчанию.

Уточнения к постановке по итогам разбора с владельцем. Тело тикета не правим — читать вместе с ним. ## 1. Объём прогона: чем определяется «последний прожитый день» События не отличают живой день от прожитого: и посреди дня D, и сразу после него `max(event_date) = D`. А спека требует различать — «сессии только что прожитого дня собираются один раз и целиком», но «внутри живого дня модель про сегодня честно молчит». Два признака проверены и отклонены: - **«пошли события следующего дня»** — точен (визиты полночь не пересекают по построению), но опаздывает на день: только что прожитый день собрался бы лишь следующим прогоном; - **приход слепка заказов** — отстаёт ещё на день, и это устройство, а не случайность: `snapshot --day D` отправляет слепок дня D−1 (`player.play_snapshots`, `cli.py`), в описи мира это 7 слепков на 8 дней старта. После проигрыша дня D в ODS `max(event_date) = D`, а `max(snapshot_date) = D−1`. Сверх того признак завёл бы сессиям зависимость от заказов, которой тикет обещает не иметь. **Решение — позиция мира плюс якорь в данных:** ``` последний прожитый день = min(event_date) + (world_position − 1) ``` Позиция ставится ровно в нужный момент: последним шагом работника и только по успеху (`world_live_day`, `Variable.set(WORLD_POSITION, str(first_day + 1))`). Читать её — как в `world_control.first_unplayed_day`: `Variable.get` и внятная ошибка на отсутствие или мусор. Якорь не требует второй копии физики мира. `EventDate` дня N — константа `ORIGIN + N` на весь день (`generator/.../day.py`), у `ods.event` нет TTL (он только у `*_errors`), а `PARTITION BY EventDate` отдаёт минимум по метаданным партиций. Сходится на трёх положениях: | положение | позиция | формула | ожидание | | --- | --- | --- | --- | | после `world_initialize` (дни 0…7) | 8 | 2026-06-08 | собрать всё, край — последний день старта | | посреди живого дня 8 | 8 | 2026-06-08 | сегодня не собирается | | после конца живого дня 8 | 9 | 2026-06-09 | день собран тем же прогоном | Это и есть формулировка резолюции карты в `etl.md`: «объём выводится из `world_position` и состояния слоёв». Довод `etl.md` против позиции целил в другой вопрос — «какой день **доехал**», и для окна заказов он в силе. Здесь спрашивается «какой день **прожит**», и отвечает на это только позиция. **За «доехал» позиция не отвечает.** Если хвост `hits` не дожёван, формула назовёт день, чьи события ещё в пути, и день замёрзнет недособранным — окна пересборки у сессий нет. Сторож (нулевое отставание потребителей) приезжает с #156; на приёмке #152 отставание проверяется руками перед счётной сверкой. **Форма передачи.** В запрос объёма позиция едет параметром ClickHouse (`{position:UInt16}`), как `window_days` у заказа. Jinja нужна только в `ALTER … PARTITION`: туда сервер параметры не подставляет (замерено, `sql/dds/order_replace.sql`). ## 2. Левый край хвоста — первый недостающий день Формулировка «хвост от последнего собранного дня» приехала из `etl.md` и написана до заказа. `sql/dds/order_scope.sql` решает наоборот и объясняет почему: замены партиций размножены по дням и падают порознь, поэтому упавший день оставляет дыру, а счёт по максимуму собранного дня её не заметил бы — прогоны продолжали бы зеленеть над щербатым слоем. У сессий устройство то же, а цена выше: дышащего окна, которое затянуло бы дыру само, здесь нет. Значит, `GLOBAL NOT IN` по дням (обе таблицы распределённые) — как у заказа. Запрос объёма читает `ods.event_dist` без `FINAL`: дубли доставки минимум и максимум даты не двигают. Формулировку в `etl.md` поправить тем же PR. ## 3. Двойник — в список DDL В «Что войдёт» названы `session_rep`, `session_dist`, `session_v`, а двойник только подразумевается следующим пунктом. Нужны `dds.session_stage_rep` и `dds.session_stage_dist`: `REPLACE PARTITION` берёт готовый кусок из другой таблицы, результат запроса ей не скормишь, а сборка прямо в цель удвоила бы день при повторе. Операция требует совпадения структуры, ключа партиции, сортировки, первичного ключа и политики хранения (отсюда `AS dds.session_rep` вместо повторённого списка колонок), а пошардовая замена — ещё и совпадения ключа шардирования. Связка `DROP PARTITION` + `INSERT` двойник не заменяет: между шагами день пуст для читателя. ## 4. Грант `bi_reader` — делать нечего У роли уже `GRANT SELECT ON dds.*` (`infra/clickhouse/users.d/access.xml`), а по ADR 0007 представление — договор чтения, не граница прав. Объектных грантов в репозитории нет ни у одного объекта. Пункт читать как «ничего не требуется», новый `GRANT` не добавлять. ## 5. Мелочь для решения в коде `parallel_distributed_insert_select`: заказ ставит `0` явно и объясняет, что совпадение ключей у него случайное. У сессий ключ источника и цели совпадает по контракту ко-локации, то есть заводская `2` тоже верна. Выбор сделать вслух и записать комментарием, а не оставить умолчанию.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: ddmitry/clickstream-data-platform#152