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:
1 parent
c361c36e3d
commit
176cf5520d
9 files changed
+495
-60
No files matched your search
+87
-10
@@ -1,18 +1,18 @@
|
||||
-- DDS: физическая модель заказа и донор её партиций.
|
||||
-- DDS: физические модели слоя и доноры их партиций.
|
||||
--
|
||||
-- Идемпотентность здесь устроена иначе, чем в приёме, и это главный урок обеих
|
||||
-- сущностей файла. В ODS повтор гасит колонка версии:
|
||||
-- ReplacingMergeTree(updated_at) оставляет позднюю строку. Здесь колонки
|
||||
-- версии нет вовсе — повтор гасит замена партиции целиком: день собирается
|
||||
-- заново и подменяется одним атомарным движением. Поэтому строка таблицы
|
||||
-- ровно одна на своё зерно, а чтению не нужен FINAL.
|
||||
|
||||
-- Локальная таблица заказа.
|
||||
--
|
||||
-- Зерно — заказ в текущем состоянии, ключ order_id. Историю версий слой не
|
||||
-- хранит: у неё нет читателя ни в одной витрине, а ODS её и не обещает
|
||||
-- (docs/architecture/dds/order.md, «Зерно»).
|
||||
--
|
||||
-- Идемпотентность здесь устроена иначе, чем в приёме, и это главный урок
|
||||
-- сущности. В ODS повтор гасит колонка версии: ReplacingMergeTree(updated_at)
|
||||
-- оставляет позднюю строку. Здесь колонки версии нет вовсе — повтор гасит
|
||||
-- замена партиции целиком: день собирается заново и подменяется одним
|
||||
-- атомарным движением. Поэтому каждая строка таблицы — ровно один заказ, а
|
||||
-- чтению не нужен FINAL.
|
||||
|
||||
-- Локальная таблица шарда.
|
||||
--
|
||||
-- Партиция — order_date, день заказа в поясе счётчика. Партиция дневная не
|
||||
-- ради размера куска, а потому что день — единица пересборки: пока заказ
|
||||
-- внутри окна изменяемости, его день «дышит» и собирается заново, за окном
|
||||
@@ -93,3 +93,80 @@ ORDER BY order_id;
|
||||
CREATE TABLE IF NOT EXISTS dds.order_stage_dist ON CLUSTER clickstream_cluster
|
||||
AS dds.order_stage_rep
|
||||
ENGINE = Distributed('clickstream_cluster', 'dds', 'order_stage_rep', cityHash64(order_id));
|
||||
|
||||
-- Локальная таблица сессии.
|
||||
--
|
||||
-- Зерно — визит: подряд идущие события одной куки без пауз длиннее тридцати
|
||||
-- минут, не пересекающие границу суток (CONTEXT.md, «Визит»). Сущность
|
||||
-- расчётная, в потоке её нет — слой режет и чеканит её сам
|
||||
-- (docs/architecture/dds/session.md).
|
||||
--
|
||||
-- Партиция — день сессии. У заказа день дышит внутри окна изменяемости, у
|
||||
-- сессии замерзает сразу: визит суток не пересекает, поэтому прожитый день
|
||||
-- собирается один раз и целиком. Отсюда и цена отказа: пропущенный день
|
||||
-- заказа вернуло бы в работу следующее окно, а день сессии вернёт только счёт
|
||||
-- хвоста от первой дыры (sql/dds/session_scope.sql).
|
||||
--
|
||||
-- Ключ сортировки — кука и начало сессии: тем же порядком идёт и нарезка, и
|
||||
-- чтение «визиты посетителя подряд». session_id в ключ не входит: он ручка
|
||||
-- для витрин и разбора расхождений, а не адрес строки.
|
||||
CREATE TABLE IF NOT EXISTS dds.session_rep ON CLUSTER clickstream_cluster
|
||||
(
|
||||
-- Суррогат слоя: cityHash64 от куки и начала сессии. Детерминированность
|
||||
-- несущая — пересборка партиции обязана отчеканить те же id
|
||||
-- (docs/architecture/dds/session.md, «Идентификатор»).
|
||||
session_id UInt64,
|
||||
client_id UInt64,
|
||||
session_date Date,
|
||||
started_at DateTime('Europe/Samara'),
|
||||
finished_at DateTime('Europe/Samara'),
|
||||
duration_seconds UInt32,
|
||||
-- Счётчики по типам событий: у визита Метрики на этом месте достигнутые
|
||||
-- цели, которых стенд не шлёт.
|
||||
events_total UInt32,
|
||||
pageviews UInt32,
|
||||
cart_adds UInt32,
|
||||
purchases UInt32,
|
||||
-- Вход и выход визита, как StartURL и EndURL у Метрики. Полного списка
|
||||
-- страниц нет и у неё: топы считает витрина по событиям.
|
||||
entry_url String,
|
||||
exit_url String,
|
||||
-- Атрибуция визита — метки его первого события.
|
||||
utm_source String,
|
||||
utm_medium String,
|
||||
utm_campaign String,
|
||||
utm_content String,
|
||||
utm_term String,
|
||||
-- Паспорт куки: устройство и город приписаны ей на всю жизнь, поэтому
|
||||
-- берутся с первого события и внутри визита не меняются.
|
||||
device_category LowCardinality(String),
|
||||
region_city String,
|
||||
_load_id String,
|
||||
_load_ts DateTime64(3, 'UTC')
|
||||
)
|
||||
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
|
||||
PARTITION BY session_date
|
||||
ORDER BY (client_id, started_at);
|
||||
|
||||
-- Лицо загрузки и диагностики. Ключ шардирования — cityHash64(client_id), то
|
||||
-- же выражение, что у событий и у карты идентичностей
|
||||
-- (docs/architecture/storage.md, «Раскладка по шардам»). Ко-локация здесь не
|
||||
-- только про соединения: на ней стоит сама сборка — все события куки лежат на
|
||||
-- одном шарде, и нарезать их можно, не собирая куку с двух нод
|
||||
-- (sql/dds/session_rebuild.sql).
|
||||
CREATE TABLE IF NOT EXISTS dds.session_dist ON CLUSTER clickstream_cluster
|
||||
AS dds.session_rep
|
||||
ENGINE = Distributed('clickstream_cluster', 'dds', 'session_rep', cityHash64(client_id));
|
||||
|
||||
-- Донор партиций сессии. Правило донора — в комментарии к dds.order_stage_rep
|
||||
-- выше: структура и ключи обязаны совпадать с целью, отсюда AS dds.session_rep
|
||||
-- и тот же ключ шардирования.
|
||||
CREATE TABLE IF NOT EXISTS dds.session_stage_rep ON CLUSTER clickstream_cluster
|
||||
AS dds.session_rep
|
||||
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
|
||||
PARTITION BY session_date
|
||||
ORDER BY (client_id, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dds.session_stage_dist ON CLUSTER clickstream_cluster
|
||||
AS dds.session_stage_rep
|
||||
ENGINE = Distributed('clickstream_cluster', 'dds', 'session_stage_rep', cityHash64(client_id));
|
||||
@@ -99,3 +99,38 @@ SELECT
|
||||
_load_id,
|
||||
_load_ts
|
||||
FROM dds.order_dist;
|
||||
|
||||
-- DDS: публичный договор сессии на языке бизнес-модели.
|
||||
--
|
||||
-- Под представлением — физическая модель, как у заказа: оконная нарезка
|
||||
-- визитов на каждое чтение означала бы считать сущность заново в каждом
|
||||
-- отчёте (docs/architecture/dds/session.md, «Отклонённые варианты»).
|
||||
--
|
||||
-- Эталонного VisitID в договоре нет намеренно: наша нарезка обязана уметь
|
||||
-- разойтись с эталоном, на этом стоит сверка. За эталоном она ходит в
|
||||
-- ods.event_v.
|
||||
CREATE VIEW IF NOT EXISTS dds.session_v ON CLUSTER clickstream_cluster
|
||||
AS
|
||||
SELECT
|
||||
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
|
||||
FROM dds.session_dist;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
Reference in new issue
Block a user