feat(dds): добавлена модель сессии и нарезка визитов окнами

Зачем:
- Дать слою расчётную сущность, которой в потоке нет: стенд режет визиты сам,
  а VisitID генератора остаётся эталоном самопроверки.
- Отделить прожитый день от живого — иначе визит, начатый в 23:50, замёрз бы
  обрезанным навсегда: пересобирать замёрзший день слой не станет.

Что:
- Добавлены dds.session_rep/_dist, двойник-донор dds.session_stage_rep/_dist и
  публичный договор dds.session_v.
- Нарезка стоит на двух окнах: первое ставит шов там, где пауза длиннее
  тридцати минут, второе считает нарастающий итог швов — номер визита. Границу
  суток держит разбиение окна, отдельной проверки ей не нужно.
- Порядок в окнах полный, по метке времени и event_id: сортировок две, и при
  равных метках они разошлись бы, разорвав сессию надвое.
- Объём прогона считается от первой дыры в слое по последний прожитый день;
  прожитый день выводится из позиции мира и первого дня событий. Собирать
  нечего — группа задач пропускается, а не зеленеет впустую.
- В dds_transform появилась группа session рядом с заказом; зависимости по
  данным между ними нет.
- Потолок max_active_tasks: размноженная замена без него выносит планировщика
  за отведённые ему 640 МиБ.
- parallel_distributed_insert_select у сессий не назначается вслух — ключ
  шардирования у источника и цели один по контракту ко-локации.
- Записано, что позиция отвечает за «прожит», а не за «доехал»: сторож хвоста
  потока приезжает отдельным тикетом.

Проверка:
- make lint
- make smoke (21 проверка), make check-clickhouse (10), make check-services
- Стартовый мир, пересобранный с нуля: 79367 сессий за восемь дней;
  расхождение с uniqExact(VisitID) по паре «кука × день» — ноль; все 401185
  событий ровно в одной сессии; session_id уникальны.
- Ни одной паузы длиннее тридцати минут внутри сессии и ни одной пары соседних
  визитов куки, разделённой меньшим промежутком.
- Повтор после DROP PARTITION ALL: те же session_id и то же содержимое строк.
- Снесённая руками партиция 2026-06-04: следующий прогон взял хвост от неё,
  дни до дыры не тронуты.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ddadminandClaude Opus 5 committed 2026-08-26 07:56:44 -04:00
1 parent c361c36e3d
commit 176cf5520d
9 files changed
+495 -60

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
-- DDS: нарезка событий на визиты и сборка дней в таблицу-двойник.
--
-- Двойник очищается целиком в начале, дни собираются одной вставкой на весь
-- отрезок — по правилу заказа (sql/dds/order_rebuild.sql).
--
-- Нарезка — центральный расчёт слоя, и стоит она на двух окнах подряд.
-- Первое спрашивает у каждого события, сколько прошло с предыдущего события
-- той же куки в тот же день, и ставит единицу там, где пауза длиннее
-- таймаута: это шов между визитами. Второе считает нарастающий итог этих
-- единиц — номер визита внутри дня куки. Дальше визит становится обычной
-- группировкой.
--
-- Границу суток окна не проверяют: она уже в разбиении 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;
INSERT INTO dds.session_stage_dist
(
session_id,
client_id,
session_date,
started_at,
finished_at,
duration_seconds,
events_total,
pageviews,
cart_adds,
purchases,
entry_url,
exit_url,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
device_category,
region_city,
_load_id,
_load_ts
)
WITH
seams AS (
SELECT
client_id,
event_date,
event_time,
event_id,
event_type,
url,
utm_source,
utm_medium,
utm_campaign,
utm_content,
utm_term,
device_category,
region_city,
event_time - lagInFrame(event_time, 1, event_time) OVER visit > 1800
AS opens_visit
FROM dds.event_v
WHERE event_date BETWEEN {first_day:Date} AND {last_day:Date}
WINDOW visit AS (
PARTITION BY client_id, event_date
ORDER BY event_time, event_id
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
)
),
numbered AS (
SELECT
*,
sum(opens_visit) OVER (
PARTITION BY client_id, event_date
ORDER BY event_time, event_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS visit_no
FROM seams
)
SELECT
-- Чекан визита: кука и его начало. Время входит в хеш секундой эпохи,
-- поэтому id держится за содержание сессии, а не за порядок сборки.
cityHash64(client_id, min(event_time)),
client_id,
event_date,
min(event_time),
max(event_time),
max(event_time) - min(event_time),
count(),
countIf(event_type = 'pageview'),
countIf(event_type = 'add_to_cart'),
countIf(event_type = 'purchase'),
argMin(url, event_time),
argMax(url, event_time),
-- Атрибуция и паспорт куки берутся с первого события визита. У паспорта
-- любое событие дало бы то же: устройство и город у куки постоянны.
argMin(utm_source, event_time),
argMin(utm_medium, event_time),
argMin(utm_campaign, event_time),
argMin(utm_content, event_time),
argMin(utm_term, event_time),
argMin(device_category, event_time),
argMin(region_city, event_time),
{load_id:String},
now64(3, 'UTC')
FROM numbered
GROUP BY client_id, event_date, visit_no
-- Капкана parallel_distributed_insert_select здесь нет, и это выигрыш
-- ко-локации: ключ шардирования у источника и цели один — cityHash64 от
-- куки, — поэтому визит ляжет туда же, где лежали его события, исполнись
-- вставка хоть на инициаторе, хоть локально на шардах. Заказу приходится
-- называть ноль вслух: совпадение ключей у него случайное
-- (sql/dds/order_rebuild.sql). Здесь выбор сделан обратный и он тоже
-- осознанный: настройку не называть, чтобы не притворяться, будто её значение
-- что-то решает.
SETTINGS distributed_foreground_insert = 1;
+11
View File
@@ -0,0 +1,11 @@
-- DDS: подмена одного дня сессий собранной партицией двойника.
--
-- Механика и доводы целиком — sql/dds/order_replace.sql: замена идёт по
-- локальным таблицам, шард за шардом, атомарность пошардовая, день
-- подставляет Jinja, потому что параметров ALTER … PARTITION не берёт.
--
-- Разница одна, и она про отказ. Несобранный день заказа вернуло бы в работу
-- следующее окно; сессиям возвращать нечем, и упавшая замена оставляет дыру
-- до тех пор, пока её не найдёт счёт хвоста (sql/dds/session_scope.sql).
ALTER TABLE dds.session_rep ON CLUSTER clickstream_cluster
REPLACE PARTITION '{{ params.day }}' FROM dds.session_stage_rep;
+50
View File
@@ -0,0 +1,50 @@
-- DDS: объём пересборки сессий — какие дни собирает этот запуск.
--
-- Ответ — две даты, границы непрерывного отрезка; список дней между ними
-- разворачивает даг. Пустой ответ означает пустой источник. Пустой первый
-- день — что собирать нечего: все прожитые дни уже в слое, и прогон честно
-- пропускает сборку.
--
-- Файл кончается без точки с запятой: почему — sql/dds/order_scope.sql.
--
-- Правый край — последний прожитый день, и знает его только позиция мира.
-- События живой день от прожитого не отличают: и посреди дня D, и сразу после
-- его конца max(EventDate) = D. А разница несущая — визит, начатый в 23:50,
-- посреди дня ещё идёт, и собранная сессия оказалась бы обрезанной навсегда:
-- пересобирать замёрзший день слой не станет
-- (docs/architecture/dds/session.md, «Движок и наполнение»).
--
-- Позиция — номер первого несыгранного дня, счётчик прожитого. Своей копии
-- начала оси стенд из-за неё не заводит: начало он спрашивает у данных, и
-- дата дня N в них — константа, начало оси плюс N. Держится якорь на том, что
-- ранние дни из ods.event не исчезают: срок жизни назначен сырью и таблицам
-- ошибок, самому событию — нет (sql/ddl/20-ods-tables.sql).
--
-- За «доехал» позиция не отвечает — только за «прожит». Дождаться хвоста
-- потока — работа отдельного шага готовности источника, и в даге его пока нет
-- (#156): до него недожёванный хвост даёт замёрзший недособранный день
-- (docs/architecture/etl.md, «Готовность источника»).
WITH
(SELECT min(EventDate) FROM ods.event_dist) AS first_source_day,
first_source_day + ({position:UInt16} - 1) AS last_lived_day,
-- Хвост считается от первой дыры, а не от последнего собранного дня:
-- замены партиций падают порознь, и счёт по максимуму собранного дня
-- дыру позади себя не заметил бы (доводы целиком — sql/dds/order_scope.sql).
-- У сессий это единственный способ дыру найти: дышащего окна, которое
-- пересобрало бы день заодно, здесь нет.
--
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
-- бы дни источника с локальной частью слоя на каждом шарде.
-- minOrNull отличает «дыр нет» от даты по умолчанию.
(
SELECT minOrNull(day)
FROM (SELECT DISTINCT EventDate AS day FROM ods.event_dist)
WHERE day GLOBAL NOT IN (SELECT session_date FROM dds.session_dist)
) AS first_missing_day
SELECT
-- Живой день в слое отсутствует всегда, и работой это не считается:
-- дырой он станет, только когда мир его проживёт.
if(first_missing_day <= last_lived_day, first_missing_day, NULL) AS first_day,
last_lived_day AS last_day
-- FINAL не нужен: повтор доставки не двигает ни край оси, ни набор дней.
WHERE (SELECT count() FROM ods.event_dist) > 0