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

+134 -25
View File
@@ -1,11 +1,12 @@
"""Трансформации слоя DDS: пересборка дней, которые ещё могут измениться.
"""Трансформации слоя DDS: сборка модели заменой дневных партиций.
Даг собирает модель поверх принятого ODS. Первая его сущность — заказ; сессии
и карта идентичностей встанут соседними группами задач.
Даг собирает модель поверх принятого ODS. Сущностей у него две — заказ и
сессия, зависимости по данным между ними нет; карта идентичностей встанет
соседней группой задач.
Расписания у дага нет и параметров тоже: объём каждого прогона он выводит сам
из состояния слоёв, поэтому один и тот же запуск одинаково верен из триггера
дневного конвейера, с кнопки менти и после падения
из состояния слоёв и позиции мира, поэтому один и тот же запуск одинаково
верен из триггера дневного конвейера, с кнопки менти и после падения
(docs/architecture/etl.md, «Объём прогона»).
Повтор прогона гасит не колонка версии, а замена партиции целиком: день
@@ -24,7 +25,11 @@ from pathlib import Path
from airflow.exceptions import AirflowException
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
from airflow.sdk import dag, task, task_group
from airflow.sdk import Variable, dag, task, task_group
# Пропуск приезжает из Task SDK: в airflow.exceptions он объявлен устаревшим
# и говорит об этом в журнал задачи (проверено на 3.3.0).
from airflow.sdk.exceptions import AirflowSkipException
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
@@ -36,6 +41,61 @@ CLICKHOUSE_CONNECTION = "clickhouse_default"
# почему у стенда лежит копия и чем она рискует — compose.yaml.
ORDER_WINDOW_DAYS = int(os.environ["ORDER_WINDOW_DAYS"])
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит
# пульт мира, и только она отличает прожитый день от живого — зачем это
# сессиям, разобрано в sql/dds/session_scope.sql.
WORLD_POSITION = "world_position"
def _world_position() -> int:
"""Позиция мира числом, с внятным отказом на непригодном значении.
Ту же переменную читает пульт, но общего модуля у дагов нет намеренно:
связь между пультом и трансформацией держит сама переменная, а не импорт.
"""
raw_position = Variable.get(WORLD_POSITION, default=None)
if raw_position is None:
raise AirflowException(
"мир ещё не создан: выполните make up и дождитесь world_initialize"
)
try:
position = int(raw_position)
except (TypeError, ValueError) as error:
raise AirflowException(
f"world_position={raw_position!r} не является номером дня; "
"выполните make rebuild-storage"
) from error
if position < 1:
raise AirflowException(
f"мир не готов: world_position={position}; "
"выполните make up или make rebuild-storage"
)
return position
def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
"""Дни отрезка строками — в таком виде их ждёт замена партиции."""
return [
(first_day + datetime.timedelta(days=shift)).isoformat()
for shift in range((last_day - first_day).days + 1)
]
@task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Разложить дни в аргументы размноженной задачи замены.
Шаг существует ради ограничения Airflow: размножать задачу можно
только по целому возврату другой задачи — ни по ключу словаря, ни по
карте поверх него. Отсюда отдельный возврат под один список.
День едет в запрос подстановкой Jinja, а не параметром ClickHouse:
в ALTER … PARTITION сервер параметр не подставляет — замерено,
отвечает синтаксической ошибкой. Значение при этом не внешнее, его
вернул сам ClickHouse запросом объёма.
"""
return [{"params": {"day": day}} for day in scope["days"]]
@dag(
dag_id="dds_transform",
@@ -45,6 +105,11 @@ ORDER_WINDOW_DAYS = int(os.environ["ORDER_WINDOW_DAYS"])
# Два прогона разом собирали бы один и тот же двойник: второй затёр бы
# чужую сборку между вставкой и заменой партиций.
max_active_runs=1,
# Потолок на задачи в прогоне. Размноженная замена норовит выстрелить
# всеми днями сразу, а каждая задача — свой процесс исполнителя, и
# планировщику отведено 640 МиБ (compose.yaml). Замерено: тринадцать дней
# двух сущностей без потолка кончаются SIGKILL от контрольной группы.
max_active_tasks=3,
template_searchpath=str(SQL_ROOT),
tags=["dds"],
)
@@ -69,10 +134,7 @@ def dds_transform():
"проверьте, дошёл ли слепок до хранилища"
)
first_day, last_day = row
days = [
(first_day + datetime.timedelta(days=shift)).isoformat()
for shift in range((last_day - first_day).days + 1)
]
days = _days(first_day, last_day)
logging.info(
"объём пересборки: %s … %s, дней %d",
first_day,
@@ -85,21 +147,6 @@ def dds_transform():
"days": days,
}
@task
def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
"""Разложить дни в аргументы размноженной задачи замены.
Шаг существует ради ограничения Airflow: размножать задачу можно
только по целому возврату другой задачи — ни по ключу словаря, ни
по карте поверх него. Отсюда отдельный возврат под один список.
День едет в запрос подстановкой Jinja, а не параметром ClickHouse:
в ALTER … PARTITION сервер параметр не подставляет — замерено,
отвечает синтаксической ошибкой. Значение при этом не внешнее, его
вернул сам ClickHouse запросом объёма.
"""
return [{"params": {"day": day}} for day in scope["days"]]
days = scope()
rebuild = SQLExecuteQueryOperator(
@@ -127,7 +174,69 @@ def dds_transform():
rebuild >> replace
@task_group(group_id="session")
def session():
"""Сессия: дни, которые мир уже прожил, а слой ещё не собрал."""
@task
def scope() -> dict[str, object]:
"""Спросить у хранилища и у позиции мира, какие дни собирает запуск."""
query = (SQL_ROOT / "dds" / "session_scope.sql").read_text(encoding="utf-8")
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
row = hook.get_first(query, parameters={"position": _world_position()})
if row is None:
raise AirflowException(
"в ods.event нет ни одного события: собирать нечего; "
"проверьте, дошёл ли поток до хранилища"
)
first_day, last_day = row
# Пропуск, а не отказ: слой полон по последний прожитый день, а
# сегодняшний ещё идёт. Это обычное состояние повторного прогона,
# и в интерфейсе оно видно пропущенной группой.
if first_day is None:
raise AirflowSkipException(
f"сессии собраны по {last_day} включительно, "
"новых прожитых дней нет"
)
days = _days(first_day, last_day)
logging.info(
"объём сборки: %s … %s, дней %d",
first_day,
last_day,
len(days),
)
return {
"first_day": first_day.isoformat(),
"last_day": last_day.isoformat(),
"days": days,
}
days = scope()
rebuild = SQLExecuteQueryOperator(
task_id="rebuild",
conn_id=CLICKHOUSE_CONNECTION,
sql="dds/session_rebuild.sql",
parameters={
"load_id": "{{ run_id }}",
"first_day": days["first_day"],
"last_day": days["last_day"],
},
split_statements=True,
do_xcom_push=False,
)
replace = SQLExecuteQueryOperator.partial(
task_id="replace",
conn_id=CLICKHOUSE_CONNECTION,
sql="dds/session_replace.sql",
do_xcom_push=False,
).expand_kwargs(replacements(days))
rebuild >> replace
order()
session()
dds_transform()