Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453950a639 | ||
|
|
c26b4530f3 | ||
|
|
eed5b4b4e6 | ||
|
|
4fd872f296 | ||
|
|
22bf09af9d | ||
|
|
502d705ca1 | ||
|
|
d22156a7da | ||
|
|
6174536cc1 | ||
|
|
989ff71495 | ||
|
|
cd82911906 | ||
|
|
3ffce6387e | ||
|
|
c999153acb | ||
|
|
c3a105a0f8 | ||
|
|
9b1826f5ff | ||
|
|
8b7987c5c2 | ||
|
|
c1b61d81b9 | ||
|
|
f93c924b33 | ||
|
|
c68a451698 | ||
|
|
8db1c58c12 | ||
|
|
58e944aa87 | ||
|
|
c28f724706 | ||
|
|
c49f39f01f | ||
|
|
dcf8db1305 | ||
|
|
6f18e9989c | ||
|
|
9cc3e5ae3b | ||
|
|
74061aa499 | ||
|
|
2a13f46a6d | ||
|
|
cd4f9c55f5 | ||
|
|
0c466c082e | ||
|
|
1f203c974d | ||
|
|
5946175efa | ||
|
|
cdfa31f51d | ||
|
|
cc836fc3c5 | ||
|
|
e422e6d1fa | ||
|
|
1fae4e589f | ||
|
|
17995d4c5e | ||
|
|
c2c4401a16 | ||
|
|
33aef73d0e | ||
|
|
f33d75113d | ||
|
|
35d43125b1 | ||
|
|
54686651df | ||
|
|
26b2e5fea1 | ||
|
|
4e60aafc31 | ||
|
|
cec11edaf3 | ||
|
|
2a6162237c | ||
|
|
20071c565f | ||
|
|
61e662c19c | ||
|
|
12a5c109b2 | ||
|
|
fb00bbe4c3 | ||
|
|
741820789f | ||
|
|
da0477f0f1 | ||
|
|
c9bcacd63b | ||
|
|
39ff0b5bb4 | ||
|
|
cdd44c469f | ||
|
|
a9cead2974 | ||
|
|
a82c75705d | ||
|
|
650efacc3f | ||
|
|
176cf5520d |
No files matched your search
@@ -157,6 +157,10 @@ Airflow) и названия из кода. Если для понятия ес
|
||||
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
|
||||
истории, а текущее описание живого формата — и собирается из кода, а не
|
||||
пишется руками.
|
||||
- Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис
|
||||
(`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных
|
||||
заданий. Ни даты, ни номера: задание — не событие истории, а живой документ,
|
||||
и правится он вместе с тем, о чем говорит. Слаг называет предмет задания.
|
||||
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
|
||||
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
|
||||
в имени не нужна.
|
||||
|
||||
+2
-2
@@ -118,8 +118,8 @@ ClickHouse. Форма файла решена, длина — нет: стро
|
||||
|
||||
**Стартовый мир**:
|
||||
Первые восемь дней оси (понедельник по понедельник), которыми стенд
|
||||
наполняется при каждом `make up`. Маленький кусок эталонного мира, всегда
|
||||
один и тот же: на нём принимаются следующие этапы.
|
||||
наполняется при каждом `make up`. Он же эталонный мир, всегда один и тот же:
|
||||
на нем принимаются следующие этапы.
|
||||
_Избегать_: зерновой мир
|
||||
|
||||
**Опись мира**:
|
||||
|
||||
@@ -262,6 +262,11 @@ uv run --project generator python -m clickstream_generator batch \
|
||||
`0` — очистка закончена, а начальная генерация ещё не завершена. Работники
|
||||
начинают играть только с позиции `8` и больше.
|
||||
|
||||
В форме запуска можно выбрать только число дней. Первый день работник берет из
|
||||
`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options».
|
||||
Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую
|
||||
позицию работник пишет в журнал шага `remember_played`.
|
||||
|
||||
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
|
||||
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
|
||||
целиком — [ADR 0009](docs/adr/0009-world-control.md).
|
||||
|
||||
+10
-16
@@ -48,20 +48,10 @@ x-kafka-target: &kafka-target
|
||||
|
||||
x-world-starting-days: &world-starting-days "8"
|
||||
|
||||
# Окно изменяемости заказа: слепок бэкенда несёт заказы последних K дней, за
|
||||
# окном заказ замерзает. Число принадлежит модельному миру, и владелец у него
|
||||
# один — generator/world.py (ORDER_WINDOW_DAYS): сделать окно настройкой
|
||||
# генератора значило бы разрешить менять физику мира снаружи, а опись мира
|
||||
# держится на её неизменности. Стенду копия нужна затем, что по этому же числу
|
||||
# трансформация DDS решает, какие дни пересобирать. Разойдутся — пересборка
|
||||
# промахнётся по краю окна. Сверять копии некому, и это осознанно: слепые пятна
|
||||
# конвейера собраны в docs/architecture/etl.md и адресованы контуру DQ.
|
||||
x-order-window-days: &order-window-days "7"
|
||||
|
||||
# Общие образы Airflow и Superset собирают службы инициализации. Причина
|
||||
# отдельного build: ADR 0001.
|
||||
x-airflow-common: &airflow-common
|
||||
image: clickstream-airflow:local
|
||||
build:
|
||||
context: ./infra/airflow
|
||||
environment:
|
||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||
AIRFLOW__CORE__PARALLELISM: "4"
|
||||
@@ -87,7 +77,6 @@ x-airflow-common: &airflow-common
|
||||
STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default
|
||||
WORLD_STARTING_DAYS: *world-starting-days
|
||||
KAFKA_ORDERS_TOPIC: *orders-topic
|
||||
ORDER_WINDOW_DAYS: *order-window-days
|
||||
volumes:
|
||||
- ./dags:/opt/airflow/dags:ro
|
||||
# Запросы дагов: даг называет файл, а обработчик DAG при разборе загружает
|
||||
@@ -116,8 +105,6 @@ x-generator-common: &generator-common
|
||||
|
||||
x-superset-common: &superset-common
|
||||
image: clickstream-superset:local
|
||||
build:
|
||||
context: ./infra/superset
|
||||
environment:
|
||||
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
|
||||
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env}
|
||||
@@ -268,6 +255,8 @@ services:
|
||||
|
||||
airflow-init:
|
||||
<<: *airflow-common
|
||||
build:
|
||||
context: ./infra/airflow
|
||||
user: "0:0"
|
||||
restart: "no"
|
||||
depends_on:
|
||||
@@ -321,7 +310,10 @@ services:
|
||||
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
|
||||
- airflow_logs:/opt/airflow/logs
|
||||
- airflow_auth:/opt/airflow/auth
|
||||
mem_limit: 640m
|
||||
# LocalExecutor запускает здесь до четырех процессов задач одновременно.
|
||||
# 640 МиБ дали OOM, а 1 ГиБ - давление на memory.max при штатном dq_check;
|
||||
# 1,5 ГиБ оставляет запас при AIRFLOW__CORE__PARALLELISM=4.
|
||||
mem_limit: 1536m
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"]
|
||||
interval: 10s
|
||||
@@ -348,6 +340,8 @@ services:
|
||||
|
||||
superset-init:
|
||||
<<: *superset-common
|
||||
build:
|
||||
context: ./infra/superset
|
||||
restart: "no"
|
||||
healthcheck:
|
||||
disable: true
|
||||
|
||||
+336
-35
@@ -1,17 +1,18 @@
|
||||
"""Трансформации слоя DDS: пересборка дней, которые ещё могут измениться.
|
||||
"""Трансформации слоя DDS: сборка моделей.
|
||||
|
||||
Даг собирает модель поверх принятого ODS. Первая его сущность — заказ; сессии
|
||||
и карта идентичностей встанут соседними группами задач.
|
||||
Заказ и сессия собираются заменой дневных партиций. Карта идентичностей
|
||||
пересчитывается целиком после заказа, потому что читает его текущую модель.
|
||||
|
||||
Расписания у дага нет и параметров тоже: объём каждого прогона он выводит сам
|
||||
из состояния слоёв, поэтому один и тот же запуск одинаково верен из триггера
|
||||
дневного конвейера, с кнопки менти и после падения
|
||||
из состояния слоёв и позиции мира, поэтому один и тот же запуск одинаково
|
||||
верен из триггера дневного конвейера, с кнопки менти и после падения
|
||||
(docs/architecture/etl.md, «Объём прогона»).
|
||||
|
||||
Повтор прогона гасит не колонка версии, а замена партиции целиком: день
|
||||
собирается в таблицу-двойник и подменяет собой партицию цели. Почему слой
|
||||
устроен так — sql/ddl/45-dds-tables.sql; механика самой подмены —
|
||||
sql/dds/order_replace.sql.
|
||||
Заказ и сессия гасят повтор заменой партиции целиком: день собирается в
|
||||
таблицу-двойник и подменяет собой партицию цели. Карта пишет полный набор пар
|
||||
новой версией, а точное чтение даёт FINAL. Замена партиции разобрана в
|
||||
sql/dds/order_replace.sql; точное чтение карты описано в
|
||||
sql/ddl/50-dds-views.sql.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,22 +20,255 @@ from __future__ import annotations
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
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)
|
||||
|
||||
SQL_ROOT = Path("/opt/airflow/sql")
|
||||
CLICKHOUSE_CONNECTION = "clickhouse_default"
|
||||
CLICKHOUSE_CLUSTER = "clickstream_cluster"
|
||||
|
||||
# Окно изменяемости заказа, модельные дни: по нему трансформация решает, какие
|
||||
# дни ещё могут измениться. Число принадлежит миру, владелец — генератор;
|
||||
# почему у стенда лежит копия и чем она рискует — compose.yaml.
|
||||
ORDER_WINDOW_DAYS = int(os.environ["ORDER_WINDOW_DAYS"])
|
||||
KAFKA_BOOTSTRAP_SERVERS = os.environ["KAFKA_BOOTSTRAP_SERVERS"]
|
||||
HITS_TOPIC = os.environ["KAFKA_TOPIC"]
|
||||
HITS_GROUP = "clickstream_hits"
|
||||
HITS_PARTITIONS = (0, 1)
|
||||
|
||||
READINESS_TIMEOUT_SECONDS = 300
|
||||
READINESS_POLL_SECONDS = 3
|
||||
|
||||
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит
|
||||
# пульт мира, и только она отличает прожитый день от живого — зачем это
|
||||
# сессиям, разобрано в sql/dds/session_scope.sql.
|
||||
WORLD_POSITION = "world_position"
|
||||
|
||||
|
||||
def _kafka_readiness() -> tuple[bool, str]:
|
||||
"""Проверить, дочитала ли группа оба раздела событий."""
|
||||
from confluent_kafka import ConsumerGroupTopicPartitions, TopicPartition
|
||||
from confluent_kafka.admin import AdminClient, OffsetSpec
|
||||
|
||||
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
|
||||
groups = admin.list_consumer_groups(request_timeout=10).result()
|
||||
if groups.errors:
|
||||
raise RuntimeError(f"Kafka не вернула список групп: {groups.errors}")
|
||||
|
||||
group_exists = any(group.group_id == HITS_GROUP for group in groups.valid)
|
||||
committed: dict[int, int] = {}
|
||||
if group_exists:
|
||||
request = ConsumerGroupTopicPartitions(
|
||||
HITS_GROUP,
|
||||
[TopicPartition(HITS_TOPIC, partition) for partition in HITS_PARTITIONS],
|
||||
)
|
||||
result = admin.list_consumer_group_offsets([request], request_timeout=10)[
|
||||
HITS_GROUP
|
||||
].result()
|
||||
for topic_partition in result.topic_partitions:
|
||||
if topic_partition.error is None and topic_partition.offset >= 0:
|
||||
committed[topic_partition.partition] = topic_partition.offset
|
||||
|
||||
high_watermarks: dict[int, int] = {}
|
||||
watermark_errors: dict[int, str] = {}
|
||||
# OffsetSpec.latest возвращает следующее смещение после хвоста; сверено
|
||||
# через Context7 и на установленном confluent-kafka 2.15.0.
|
||||
latest = admin.list_offsets(
|
||||
{
|
||||
TopicPartition(HITS_TOPIC, partition): OffsetSpec.latest()
|
||||
for partition in HITS_PARTITIONS
|
||||
},
|
||||
request_timeout=10,
|
||||
)
|
||||
for topic_partition, future in latest.items():
|
||||
try:
|
||||
offset = future.result().offset
|
||||
if offset >= 0:
|
||||
high_watermarks[topic_partition.partition] = offset
|
||||
except Exception as error:
|
||||
watermark_errors[topic_partition.partition] = str(error)
|
||||
|
||||
all_known = group_exists
|
||||
total_lag = 0
|
||||
parts: list[str] = []
|
||||
for partition in HITS_PARTITIONS:
|
||||
offset = committed.get(partition)
|
||||
high = high_watermarks.get(partition)
|
||||
lag = high - offset if offset is not None and high is not None else None
|
||||
if lag is None or lag < 0:
|
||||
all_known = False
|
||||
else:
|
||||
total_lag += lag
|
||||
logging.info(
|
||||
"Kafka %s[%s]: подтверждено=%s, верхнее смещение=%s, отставание=%s",
|
||||
HITS_TOPIC,
|
||||
partition,
|
||||
offset,
|
||||
high,
|
||||
lag,
|
||||
)
|
||||
parts.append(
|
||||
f"{HITS_TOPIC}[{partition}]: подтверждено={offset}, "
|
||||
f"верхнее смещение={high}, отставание={lag}"
|
||||
)
|
||||
if partition in watermark_errors:
|
||||
parts[-1] += (
|
||||
f", ошибка чтения верхнего смещения={watermark_errors[partition]}"
|
||||
)
|
||||
|
||||
if not group_exists:
|
||||
logging.info("группа потребителей %s отсутствует", HITS_GROUP)
|
||||
lag_text = str(total_lag) if all_known else "неизвестно"
|
||||
logging.info("суммарное отставание Kafka: %s", lag_text)
|
||||
detail = "; ".join(parts)
|
||||
if not group_exists:
|
||||
detail = f"группа {HITS_GROUP} отсутствует; {detail}"
|
||||
return all_known and total_lag == 0, detail
|
||||
|
||||
|
||||
def _delivery_queue_readiness() -> tuple[bool, str]:
|
||||
"""Проверить, пуста ли очередь доставки событий на каждой ноде."""
|
||||
query = f"""
|
||||
SELECT
|
||||
node,
|
||||
sum(queued_files) AS files,
|
||||
arrayStringConcat(
|
||||
groupArrayIf(
|
||||
last_exception,
|
||||
queued_files > 0 AND notEmpty(last_exception)
|
||||
),
|
||||
'; '
|
||||
) AS last_exception
|
||||
FROM
|
||||
(
|
||||
-- У пустой очереди нет строк; system.one оставляет обе ноды
|
||||
-- видимыми и в зеленом опросе.
|
||||
SELECT
|
||||
hostName() AS node,
|
||||
toUInt64(0) AS queued_files,
|
||||
'' AS last_exception
|
||||
FROM clusterAllReplicas('{CLICKHOUSE_CLUSTER}', system.one)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
hostName(),
|
||||
data_files + broken_data_files AS queued_files,
|
||||
last_exception
|
||||
FROM clusterAllReplicas(
|
||||
'{CLICKHOUSE_CLUSTER}', system.distribution_queue
|
||||
)
|
||||
WHERE database = 'ods' AND table = 'event_dist'
|
||||
)
|
||||
GROUP BY node
|
||||
ORDER BY node
|
||||
"""
|
||||
rows = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION).get_records(query)
|
||||
ready = True
|
||||
nodes: list[str] = []
|
||||
for node, files, last_exception in rows:
|
||||
files = int(files)
|
||||
ready = ready and files == 0
|
||||
logging.info(
|
||||
"очередь ods.event_dist на %s: файлов=%s, последняя ошибка=%s",
|
||||
node,
|
||||
files,
|
||||
last_exception or "нет",
|
||||
)
|
||||
detail = f"{node}: файлов={files}"
|
||||
if last_exception:
|
||||
detail += f", последняя ошибка={last_exception}"
|
||||
nodes.append(detail)
|
||||
return ready, "; ".join(nodes)
|
||||
|
||||
|
||||
def _wait_for_phase(
|
||||
probe,
|
||||
phase: str,
|
||||
deadline: float,
|
||||
) -> None:
|
||||
"""Дождаться прохождения одного барьера до общего крайнего срока."""
|
||||
last_observation = "первый опрос не выполнен"
|
||||
last_error: str | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
ready, last_observation = probe()
|
||||
last_error = None
|
||||
except Exception as error:
|
||||
ready = False
|
||||
last_error = str(error)
|
||||
logging.warning("%s: ошибка опроса: %s", phase, last_error)
|
||||
if ready:
|
||||
return
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining > 0:
|
||||
time.sleep(min(READINESS_POLL_SECONDS, remaining))
|
||||
|
||||
detail = last_observation
|
||||
if last_error:
|
||||
detail += f"; ошибка последнего опроса: {last_error}"
|
||||
raise AirflowException(
|
||||
f"общий срок ожидания готовности в {READINESS_TIMEOUT_SECONDS} с "
|
||||
f'истек на этапе "{phase}": {detail}'
|
||||
)
|
||||
|
||||
|
||||
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(
|
||||
@@ -51,6 +285,17 @@ ORDER_WINDOW_DAYS = int(os.environ["ORDER_WINDOW_DAYS"])
|
||||
def dds_transform():
|
||||
"""Пересобрать сущности DDS по текущему состоянию источников."""
|
||||
|
||||
@task
|
||||
def wait_for_source() -> None:
|
||||
"""Дождаться чтения событий из Kafka и их доставки в ODS."""
|
||||
deadline = time.monotonic() + READINESS_TIMEOUT_SECONDS
|
||||
_wait_for_phase(_kafka_readiness, "Kafka", deadline)
|
||||
_wait_for_phase(
|
||||
_delivery_queue_readiness,
|
||||
"очередь доставки ClickHouse",
|
||||
deadline,
|
||||
)
|
||||
|
||||
@task_group(group_id="order")
|
||||
def order():
|
||||
"""Заказ: дышащее окно плюс дни, которых в слое ещё нет."""
|
||||
@@ -60,7 +305,7 @@ def dds_transform():
|
||||
"""Спросить у хранилища, какие дни собирает этот запуск."""
|
||||
query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8")
|
||||
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
|
||||
row = hook.get_first(query, parameters={"window_days": ORDER_WINDOW_DAYS})
|
||||
row = hook.get_first(query)
|
||||
# Пустой ответ означает пустой источник: собирать нечего, и
|
||||
# зеленеть на этом нельзя — прогон встаёт с внятной причиной.
|
||||
if row is None:
|
||||
@@ -69,10 +314,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 +327,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 +354,81 @@ def dds_transform():
|
||||
|
||||
rebuild >> replace
|
||||
|
||||
order()
|
||||
@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
|
||||
|
||||
source_ready = wait_for_source()
|
||||
orders = order()
|
||||
sessions = session()
|
||||
source_ready >> [orders, sessions]
|
||||
|
||||
identity_map = SQLExecuteQueryOperator(
|
||||
task_id="identity_map",
|
||||
conn_id=CLICKHOUSE_CONNECTION,
|
||||
sql="dds/identity_map_rebuild.sql",
|
||||
parameters={"load_id": "{{ run_id }}"},
|
||||
do_xcom_push=False,
|
||||
)
|
||||
|
||||
orders >> identity_map
|
||||
|
||||
|
||||
dds_transform()
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Трансформации слоя DM: сборка стендовых витрин.
|
||||
|
||||
Выручка наследует ритм заказов. Сверка соединяет окно заказов с прожитым
|
||||
хвостом событий. Обе заменяют дневные партиции. Трафик наследует
|
||||
ретроспективность карты идентичностей и пересчитывает всю историю новой
|
||||
версией. Объем каждого прогона выводится из состояния слоев.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
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 Variable, dag, task, task_group
|
||||
|
||||
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||
|
||||
SQL_ROOT = Path("/opt/airflow/sql")
|
||||
CLICKHOUSE_CONNECTION = "clickhouse_default"
|
||||
# Та же ось модельного времени, что у DDS. Общего модуля у дагов нет
|
||||
# намеренно: связь держит переменная Airflow, а не импорт одного дага другим.
|
||||
WORLD_POSITION = "world_position"
|
||||
|
||||
|
||||
def _world_position() -> int:
|
||||
"""Позиция мира для границы прожитых событий сверки.
|
||||
|
||||
Причина отдельного чтения переменной разобрана у одноименной функции в
|
||||
``dds_transform.py``.
|
||||
"""
|
||||
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]]:
|
||||
"""Разложить дни в аргументы размноженной задачи замены.
|
||||
|
||||
Отдельный словарь нужен каждому экземпляру ``expand_kwargs``; тот же
|
||||
прием разобран в ``dds_transform.py`` на замене партиций заказов.
|
||||
"""
|
||||
return [{"params": {"day": day}} for day in scope["days"]]
|
||||
|
||||
|
||||
@dag(
|
||||
dag_id="dm_transform",
|
||||
schedule=None,
|
||||
start_date=START_DATE,
|
||||
is_paused_upon_creation=False,
|
||||
max_active_runs=1,
|
||||
template_searchpath=str(SQL_ROOT),
|
||||
tags=["dm"],
|
||||
)
|
||||
def dm_transform():
|
||||
"""Пересобрать стендовые витрины по текущему состоянию DDS."""
|
||||
|
||||
@task_group(group_id="revenue_daily")
|
||||
def revenue_daily():
|
||||
"""Выручка: изменяемое окно заказов и пропущенная история."""
|
||||
|
||||
@task
|
||||
def scope() -> dict[str, object]:
|
||||
"""Спросить у хранилища, какие дни пересобирает запуск."""
|
||||
query = (SQL_ROOT / "dm" / "revenue_daily_scope.sql").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
|
||||
row = hook.get_first(query)
|
||||
if row is None:
|
||||
raise AirflowException(
|
||||
"нет слепка заказов в ODS или готовых заказов в DDS; "
|
||||
"дождитесь загрузки слепка и сначала выполните dds_transform"
|
||||
)
|
||||
first_day, last_day = row
|
||||
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="dm/revenue_daily_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="dm/revenue_daily_replace.sql",
|
||||
do_xcom_push=False,
|
||||
).expand_kwargs(replacements(days))
|
||||
|
||||
rebuild >> replace
|
||||
|
||||
revenue_daily()
|
||||
|
||||
@task_group(group_id="purchase_vs_orders")
|
||||
def purchase_vs_orders():
|
||||
"""Сверка: окно заказов, прожитый хвост и предварительные дни."""
|
||||
|
||||
@task
|
||||
def scope() -> dict[str, object]:
|
||||
"""Спросить у обоих источников, какие дни пересобрать."""
|
||||
query = (SQL_ROOT / "dm" / "purchase_vs_orders_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(
|
||||
"нет принятого слепка заказов или прожитых покупок; "
|
||||
"дождитесь источников и сначала выполните dds_transform"
|
||||
)
|
||||
first_day, last_day = row
|
||||
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="dm/purchase_vs_orders_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="dm/purchase_vs_orders_replace.sql",
|
||||
do_xcom_push=False,
|
||||
).expand_kwargs(replacements(days))
|
||||
|
||||
rebuild >> replace
|
||||
|
||||
purchase_vs_orders()
|
||||
|
||||
SQLExecuteQueryOperator(
|
||||
task_id="daily_traffic",
|
||||
conn_id=CLICKHOUSE_CONNECTION,
|
||||
sql="dm/daily_traffic_rebuild.sql",
|
||||
parameters={"load_id": "{{ run_id }}"},
|
||||
do_xcom_push=False,
|
||||
)
|
||||
|
||||
|
||||
dm_transform()
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Проверки качества стендовых моделей и витрин.
|
||||
|
||||
Пять проверок сначала собирают общий донор. После его полной сборки дневные
|
||||
партиции публикуются одной серией замен, и только затем именованные задачи
|
||||
утверждают результат. Расхождение поэтому остаётся в ``dm.dq_summary_v`` до
|
||||
красного завершения дага; ошибка сборки прежнюю сводку не меняет.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
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 Variable, dag, get_current_context, task, task_group
|
||||
|
||||
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||
|
||||
SQL_ROOT = Path("/opt/airflow/sql")
|
||||
INVENTORY_PATH = Path("/opt/airflow/world/data/world-inventory.json")
|
||||
CLICKHOUSE_CONNECTION = "clickhouse_default"
|
||||
WORLD_POSITION = "world_position"
|
||||
DETAIL_LIMIT = 20
|
||||
|
||||
SUMMARY_COLUMNS = [
|
||||
"data_date",
|
||||
"check_name",
|
||||
"reference_rows",
|
||||
"actual_rows",
|
||||
"failed_rows",
|
||||
"status",
|
||||
"_load_id",
|
||||
"_load_ts",
|
||||
]
|
||||
|
||||
|
||||
def _clickhouse_hook() -> ClickHouseHook:
|
||||
"""Подключение, которое дожидается записи в Distributed-таблицу."""
|
||||
return ClickHouseHook(
|
||||
clickhouse_conn_id=CLICKHOUSE_CONNECTION,
|
||||
session_settings={"distributed_foreground_insert": 1},
|
||||
)
|
||||
|
||||
|
||||
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 _publish_summary(
|
||||
check_name: str,
|
||||
summaries: list[tuple[object, ...]],
|
||||
) -> list[str]:
|
||||
"""Записать дневную сводку и вернуть малый срез готовой диагностики."""
|
||||
if not summaries:
|
||||
raise AirflowException(f"{check_name}: область проверки пуста")
|
||||
|
||||
diagnostics: list[str] = []
|
||||
context = get_current_context()
|
||||
load_id = context["run_id"]
|
||||
load_ts = datetime.datetime.now(datetime.UTC)
|
||||
rows = []
|
||||
for (
|
||||
data_date,
|
||||
reference_rows,
|
||||
actual_rows,
|
||||
failed_rows,
|
||||
details,
|
||||
) in summaries:
|
||||
rows.append(
|
||||
(
|
||||
data_date,
|
||||
check_name,
|
||||
reference_rows,
|
||||
actual_rows,
|
||||
failed_rows,
|
||||
"pass" if failed_rows == 0 else "fail",
|
||||
load_id,
|
||||
load_ts,
|
||||
)
|
||||
)
|
||||
diagnostics.extend(details[: DETAIL_LIMIT - len(diagnostics)])
|
||||
|
||||
_clickhouse_hook().bulk_insert_rows(
|
||||
"dm.dq_summary_stage_dist",
|
||||
rows,
|
||||
column_names=SUMMARY_COLUMNS,
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _build_sql_check(
|
||||
check_name: str,
|
||||
sql_name: str,
|
||||
parameters: dict[str, object] | None = None,
|
||||
) -> list[str]:
|
||||
"""Выполнить SQL-сверку и записать ее дневные итоги в донор."""
|
||||
query = (SQL_ROOT / "dq" / sql_name).read_text(encoding="utf-8")
|
||||
summaries = _clickhouse_hook().get_records(query, parameters=parameters)
|
||||
return _publish_summary(check_name, summaries)
|
||||
|
||||
|
||||
@task
|
||||
def build_sql_check(check_name: str, sql_name: str) -> list[str]:
|
||||
"""Собрать проверку, которой не нужны координаты модельного мира."""
|
||||
return _build_sql_check(check_name, sql_name)
|
||||
|
||||
|
||||
@task
|
||||
def build_sessions_vs_reference() -> list[str]:
|
||||
"""Сверить сессии только в пределах прожитых дней мира."""
|
||||
return _build_sql_check(
|
||||
"sessions_vs_reference",
|
||||
"sessions_vs_reference.sql",
|
||||
parameters={"position": _world_position()},
|
||||
)
|
||||
|
||||
|
||||
@task
|
||||
def build_classes_vs_inventory() -> list[str]:
|
||||
"""Сопоставить классы закрытых дней с техническим оракулом генератора."""
|
||||
inventory = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
||||
expected = {
|
||||
(datetime.date.fromisoformat(day["date"]), mismatch_class): count
|
||||
for day in inventory["days"]
|
||||
if "orders" in day
|
||||
for mismatch_class, count in day["orders"].items()
|
||||
}
|
||||
if not expected:
|
||||
raise AirflowException("classes_vs_inventory: в описи нет закрытых дней")
|
||||
|
||||
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
|
||||
# GROUP BY закономерно не возвращает группу из нуля строк.
|
||||
actual = {key: 0 for key, count in expected.items() if count == 0}
|
||||
actual.update(
|
||||
{
|
||||
(data_date, mismatch_class): count
|
||||
for data_date, mismatch_class, count in _clickhouse_hook().get_records(
|
||||
query
|
||||
)
|
||||
if (data_date, mismatch_class) in expected
|
||||
}
|
||||
)
|
||||
summaries = []
|
||||
for data_date in sorted({day for day, _ in expected}):
|
||||
details = []
|
||||
reference_rows = 0
|
||||
actual_rows = 0
|
||||
failed_rows = 0
|
||||
for (day, mismatch_class), reference_count in sorted(expected.items()):
|
||||
if day != data_date:
|
||||
continue
|
||||
actual_key = (day, mismatch_class)
|
||||
actual_count = actual.get(actual_key)
|
||||
reference_rows += 1
|
||||
actual_rows += int(actual_key in actual)
|
||||
if reference_count != actual_count:
|
||||
failed_rows += 1
|
||||
actual_text = actual_count if actual_count is not None else "нет ключа"
|
||||
details.append(
|
||||
f"{day} key={mismatch_class}: "
|
||||
f"эталон={reference_count}, "
|
||||
f"объект={actual_text}"
|
||||
)
|
||||
summaries.append((data_date, reference_rows, actual_rows, failed_rows, details))
|
||||
return _publish_summary("classes_vs_inventory", summaries)
|
||||
|
||||
|
||||
@task
|
||||
def publication_scope() -> list[dict[str, dict[str, str]]]:
|
||||
"""Вернуть дни полностью собранного донора для размноженной замены."""
|
||||
rows = _clickhouse_hook().get_records(
|
||||
"SELECT DISTINCT data_date FROM dm.dq_summary_stage_dist ORDER BY data_date"
|
||||
)
|
||||
if not rows:
|
||||
raise AirflowException("донор dm.dq_summary пуст: публиковать нечего")
|
||||
return [{"params": {"day": data_date.isoformat()}} for (data_date,) in rows]
|
||||
|
||||
|
||||
@task
|
||||
def drop_stale_partitions() -> None:
|
||||
"""Убрать дни, исчезнувшие после починки с обеих сторон сверки."""
|
||||
hook = _clickhouse_hook()
|
||||
stale_days = hook.get_records(
|
||||
"""
|
||||
SELECT DISTINCT data_date
|
||||
FROM dm.dq_summary_dist
|
||||
WHERE data_date GLOBAL NOT IN
|
||||
(SELECT data_date FROM dm.dq_summary_stage_dist)
|
||||
ORDER BY data_date
|
||||
"""
|
||||
)
|
||||
for (data_date,) in stale_days:
|
||||
# ALTER PARTITION не принимает параметр ClickHouse. Значение пришло
|
||||
# из колонки Date самого хранилища и форматируется без внешнего ввода.
|
||||
hook.run(
|
||||
"ALTER TABLE dm.dq_summary_rep ON CLUSTER clickstream_cluster "
|
||||
f"DROP PARTITION '{data_date.isoformat()}'"
|
||||
)
|
||||
|
||||
|
||||
@task
|
||||
def assert_check(check_name: str, diagnostics: list[str]) -> None:
|
||||
"""Покрасить именованную задачу по уже опубликованной сводке."""
|
||||
failed_days = _clickhouse_hook().get_records(
|
||||
"""
|
||||
SELECT data_date, reference_rows, actual_rows, failed_rows
|
||||
FROM dm.dq_summary_v
|
||||
WHERE check_name = {check_name:String} AND failed_rows > 0
|
||||
ORDER BY data_date
|
||||
""",
|
||||
parameters={"check_name": check_name},
|
||||
)
|
||||
if not failed_days:
|
||||
return
|
||||
|
||||
for detail in diagnostics:
|
||||
logging.error("%s: %s", check_name, detail)
|
||||
summary = "; ".join(
|
||||
f"{data_date}: эталонных ключей {reference_rows}, "
|
||||
f"фактических {actual_rows}, нарушено {failed_rows}"
|
||||
for data_date, reference_rows, actual_rows, failed_rows in failed_days
|
||||
)
|
||||
raise AirflowException(f"{check_name}: {summary}")
|
||||
|
||||
|
||||
@dag(
|
||||
dag_id="dq_check",
|
||||
schedule=None,
|
||||
start_date=START_DATE,
|
||||
is_paused_upon_creation=False,
|
||||
max_active_runs=1,
|
||||
template_searchpath=str(SQL_ROOT),
|
||||
tags=["dq"],
|
||||
)
|
||||
def dq_check():
|
||||
"""Собрать, опубликовать и утвердить текущее качество данных."""
|
||||
prepare = SQLExecuteQueryOperator(
|
||||
task_id="prepare",
|
||||
conn_id=CLICKHOUSE_CONNECTION,
|
||||
sql="dq/prepare.sql",
|
||||
do_xcom_push=False,
|
||||
)
|
||||
|
||||
sessions = build_sessions_vs_reference()
|
||||
classes = build_classes_vs_inventory.override(
|
||||
task_id="build_classes_vs_inventory"
|
||||
)()
|
||||
revenue = build_sql_check.override(task_id="build_revenue_daily_vs_dds")(
|
||||
"revenue_daily_vs_dds", "revenue_daily_vs_dds.sql"
|
||||
)
|
||||
purchases = build_sql_check.override(task_id="build_purchase_vs_orders_vs_dds")(
|
||||
"purchase_vs_orders_vs_dds", "purchase_vs_orders_vs_dds.sql"
|
||||
)
|
||||
traffic = build_sql_check.override(task_id="build_daily_traffic_vs_dds")(
|
||||
"daily_traffic_vs_dds", "daily_traffic_vs_dds.sql"
|
||||
)
|
||||
builders = [sessions, classes, revenue, purchases, traffic]
|
||||
prepare >> builders
|
||||
|
||||
days = publication_scope()
|
||||
for builder in builders:
|
||||
builder >> days
|
||||
|
||||
replace = SQLExecuteQueryOperator.partial(
|
||||
task_id="publish",
|
||||
conn_id=CLICKHOUSE_CONNECTION,
|
||||
sql="dq/replace.sql",
|
||||
do_xcom_push=False,
|
||||
).expand_kwargs(days)
|
||||
|
||||
sessions_assertion = assert_check.override(task_id="sessions_vs_reference")(
|
||||
"sessions_vs_reference", sessions
|
||||
)
|
||||
classes_assertion = assert_check.override(task_id="classes_vs_inventory")(
|
||||
"classes_vs_inventory", classes
|
||||
)
|
||||
|
||||
@task_group(group_id="marts_vs_dds")
|
||||
def assert_marts():
|
||||
return [
|
||||
assert_check.override(task_id="revenue_daily_vs_dds")(
|
||||
"revenue_daily_vs_dds", revenue
|
||||
),
|
||||
assert_check.override(task_id="purchase_vs_orders_vs_dds")(
|
||||
"purchase_vs_orders_vs_dds", purchases
|
||||
),
|
||||
assert_check.override(task_id="daily_traffic_vs_dds")(
|
||||
"daily_traffic_vs_dds", traffic
|
||||
),
|
||||
]
|
||||
|
||||
assertions = [sessions_assertion, classes_assertion, *assert_marts()]
|
||||
remove_stale = drop_stale_partitions()
|
||||
replace >> remove_stale >> assertions
|
||||
|
||||
|
||||
dq_check()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Дневной конвейер: DDS, DM и проверка качества.
|
||||
|
||||
Своих преобразований у дага нет. Ждущие триггеры связывают состояния:
|
||||
если слой краснеет, конвейер не запускает следующий слой и краснеет сам.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
|
||||
from airflow.sdk import dag
|
||||
|
||||
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||
|
||||
|
||||
@dag(
|
||||
dag_id="etl_pipeline",
|
||||
schedule=None,
|
||||
start_date=START_DATE,
|
||||
is_paused_upon_creation=False,
|
||||
max_active_runs=1,
|
||||
tags=["etl"],
|
||||
)
|
||||
def etl_pipeline():
|
||||
"""Собрать слои последовательно по текущему состоянию данных."""
|
||||
dds = TriggerDagRunOperator(
|
||||
task_id="trigger_dds",
|
||||
trigger_dag_id="dds_transform",
|
||||
wait_for_completion=True,
|
||||
poke_interval=10,
|
||||
)
|
||||
dm = TriggerDagRunOperator(
|
||||
task_id="trigger_dm",
|
||||
trigger_dag_id="dm_transform",
|
||||
wait_for_completion=True,
|
||||
poke_interval=10,
|
||||
)
|
||||
# Airflow 3.3: оператор провайдера standard с wait_for_completion=True
|
||||
# поднимает AirflowException, если запущенный даг завершился красным.
|
||||
# Поведение и путь импорта повторно сверены через Context7 27 августа
|
||||
# 2026 года.
|
||||
dq = TriggerDagRunOperator(
|
||||
task_id="trigger_dq",
|
||||
trigger_dag_id="dq_check",
|
||||
wait_for_completion=True,
|
||||
poke_interval=10,
|
||||
)
|
||||
|
||||
dds >> dm >> dq
|
||||
|
||||
|
||||
etl_pipeline()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Ручная самопроверка учебных витрин DM против их источников."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from airflow.exceptions import AirflowException
|
||||
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
|
||||
from airflow.sdk import dag, task
|
||||
|
||||
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||
SQL_ROOT = Path("/opt/airflow/sql/dq")
|
||||
CLICKHOUSE_CONNECTION = "clickhouse_default"
|
||||
DETAIL_LIMIT = 20
|
||||
|
||||
|
||||
def _clickhouse_hook() -> ClickHouseHook:
|
||||
"""Подключение к ClickHouse для чтения обеих сторон сверки."""
|
||||
return ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
|
||||
|
||||
|
||||
def _mart_exists(hook: ClickHouseHook, mart: str) -> bool:
|
||||
"""Проверить наличие публичной витрины на ноде подключения."""
|
||||
database, name = mart.split(".", maxsplit=1)
|
||||
rows = hook.get_records(
|
||||
"""
|
||||
SELECT count()
|
||||
FROM system.tables
|
||||
WHERE database = {database:String} AND name = {name:String}
|
||||
""",
|
||||
parameters={"database": database, "name": name},
|
||||
)
|
||||
return rows[0][0] == 1
|
||||
|
||||
|
||||
@task
|
||||
def check_mart(
|
||||
mart: str,
|
||||
lab_path: str,
|
||||
sql_name: str,
|
||||
scope_sql_name: str | None = None,
|
||||
) -> None:
|
||||
"""Сверить одну домашнюю витрину с независимым ответом из источника."""
|
||||
hook = _clickhouse_hook()
|
||||
if not _mart_exists(hook, mart):
|
||||
raise AirflowException(f"витрина {mart} не найдена; выполните ТЗ {lab_path}")
|
||||
|
||||
try:
|
||||
query = (SQL_ROOT / sql_name).read_text(encoding="utf-8")
|
||||
scope_query = (
|
||||
(SQL_ROOT / scope_sql_name).read_text(encoding="utf-8")
|
||||
if scope_sql_name is not None
|
||||
else None
|
||||
)
|
||||
except OSError as error:
|
||||
raise AirflowException(
|
||||
f"стенд не может прочитать запрос проверки {sql_name}: {error}; "
|
||||
"сообщите наставнику"
|
||||
) from error
|
||||
|
||||
try:
|
||||
parameter_sets = [None]
|
||||
if scope_query is not None:
|
||||
parameter_sets = [{"day": day} for (day,) in hook.get_records(scope_query)]
|
||||
summaries = [
|
||||
summary
|
||||
for parameters in parameter_sets
|
||||
for summary in hook.get_records(query, parameters=parameters)
|
||||
]
|
||||
except Exception as error:
|
||||
raise AirflowException(
|
||||
f"не удалось проверить витрину {mart}: {error}; "
|
||||
f"сверьте договор колонок и проверку в ТЗ {lab_path}"
|
||||
) from error
|
||||
|
||||
failed_rows = sum(summary[3] for summary in summaries)
|
||||
if failed_rows == 0:
|
||||
return
|
||||
|
||||
diagnostics = [detail for summary in summaries for detail in summary[4]][
|
||||
:DETAIL_LIMIT
|
||||
]
|
||||
for detail in diagnostics:
|
||||
logging.error("%s: %s", mart, detail)
|
||||
days = "; ".join(
|
||||
f"{data_date}: ключей источника {reference_rows}, "
|
||||
f"ключей витрины {actual_rows}, расходятся {day_failed_rows}"
|
||||
for data_date, reference_rows, actual_rows, day_failed_rows, _ in summaries
|
||||
if day_failed_rows > 0
|
||||
)
|
||||
compared_sides = diagnostics[0]
|
||||
raise AirflowException(
|
||||
f"витрина {mart} расходится с источником: {days}. "
|
||||
f"Пример сравнения: {compared_sides}. Сверьтесь с ТЗ {lab_path}"
|
||||
)
|
||||
|
||||
|
||||
@dag(
|
||||
dag_id="homework_check",
|
||||
schedule=None,
|
||||
start_date=START_DATE,
|
||||
is_paused_upon_creation=False,
|
||||
max_active_runs=1,
|
||||
tags=["dm", "labs"],
|
||||
)
|
||||
def homework_check():
|
||||
"""Дать отдельный результат самопроверки по каждой домашней витрине."""
|
||||
check_mart.override(task_id="events_enriched")(
|
||||
"dm.events_enriched_v",
|
||||
"docs/labs/events-enriched.md",
|
||||
"events_enriched_vs_dds.sql",
|
||||
"events_enriched_scope.sql",
|
||||
)
|
||||
check_mart.override(task_id="top_pages_daily")(
|
||||
"dm.top_pages_daily_v",
|
||||
"docs/labs/top-pages-daily.md",
|
||||
"top_pages_daily_vs_dds.sql",
|
||||
)
|
||||
check_mart.override(task_id="session_overview")(
|
||||
"dm.session_overview_v",
|
||||
"docs/labs/session-overview.md",
|
||||
"session_overview_vs_dds.sql",
|
||||
)
|
||||
check_mart.override(task_id="dq_errors_daily")(
|
||||
"dm.dq_errors_daily_v",
|
||||
"docs/labs/dq-errors-daily.md",
|
||||
"dq_errors_daily_vs_ods.sql",
|
||||
)
|
||||
check_mart.override(task_id="utm_effectiveness")(
|
||||
"dm.utm_effectiveness_v",
|
||||
"docs/labs/utm-effectiveness.md",
|
||||
"utm_effectiveness_vs_dds.sql",
|
||||
)
|
||||
|
||||
|
||||
homework_check()
|
||||
+36
-4
@@ -12,6 +12,9 @@
|
||||
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
|
||||
`orders_ingest`.
|
||||
|
||||
Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка
|
||||
только разгоняет источники: слои после нее менти догоняет ручным запуском.
|
||||
|
||||
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку
|
||||
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник
|
||||
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
|
||||
@@ -25,6 +28,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
|
||||
from airflow.exceptions import AirflowException
|
||||
@@ -138,7 +142,19 @@ def _ingest_orders() -> TriggerDagRunOperator:
|
||||
tags=TAGS,
|
||||
params={
|
||||
"days": Param(
|
||||
1, type="integer", minimum=1, maximum=7, title="Сколько дней прожить"
|
||||
1,
|
||||
type="integer",
|
||||
minimum=1,
|
||||
maximum=7,
|
||||
title="Сколько дней прожить",
|
||||
# description_md рендерится подсказкой прямо в форме запуска
|
||||
# (params.rst, Airflow 3.3), чтобы правило было видно сразу.
|
||||
description_md=(
|
||||
"Форма задает только число дней. Номер первого несыгранного "
|
||||
"дня работник берет из переменной `world_position` (Admin → "
|
||||
"Variables). Поля Logical date и Run ID нужны самому Airflow "
|
||||
"и на выбор дня не влияют."
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -146,14 +162,20 @@ def world_next_day():
|
||||
"""Прожить следующие дни пачкой, без пауз.
|
||||
|
||||
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
|
||||
нажимом, а не десятью: сколько дней играть — параметр запуска.
|
||||
нажимом, а не десятью: сколько дней играть — параметр запуска. Первый
|
||||
день работник берет из `world_position`. Логическая дата прогона на выбор
|
||||
дня не влияет.
|
||||
"""
|
||||
|
||||
@task
|
||||
def remember_played(first_day: int) -> None:
|
||||
"""Позиция ставится по сыгранным дням и только по успеху."""
|
||||
days = get_current_context()["params"]["days"]
|
||||
Variable.set(WORLD_POSITION, str(first_day + days))
|
||||
position = first_day + days
|
||||
Variable.set(WORLD_POSITION, str(position))
|
||||
logging.info(
|
||||
"дни %s–%s сыграны; новая позиция — %s", first_day, position - 1, position
|
||||
)
|
||||
|
||||
first_day = first_unplayed_day()
|
||||
played = _generator(
|
||||
@@ -193,12 +215,15 @@ def world_live_day():
|
||||
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
|
||||
реальные минуты, и суточная волна разворачивается на глазах. Запускается
|
||||
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
|
||||
Следующий день работник берет из `world_position`. Логическая дата прогона
|
||||
на выбор дня не влияет.
|
||||
"""
|
||||
|
||||
@task
|
||||
def remember_played(first_day: int) -> None:
|
||||
"""Позиция ставится по сыгранному дню и только по успеху."""
|
||||
Variable.set(WORLD_POSITION, str(first_day + 1))
|
||||
logging.info("сыгран день %s; новая позиция — %s", first_day, first_day + 1)
|
||||
|
||||
first_day = first_unplayed_day()
|
||||
played = _generator("play_day", ["live", "--day", PLAYED_DAY])
|
||||
@@ -209,7 +234,14 @@ def world_live_day():
|
||||
["snapshot", "--day", PLAYED_DAY, "--topic", ORDERS_TOPIC],
|
||||
)
|
||||
|
||||
first_day >> played >> sent >> _ingest_orders() >> remember_played(first_day)
|
||||
position = remember_played(first_day)
|
||||
etl = TriggerDagRunOperator(
|
||||
task_id="trigger_etl_pipeline",
|
||||
trigger_dag_id="etl_pipeline",
|
||||
wait_for_completion=True,
|
||||
poke_interval=10,
|
||||
)
|
||||
first_day >> played >> sent >> _ingest_orders() >> position >> etl
|
||||
|
||||
|
||||
@dag(
|
||||
|
||||
+11
-4
@@ -1,9 +1,10 @@
|
||||
"""Создание и пересоздание прикладного мира.
|
||||
|
||||
Compose поднимает службы, а этот граф создаёт всё прикладное поверх них:
|
||||
топики, схему ClickHouse, данные стартовых дней и позицию модельного времени.
|
||||
`world_initialize` безопасен для обычного `make up`; `world_recreate` — явная
|
||||
операция обслуживания, которая сначала объявляет прежний мир недействительным.
|
||||
топики, схему ClickHouse, данные стартовых дней, позицию модельного времени и
|
||||
готовые слои. `world_initialize` безопасен для обычного `make up`;
|
||||
`world_recreate` — явная операция обслуживания, которая сначала объявляет
|
||||
прежний мир недействительным.
|
||||
|
||||
Контракт состояний и доводы — ADR 0013 и спека жизненного цикла мира.
|
||||
"""
|
||||
@@ -262,9 +263,15 @@ def world_initialize():
|
||||
poke_interval=10,
|
||||
)
|
||||
position = remember_starting_position()
|
||||
etl = TriggerDagRunOperator(
|
||||
task_id="trigger_etl_pipeline",
|
||||
trigger_dag_id="etl_pipeline",
|
||||
wait_for_completion=True,
|
||||
poke_interval=10,
|
||||
)
|
||||
|
||||
route >> [ready, topics]
|
||||
chain(topics, *ddl_tasks, image, events, arrived, snapshots, orders, position)
|
||||
chain(topics, *ddl_tasks, image, events, arrived, snapshots, orders, position, etl)
|
||||
|
||||
|
||||
@dag(
|
||||
|
||||
+25
-23
@@ -6,92 +6,94 @@
|
||||
{
|
||||
"day": 0,
|
||||
"date": "2026-06-01",
|
||||
"events": 50626,
|
||||
"sha256": "81ad7f402bdd4c8b56425f6439a024b9996e5ca95dd207d444290d20a70ed407",
|
||||
"events": 50628,
|
||||
"sha256": "d09704ed99e45824e451e2f6bbbbd5c547df382f7a582699955010f3807eb0ee",
|
||||
"orders": {
|
||||
"match": 197,
|
||||
"match": 192,
|
||||
"cancelled": 20,
|
||||
"lost_event": 3,
|
||||
"duplicate_event": 2,
|
||||
"amount_delta": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"day": 1,
|
||||
"date": "2026-06-02",
|
||||
"events": 49722,
|
||||
"sha256": "5f7aa0301832008db612eff05899b1210af958c888ac2e9e89f4b7a552569eff"
|
||||
"events": 49727,
|
||||
"sha256": "8005d2934eb353478e35331d7ae7ae6876c7934b2d9032fde92cf6ce86d6460f"
|
||||
},
|
||||
{
|
||||
"day": 2,
|
||||
"date": "2026-06-03",
|
||||
"events": 52361,
|
||||
"sha256": "10ac9ce65ca6dc171689edf78ef5cd785e8c8a80e54d236bf2b9d6dbf6997070"
|
||||
"events": 52363,
|
||||
"sha256": "e13f6f3f3977386a94d0e63b6849675cb5d2486a277d559fd415923cfc82e53a"
|
||||
},
|
||||
{
|
||||
"day": 3,
|
||||
"date": "2026-06-04",
|
||||
"events": 53157,
|
||||
"sha256": "7bf4deadd8f69a6c479fceabfe2d1e8ea8db76919b10a8b9e780006708da31c3"
|
||||
"events": 53159,
|
||||
"sha256": "91c713add62222adc3f89c04e6cc087d8c44e2385d14d0464e2c016b6952b9fd"
|
||||
},
|
||||
{
|
||||
"day": 4,
|
||||
"date": "2026-06-05",
|
||||
"events": 50078,
|
||||
"sha256": "b9e758fb72f937aa5ad1f2e90dee5ec147b16cfd89e49e51a00582f909458c29"
|
||||
"events": 50087,
|
||||
"sha256": "b7fc27d73fc157a48bece0189c7983f6bc672f052e0703cd799b9774da4b904e"
|
||||
},
|
||||
{
|
||||
"day": 5,
|
||||
"date": "2026-06-06",
|
||||
"events": 47164,
|
||||
"sha256": "f08a9f9a439f1da9e4ad8d94b9f65b7f3329256e41446d22d900cc4825f2480c"
|
||||
"events": 47169,
|
||||
"sha256": "82cb8017d53d6dee8438d3683b7a901f1ace139f3b8ae836215722bb580fe207"
|
||||
},
|
||||
{
|
||||
"day": 6,
|
||||
"date": "2026-06-07",
|
||||
"events": 48683,
|
||||
"sha256": "7e45d9dd59c902067fa6847d813669b1ab9a4ee88d2f192382577a5ed8d590a7"
|
||||
"events": 48686,
|
||||
"sha256": "b50ad0fcc7d1ea0c637cfcba98e5c2b55d65215cffa3693bc77edeaa83e6c783"
|
||||
},
|
||||
{
|
||||
"day": 7,
|
||||
"date": "2026-06-08",
|
||||
"events": 49394,
|
||||
"sha256": "286ccc2451847ac8de215b5e902c328371021ad5502bd4afca0494b6f313aef7"
|
||||
"sha256": "358d51b36221d822960237702fa6327fb663a57cf49cc4415a859b16e5aa05a2"
|
||||
}
|
||||
],
|
||||
"snapshots": [
|
||||
{
|
||||
"day": 0,
|
||||
"date": "2026-06-01",
|
||||
"sha256": "6c3e3c1c8689ec23f53f0953db1b941e6f010e9e56a74d8d017f40195d3dec17"
|
||||
"sha256": "93b39f261cbbe894402ebe0525460fed1809e5712639be4b73b79dac2b39f5bd"
|
||||
},
|
||||
{
|
||||
"day": 1,
|
||||
"date": "2026-06-02",
|
||||
"sha256": "9acb523cfd47f3d97d55122025852982ba5a1d8362036308ce72a4d9fb767f43"
|
||||
"sha256": "4fef382cd84868ccfb61a9d8610cdd26f9a749bbae8b20800c45e520e9ce414e"
|
||||
},
|
||||
{
|
||||
"day": 2,
|
||||
"date": "2026-06-03",
|
||||
"sha256": "b81482491c4c0b874ea84d5f3f05144100c906dacc059c068b331df258b97172"
|
||||
"sha256": "caf9ed2e77d2fcc99066a548dcba60f61f1ff0d4402de31457a06f23fd2d8306"
|
||||
},
|
||||
{
|
||||
"day": 3,
|
||||
"date": "2026-06-04",
|
||||
"sha256": "9d1ab326c4bf8b78c9fcaf7015d8029f9ad4cff5558b89c730eb428d175cc9ee"
|
||||
"sha256": "cdb9e4e00b57c04985866b5a851a8e2f23b3e9205ea131c7a5076672ee8b5669"
|
||||
},
|
||||
{
|
||||
"day": 4,
|
||||
"date": "2026-06-05",
|
||||
"sha256": "0e30d0e192e121121003c0cc9aaa55e24924afb983caff15ab105705fbf2bd00"
|
||||
"sha256": "c58d2c20fe3b4cee2973f34c322090dbb66d3793bfaab0337c22b53fb5a3ec7f"
|
||||
},
|
||||
{
|
||||
"day": 5,
|
||||
"date": "2026-06-06",
|
||||
"sha256": "9a2ad812fcff9a40a988f24f8aa4fc7a379873d173a95ddacd438592aed0bfb5"
|
||||
"sha256": "bbf28b1d375e4815695c72a5915a22a3c9b36195b97ec4cc7c739466dbe70b53"
|
||||
},
|
||||
{
|
||||
"day": 6,
|
||||
"date": "2026-06-07",
|
||||
"sha256": "e1706a27ff55de489a505489148c7a959329d95ad6597d92d3f78505f67bb221"
|
||||
"sha256": "8ca077576bcf014c0d492c7d7190e38c0e46ec95951ad840df93eb75ee5a00d3"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,13 @@
|
||||
возвращает единицу — поведение проверено при исполнении #37. Зависимость
|
||||
включает результат подготовки в общий граф ожидания.
|
||||
|
||||
У каждого общего локального образа один блок `build`. Он находится у службы
|
||||
инициализации. Остальные службы зависят от неё и используют тот же `image`.
|
||||
`docker compose up --build` завершает сборку до запуска контейнеров, поэтому
|
||||
одного блока достаточно. В Buildx 0.30.1 одинаковые блоки у нескольких служб
|
||||
запускают параллельный экспорт одного тега. Иногда сборка завершается ошибкой
|
||||
`image already exists`.
|
||||
|
||||
Для входа выбран SimpleAuthManager. У него нет команды создания пользователя,
|
||||
поэтому `airflow-init` записывает пароль администратора из окружения в
|
||||
JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит
|
||||
|
||||
@@ -19,16 +19,23 @@
|
||||
|
||||
Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Дальше в этом тексте предел
|
||||
памяти контейнера (`mem_limit`) называется коробкой: сервер живёт внутри неё и
|
||||
о самой машине ничего не знает. Остальные `mem_limit` не меняются.
|
||||
о самой машине ничего не знает. На 1 августа остальные `mem_limit` не менялись.
|
||||
|
||||
Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются
|
||||
статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`,
|
||||
`AIRFLOW__CORE__PARALLELISM` и девять неизменённых `mem_limit`. Основания,
|
||||
`AIRFLOW__CORE__PARALLELISM` и восемь оставшихся `mem_limit`. Основания,
|
||||
кроме отменённого, у них нет. Кто в них упрётся — меняет по первому
|
||||
свидетельству и этот ADR не пересматривает. Срок хранения метрик Prometheus
|
||||
сюда не входит: неделя истории — осознанный учебный выбор, и цена у него
|
||||
дисковая.
|
||||
|
||||
27 августа таким пределом впервые стала коробка `airflow-scheduler`:
|
||||
LocalExecutor исполняет в ней до четырех процессов задач одновременно, а
|
||||
640 МиБ этого не вместили. Коробка поднята до 1,5 ГиБ. Ресурсные ограничения
|
||||
из отдельных дагов удалены. Общий предел параллелизма уже задает Airflow.
|
||||
`max_active_runs=1` остается там, где два запуска делили бы один донор и могли
|
||||
повредить публикацию.
|
||||
|
||||
Ресурсный довод ADR 0001 отозван.
|
||||
|
||||
## Почему
|
||||
@@ -94,8 +101,8 @@ ClickHouse сам понижает каждый до 512 МиБ, а на сте
|
||||
заранее, и в покое стенд занимает около 3,2 ГБ. Восемь гигабайт нужны не сумме
|
||||
пределов, а работе с запасом.
|
||||
|
||||
Остальные девять лимитов не меняются, потому что ни один из них ни разу не
|
||||
сработал. Снять их скопом — то же изменение без свидетельств, каким они были
|
||||
На 1 августа остальные девять лимитов не менялись, потому что ни один из них
|
||||
не сработал. Снять их скопом — то же изменение без свидетельств, каким они были
|
||||
выставлены, только в обратную сторону. Лечение здесь не в том, чтобы стереть
|
||||
числа, а в том, чтобы лишить их статуса закона: число без основания меняют,
|
||||
когда оно мешает, и не защищают как принятое решение.
|
||||
@@ -151,3 +158,13 @@ to 2.00 GiB because the system has limited RAM` и такие же строки
|
||||
Значение `max_server_memory_usage_to_ram_ratio` = 0,9 и то, что в cgroup предел
|
||||
считается от коробки, а не от машины, сверены по документации ClickHouse через
|
||||
MCP Context7.
|
||||
|
||||
`airflow-scheduler` проверен отдельно 27 августа 2026 года. На свежем
|
||||
контейнере две попытки запустить четыре DQ-сверки параллельно в коробке
|
||||
640 МиБ дошли ровно до `memory.max` и дали `oom_kill=1`: сначала при выгрузке
|
||||
57 567 ключей сессий в Python, затем и после переноса дневной агрегации в
|
||||
ClickHouse. В коробке 1 ГиБ сам `dq_check` прошел, но снова коснулся верхней
|
||||
границы (`memory.events.max=57`). Полный `etl_pipeline` без локальных
|
||||
ограничений задач в коробке 1,5 ГиБ завершился успешно: пик - 1 013 760 000
|
||||
байт, `memory.events.max=0`, `oom_kill=0`. Docker не резервирует разницу между
|
||||
пиком и пределом, поэтому этот запас не увеличивает расход памяти в покое.
|
||||
@@ -25,9 +25,11 @@ Kafka, DDL прикладных баз ClickHouse, стартовыми данн
|
||||
|
||||
`world_recreate` ставит `-1` до первого удаления и `0` только после успешной
|
||||
очистки. `world_initialize` принимает отсутствие позиции или `0`, а значение
|
||||
`8` и больше считает готовым миром. Позиция становится `8` последней задачей,
|
||||
после генерации стартовых дней и успешного приёма обоих источников. Обычные
|
||||
даги движения мира работают только с готовой позицией.
|
||||
`8` и больше считает готовым миром. После генерации стартовых дней и успешного
|
||||
приема обоих источников позиция становится `8`; затем инициализация последним
|
||||
шагом ждет дневной конвейер `etl_pipeline`. При отказе преобразований мир
|
||||
остается готовым на позиции `8`, а конвейер после исправления запускают рукой.
|
||||
Обычные даги движения мира работают только с готовой позицией.
|
||||
|
||||
## Почему
|
||||
|
||||
@@ -39,9 +41,10 @@ Kafka, DDL прикладных баз ClickHouse, стартовыми данн
|
||||
Kafka сообщения и офсеты прежнего мира.
|
||||
|
||||
Airflow уже ведёт последующие дни и приём заказов. Отдав ему начало той же оси,
|
||||
получаем один наблюдаемый процесс и одну точку успеха: зелёный
|
||||
`world_initialize` означает, что схема создана, стартовый мир принят и его
|
||||
следующий день известен.
|
||||
получаем один наблюдаемый процесс и одну точку успеха: зеленая ветка создания в
|
||||
`world_initialize` подтверждает, что схема создана, стартовый мир принят, его
|
||||
следующий день известен, а слои DDS и DM собраны. Короткая ветка готового мира
|
||||
по-прежнему подтверждает позицию и ничего не меняет.
|
||||
|
||||
## Что это решение уточняет
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# ADR 0015. Модельный день считается в часовом поясе счетчика
|
||||
|
||||
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
|
||||
|
||||
## Решение
|
||||
|
||||
Модельные сутки считаются в часовом поясе счетчика UTC+4. `EventDate` обозначает
|
||||
дату в этом поясе, а `UTCEventTime` — абсолютную метку времени. Граница
|
||||
модельного дня приходится на полночь счетчика.
|
||||
|
||||
Суточная волна посетителя привязана к его местному времени. У гостя из другого
|
||||
пояса она сдвигается на разницу с поясом счетчика, но общая граница дня остается
|
||||
прежней.
|
||||
|
||||
## Почему
|
||||
|
||||
Так устроена выгрузка Метрики: дата события уже посчитана в поясе счетчика.
|
||||
Полночь в этом поясе не разрезает утренние сессии в регионе мира. Регион можно
|
||||
выбирать независимо от технического пояса счетчика.
|
||||
|
||||
Для всего потока дня нужна одна граница суток. Местное время гостя описывает
|
||||
поведение в пределах этих суток, а отдельная дата для каждого посетителя не
|
||||
нужна.
|
||||
|
||||
## Отклонено
|
||||
|
||||
В разделе 9 отклоненные варианты не зафиксированы.
|
||||
|
||||
## Следствия
|
||||
|
||||
У ночных событий `toDate(UTCEventTime) != EventDate`. Хранилище берет день из
|
||||
готового `EventDate`, а не выводит его из абсолютной метки. Поворот суточной
|
||||
волны слегка размывает общий пик активности гостей из разных поясов.
|
||||
|
||||
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
|
||||
раздел 9.
|
||||
@@ -0,0 +1,28 @@
|
||||
# ADR 0016. Паспорт куки хранится в плане состава на всю ее жизнь
|
||||
|
||||
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
|
||||
|
||||
## Решение
|
||||
|
||||
Браузер, операционная система, устройство, экран и город принадлежат куке на всю
|
||||
ее жизнь. План состава выбирает и хранит паспорт, а день только переносит его в
|
||||
поля событий.
|
||||
|
||||
Две куки одного человека получают один город и разные устройства. Броски
|
||||
паспорта идут после прежних бросков состава, поэтому прежние измерения не
|
||||
сдвинулись.
|
||||
|
||||
## Почему
|
||||
|
||||
Кука представляет браузер на устройстве, поэтому ее паспорт не должен меняться
|
||||
между днями. Только план состава связывает две куки с одним человеком. Поэтому
|
||||
он может задать им один город и разные устройства.
|
||||
|
||||
## Отклонено
|
||||
|
||||
- Выводить паспорт арифметикой из `ClientID`: этот идентификатор не несет
|
||||
сведений о двухкуковой паре, поэтому по нему нельзя задать двум кукам одного
|
||||
человека один город и разные устройства.
|
||||
|
||||
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
|
||||
раздел 9.
|
||||
@@ -0,0 +1,34 @@
|
||||
# ADR 0017. Номер заказа объединяет две проекции покупки
|
||||
|
||||
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #40.
|
||||
|
||||
## Решение
|
||||
|
||||
Номер заказа включает день по модельному времени и порядковый номер покупки в
|
||||
этот день, например `20260603-0042`. Событийная сторона пишет его в
|
||||
`purchaseID`, бэкенд — в `order_id`; обе стороны берут один номер из плана
|
||||
покупки.
|
||||
|
||||
Все покупки, попавшие в поток дня, нумеруются в порядке событий и до любых
|
||||
потерь следующих этапов.
|
||||
|
||||
## Почему
|
||||
|
||||
По читаемому номеру сразу виден день заказа. Один номер соединяет две проекции
|
||||
одной покупки. Генераторы кликстрима и бэкенда не согласуют его отдельно.
|
||||
|
||||
Присвоение до потерь сохраняет номера остальных заказов: исчезновение одного
|
||||
события не должно перенумеровывать чужие покупки и разрывать связь с бэкендом.
|
||||
|
||||
## Отклонено
|
||||
|
||||
- Присваивать номера после внесения потерь: одна потеря сдвинула бы все
|
||||
следующие номера за день и разорвала бы связь уже созданных проекций.
|
||||
|
||||
## Следствия
|
||||
|
||||
`purchaseID` и `order_id` изначально совпадают. Из-за расхождения событие может
|
||||
исчезнуть после присвоения номера, но номера сохранившихся заказов не меняются.
|
||||
|
||||
Решение принято при исполнении тикета #40 и зафиксировано в спеке этапа 2,
|
||||
раздел 9.
|
||||
@@ -45,6 +45,11 @@
|
||||
своё имя здесь создало бы третий синоним. `client_timezone` тоже остаётся —
|
||||
это атрибут посетителя, а не линза времени.
|
||||
|
||||
`purchase_revenue` сохраняет смысл поля источника. Тип денег нормализуется в
|
||||
ODS; точный тип и проверка описаны в разделе
|
||||
[«Актуальные события»](../storage.md#актуальные-события). DDS только
|
||||
переименовывает уже нормализованную колонку.
|
||||
|
||||
## Расшифровки
|
||||
|
||||
Одна: `device_category` — коды 1–4 в строки `desktop` / `mobile` /
|
||||
@@ -89,10 +94,8 @@
|
||||
- Сборка сессий: ключи нарезки (`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` (пара «посетители / известные» через
|
||||
`dds.identity_map_v`).
|
||||
- Витрины `dm.events_enriched_v`, `dm.top_pages_daily_v` и
|
||||
`dm.utm_effectiveness_v` (`declared_revenue` берется из `purchase_revenue`).
|
||||
- Событийная сторона сверки `dm.purchase_vs_orders_v`: `purchase_id`,
|
||||
`purchase_revenue`, `event_date`.
|
||||
- DQ-сверка сессий с эталоном читает не его, а `ods.event_v` —
|
||||
|
||||
@@ -65,16 +65,15 @@ ODS: это единственный устойчивый ключ сущнос
|
||||
одинаковы у всех строк дня и отвечают на вопрос «каким запуском и когда
|
||||
собран этот день».
|
||||
|
||||
Ярлыки ODS — `snapshot_date`, `_load_id` приёма, `_load_ts` прибытия — в
|
||||
модель не переезжают. Версию заказа они не задают, а физически выжившие их
|
||||
значения — жребий: у строк с равным `updated_at` деловые поля совпадают
|
||||
побайтово, и какая переживёт схлопывание — произвол (замер —
|
||||
[приём заказов](../orders/ingestion.md), «Что проверено»). Перенесённый
|
||||
ярлык выглядел бы фактом, оставаясь жребием; лживая координата хуже
|
||||
отсутствующей. Отсюда правило слоя: **служебные колонки — координаты
|
||||
собственной загрузки слоя; чужие координаты спрашиваются у их слоя по ключу
|
||||
сущности**. Этим закрыто наследство #85: читатель у чужих ярлыков выше ODS
|
||||
не нашёлся и при названных читателях модели.
|
||||
Ярлыки ODS (`snapshot_date`, `_load_id` приёма, `_load_ts` прибытия) в модель
|
||||
не переезжают. Они описывают наблюдение и доставку строки в ODS. Модель DDS
|
||||
хранит текущее деловое состояние заказа. В ODS `snapshot_date` задаёт версию и
|
||||
называет последний слепок, в котором источник показал заказ
|
||||
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Дату
|
||||
наблюдения и координаты доставки читатель спрашивает у ODS по
|
||||
`order_id`. Правило слоя:
|
||||
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
|
||||
спрашиваются у их слоя по ключу сущности**.
|
||||
|
||||
Происшествие качества данных расследуется цепочкой, где каждый шаг
|
||||
спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой
|
||||
@@ -109,8 +108,8 @@ ODS: это единственный устойчивый ключ сущнос
|
||||
позиций и пересчёт времён при каждом чтении собирали бы модель заново на
|
||||
лету. Публичное `dds.order_v` поверх готовой физической модели этому доводу
|
||||
не противоречит: оно ничего заново не вычисляет.
|
||||
- **`ReplacingMergeTree(updated_at)` ещё раз.** Повторил бы механику ODS
|
||||
вторым разом: физически хранил бы устаревшие состояния, вернул бы `FINAL`
|
||||
- **`ReplacingMergeTree` ещё раз.** Повторил бы механику ODS вторым разом:
|
||||
физически хранил бы устаревшие состояния, вернул бы `FINAL`
|
||||
и обёртку-представление — а урок версий менти уже отыграл.
|
||||
- **Отдельная сущность `dds.order_item`.** Вторая таблица, второй вопрос
|
||||
шардирования и `JOIN` там, где хватает `ARRAY JOIN`; нормализация из
|
||||
|
||||
@@ -35,10 +35,9 @@
|
||||
ломают.
|
||||
|
||||
`session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка:
|
||||
витрины сессий читают `dds.session_v`, их зерно совпадает с зерном модели, и в
|
||||
соединениях id не участвует — события его не несут, мост к заказам живёт на
|
||||
событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со стендовой и
|
||||
назвать расхождение одним числом) и разбор DQ-расхождений.
|
||||
в соединениях он не участвует — события его не несут, мост к заказам живёт
|
||||
на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со
|
||||
стендовой и назвать расхождение одним числом) и разбор DQ-расхождений.
|
||||
|
||||
## Состав строки
|
||||
|
||||
@@ -73,15 +72,18 @@
|
||||
шарде, поэтому сессионизация локальна по природе — капкан смены ключа при
|
||||
вставке, который дока хранилища обещает сборкам DDS, у сессий не стреляет.
|
||||
|
||||
Сборка — дневной батч после прихода слепка заказов: хранилище выше ODS по
|
||||
смыслу батчевое, раз второй источник приходит раз в сутки. К этому моменту
|
||||
события дня полны, и сессии только что прожитого дня собираются один раз и
|
||||
целиком; идемпотентность держит замена партиции. В отличие от заказов, где
|
||||
батч пересобирает до K «дышащих» дней, у сессий пересобирается только одна
|
||||
партиция: день самодостаточен и замерзает сразу (правило спеки, раздел 6).
|
||||
Внутри живого дня модель сессии про сегодня честно молчит — «сегодня»
|
||||
смотрят через `dds.event_v`; сессия и по смыслу не финальна, пока день не
|
||||
прожит.
|
||||
Сборка — дневной батч: хранилище выше ODS по смыслу батчевое, раз второй
|
||||
источник приходит раз в сутки. Собирается день, который мир уже прожил, и
|
||||
собирается один раз и целиком; какой день прожит, слой спрашивает у позиции
|
||||
мира ([дневной конвейер](../etl.md), «Объём прогона»). Идемпотентность держит
|
||||
замена партиции. В отличие от заказов, где батч пересобирает до K «дышащих»
|
||||
дней, у сессии дышащего окна нет: прожитый день самодостаточен (правило
|
||||
спеки, раздел 6). Второй раз он соберётся только
|
||||
вынужденно — когда упавшая замена оставила позади дыру и хвост от неё накрыл
|
||||
соседние дни. Дней в одном прогоне при этом бывает несколько — столько,
|
||||
сколько мир успел прожить без слоя. Внутри живого дня
|
||||
модель сессии про сегодня честно молчит — «сегодня» смотрят через
|
||||
`dds.event_v`; сессия и по смыслу не финальна, пока день не прожит.
|
||||
|
||||
Публичная поверхность — `dds.session_v`: обычное представление с явным
|
||||
списком деловых и собственных служебных колонок над `dds.session_dist`.
|
||||
@@ -107,8 +109,9 @@
|
||||
|
||||
## Читатели
|
||||
|
||||
- Витрина обзора сессий `session_overview_v`: длительности, глубина,
|
||||
вход/выход; читает `dds.session_v`, зерно совпадает (#135).
|
||||
- Витрина обзора сессий `session_overview_v`: дневные итоги визитов в
|
||||
срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод
|
||||
в ее [ТЗ](../../labs/session-overview.md) (#135).
|
||||
- DQ-сверка с эталоном (см. выше).
|
||||
- Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда,
|
||||
потом сравнивает — с эталоном и со стендовой.
|
||||
|
||||
+153
-46
@@ -31,22 +31,24 @@
|
||||
витрины, на него ссылаются учебные задания;
|
||||
- `purchase_vs_orders` — сверка A+C с классами расхождений; зерно — заказ,
|
||||
шардирование сохраняет ко-локацию заказов по `cityHash64(order_id)`;
|
||||
- `daily_traffic` — пара «посетители» / «известные пользователи», локальное
|
||||
обогащение [картой идентичностей](dds/identity.md);
|
||||
- `daily_traffic` — пара «посетители» / «известные пользователи», обогащение
|
||||
[картой идентичностей](dds/identity.md);
|
||||
- `dq_summary` — сводка проверок качества (ниже).
|
||||
|
||||
Остальные витрины спеки — учебные задания: менти строит их сам по ТЗ с
|
||||
образцом. Разминка — представление `events_enriched_v` (зерно события),
|
||||
затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`,
|
||||
последней — самая содержательная `utm_effectiveness` с уроком `declared_*`.
|
||||
Построенная витрина встаёт в конвейер руками менти: финальный пункт
|
||||
каждого ТЗ — добавить задачу своей витрины в `dm_transform`. До этого
|
||||
Построенная витрина встаёт в конвейер руками менти: финальный пункт ТЗ
|
||||
каждого агрегата — добавить задачу своей витрины в `dm_transform`. До этого
|
||||
конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается
|
||||
наравне со стендовыми.
|
||||
наравне со стендовыми. У разминочного представления такого пункта нет:
|
||||
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
|
||||
|
||||
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит
|
||||
от выполненных домашек. Расширение дашборда визуализациями по своим
|
||||
витринам — отдельная домашка после этапа 8.
|
||||
от выполненных домашек. Свой дашборд по витринам менти —
|
||||
[необязательная лаба](../labs/superset-dashboard.md) после этапа 8. Устройство
|
||||
стендового дашборда — в [доке зоны Superset](superset.md).
|
||||
|
||||
## Ритм пересборки
|
||||
|
||||
@@ -55,8 +57,8 @@
|
||||
|
||||
- **от событий и сессий** — день замерзает сразу; пересборка догоняет
|
||||
хвост несобранных дней заменой дневных партиций;
|
||||
- **от заказов** — день дышит окном K и закрывается; пересборка заменяет
|
||||
партиции окна, дни за ним закрыты по построению;
|
||||
- **от заказов** — день дышит, пока входит в последний слепок; пересборка
|
||||
заменяет партиции этого отрезка, дни за ним закрыты по построению;
|
||||
- **от карты идентичностей** — не замерзает никогда: склейка
|
||||
ретроспективна, покупка сегодня делает куку известной во всех её прошлых
|
||||
днях, а карта пересчитывается целиком.
|
||||
@@ -64,61 +66,166 @@
|
||||
Поэтому `daily_traffic` наследует у своего источника и ритм, и механику:
|
||||
полный пересчёт всех дней каждый прогон в `ReplacingMergeTree(_load_ts)`,
|
||||
как у самой карты. Дневных партиций у неё нет: партиция — единица замены,
|
||||
а эта витрина заменяется только целиком. Пересчёт дешёв:
|
||||
витрина читает сессии и карту локальным соединением по ключу ко-локации.
|
||||
а эта витрина заменяется только целиком. На стенде такая работа остается
|
||||
быстрой; витрина читает сессии и карту через их публичные договоры.
|
||||
Пересчитывать точечно только затронутые дни не из чего: для этого надо
|
||||
знать, какие связки новые, а полный пересчёт карты этот след затирает —
|
||||
восстановление следа оставлено лабе повышенной сложности (issue #149).
|
||||
|
||||
У `purchase_vs_orders` два источника и одна непрерывная пересборка. Заказная
|
||||
сторона задает изменяемое окно последним принятым слепком ODS, событийная —
|
||||
последний прожитый день с `purchase`. Левая граница возвращает первую дыру и
|
||||
самый ранний день с `awaiting_order`: предварительная строка уже есть в
|
||||
витрине, поэтому поиск отсутствующего дня ее не заметит. Правая граница
|
||||
доходит до более позднего из двух хвостов. Так один прогон догоняет пакетный
|
||||
разгон длиннее K и не оставляет старое ожидание замороженным.
|
||||
|
||||
## Витрины стенда
|
||||
|
||||
Публичные представления перечисляют деловые колонки и координаты запуска
|
||||
`_load_id`, `_load_ts` явно. Новая колонка физической таблицы сама в договор не
|
||||
попадет.
|
||||
|
||||
### `dm.revenue_daily_v`
|
||||
|
||||
Зерно: день заказа × категория товара. Витрина читает только оплаченные
|
||||
заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает
|
||||
категорию артикула через `dic.products`. Один заказ с несколькими позициями
|
||||
категории считается в `orders` один раз. `units` складывает количество единиц,
|
||||
`revenue` складывает цену позиции × количество. Скидка, доставка и итог заказа
|
||||
между категориями не делятся. `aov` равен `revenue / orders` в том же срезе и
|
||||
хранит два знака после запятой.
|
||||
|
||||
Первый запуск строит всю доступную историю. Следующие берут границы дней из
|
||||
последнего слепка ODS: только источник знает, какие даты еще может изменить.
|
||||
Пропущенный оплаченный день расширяет левую границу, чтобы частичный отказ
|
||||
не оставил дыру после следующего прогона. Агрегат при этом читает DDS; ODS
|
||||
отвечает только на вопрос об объеме.
|
||||
|
||||
`dm.revenue_daily_rep` нарезана по `report_date`; день собирается в
|
||||
таблице-доноре и заменяется через `REPLACE PARTITION`. Цель и донор шардируются
|
||||
по `cityHash64(product_category)`. Кроме дня, у витрины только категория, а
|
||||
шесть категорий стартового мира близки по объему. Более сложный составной ключ
|
||||
распределил бы эту маленькую таблицу без нового урока.
|
||||
|
||||
### `dm.purchase_vs_orders_v`
|
||||
|
||||
Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по
|
||||
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную
|
||||
сумму, а отдельный счет событий оставляет класс `duplicate_event`
|
||||
наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны
|
||||
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая
|
||||
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда
|
||||
([исследование](../research/2026-08-22-distributed-join.md)).
|
||||
|
||||
Непарная сторона остается `NULL` благодаря `join_use_nulls = 1`. При
|
||||
`join_use_nulls = 0` ClickHouse заполнил бы ее нулем или пустой строкой и скрыл
|
||||
отсутствие источника. Клиентская `declared_revenue` и серверная `items_total`
|
||||
сравниваются как `Decimal(18, 2)`. Классы проверяются по порядку:
|
||||
`awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`,
|
||||
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
|
||||
события остается отмененным, а не потерянным.
|
||||
|
||||
Витрина нарезана по `order_day`, цель и донор шардируются по
|
||||
`cityHash64(order_id)`. Диапазон собирается в доноре одной вставкой и
|
||||
заменяется по дням. Первичная сборка охватывает всю историю обоих источников;
|
||||
следующие прогоны идут по двустороннему ритму, описанному выше.
|
||||
|
||||
### `dm.daily_traffic_v`
|
||||
|
||||
В строке один день. `visitors` считает разные `client_id` из
|
||||
`dds.session_v`; `known_users` считает разные `user_id` после левого соединения
|
||||
с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине.
|
||||
Несколько кук одного человека увеличивают `visitors`, но дают одного
|
||||
`known_user`, поэтому `known_users <= visitors`.
|
||||
|
||||
Сессии и карта лежат по `client_id`, но публичные `_v` скрывают
|
||||
`Distributed`-таблицы от локальной подстановки. На ClickHouse 26.3
|
||||
`EXPLAIN PLAN` показал чтение обоих шардов на инициаторе: настройка
|
||||
`distributed_product_mode = 'local'` локального соединения не дала. Поэтому
|
||||
витрина использует явный `GLOBAL LEFT JOIN` через публичные договоры, а не
|
||||
подменяет их физическими таблицами. Это дороже по сети, зато граница слоев
|
||||
остается явной. Новая связь куки меняет прошлые дни, поэтому каждый запуск
|
||||
пишет полную историю новой версией в `ReplacingMergeTree(_load_ts)`, а
|
||||
публичное представление выбирает текущие строки через `FINAL`.
|
||||
|
||||
## `dm.dq_summary`
|
||||
|
||||
Зерно — «день данных × проверка»: строка отвечает, каково качество дня
|
||||
сейчас, а не сколько раз его проверяли, — история прогонов и так живёт в
|
||||
Airflow. Партиция — день данных; прогон звена DQ заменяет партиции
|
||||
проверенных дней через `REPLACE PARTITION`. Обещанный спекой перевод с
|
||||
`TRUNCATE+INSERT` выполняется той же механикой замены, что и у витрин, —
|
||||
ничего отдельного учить не надо.
|
||||
Зерно публичного `dm.dq_summary_v` — «день данных × атомарная проверка».
|
||||
Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
|
||||
Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
|
||||
|
||||
Деловые колонки сводки:
|
||||
|
||||
- `data_date` — проверяемый день;
|
||||
- `check_name` — устойчивое имя инварианта;
|
||||
- `reference_rows` — число деловых ключей на эталонной стороне;
|
||||
- `actual_rows` — число деловых ключей в проверяемом объекте;
|
||||
- `failed_rows` — число отсутствующих или различающихся ключей;
|
||||
- `status` — `pass` при нуле нарушений, иначе `fail`.
|
||||
|
||||
Служебные `_load_id` и `_load_ts` называют собравший запуск. Представление
|
||||
перечисляет колонки явно. Порогов и промежуточного состояния нет.
|
||||
|
||||
## Звено DQ конвейера
|
||||
|
||||
Встаёт в `etl_pipeline` после DM ([конвейер](etl.md)) и проверяет только
|
||||
объекты стенда:
|
||||
Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять
|
||||
атомарных договоров:
|
||||
|
||||
- счётная сверка сессий с эталоном `VisitID` — кука × день, эталон читается
|
||||
из `ods.event_v` ([модель сессии](dds/session.md));
|
||||
- счётчики классов расхождений против описи мира — по дням с закрытым
|
||||
окном. Проверка техническая, не урок DQ: её оракул — опись генератора,
|
||||
а у настоящего магазина оракула нет. Обещание узкое и честное: до
|
||||
витрин доехал тот мир, который родился, — одна растяжка через весь
|
||||
путь вместо сторожей на каждом шаге. Верно ли сам мир рождён, она
|
||||
не видит: опись считает тот же генератор;
|
||||
- счётные сверки витрин стенда с их DDS-источниками.
|
||||
- `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон —
|
||||
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк
|
||||
`dds.session_v`;
|
||||
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
|
||||
`orders` в описи мира, объект — число заказов класса в
|
||||
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
|
||||
`duplicate_event` и `amount_delta` только для покрытых описью закрытых дней;
|
||||
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
|
||||
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
|
||||
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
|
||||
поля сверки сравниваются с независимой сборкой из `dds.event_v` и
|
||||
`dds.order_v`;
|
||||
- `daily_traffic_vs_dds`: ключ — день. `visitors` и `known_users`
|
||||
сравниваются с прямыми агрегатами `dds.session_v` и
|
||||
`dds.identity_map_v`.
|
||||
|
||||
Результаты ложатся в `dm.dq_summary`. Звено — не только сторож конвейера,
|
||||
но и экспонат: менти видит, как контроль качества устроен в бою — по
|
||||
расписанию, с историей, рядом с трансформациями.
|
||||
Область каждой проверки выводится из объединения сторон. Полностью потерянный
|
||||
день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут
|
||||
скомпенсировать друг друга общим итогом: `failed_rows` считается после
|
||||
сопоставления деловых ключей.
|
||||
|
||||
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
|
||||
подтверждает, что до витрины доехал записанный в описи мир, но не проверяет
|
||||
сам генератор: у настоящего магазина такого оракула нет.
|
||||
|
||||
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow
|
||||
только сводку и малый срез диагностики. Техническая сверка с описью читает
|
||||
небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их
|
||||
успеха даг заменяет проверенные дневные партиции. Затем именованные задачи
|
||||
читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
|
||||
сохраниться до красного завершения, а ошибка сборки не затирает прежний
|
||||
результат. Красная задача печатает не больше двадцати нарушенных ключей за
|
||||
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
|
||||
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
|
||||
business_key`.
|
||||
|
||||
Если после починки день исчез с обеих сторон, его больше нет и в новом доноре.
|
||||
Такой день удаляется из сводки отдельно: старая красная строка не должна
|
||||
превращаться в историю попыток.
|
||||
|
||||
## Даг самопроверки
|
||||
|
||||
Домашки контролирует отдельный даг без расписания: менти запускает его
|
||||
рукой. Задача витрины красная, пока витрины нет или счёт не сходится, и
|
||||
зелёная, когда построено верно; сообщение об ошибке говорит, что не так и
|
||||
куда смотреть. Проверки — инварианты против DDS-источников, золотые
|
||||
константы не прибиваются. В `dq_summary` даг не пишет: он инструмент лабы,
|
||||
а не история качества конвейера.
|
||||
Домашки контролирует `homework_check` — отдельный даг без расписания: менти
|
||||
запускает его рукой. По задаче на каждое из
|
||||
[пяти заданий по витринам](../labs/README.md).
|
||||
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
|
||||
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
|
||||
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
|
||||
лабы, а не история качества конвейера.
|
||||
|
||||
Приём проверен на соседнем учебном стенде Airflow + Greenplum:
|
||||
валидационный даг с группами проверок по слоям давал менти обратную связь
|
||||
по каждому шагу домашки, не трогая рабочий конвейер.
|
||||
|
||||
## Остаётся нарезке
|
||||
|
||||
- Имена задач `dm_transform`, звена DQ и дага самопроверки.
|
||||
- Ключи шардирования остальных материализованных витрин — вместе с их
|
||||
DDL; правило партиции-донора — в [доке хранилища](storage.md).
|
||||
- Тексты учебных заданий и точный список проверок обоих контуров.
|
||||
- Механика сверки A+C в SQL — рамка в спеке, разделы 4 и 7.
|
||||
|
||||
## Отклонённые варианты
|
||||
|
||||
- **Весь слой представлениями.** Дёшево и всегда свежо, но главный
|
||||
@@ -136,7 +243,7 @@ Airflow. Партиция — день данных; прогон звена DQ
|
||||
расшифровывать. Красное «ещё не построено» честнее.
|
||||
- **Самопроверка пишет в `dq_summary`.** Попытки менти смешались бы с
|
||||
историей качества конвейера.
|
||||
- **Окно K для `daily_traffic` — прошлое замерзает.** Дни за окном хранили
|
||||
- **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили
|
||||
бы число известных на момент закрытия — витрина тихо расходится с картой
|
||||
и протаскивает обратно «знание на дату», отвергнутое развилкой карты
|
||||
(#133).
|
||||
|
||||
+66
-42
@@ -5,17 +5,20 @@
|
||||
движки, что именно пересобирается — живут в доках слоя
|
||||
([карта модели DDS](dds/README.md)); здесь — только конвейер.
|
||||
|
||||
Спека обещала `etl_pipeline` этапом 5 — он понадобился раньше. Конвейер
|
||||
рождается этапом 4, и временного в нём ничего нет: дневная сборка вперёд
|
||||
останется и после. Этапу 5 достаётся его законный предмет — партиционная
|
||||
переобработка, умение переиграть произвольный прошлый день по команде.
|
||||
Дневная сборка вперёд появляется на этапе 4 и остаётся основным режимом
|
||||
конвейера. На этапе 5 к ней добавляется партиционная переобработка: запуск
|
||||
произвольного прошлого дня по команде.
|
||||
|
||||
## Форма
|
||||
|
||||
Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы
|
||||
у него нет: он дёргает слойные даги последовательно и ждёт каждый —
|
||||
`dds_transform`, затем `dm_transform`. Контур DQ этапа 4 встаёт его же
|
||||
звеном после DM; состав звена — в [доке слоя DM](dm.md).
|
||||
`dds_transform`, затем `dm_transform` и `dq_check`. Состав проверок описан в
|
||||
[доке слоя DM](dm.md).
|
||||
|
||||
Ждущие вызовы сделаны `TriggerDagRunOperator`. Проверка через Context7 по
|
||||
ближайшей доступной документации Airflow 3.x (версия 3.1.6) подтвердила, что
|
||||
оператор опрашивает точный созданный им прогон и краснеет вслед за потомком.
|
||||
|
||||
Слойные даги устроены по образцу `orders_ingest`: без расписания, один
|
||||
активный прогон, запускаются триггером конвейера или рукой при разборе
|
||||
@@ -23,6 +26,14 @@
|
||||
|
||||
## Три пути запуска
|
||||
|
||||
```text
|
||||
world_initialize ─┐
|
||||
world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transform ─> dq_check
|
||||
запуск рукой ─────┘
|
||||
|
||||
world_next_day ─────> источники без ETL
|
||||
```
|
||||
|
||||
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
|
||||
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
|
||||
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
|
||||
@@ -40,40 +51,56 @@
|
||||
## Объём прогона
|
||||
|
||||
Параметров у конвейера нет: объём он выводит сам из `world_position` и
|
||||
состояния слоёв. Сессии догоняют хвост — от последнего собранного дня до
|
||||
последнего прожитого; заказы пересобирают дышащее окно K и тот же хвост; карта
|
||||
состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по
|
||||
последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта
|
||||
идентичностей пересчитывается целиком; правила витрин — у
|
||||
[слоя DM](dm.md).
|
||||
Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и
|
||||
после падения, а разгону на семь дней хватает одного прогона.
|
||||
|
||||
Окно заказов отсчитывается от **последнего дня, о котором рассказал сам
|
||||
источник**, — от позднейшего дня заказа в ODS. Рядом напрашиваются ещё два
|
||||
способа, и оба отвергнуты по одной причине: они отвечают не на тот вопрос.
|
||||
Окно заказов задаёт последний слепок, наблюдаемый в ODS. У каждого заказа
|
||||
`snapshot_date` равен последнему слепку, в котором источник его показал.
|
||||
Трансформация выбирает строки с максимальной `snapshot_date` и берёт среди них
|
||||
минимальный и максимальный `order_date`. Это границы дней, состояние которых
|
||||
источник ещё может изменить
|
||||
([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Кроме живого окна,
|
||||
трансформация закрывает пропуски: находит самый ранний день, представленный в
|
||||
ODS и ещё не собранный в DDS, затем при необходимости расширяет до него левую
|
||||
границу.
|
||||
|
||||
Позиция мира говорит, какой день прожит, а не какой доехал: прогон дня D везёт
|
||||
слепок дня D − 1, да и позиция ставится раньше трансформации, так что после
|
||||
неудачного приёма она обгоняет хранилище. Слой обязан считать объём по тому,
|
||||
что у него есть, а не по тому, что мир о себе заявляет.
|
||||
Позиция мира заказам не отвечает: она говорит, какой день прожит, а не какой
|
||||
доехал. Прогон дня D везёт слепок дня D − 1, да и ставится позиция раньше
|
||||
трансформации, так что после неудачного приёма обгоняет хранилище. Слой обязан
|
||||
считать объём по тому, что у него есть, а не по тому, что мир о себе заявляет.
|
||||
|
||||
Ярлыки доставки — `snapshot_date`, `_load_id`, `_load_ts` — не отвечают тоже, и
|
||||
это свойство ODS, а не недосмотр: слой схлопывает доставки, сохраняя деловые
|
||||
версии, поэтому у заказа, который не менялся, ярлык выжившей строки достаётся
|
||||
жребием ([приём заказов](orders/ingestion.md), «Роль ODS»). Замер на стартовом
|
||||
мире: последний слепок нёс все 1662 заказа, а его ярлык сохранили 1188 —
|
||||
отбор «дни, у которых `snapshot_date` равен последнему» потерял бы четверть
|
||||
заказов и молча пропустил бы дальний край окна, где заказы давно замёрзли.
|
||||
К времени создания это не относится: у всех версий заказа оно одно.
|
||||
Для сессий границу задаёт `world_position`: она отличает полностью прожитый
|
||||
день от ещё идущего. Сами события этой границы не показывают: и посреди дня D,
|
||||
и сразу после его конца их позднейший день равен D. Если собрать сессии посреди
|
||||
дня, визит, начатый в 23:50, останется обрезанным: завершённый день слой
|
||||
повторно не собирает. Поэтому последний прожитый день равен первому дню
|
||||
событий плюс позиция минус один
|
||||
([`sql/dds/session_scope.sql`](../../sql/dds/session_scope.sql)).
|
||||
Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не
|
||||
позиция, а «Готовность источника».
|
||||
|
||||
## Готовность источника
|
||||
|
||||
Первый шаг `dds_transform` — дождаться, пока хвост топика `hits` дожёван:
|
||||
отставание его потребителей нулевое (`system.kafka_consumers`). Зелёный
|
||||
приём заказов о событиях не говорит ничего — топики разные и потребители
|
||||
разные. В живом режиме зазор ничтожен, но в пакетном генератор выстреливает
|
||||
дни в топик быстрее, чем чтец их прожёвывает, и сессии собрались бы по
|
||||
недожёванному дню. DQ-сверка такой день подсветит, но починить его до
|
||||
этапа 5 нечем: профилактика дешевле ремонта, которого ещё нет.
|
||||
Первый шаг `dds_transform` проходит два барьера за общий срок в 300 секунд.
|
||||
Сначала клиент `confluent-kafka` сверяет подтвержденные смещения группы
|
||||
`clickstream_hits` с верхними смещениями обоих разделов `hits`. Группа, оба
|
||||
раздела и все смещения должны существовать. Суммарное отставание должно стать
|
||||
нулевым. Каждый опрос пишет в журнал оба смещения и отставание по разделу.
|
||||
|
||||
Только после этого шаг проверяет обе ноды ClickHouse через
|
||||
`clusterAllReplicas`: у `ods.event_dist` в `system.distribution_queue` не
|
||||
должно остаться файлов. Каждый опрос пишет в журнал ноды, число файлов и
|
||||
`last_exception` очереди. Оба барьера опрашиваются раз в три секунды и делят
|
||||
один крайний срок. После Kafka срок не отсчитывается заново.
|
||||
|
||||
Зелёный приём заказов о событиях не говорит ничего — топики разные и
|
||||
потребители разные. Одного нулевого отставания Kafka тоже мало: чтец мог
|
||||
подтвердить сообщение раньше, чем Distributed-таблица доставила файл на шард.
|
||||
Два барьера закрывают обе части пути до того, как сессии выберут свой объем.
|
||||
|
||||
## Идемпотентность
|
||||
|
||||
@@ -88,14 +115,11 @@
|
||||
## Чего конвейер о себе не знает
|
||||
|
||||
Трансформация собирает слой по правилу, а не сверяет его с источником. Отсюда
|
||||
четыре вещи, которых она не заметит, и у всех четырёх один адрес — контур DQ.
|
||||
три вещи, которых она не заметит, и у всех трёх один адрес — контур DQ.
|
||||
|
||||
- **Окно K разъехалось с источником.** Число живёт двумя копиями: у генератора
|
||||
в `world.py` и у стенда в `compose.yaml`. Разойдутся — слой начнёт
|
||||
недобирать край окна или переписывать замёрзший день, и оба прогона будут
|
||||
зелёными.
|
||||
- **Пустой свежий день сдвигает край окна.** Если в позднейший день источника
|
||||
не родилось ни одного заказа, окно съезжает на день назад
|
||||
- **Часть последнего слепка не доехала.** Отказ вставки после коммита офсетов
|
||||
уносит прочитанное с собой. Пропади так строки самого старого дня окна —
|
||||
окно, выведенное из ярлыков, молча сузится, и день не пересоберётся
|
||||
([`sql/dds/order_scope.sql`](../../sql/dds/order_scope.sql)).
|
||||
- **Замена партиции прошла на одном шарде из двух.** День в слое есть — значит,
|
||||
объём следующего прогона его не тронет, а слой останется собранным наполовину
|
||||
@@ -113,11 +137,11 @@
|
||||
## Не входит
|
||||
|
||||
- Партиционная переобработка — переиграть произвольный прошлый день или
|
||||
диапазон, включая форму необязательных параметров того же дага, — этап 5
|
||||
(#7); туда же стык #111 про дату модельного мира и `logical_date`.
|
||||
- Инкрементальный ETL вместо пересборки — техдолг #2.
|
||||
- Состав витрин `dm_transform` и звена DQ — [дока слоя DM](dm.md);
|
||||
имена задач — нарезка этапа 4.
|
||||
диапазон, включая форму необязательных параметров того же дага.
|
||||
- Инкрементальный ETL вместо пересборки отклонен (#2, wontfix). Идея отдельной
|
||||
лабы на эту тему — #149.
|
||||
- Состав витрин `dm_transform` и проверок `dq_check` —
|
||||
[дока слоя DM](dm.md).
|
||||
|
||||
## Отклонённые варианты
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Генератор кликстрима
|
||||
|
||||
Путеводитель показывает, как пройти генератор от корневого зерна до двух
|
||||
выходов дня, не пересказывая код и спеки.
|
||||
|
||||
## Целевая картина одним взглядом
|
||||
|
||||
- **Подпоток определяется местом в дереве, а мир внутри подпотока — порядком
|
||||
бросков** — [дерево зерен и порядок бросков](seeds.md).
|
||||
- **Мир поднимает только когорты окна, нужные запрошенному дню** —
|
||||
[ленивость мира](lazy-world.md).
|
||||
- **Устройство и город принадлежат куке на всю жизнь и потому решаются
|
||||
планом состава** — [паспорт куки](cookie-passport.md).
|
||||
- **Выбор по весам сведен к целым броскам и арифметике интервалов** —
|
||||
[целочисленная случайность](randomness.md).
|
||||
- **Торговля получает готовые строки страниц и возвращает поток событий
|
||||
вместе с покупками для бэкенда** — [шов торговых событий](commerce-seam.md).
|
||||
- **Сутки принадлежат поясу счетчика, а `UTCEventTime` остается абсолютной
|
||||
меткой** — [время модельного дня](counter-time.md).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
seed["Корневое зерно"] --> streams["Подпотоки"]
|
||||
streams --> cohorts["Когорты"]
|
||||
cohorts --> audience["Аудитория дня"]
|
||||
audience --> visits["Визиты"]
|
||||
visits --> output["Выход day.stream"]
|
||||
output --> events["Поток событий"]
|
||||
output --> backend["Заказы дня<br/>orders.of_day"]
|
||||
```
|
||||
|
||||
За правой ветвью начинается отдельный набор документов —
|
||||
[устройство заказов](../orders/README.md).
|
||||
|
||||
## Живой проход
|
||||
|
||||
Из каталога `generator/` одна команда собирает канонический D0 и показывает
|
||||
все стадии на одном экране:
|
||||
|
||||
```sh
|
||||
make demo
|
||||
```
|
||||
|
||||
Когорту и аудиторию пример получает через открытый интерфейс плана, а визиты
|
||||
считает группировкой выходных строк по `VisitID`. Код прохода —
|
||||
[`demo.main`](../../../generator/src/clickstream_generator/demo.py).
|
||||
|
||||
## Родословная
|
||||
|
||||
Устройство генератора и основания решений записаны в [спеке этапа
|
||||
2](../../specs/2026-08-01-generator.md), а границы двух источников и склейки —
|
||||
в [мастер-спеке стенда](../../specs/2026-07-30-stand-v2-realism.md).
|
||||
Основания трех решений записаны в
|
||||
[ADR 0015](../../adr/0015-counter-timezone.md),
|
||||
[ADR 0016](../../adr/0016-cookie-passport.md) и
|
||||
[ADR 0017](../../adr/0017-order-numbering.md), а спека этапа 2 закрыта.
|
||||
Путеводитель появился по тикету
|
||||
[#48](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/48):
|
||||
он отвечает на другой вопрос — как пройти принятое устройство по коду.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Шов торговых событий
|
||||
|
||||
Этот документ отвечает на вопрос, что день передает торговой части и что
|
||||
получает от нее назад.
|
||||
|
||||
[`day.stream`](../../../generator/src/clickstream_generator/day.py) сначала
|
||||
собирает выровненные по строкам ряды трафика:
|
||||
|
||||
- `columns` — готовые колонки просмотров страниц;
|
||||
- `page` — вид страницы магазина;
|
||||
- `product` — номер товара у карточки;
|
||||
- `person` — человек за кукой строки.
|
||||
|
||||
`page` указывает, куда может сесть торговое событие, а `product` — какой товар
|
||||
посетитель действительно открыл. `person` в кликстрим не выходит: он нужен
|
||||
только для второй проекции покупки — заказа бэкенда.
|
||||
|
||||
[`commerce.weave`](../../../generator/src/clickstream_generator/commerce.py)
|
||||
принимает эти ряды, вплетает `add_to_cart` и `purchase`, снова упорядочивает
|
||||
поток и возвращает обновленные `columns`, `page`, `product` вместе с
|
||||
[`commerce.Purchases`](../../../generator/src/clickstream_generator/commerce.py).
|
||||
|
||||
`Purchases` — узкая передача следующей стороне. В ней лежат номер заказа,
|
||||
купленные товары и количества, клиентская выручка, промокод, момент события и
|
||||
человек. Все они получены из тех же покупок, что уехали в поток, а не разобраны
|
||||
назад из JSON.
|
||||
|
||||
[`orders.of_day`](../../../generator/src/clickstream_generator/orders.py)
|
||||
принимает `Purchases`, а [`day.Day`](../../../generator/src/clickstream_generator/day.py)
|
||||
отдает заказы рядом с колонками событий. Здесь поток дня расходится на две
|
||||
ветви, показанные в индексе.
|
||||
|
||||
За этой границей начинается [устройство заказов](../orders/README.md). Судьба,
|
||||
слепки и деньги бэкенда описаны там.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Паспорт куки
|
||||
|
||||
Этот документ отвечает на вопрос, почему паспорт куки решает план состава, а
|
||||
день только разворачивает его в события.
|
||||
|
||||
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) получает
|
||||
случайность через
|
||||
[`seeds.cohort_stream`](../../../generator/src/clickstream_generator/seeds.py)
|
||||
и вызывает [`plan._passports`](../../../generator/src/clickstream_generator/plan.py)
|
||||
в ветви «состав мира». Это не подпоток отдельного дня.
|
||||
|
||||
`plan._passports` выбирает строки устройства и города из справочников. У
|
||||
двухкуковой пары город первой куки переносится во вторую, а устройство
|
||||
выбирается из другой половины профилей. Сведения о парах есть только у плана.
|
||||
Если независимо вывести паспорт каждой куки из `ClientID`, один человек может
|
||||
оказаться в двух городах — это будет ложью в данных.
|
||||
|
||||
[`plan.Cohort`](../../../generator/src/clickstream_generator/plan.py) хранит
|
||||
номера устройства и города. `Cohort.visitors_on` переносит их в
|
||||
[`plan.DayAudience`](../../../generator/src/clickstream_generator/plan.py), а
|
||||
[`day.stream`](../../../generator/src/clickstream_generator/day.py) разворачивает
|
||||
профили в колонки событий. День не бросает паспорт заново, поэтому кука
|
||||
сохраняет его на всю жизнь.
|
||||
|
||||
Броски паспорта стоят после прежнего состава когорты. Правило такого
|
||||
продолжения подпотока описано в [дереве зерен](seeds.md), а выбор
|
||||
паспорта объясняет [ADR 0016](../../adr/0016-cookie-passport.md). Зачем стенду
|
||||
нужна двухкуковая пара одного человека, фиксирует
|
||||
[мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 5.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Время модельного дня
|
||||
|
||||
Этот документ отвечает на вопрос, почему дата модельных суток не обязана
|
||||
совпадать с UTC-датой события.
|
||||
|
||||
В коде встречаются три координаты времени:
|
||||
|
||||
- D — номер дня на оси модельного времени;
|
||||
- `EventDate` — дата D в поясе счетчика;
|
||||
- `UTCEventTime` — абсолютная метка события.
|
||||
|
||||
Местное время гостя задает форму активности внутри суток, а границу дня задает
|
||||
пояс счетчика.
|
||||
|
||||
[`world.ORIGIN`](../../../generator/src/clickstream_generator/world.py) — D0
|
||||
календарной оси: он переводит номер D в конкретную дату. Это не механизм
|
||||
часового пояса. Механизм —
|
||||
[`world.COUNTER_TIMEZONE_MINUTES`](../../../generator/src/clickstream_generator/world.py),
|
||||
смещение пояса счетчика от UTC.
|
||||
|
||||
В [`day._columns`](../../../generator/src/clickstream_generator/day.py)
|
||||
`EventDate` получается как `ORIGIN + D`. Чтобы получить абсолютную полночь
|
||||
тех же суток, код вычитает смещение счетчика, а затем прибавляет секунды
|
||||
события внутри дня. Поэтому сутки `2026-06-01` в UTC+4 начинаются в
|
||||
`2026-05-31 20:00:00Z`: у первых четырех часов
|
||||
`toDate(UTCEventTime) != EventDate`.
|
||||
|
||||
[`day._starts`](../../../generator/src/clickstream_generator/day.py) сначала
|
||||
выбирает час из городской строки весов, собранной
|
||||
[`day._hour_cumulative`](../../../generator/src/clickstream_generator/day.py),
|
||||
а затем равномерно выбирает секунду внутри часа. Строка поворачивает суточную
|
||||
волну на разницу между поясом гостя и поясом счетчика. Так вечерний пик гостя
|
||||
попадает в нужные часы внутри дня, но граница суток и `EventDate` остаются
|
||||
счетчиковыми.
|
||||
|
||||
Как часовой пояс меняет представление времени в ClickHouse, описано в разделе
|
||||
«Часовые пояса» [документа о хранилище](../storage.md). Типы и публичный смысл
|
||||
полей записаны в [описании события](../../formats/clickstream-event.md).
|
||||
Выбор модели времени объясняет
|
||||
[ADR 0015](../../adr/0015-counter-timezone.md).
|
||||
@@ -0,0 +1,26 @@
|
||||
# Ленивость мира
|
||||
|
||||
Этот документ отвечает на вопрос, как любой день собирается без прожитой
|
||||
истории и без глобального списка посетителей.
|
||||
|
||||
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) строит
|
||||
одну когорту по зерну и дню ее появления. Отрицательные дни обозначают
|
||||
предысторию; ее глубину ограничивает
|
||||
[`world.RETURN_TAIL_DAYS`](../../../generator/src/clickstream_generator/world.py).
|
||||
Запоминание уже рассчитанных когорт ускоряет соседние вызовы, но не становится
|
||||
состоянием мира: очистка кеша не меняет результат.
|
||||
|
||||
[`plan.audience`](../../../generator/src/clickstream_generator/plan.py) для
|
||||
дня D спрашивает `Cohort.visitors_on(D)` у когорт от
|
||||
`D - RETURN_TAIL_DAYS` до D и соединяет полученные части. Поэтому вызов для
|
||||
D0 поднимает всю предысторию в окне — 91 когорту от D−90 до D0.
|
||||
|
||||
Когорты предыстории дают D0 вернувшихся посетителей, но собственных событий
|
||||
не порождают. Ось событий начинается в D0. Окно активности отсчитывается от
|
||||
первого дня человека и остается общим для обеих его кук; за краем окна
|
||||
когорта больше не может попасть в аудиторию.
|
||||
|
||||
Отсюда самостоятельность дня: D500 и D5 заглядывают назад на одинаковое число
|
||||
когорт. Ни плану состава, ни день-функции горизонт мира не нужен.
|
||||
Зачем приняты предыстория и ограниченное окно, объясняет [спека
|
||||
генератора](../../specs/2026-08-01-generator.md), раздел 1.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Целочисленная случайность
|
||||
|
||||
Этот документ отвечает на вопрос, как читать целочисленный выбор по весам и
|
||||
зачем генератору свой маленький `weights.py`.
|
||||
|
||||
За `PCG64` и целые броски `Generator.integers` отвечает NumPy.
|
||||
Генератор стенда не пользуется готовым выбором по вещественным вероятностям и
|
||||
плавающими распределениями: их алгоритмы и системная математика могут
|
||||
различаться между версиями и архитектурами. Зависимости закреплены, а броски
|
||||
остаются целыми — так выполняется побайтовое обещание, записанное в [спеке
|
||||
генератора](../../specs/2026-08-01-generator.md), раздел 2.
|
||||
|
||||
## Одна таблица весов
|
||||
|
||||
[`weights.pick`](../../../generator/src/clickstream_generator/weights.py)
|
||||
сначала получает накопленные веса. Для весов `2, 3, 1` это границы
|
||||
`2, 5, 6`. Затем он бросает целое число из полуинтервала `[0, 6)` и находит
|
||||
первую границу справа:
|
||||
|
||||
- броски `0–1` выбирают вариант 0;
|
||||
- броски `2–4` выбирают вариант 1;
|
||||
- бросок `5` выбирает вариант 2.
|
||||
|
||||
Размер участка и есть вес варианта. Нормировать веса до долей единицы не
|
||||
нужно.
|
||||
|
||||
## Своя таблица для каждой строки
|
||||
|
||||
[`weights.pick_row`](../../../generator/src/clickstream_generator/weights.py)
|
||||
работает так же, когда у каждого броска свой ряд весов. Ряды с одинаковой
|
||||
суммой укладываются друг за другом. Номер ряда смещает бросок на его участок,
|
||||
единый поиск находит ячейку, а обратное смещение возвращает номер варианта.
|
||||
|
||||
Конкретный проход виден в
|
||||
[`day._hour_cumulative`](../../../generator/src/clickstream_generator/day.py)
|
||||
и [`day._starts`](../../../generator/src/clickstream_generator/day.py): для
|
||||
каждого города суточная волна повернута в свой ряд, но сумма весов от поворота
|
||||
не меняется.
|
||||
|
||||
При чтении вызова `pick` полезно представлять ряд целочисленных участков:
|
||||
бросок выбирает вариант, в чей участок он попал.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Дерево зерен и порядок бросков
|
||||
|
||||
Этот документ отвечает на вопрос, почему место подпотока стабильно, а порядок
|
||||
бросков внутри него менять нельзя.
|
||||
|
||||
## Два порядка
|
||||
|
||||
[`seeds.cohort_stream`](../../../generator/src/clickstream_generator/seeds.py)
|
||||
и [`seeds.day_stream`](../../../generator/src/clickstream_generator/seeds.py)
|
||||
находят ветвь по позиции `SeedSequence(entropy, spawn_key)`. Поэтому результат
|
||||
ветви не зависит от того, какую соседнюю ветвь вычислили раньше.
|
||||
|
||||
Внутри найденной ветви живет один `Generator`. Каждый его вызов забирает
|
||||
следующие значения. Поэтому порядок вызовов меняет результат: прежний
|
||||
пятый бросок после вставки становится шестым и достается другому свойству
|
||||
мира.
|
||||
|
||||
Дерево целиком описано в модуле
|
||||
[`seeds`](../../../generator/src/clickstream_generator/seeds.py), а
|
||||
[`seeds.Component`](../../../generator/src/clickstream_generator/seeds.py)
|
||||
задает его нижний уровень — четыре подпотока дня:
|
||||
|
||||
```text
|
||||
корневое зерно
|
||||
├── состав мира
|
||||
│ ├── ось событий → номер дня → когорта
|
||||
│ └── предыстория → глубина → когорта до D0
|
||||
└── дни
|
||||
└── номер дня
|
||||
├── TRAFFIC
|
||||
├── COMMERCE
|
||||
├── ORDERS
|
||||
└── BEACONS — потеря и повторная отправка событий
|
||||
```
|
||||
|
||||
Отрицательному дню нельзя отдать отрицательную позицию, поэтому предыстория
|
||||
получила отдельную ветвь. Компоненты дня тоже занимают позиции, а не получают
|
||||
зерна в порядке запуска.
|
||||
|
||||
## Что ломает вставка
|
||||
|
||||
Новый бросок в середине функции сдвигает все последующие значения этого
|
||||
подпотока. Код продолжает работать, но тем же свойствам достается другая
|
||||
случайность. Соседние подпотоки остаются прежними, а измененная часть
|
||||
канонического мира проявляется в описи и побайтовом сравнении.
|
||||
|
||||
Какое обещание воспроизводимости это защищает, записано в
|
||||
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
|
||||
различать два свойства: `SeedSequence` стабилизирует адрес ветви, но порядок
|
||||
бросков внутри `Generator` стабилизирует только сам код.
|
||||
|
||||
## Броски последними
|
||||
|
||||
Новое свойство продолжает существующий подпоток после прежних бросков. Такой
|
||||
бросок не отнимает значения у уже рассчитанных свойств. В
|
||||
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) паспорт
|
||||
куки был дописан после состава и назначенных заказов, а личность человека —
|
||||
после паспорта. Поэтому прежние ряды остались прежними.
|
||||
|
||||
Почему паспорт вообще принадлежит этой ветви, разобрано отдельно в
|
||||
[документе о паспорте куки](cookie-passport.md).
|
||||
@@ -21,8 +21,10 @@ ClickHouse, два подготовленных источника данных,
|
||||
| `grafana` | `clickstream-grafana:local` | интерфейс мониторинга; собирается из `infra/grafana/`, порт стенда `23000` |
|
||||
| `kafka-exporter` | `danielqsj/kafka-exporter:v1.9.0` | переводит состояние брокера в метрики Prometheus, порт `9308` |
|
||||
|
||||
Плагин ClickHouse 4.20.0 входит в образ Grafana. Фоновая установка плагинов
|
||||
при запуске отключена.
|
||||
Плагин ClickHouse 4.20.0 входит в образ Grafana. При сборке Grafana CLI берёт
|
||||
закреплённый выпуск из официального репозитория на GitHub, а не из каталога
|
||||
Grafana: прямой запрос к каталогу при проверке вернул HTTP 451. Фоновая
|
||||
установка плагинов при запуске отключена.
|
||||
|
||||
## Кого опрашивает Prometheus
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
|
||||
[судьба заказа](fate.md).
|
||||
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
|
||||
заказная и событийная стороны; броски независимы, пересечения выходят
|
||||
арифметикой, приоритет классов работает по-настоящему —
|
||||
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
|
||||
пересечения выходят арифметикой, приоритет классов работает по-настоящему —
|
||||
[классы расхождений и опоздание](fate.md).
|
||||
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
|
||||
каждого слепка и счётчики наблюдаемых классов —
|
||||
@@ -38,7 +38,7 @@
|
||||
[мост к склейке](identity.md).
|
||||
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
|
||||
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order`
|
||||
принимает версии заказа на `ReplacingMergeTree(updated_at)` —
|
||||
принимает версии заказа на `ReplacingMergeTree(snapshot_date)` —
|
||||
[приём из Kafka в ODS](ingestion.md).
|
||||
- **Дальше заказ живёт моделью DDS** — уже по хранилищную сторону границы,
|
||||
в наборе слоя: [модель заказа](../dds/order.md).
|
||||
@@ -51,14 +51,6 @@
|
||||
|
||||
## Открытые решения
|
||||
|
||||
- **Имя подпотока событийной стороны** — вместе с её содержимым на этапе 6;
|
||||
из мёртвых имён никто не бросает, переименование ничего не сдвигает (#72).
|
||||
Заказная сторона уже названа — подпоток `ORDERS` ([судьба
|
||||
заказа](fate.md)).
|
||||
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
|
||||
доли классов, стоимость доставки — калибровка при реализации; финальная
|
||||
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
|
||||
потребуют только числа, не устройство.
|
||||
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
|
||||
остаётся в тумане карты #69; естественное место разговора — этап 4.
|
||||
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
|
||||
|
||||
@@ -16,11 +16,10 @@
|
||||
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
||||
компонент называет часть мира; новые стороны занимают те же позиции.
|
||||
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
|
||||
событийная получит имя вместе с содержимым на этапе 6, до тех пор позиция 3
|
||||
занята мёртвым именем прежней нарезки. Отклонено: *один компонент на всю
|
||||
судьбу* — правка событийной механики молча меняла бы байты слепка;
|
||||
*компонент на класс* — пять имён под ручки калибровки, которые крутятся
|
||||
разом.
|
||||
событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо
|
||||
отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка
|
||||
событийной механики молча меняла бы байты слепка; *компонент на класс* —
|
||||
пять имён под ручки калибровки, которые крутятся разом.
|
||||
|
||||
**Правило формы, без которого разделение не работает: броски заказной стороны
|
||||
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
||||
@@ -56,8 +55,11 @@
|
||||
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
|
||||
|
||||
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
|
||||
она делится между двумя дорогами — строки таблицы исходов. Точные числа —
|
||||
калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим.
|
||||
она делится между двумя дорогами — строки таблицы исходов. Замер на
|
||||
каноническом мире (28 дней, 2026-08-30, docs/architecture/testing.md, «Что
|
||||
проверено»): отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12%
|
||||
отменен неоплаченным; в пределах ориентира. Проверок вида «отмен от 4 до 6
|
||||
процентов» не заводим.
|
||||
|
||||
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
||||
версия; различает их история версий: сырьё STG и физические версии
|
||||
@@ -108,12 +110,13 @@
|
||||
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
|
||||
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
|
||||
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
|
||||
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где
|
||||
до следующего визита куки остаётся запас сверх таймаута: иначе сборка сессий у
|
||||
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID`
|
||||
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита,
|
||||
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с
|
||||
покупкой, по тому же правилу, что у подтверждения с торговым хвостом.
|
||||
один в один — посчитал наивно, удвоил выручку. Дубль случается только там,
|
||||
где пауза до следующего визита куки длиннее таймаута и задержки дубля вместе:
|
||||
иначе сборка сессий у менти разошлась бы с `VisitID` — сломался бы эталон,
|
||||
ради которого `VisitID` в потоке лежит. Задержка дубля —
|
||||
секунды-минуты, короче таймаута визита, поэтому `VisitID` тот же. Полночь
|
||||
режет дубль парой — просмотр вместе с покупкой, по тому же правилу, что у
|
||||
подтверждения с торговым хвостом.
|
||||
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
|
||||
потеря перестаёт быть видна со стороны трекера.
|
||||
|
||||
@@ -127,18 +130,20 @@
|
||||
подделывается — строка создана, когда заказ родился, — но в слепках дней
|
||||
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
|
||||
дожил: отменённый на второй день и опоздавший на третий приедет в первом же
|
||||
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Задержка — таблица
|
||||
весов из трёх строк: 0 на подавляющем весе, 1 и 2 — это и есть «D+1/D+2»
|
||||
мастер-спеки. Меряется она в днях снятия слепка, а не отправки: иначе сдвиг
|
||||
отправки удвоился бы, и обещанные D+1/D+2 стали бы D+2/D+3. Дальше таблица не
|
||||
идёт: заказ с δ = 6 приехал бы ровно в одном слепке, и обещание «пропущенный
|
||||
день ничего не ломает» на нём перестало бы быть верным; при δ ≤ 2 у всякого
|
||||
заказа слепков не меньше пяти. Легенда: заказ ушёл в ручную обработку и попал
|
||||
в выгрузку позже. Отклонено: *сдвиг `created_at`* — подделка аудита источника:
|
||||
день создания строки разошёлся бы с днём покупки, чьё равенство держит
|
||||
синхронная модель ([откуда берётся заказ](snapshot.md)), заказ уехал бы в
|
||||
чужую партицию, и «выручка дня D» перестала бы отвечать покупкам дня D —
|
||||
сломалась бы та самая сверка, ради которой всё строится.
|
||||
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Таблица задержки
|
||||
состоит из трёх строк: δ = 0/1/2 с весами 90/5/5; 1 и 2 — это D+1 и D+2
|
||||
мастер-спеки. Бросок идёт последним в подпотоке `ORDERS`; затем δ назначенного
|
||||
планом заказа обнуляется. Меряется задержка в днях снятия слепка, а не
|
||||
отправки: иначе сдвиг отправки удвоился бы, и обещанные D+1/D+2 стали бы
|
||||
D+2/D+3. Дальше таблица не идёт: заказ с δ = 6 приехал бы ровно в одном
|
||||
слепке, и обещание «пропущенный день ничего не ломает» на нём перестало бы
|
||||
быть верным; при δ ≤ 2 у всякого заказа слепков не меньше пяти.
|
||||
Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Отклонено:
|
||||
*сдвиг `created_at`* — подделка аудита источника: день создания строки
|
||||
разошёлся бы с днём покупки, чьё равенство держит синхронная модель
|
||||
([откуда берётся заказ](snapshot.md)), заказ уехал бы в чужую партицию,
|
||||
и «выручка дня D» перестала бы отвечать покупкам дня D — сломалась бы та
|
||||
самая сверка, ради которой всё строится.
|
||||
|
||||
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
|
||||
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
## Проблема
|
||||
|
||||
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
|
||||
отдельными сообщениями и не сообщает потребителю, где слепок закончился. Прямое
|
||||
чтение Kafka Engine возвращает одну порцию. Поэтому прежняя публикация через
|
||||
`REPLACE PARTITION snapshot_date` приравнивала дату наблюдения к отсутствующей
|
||||
транспортной границе и могла заменить день неполным набором строк.
|
||||
отдельными сообщениями и не сообщает потребителю, где слепок закончился.
|
||||
Прямое чтение Kafka Engine возвращает одну порцию без признака полноты.
|
||||
Поэтому `snapshot_date` не может служить границей публикации: `REPLACE
|
||||
PARTITION` могла бы заменить партицию дня неполным набором строк.
|
||||
|
||||
Одновременно типизированный ODS не должен принимать правдоподобные значения по
|
||||
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
- сохранить пакетный забор как контраст потоковому приёму событий;
|
||||
- один раз принять байты в STG и независимо разложить строки на годные и брак;
|
||||
- хранить в ODS типизированные версии заказов, не привязывая идемпотентность к
|
||||
`snapshot_date`;
|
||||
- хранить в ODS типизированные версии заказов, не публикуя слепок заменой
|
||||
партиции;
|
||||
- дать следующим слоям один корректный способ прочитать текущее состояние;
|
||||
- оставить код проверки коротким и ограничить его контрактом провода.
|
||||
|
||||
@@ -81,6 +81,13 @@ JSON-объектом с точным набором ключей: `order_id`, `
|
||||
малый контракт заказа нет причины. Граница строгости заканчивается на форме
|
||||
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
|
||||
|
||||
Контракт источника задаёт частоту: **одной дате соответствует один слепок**. Это
|
||||
свойство всего слепка, поэтому предикат одной строки его не проверяет. Вторая
|
||||
выгрузка того же дня даст ничью по `snapshot_date`, и выбор строки потеряет
|
||||
однозначность. Модельный источник выполняет условие; в настоящей интеграции
|
||||
его фиксируют в договоре с источником
|
||||
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)).
|
||||
|
||||
Класс брака выбирается первым совпадением:
|
||||
|
||||
1. `not_an_object`;
|
||||
@@ -94,31 +101,34 @@ JSON-объектом с точным набором ключей: `order_id`, `
|
||||
## Роль ODS
|
||||
|
||||
`ods.order_rep` хранит физически принятые версии в
|
||||
`ReplacingMergeTree(updated_at)`. Ключ сортировки — `order_id`, партиция — день
|
||||
неизменного `created_at`. `ods.order_dist` шардирует по
|
||||
`ReplacingMergeTree(snapshot_date)`. Ключ сортировки — `order_id`, партиция —
|
||||
день неизменного `created_at`. `ods.order_dist` шардирует по
|
||||
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
|
||||
`FINAL` даёт корректный результат через распределённую таблицу.
|
||||
|
||||
Четыре координаты отвечают на разные вопросы:
|
||||
|
||||
- `updated_at` — какая бизнес-версия заказа новее;
|
||||
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
|
||||
- `updated_at` — когда строку последний раз меняли в источнике;
|
||||
- `_load_id` — какой запуск Airflow принял строку;
|
||||
- `_load_ts` — когда строка приехала в хранилище.
|
||||
|
||||
Версию выбирает только `updated_at`. Заказ, не менявшийся несколько дней,
|
||||
приезжает в нескольких слепках одинаковыми деловыми полями, и какая из этих
|
||||
строк переживёт схлопывание — не обещано. Поэтому `snapshot_date` выжившей
|
||||
строки — один из дней, когда источник показывал эту версию, не обязательно
|
||||
последний; то же с `_load_id` и `_load_ts`. Вопрос «когда заказ наблюдался в
|
||||
последний раз» задаётся сырью STG, пока то живо, — а не одной строке ODS.
|
||||
Версию выбирает `snapshot_date`: выживает строка с максимальной датой слепка
|
||||
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Поэтому
|
||||
`snapshot_date` показывает последнее наблюдение заказа. По строкам последней
|
||||
даты слепка DDS определяет окно пересборки. `_load_id` и `_load_ts` принадлежат
|
||||
той физической строке, которую сохранило слияние, и описывают её доставку.
|
||||
|
||||
Сама схема версий стоит на допущении об источнике: уточнение заказа поднимает
|
||||
`updated_at`. Генератор моделирует источник с триггерной меткой и допущение
|
||||
выполняет (замер — «Что проверено»); источник, умеющий уточнить строку тихо —
|
||||
ручным `UPDATE` мимо приложения, миграцией, — ломает его, и тогда версии по
|
||||
метке времени мало: нужна сверка самих полей, в терминах dbt snapshots —
|
||||
стратегия `check` вместо `timestamp`.
|
||||
Схема версий стоит на дисциплине источника: слепок с поздней датой не
|
||||
показывает состояния старше. Модельный мир её держит — состояние читается на
|
||||
границе суток, и слепок дня выходит чистой функцией зерна и дня; источник,
|
||||
собирающий слепок из отставшей реплики, её ломает. Деловая метка `updated_at`
|
||||
при этом монотонна по слепкам, поэтому выбор по дате слепка даёт тот же ответ,
|
||||
что выбор по метке (замер — «Что проверено»). Тихая правка строки — ручным
|
||||
`UPDATE` мимо приложения, миграцией — метку не двигает, но следующий слепок
|
||||
перекроет её сам, пока заказ в окне изменяемости. За окном источник заказ
|
||||
больше не показывает, и там помогает только сверка самих полей, в терминах
|
||||
dbt snapshots — стратегия `check` вместо `timestamp`.
|
||||
|
||||
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
|
||||
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
|
||||
@@ -201,12 +211,15 @@ JSON-объектом с точным набором ключей: `order_id`, `
|
||||
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу
|
||||
пользователя `etl` для забора в STG, записи годных строк и записи брака.
|
||||
|
||||
Допущение и произвол ярлыков замерены 22 августа 2026 года при исполнении
|
||||
#137: во всех 3034 парах строк с равным `updated_at` деловые поля совпали
|
||||
побайтово, по слепкам метка ни разу не убывала, а `ods.order_v` сошёлся
|
||||
построчно с эталоном `argMax` по `updated_at`, посчитанным прямо из сырья.
|
||||
Произвол ярлыков виден въявь: у 86 заказов стартового мира выжила строка
|
||||
серединного слепка — не первого и не последнего.
|
||||
Дисциплина источника замерена 22 августа 2026 года при исполнении #137.
|
||||
Во всех 3034 парах строк с равным `updated_at` деловые поля совпали
|
||||
побайтово, а по слепкам метка ни разу не убывала.
|
||||
|
||||
Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
|
||||
исполнении #165. После двух слепков физическая таблица содержала 3356 версий
|
||||
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним
|
||||
слепком заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из
|
||||
сырья. В обеих сверках расхождений нет.
|
||||
|
||||
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
|
||||
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
в трекере — не отдельная порода, а класс B, и делает его событийная сторона
|
||||
выбрасыванием события после присвоения номера
|
||||
([классы расхождений](fate.md));
|
||||
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`,
|
||||
читаемый номер «день и порядковый номер покупки» ([спека
|
||||
генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все
|
||||
- обе проекции используют один номер заказа: `order_id` = клиентский
|
||||
`purchaseID`;
|
||||
читаемый номер «день и порядковый номер покупки» описан в
|
||||
[ADR 0017](../../adr/0017-order-numbering.md); нумеруются все
|
||||
покупки, дошедшие до потока дня, — до всяких потерь;
|
||||
- скидка заказа выводится из промокода события по таблице «код → скидка» —
|
||||
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
|
||||
@@ -59,12 +60,14 @@
|
||||
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
|
||||
топиком `hits`* — убивает два режима приёма.
|
||||
|
||||
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и
|
||||
собирается переигровкой этих семи дней: заказы дня — производная всей воронки
|
||||
дня, дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; у
|
||||
начала оси окно усекается само. Отклонено: *кэш заказов на томе* — состояние
|
||||
между прогонами; *окно держит хранилище* — топик перестаёт нести слепок;
|
||||
*K = 1* — это страховочный срез 1 мастер-спеки, он в резерве.
|
||||
**Сборка окна.** Слепок дня D берет заказы, рожденные в дни D−6…D, а несет из
|
||||
них те, что уже попали в выгрузку: механика опоздания живет в
|
||||
[судьбе заказа](fate.md). Окно собирается переигровкой этих семи дней: заказы
|
||||
дня — производная всей воронки дня, дешёвого пути к ним нет. Цена — семь
|
||||
проигрышей дня (~14 с) на слепок; у начала оси окно усекается само. Отклонено:
|
||||
*кэш заказов на томе* — состояние между прогонами; *окно держит хранилище* —
|
||||
топик перестаёт нести слепок; *K = 1* — это страховочный срез 1 мастер-спеки,
|
||||
он в резерве.
|
||||
|
||||
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
|
||||
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
|
||||
|
||||
@@ -16,8 +16,9 @@ keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/d
|
||||
CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а
|
||||
подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат
|
||||
публичным договором события `dds.event_v` над точным состоянием ODS, а следом
|
||||
получил первую физическую модель слоя — заказ: его пересобирает заменой
|
||||
партиций даг `dds_transform`.
|
||||
получил две физические модели слоя — заказ и сессию: их собирает заменой
|
||||
партиций даг `dds_transform`. Сессия среди них особая: в потоке такой сущности
|
||||
нет, стенд режет её из событий сам.
|
||||
Дальше по тексту устройство описано так, как оно проектируется; построенное от
|
||||
заложенного отличает карта таблиц в конце.
|
||||
|
||||
@@ -182,8 +183,8 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
|
||||
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
|
||||
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
|
||||
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
|
||||
повтор доставки. У `ods.order` версию задаёт `updated_at` источника;
|
||||
`_load_ts` только показывает, когда конкретная строка приехала.
|
||||
повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка
|
||||
источника; `_load_ts` только показывает, когда конкретная строка приехала.
|
||||
|
||||
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
|
||||
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
|
||||
@@ -220,10 +221,17 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
|
||||
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том
|
||||
числе snake_case-имена и расшифровку кодов.
|
||||
|
||||
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
|
||||
исключение: источник объявляет `Array(Float64)`, а материализованное
|
||||
представление ODS разбирает числа сырого JSON сразу в
|
||||
`Array(Decimal(18, 2))`. DDS наследует этот тип без нового преобразования.
|
||||
Прямой разбор подтвержден документацией ClickHouse через Context7 и запросом
|
||||
к ClickHouse 26.3; сравнение с сырьем подтвердило: копейки всех покупок целы.
|
||||
|
||||
## Версии заказов
|
||||
|
||||
`ods.order_rep` хранит принятые версии в
|
||||
`ReplacingMergeTree(updated_at)`: ключ сортировки — `order_id`, партиция —
|
||||
`ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция —
|
||||
`toDate(created_at)`. `ods.order_dist` шардирует по
|
||||
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
|
||||
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
|
||||
@@ -233,11 +241,13 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
|
||||
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
|
||||
`ods.order_v` служит поверхностью точного текущего состояния для следующих
|
||||
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
|
||||
DDS. `snapshot_date` в нём остаётся датой наблюдения строки, а не ключом
|
||||
публикации. Полное решение — в
|
||||
[ADR 0010](../adr/0010-order-versions-in-ods.md) и
|
||||
[спецификации приёма заказов](orders/ingestion.md). Какой моделью заказ
|
||||
живёт дальше — [модель заказа в DDS](dds/order.md).
|
||||
DDS. `snapshot_date` в нём одновременно дата наблюдения и колонка версии. Она
|
||||
не доказывает полноту принятой порции, поэтому ODS не публикует партиции по
|
||||
дате слепка. Модель версионного ODS записана в
|
||||
[ADR 0010](../adr/0010-order-versions-in-ods.md), выбор колонки версии описан в
|
||||
[ADR 0014](../adr/0014-order-version-snapshot-date.md), пакетный приём описан в
|
||||
[спецификации приёма заказов](orders/ingestion.md). Контракт следующего слоя
|
||||
задан в [модели заказа в DDS](dds/order.md).
|
||||
|
||||
## Часовые пояса
|
||||
|
||||
@@ -286,7 +296,7 @@ UTC+4), и пересчёт идёт один раз при наполнении
|
||||
**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана
|
||||
генератором и лежит колонкой, так что суточные срезы группируются по ней и
|
||||
пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней
|
||||
расходится — [спека генератора](../specs/2026-08-01-generator.md), раздел 9.
|
||||
расходится, объясняет [ADR 0015](../adr/0015-counter-timezone.md).
|
||||
|
||||
## Путь реплицированных таблиц в keeper
|
||||
|
||||
@@ -480,9 +490,9 @@ kafka_offset)`: смотрят такую таблицу от класса, а
|
||||
административная операция, роль `etl` права на неё не получает.
|
||||
|
||||
Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт
|
||||
`Decimal(18, 2)`, `productPrice` события — целые рубли. Разрыв намеренный, на
|
||||
нём стоит урок про `Float64` ([описание
|
||||
выгрузки](../formats/clickstream-event.md)).
|
||||
`Decimal(18, 2)`, `productPrice` события — целые рубли. Поэтому точную сумму
|
||||
заказа берут из `purchaseRevenue`, а не складывают округленные цены товаров
|
||||
([описание выгрузки](../formats/clickstream-event.md)).
|
||||
|
||||
Устройство обоих объектов — почему такой движок, такая форма пути, такой
|
||||
пользователь и такое окно — расписано в
|
||||
@@ -518,8 +528,10 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
||||
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
|
||||
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
|
||||
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
|
||||
| `45-dds-tables.sql` | физическая модель заказа и таблица-двойник, донор её партиций |
|
||||
| `50-dds-views.sql` | публичные договоры события и заказа на языке модели DDS |
|
||||
| `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций |
|
||||
| `50-dds-views.sql` | публичные договоры сущностей DDS |
|
||||
| `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций |
|
||||
| `60-dm-views.sql` | публичные договоры витрин DM |
|
||||
|
||||
Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и
|
||||
правила порядка слоёв к ней не применяются.
|
||||
@@ -581,6 +593,22 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
||||
| `dds` | `dds.order_rep` / `_dist` | заказ в текущем состоянии, партиция — день заказа |
|
||||
| `dds` | `dds.order_stage_rep` / `_dist` | двойник заказа: в нём день собирается перед заменой партиции |
|
||||
| `dds` | `dds.order_v` | публичный договор заказа |
|
||||
| `dds` | `dds.session_rep` / `_dist` | визит, нарезанный слоем; партиция — день сессии |
|
||||
| `dds` | `dds.session_stage_rep` / `_dist` | двойник сессии: в нём день собирается перед заменой партиции |
|
||||
| `dds` | `dds.session_v` | публичный договор сессии |
|
||||
| `dds` | `dds.identity_map_rep` / `_dist` | известная пара куки с пользователем; таблицы полного пересчёта |
|
||||
| `dds` | `dds.identity_map_v` | точный публичный договор карты идентичностей с `FINAL` |
|
||||
| `dm` | `dm.revenue_daily_rep` / `_dist` | дневная выручка по категориям |
|
||||
| `dm` | `dm.revenue_daily_stage_rep` / `_dist` | таблица-двойник, донор дневных партиций выручки |
|
||||
| `dm` | `dm.revenue_daily_v` | публичный договор дневной выручки |
|
||||
| `dm` | `dm.purchase_vs_orders_rep` / `_dist` | сверка покупок с заказами, партиция — день заказа |
|
||||
| `dm` | `dm.purchase_vs_orders_stage_rep` / `_dist` | донор дневных партиций сверки |
|
||||
| `dm` | `dm.purchase_vs_orders_v` | публичный договор сверки с `NULL` для отсутствующей стороны |
|
||||
| `dm` | `dm.daily_traffic_rep` / `_dist` | версии дневных счетчиков трафика |
|
||||
| `dm` | `dm.daily_traffic_v` | точные текущие счетчики трафика с `FINAL` |
|
||||
| `dm` | `dm.dq_summary_rep` / `_dist` | текущее качество дня по атомарным проверкам |
|
||||
| `dm` | `dm.dq_summary_stage_rep` / `_dist` | общий донор дневных партиций DQ-сводки |
|
||||
| `dm` | `dm.dq_summary_v` | публичная сводка качества дней |
|
||||
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
|
||||
| `dic` | `dic.products` | словарь товаров поверх подложки |
|
||||
|
||||
@@ -588,10 +616,10 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
||||
`INSERT SELECT` одной задачи `parse_batch` из файла
|
||||
`sql/ods/order_parse.sql`.
|
||||
|
||||
В слое DDS построены событие и заказ, и устроены они намеренно по-разному:
|
||||
событие остаётся представлением над ODS, заказ получил физическую модель.
|
||||
Сессии, карту идентичностей и слой DM принесут следующие тикеты этапа. Их
|
||||
состав задан разделом 7 мастер-спеки и переносится сюда по мере постройки.
|
||||
В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие
|
||||
остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции;
|
||||
карта пишет полный набор пар в версионную таблицу. В DM выручка и сверка
|
||||
заменяют партиции изменяемых дней, а трафик пишет полную историю новой версией.
|
||||
|
||||
## Что проверено
|
||||
|
||||
@@ -664,8 +692,8 @@ DDL-словарь с файловым источником внутри `user_f
|
||||
порции из пяти строк (две версии одного заказа плюс по одной строке каждого
|
||||
класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с
|
||||
ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL`
|
||||
через распределённую таблицу вернул одну строку — ту, у которой `updated_at`
|
||||
позже, — тогда как физический счёт показывал две. Повтор задачи с тем же
|
||||
через распределённую таблицу вернул одну строку — ту, что несла позднейшую
|
||||
версию, — тогда как физический счёт показывал две. Повтор задачи с тем же
|
||||
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок
|
||||
записала тот же брак второй раз, как и обещано спецификацией приёма.
|
||||
|
||||
@@ -811,6 +839,11 @@ Kafka с пустым значением (ноль байт) и запись-н
|
||||
двойник разложила 1662 заказа строго по ключу: шард 1 получил только чётные
|
||||
`cityHash64(order_id)`, шард 2 — только нечётные.
|
||||
|
||||
**Проверка карты идентичностей 26 августа 2026 года (#153).** Context7
|
||||
подтвердил версию в `ReplacingMergeTree` и точное чтение через `FINAL`. Два
|
||||
полных прогона оставили 3180 физических строк и 1590 пар в `_v`; все пары
|
||||
подтверждены покупкой и заказом, а строк на чужом шарде нет.
|
||||
|
||||
Тем же днём измерены ещё три поведения ClickHouse.
|
||||
|
||||
- **Параметр в `ALTER … PARTITION` не подставляется.** Запрос с `{day:Date}` в
|
||||
@@ -829,6 +862,10 @@ Kafka с пустым значением (ноль байт) и запись-н
|
||||
промежуточного `toDecimal64`. Проверено на живом слепке; честность разбора
|
||||
сторожит инвариант бэкенда — сумма «цена × количество» совпала с `items_total`
|
||||
у всех 1662 заказов.
|
||||
- **`JSONExtract` читает `Decimal` и из числа JSON.** Массив
|
||||
`purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается
|
||||
сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на
|
||||
ClickHouse 26.3; все суммы после ODS совпали с сырьем до копейки.
|
||||
|
||||
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
|
||||
матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Superset: витрины глазами
|
||||
|
||||
Документ описывает зону BI: куда Superset ходит за данными и за своими
|
||||
метаданными, из чего собран стендовый дашборд, как снять свежий экспорт после
|
||||
правки в интерфейсе и почему правка руками живет только до перезапуска.
|
||||
|
||||
Состав сюжетов и колонок витрин здесь не повторяется: он живет в
|
||||
[спеке](../specs/2026-07-30-stand-v2-realism.md), раздел 7, и в
|
||||
[доке слоя DM](dm.md).
|
||||
|
||||
## Устройство зоны
|
||||
|
||||
Superset читает ClickHouse пользователем `bi` **на ноде 2** — так задано
|
||||
подключением `ClickHouse` (`clickhousedb://bi@clickhouse-02:8123`). Нода 2
|
||||
здесь — ловушка правильных ошибок из
|
||||
[спеки](../specs/2026-07-30-stand-v2-realism.md), раздел «Роли нод»: DDL идет
|
||||
с ноды 1, и забытый `ON CLUSTER` виден в дашборде.
|
||||
|
||||
Пароля в подключении нет. Его отдает штатное хранилище паролей Superset
|
||||
(`SQLALCHEMY_CUSTOM_PASSWORD_STORE` в `infra/superset/superset_config.py`),
|
||||
которое читает `CLICKHOUSE_BI_PASSWORD` из окружения. Поэтому файл подключения
|
||||
безопасно лежит в git, и экспорт из интерфейса не должен возвращать пароль
|
||||
обратно в YAML.
|
||||
|
||||
Свои метаданные — подключения, датасеты, графики, дашборды, пользователи —
|
||||
Superset держит в базе `superset` служебного `postgres-metadata`, рядом с базой
|
||||
Airflow. В ClickHouse он не пишет ничего.
|
||||
|
||||
## Дерево импорта
|
||||
|
||||
`infra/superset/import/` — это дашборд стенда в виде файлов. Каталог
|
||||
смонтирован в контейнер как `/app/import`.
|
||||
|
||||
| Файл | Что описывает |
|
||||
|---|---|
|
||||
| `metadata.yaml` | версия формата, `1.0.0`; больше в нем ничего нет |
|
||||
| `databases/clickhouse.yaml` | подключение к ClickHouse: URI без пароля и постоянный uuid |
|
||||
| `datasets/ClickHouse/*.yaml` | три витрины: `revenue_daily_v`, `purchase_vs_orders_v`, `daily_traffic_v` |
|
||||
| `charts/*.yaml` | три графика: столбики выручки, сводная таблица сверки, линии трафика |
|
||||
| `dashboards/business_stories.yaml` | дашборд «Бизнес-сюжеты» и раскладка панелей |
|
||||
|
||||
Связаны файлы не путями, а uuid. Датасет называет `database_uuid`, график —
|
||||
`dataset_uuid`, а дашборд не называет графики вовсе: единственная связь с ними
|
||||
лежит в его `position`, где узел `CHART-*` несет `meta.uuid` нужного графика.
|
||||
График, которого нет в `position`, импортируется молча и не появляется нигде.
|
||||
|
||||
Uuid у стенда розданы рукой: общее начало
|
||||
`19600000-0000-4000-8000-`, дальше номер объекта в последнем блоке — `1`–`3`
|
||||
у датасетов, `101`–`103` у графиков, `201` у дашборда
|
||||
(`…-000000000101`). Экспорт из интерфейса приносит свои, сгенерированные;
|
||||
их заменяют на эти, иначе повторный импорт заведет вторую копию объекта вместо
|
||||
обновления первой.
|
||||
|
||||
Датасет несет русские имена колонок, формат денег и метрику — а график только
|
||||
ссылается на них по имени. Значит, подпись или формат правятся в одном месте, а
|
||||
не в каждом графике.
|
||||
|
||||
Датасеты объявлены физическими (`schema: dm`, `sql: null`), хотя за именем стоит
|
||||
представление ClickHouse. Импорт в источник не ходит и разницы между таблицей и
|
||||
представлением не знает, а виртуальный датасет со своим SQL просто повторил бы
|
||||
запрос, который уже живет в `sql/ddl/60-dm-views.sql`.
|
||||
|
||||
## Импорт при каждой инициализации
|
||||
|
||||
Последняя строка `infra/superset/init.sh`:
|
||||
|
||||
```
|
||||
superset import-directory /app/import --overwrite
|
||||
```
|
||||
|
||||
Служба `superset-init` отрабатывает перед подъемом самого Superset, то есть при
|
||||
каждом `make up`. Без `--overwrite` второй прогон падал бы на первом же
|
||||
знакомом uuid; с ним объект обновляется на месте, и повторная инициализация
|
||||
проходит без ошибок.
|
||||
|
||||
Отсюда правило зоны: **стендовый дашборд не расширяют руками в интерфейсе.**
|
||||
Правка живет до следующей инициализации, а потом молча заменяется тем, что
|
||||
лежит в git. Пробовать интерфейс менти никто не мешает — но все, что должно
|
||||
пережить перезапуск, приезжает файлом.
|
||||
|
||||
Это правило относится только к стендовым объектам. Свои датасеты, графики и
|
||||
дашборды менти живут в той же базе метаданных Superset, а импорт трогает
|
||||
только объекты с известными ему uuid. Поэтому свои объекты переживают
|
||||
`make up` и `make rebuild-storage`. Их стирает только `make clean` или
|
||||
адресный сброс базы метаданных по рецепту ниже.
|
||||
|
||||
## Как снять свежий экспорт
|
||||
|
||||
Маршрут один: правишь на живом стенде, забираешь экспорт, кладешь дерево в git.
|
||||
|
||||
1. Собрать или поправить график и дашборд в интерфейсе — на стенде, а не в
|
||||
файлах. Ключи `params` графиков велики, недокументированы и меняются от
|
||||
версии к версии; неверный ключ не ошибается, а рисует пустоту. Интерфейс
|
||||
здесь единственный надежный оракул.
|
||||
2. Снять экспорт и достать его из контейнера:
|
||||
|
||||
```
|
||||
docker compose exec superset superset export-dashboards -f /tmp/export.zip
|
||||
docker compose cp superset:/tmp/export.zip /tmp/export.zip
|
||||
unzip -d /tmp/superset-export /tmp/export.zip
|
||||
```
|
||||
|
||||
Внутри архива — один каталог `dashboard_export_<дата>` ровно той же формы,
|
||||
что и дерево импорта. Команда выгружает все дашборды сразу; на стенде он
|
||||
один.
|
||||
3. Вычистить экспортный сор перед коммитом:
|
||||
- выбросить экспортный `databases/` — наш `clickhouse.yaml` остается как
|
||||
есть, с постоянным uuid и без пароля;
|
||||
- оставить в `metadata.yaml` одну строку `version: 1.0.0`;
|
||||
- убрать из графиков `params.datasource`, `params.slice_id` и
|
||||
`params.dashboards` — это идентификаторы строк того Superset, где
|
||||
снимали, а импорт выводит связи из uuid;
|
||||
- убрать `query_context` — снимок запроса оттуда же. Импорт его не
|
||||
требует и обратно не заполняет: после переимпорта он остается пустым, на
|
||||
отрисовку это не влияет;
|
||||
- перезаписать русские строки читаемыми. Экспорт кодирует их
|
||||
`\uXXXX`-последовательностями, и после каждого круга их приходится
|
||||
раскодировать заново: имена графиков и подписи колонок читает менти, и в
|
||||
файле, и в диффе. Годится любой повторный дамп YAML с
|
||||
`allow_unicode=True`;
|
||||
- вернуть постоянные uuid из схемы выше.
|
||||
4. Скопировать вычищенное дерево в `infra/superset/import/` и проверить
|
||||
импортом **на пустых метаданных** — иначе прогон проверит только обновление
|
||||
уже стоящих объектов, а молчаливый пропуск нового спрячется за старым.
|
||||
Активы Superset лежат в базе `superset` службы `postgres-metadata`, поэтому
|
||||
адресный сброс — эта база:
|
||||
|
||||
```
|
||||
docker compose stop superset
|
||||
docker compose exec postgres-metadata sh -c \
|
||||
'psql -U "$POSTGRES_USER" -d postgres \
|
||||
-c "DROP DATABASE superset WITH (FORCE)" \
|
||||
-c "CREATE DATABASE superset OWNER \"$SUPERSET_METADATA_USER\""'
|
||||
docker compose run --rm superset-init
|
||||
docker compose up -d superset
|
||||
```
|
||||
|
||||
Базу приходится создавать тут же: скрипт `infra/postgres/init/` заводит ее
|
||||
один раз, при первой инициализации тома Postgres. `make rebuild-storage`
|
||||
для этой проверки не годится — он гоняет `world_recreate` в ClickHouse и
|
||||
метаданных Superset не касается. `make clean` сбросит и их, но заодно снесет
|
||||
весь стенд, включая метаданные Airflow в той же службе.
|
||||
|
||||
После импорта дашборд смотрят глазами: это единственное доказательство —
|
||||
см. ловушку ниже.
|
||||
|
||||
## Ловушки
|
||||
|
||||
**Импорт молчит.** График, чей `dataset_uuid` не разрешился, отбрасывается без
|
||||
единого слова; дашборд, у которого не сошлись ссылки на графики,
|
||||
пропускается. Ошибки уходят в журнал, и `init.sh`
|
||||
завершается нулем в любом случае. Поэтому импорт проверяют глазами или
|
||||
запросом к API — но не тем, что команда отработала.
|
||||
|
||||
**`--overwrite` синхронизирует датасет разрушительно.** Колонки и метрики
|
||||
берутся из YAML целиком: добавленная в интерфейсе метрика или подпись колонки
|
||||
исчезнет при следующем старте контейнера. Правка датасета — это правка файла.
|
||||
|
||||
## Что проверяет стенд
|
||||
|
||||
`make check-services` входит в Superset, находит подключение `ClickHouse`,
|
||||
сверяет его uuid и безопасный URI и требует схему `dm` — то есть подтверждает
|
||||
живое подключение и хранилище пароля. Сами графики и дашборд ни одна цель не
|
||||
проверяет: их смотрят глазами после переинициализации. Полная карта целей — в
|
||||
[доке проверок](testing.md).
|
||||
+132
-10
@@ -34,20 +34,21 @@
|
||||
спрашивают его отдельно.
|
||||
|
||||
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
|
||||
`make config-test`, `make smoke` и `make check-services` перемерены 21 августа
|
||||
2026 года, `make check-clickhouse` — 19 августа, корневой `make lint` —
|
||||
9 августа, `make test` — 7 августа; остальные стоят с замера 6 августа.
|
||||
`make smoke` и `make check-services` перемерены 28 августа 2026 года,
|
||||
`make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
|
||||
корневой `make lint` — 9 августа, `make test` — 30 августа; остальные стоят с
|
||||
замера 6 августа.
|
||||
|
||||
| Цель | Откуда | Что утверждает | Стенд | Цена |
|
||||
|---|---|---|---|---|
|
||||
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
||||
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 17 с |
|
||||
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
|
||||
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с |
|
||||
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 93 с |
|
||||
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
|
||||
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
||||
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 71 с |
|
||||
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
|
||||
|
||||
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
|
||||
разными дверями охватывают разное: корневая не видит ни одного файла
|
||||
@@ -120,10 +121,11 @@ ClickHouse отвечает сразу.
|
||||
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
|
||||
дорогая. Правки дагов и Superset — корневой `make lint`.
|
||||
|
||||
Тринадцать секунд из её семидесяти одной — пересборка восьми модельных дней
|
||||
для сверки с описью мира. Дешевле хеши не сравнить: чтобы узнать, тот ли
|
||||
получается мир, его надо получить. Место выбрано по той же оси «кого
|
||||
спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и
|
||||
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
|
||||
для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
|
||||
узнать, тот ли получается мир, его надо получить. Место выбрано по той же
|
||||
оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не
|
||||
нужен, — и
|
||||
краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
|
||||
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
|
||||
поломкой хранилища, а не забытой пересборкой.
|
||||
@@ -237,6 +239,126 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
||||
|
||||
## Что проверено
|
||||
|
||||
**Калибровка заказной стороны 30 августа 2026 года при исполнении #194.**
|
||||
Пересборки эталонного мира не будет (#192), поэтому доли заказной стороны
|
||||
сверены замером внутренностей генератора — окно 28 модельных дней,
|
||||
канонический seed, без транспорта, — тем же приемом, каким `test_plan.py` и
|
||||
`test_commerce.py` меряют мир. Команда (из `generator/`):
|
||||
|
||||
```bash
|
||||
uv run python - <<'PY'
|
||||
from collections import Counter
|
||||
from clickstream_generator import day, inventory, orders, world
|
||||
from clickstream_generator.seeds import CANONICAL_SEED
|
||||
|
||||
WINDOW = 28
|
||||
class_totals: Counter[str] = Counter()
|
||||
outcome_totals: Counter[int] = Counter()
|
||||
delivery_totals: Counter[int] = Counter()
|
||||
delay_totals: Counter[int] = Counter()
|
||||
total_orders = 0
|
||||
|
||||
for d in range(WINDOW):
|
||||
today = day.stream(CANONICAL_SEED, d)
|
||||
for key, value in inventory._order_class_counts(today).items():
|
||||
class_totals[key] += value
|
||||
money = today.orders
|
||||
total_orders += len(money)
|
||||
for value in money.outcome.tolist():
|
||||
outcome_totals[value] += 1
|
||||
for value in money.delivery.tolist():
|
||||
delivery_totals[value] += 1
|
||||
for value in money.snapshot_delay.tolist():
|
||||
delay_totals[value] += 1
|
||||
|
||||
print(total_orders, dict(class_totals), dict(outcome_totals),
|
||||
dict(delivery_totals), dict(delay_totals))
|
||||
PY
|
||||
```
|
||||
|
||||
6973 заказов за 28 дней. Доли из таблицы сверки (после приоритета классов):
|
||||
отменено 5,15% (ориентир мастер-спеки ~5%, в пределах; из них 3,03%
|
||||
оплачен-потом-отменен и 2,12% отменен неоплаченным — веса `world.py`
|
||||
95/3/2); дубль `purchase` 1,89% (ориентир ~2%, в пределах); дельта суммы
|
||||
1,32% (ориентир 1–2%, в пределах); потеря `purchase` 1,78% при броске 3% —
|
||||
вычеты штатные: назначенные планом покупки из броска исключены (мост
|
||||
склейки), часть случаев уходит в отмену приоритетом; наблюдаемая доля
|
||||
принята решением владельца (#194). Стоимость доставки — 35,5% / 49,2%
|
||||
/ 15,4%, совпадает с весами `DELIVERY_KOPECKS_WEIGHTS`. Опоздание слепков
|
||||
(`ORDER_SNAPSHOT_DELAY_WEIGHTS`) — доля заказов с δ>0 составила 8,39%
|
||||
(ориентир мастер-спеки ~10%, в пределах). Скрипт замера в репозиторий не
|
||||
входит; вывод — в отчете issue #194.
|
||||
|
||||
**Перезамер 30 августа 2026 года при исполнении #192.** Команды запускались
|
||||
из `generator/`:
|
||||
|
||||
```bash
|
||||
time make inventory
|
||||
uv run python -m clickstream_generator batch --day 0 --days 8 \
|
||||
--file /tmp/stage7-events.jsonl
|
||||
for snapshot_day in 1 2 3 4 5 6 7; do
|
||||
uv run python -m clickstream_generator snapshot --day "$snapshot_day" \
|
||||
--file "/tmp/stage7-snapshot-${snapshot_day}.jsonl"
|
||||
done
|
||||
wc -c /tmp/stage7-events.jsonl /tmp/stage7-snapshot-{1,2,3,4,5,6,7}.jsonl
|
||||
time make test
|
||||
```
|
||||
|
||||
Опись пересобралась за 20,515 с. Файловый приемник записал 460 731 812 байт:
|
||||
458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0
|
||||
за 176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов
|
||||
стало 437 против 407, и потяжелел сам мир — та же пересборка восьми дней
|
||||
стоила 13 с, теперь 20,5. Эта правка добавила один тест — проигрыш дня.
|
||||
|
||||
**Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
|
||||
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
|
||||
30 секунд почти целиком уходил на запуск второго приложения. Увеличивать его
|
||||
не стали: Superset не обещает постоянного времени `create_app()`.
|
||||
|
||||
После входа проверка делает три GET-запроса к уже работающему Superset: находит
|
||||
`ClickHouse`, сверяет UUID и безопасный URI, затем требует схему `dm`. Так без
|
||||
второго запуска проверяются сохраненное подключение и хранилище пароля. Форму
|
||||
API сверили через Context7 и с исходным кодом Superset 6.1.0. `superset test-db`
|
||||
отклонен, потому что тоже запускает приложение; прямой SQL к `dbs` не проверяет
|
||||
соединение.
|
||||
|
||||
Проверка больше не подтверждает, что сам Superset использует Postgres для
|
||||
метаданных. Короткий запрос к Postgres показывает лишь готовность базы, а REST
|
||||
API - подключение к ClickHouse. Для `check-services` этого достаточно.
|
||||
|
||||
После перезапуска Superset три прогона прошли 7/7 за 71, 67 и 70 секунд.
|
||||
`make config-test` прошел. `make smoke` дал 21/21 за 25 секунд; его код не
|
||||
менялся, прежний замер был 17 секунд. Неверный UUID дал 5/1: красной стала
|
||||
только проверка метаданных, пароль в вывод не попал.
|
||||
|
||||
**Приемка #156 27 августа 2026 года.** `make rebuild-storage` занял 7 минут.
|
||||
На чистом прикладном мире `world_initialize` сам довел позицию до `8`, дождался
|
||||
`etl_pipeline` и оставил заполненными DDS и обе витрины. После пересборки прошли
|
||||
`make smoke` 21/21 и `make check-clickhouse` 10/10. Перед стендовыми сценариями
|
||||
прошли `make config-test` и `make lint`.
|
||||
|
||||
Два прогона `world_next_day` не изменили слои; один ручной `etl_pipeline` без
|
||||
параметров догнал оба дня. Полный прогон `world_live_day` занял 27 м 2 с и
|
||||
завершился только после успешных DDS и DM.
|
||||
|
||||
Временная синтаксическая поломка `sql/dm/daily_traffic_rebuild.sql` дала
|
||||
красную цепочку: DDS завершился успешно, а `daily_traffic`, DM, `etl_pipeline`
|
||||
и `world_live_day` покраснели. После возврата исправного SQL ручной
|
||||
`etl_pipeline` без параметров завершился успешно на позиции `12` и восстановил
|
||||
слои.
|
||||
|
||||
Для проверки пакетного барьера `stg.hits_raw_mv` временно отсоединили командой
|
||||
`DETACH` на обеих нодах. После `world_next_day` отставание Kafka составило
|
||||
46 367 (19 128 + 27 239). Во время ожидания матвью вернули командой `ATTACH`.
|
||||
Журнал готовности записал повторные опросы с отставанием 46 367, затем 27 239
|
||||
и 0; после этого очередь `ods.event_dist` содержала 0 файлов на обеих нодах,
|
||||
и ETL завершился успешно. Проверка `EXISTS` снова дала `1` на обеих нодах,
|
||||
позиция равна `13`.
|
||||
|
||||
После пересборки `make check-services` дважды дал 5/1: только импорт Superset
|
||||
не уложился в существующий тайм-аут 30 с. Диагностический пробник импорта с
|
||||
ожиданием 90 с прошел 3/0. Финальный штатный повтор прошел 7/7 за 129 с.
|
||||
|
||||
**Перезамер 21 августа 2026 года при исполнении #124.** Образ Airflow собран с
|
||||
`apache-airflow-providers-clickhousedb==1.0.0`,
|
||||
`apache-airflow-providers-common-sql==2.0.1` и
|
||||
|
||||
@@ -11,10 +11,12 @@
|
||||
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
|
||||
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
|
||||
|
||||
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
|
||||
нормализует у себя: то же имя в нашем стиле ждёт в столбце «Нормализованное
|
||||
имя». Это имя источника, приведённое к snake_case, а не имя атрибута в
|
||||
модели данных: слой DDS складывает свою модель и называет атрибуты по ней.
|
||||
Имена и типы колонок описывают сторону источника. Обычно ODS принимает их без
|
||||
изменений. Исключение — `purchaseRevenue`: источник объявляет Float64, а ODS
|
||||
хранит деньги в Decimal. Точный локальный тип назван в документации
|
||||
хранилища. Имя в нашем стиле ждет в столбце «Нормализованное имя». Это имя
|
||||
источника, приведенное к snake_case, а не имя атрибута в модели данных: слой
|
||||
DDS складывает свою модель и называет атрибуты по ней.
|
||||
Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора;
|
||||
у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан
|
||||
контрактом.
|
||||
@@ -90,7 +92,7 @@
|
||||
| № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий |
|
||||
|---|---|---|---|---|---|
|
||||
| 37 | `purchaseID` | `Array(String)` | `object` | `purchase_id` | номер заказа; у события purchase — один элемент |
|
||||
| 38 | `purchaseRevenue` | `Array(Float64)` | `float64` | `purchase_revenue` | выручка заказа глазами клиента; Float64, как у Метрики — на этом держится урок о расхождениях с бэкендом |
|
||||
| 38 | `purchaseRevenue` | `Array(Float64)` | `float64` | `purchase_revenue` | выручка заказа глазами клиента; источник объявляет Float64, ODS хранит точный Decimal |
|
||||
| 39 | `purchaseCurrency` | `Array(String)` | `object` | `purchase_currency` | валюта заказа |
|
||||
| 40 | `purchaseCoupon` | `Array(String)` | `object` | `purchase_coupon` | купон заказа, если был применён |
|
||||
| 41 | `productID` | `Array(String)` | `object` | `product_id` | id товаров события |
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Учебные задания: витрины слоя DM и дашборд в Superset
|
||||
|
||||
Пять домашних заданий — про витрины слоя DM. Стенд привозит слой
|
||||
наполовину: витрины трех сюжетов дашборда и контроль качества построены,
|
||||
остальные витрины строит менти. Каждое из них — самостоятельное ТЗ: что за
|
||||
витрина, какое у нее зерно, из чего она собирается и как проверить, что
|
||||
собралась верно.
|
||||
|
||||
Шестое задание — необязательная лаба в Superset. В ней менти собирает свой
|
||||
дашборд поверх пяти витрин.
|
||||
|
||||
Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на
|
||||
вопросы «почему так», задание отвечает на вопрос «что сделать».
|
||||
|
||||
## Порядок
|
||||
|
||||
Задания идут по нарастанию: каждое следующее опирается на то, что уже
|
||||
усвоено.
|
||||
|
||||
| № | Результат | Чему учит |
|
||||
|---|---|---|
|
||||
| 1 | [`events_enriched_v`](events-enriched.md) | слой — это контракт, а не копия данных |
|
||||
| 2 | [`top_pages_daily`](top-pages-daily.md) | зерно сменилось — витрина материализуется и встает в конвейер |
|
||||
| 3 | [`session_overview`](session-overview.md) | зерно решает, на какие вопросы витрина уже не ответит |
|
||||
| 4 | [`dq_errors_daily`](dq-errors-daily.md) | у брака свой день и свой срок жизни |
|
||||
| 5 | [`utm_effectiveness`](utm-effectiveness.md) | `declared_` в имени — предупреждение, а не украшение |
|
||||
| 6 | [Свой дашборд в Superset](superset-dashboard.md) — необязательная лаба | свои объекты BI живут отдельно от стендовых |
|
||||
|
||||
## Общие правила
|
||||
|
||||
Они одни для пяти заданий по витринам, поэтому записаны здесь, а не
|
||||
повторяются в каждом ТЗ.
|
||||
|
||||
**Читать — через `_v`.** Публичный договор сущности DDS — представление с
|
||||
суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только
|
||||
его; физические `_rep` и `_dist` служат загрузке и обслуживанию. Соседнюю
|
||||
домашку витрина источником тоже не берет: витрина слоя DM читает договор
|
||||
DDS, и задания остаются независимыми. Исключение одно и оно оговорено в
|
||||
задании 4: у таблиц брака публичного договора нет.
|
||||
|
||||
**Писать — только в `_dist`.** При записи в локальную таблицу раскладку
|
||||
определяет то, какая нода случайно выполняла код, а не свойство данных.
|
||||
Правило и его следствия — [дока хранилища](../architecture/storage.md),
|
||||
«Раскладка по шардам».
|
||||
|
||||
**Форма — по зерну.** Витрина, меняющая зерно источника, материализуется:
|
||||
`dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` / `_stage_dist` и
|
||||
публичное `dm.<имя>_v` с явным списком колонок. Витрина, сохраняющая зерно,
|
||||
остается обычным представлением. Довод — [дока DM](../architecture/dm.md),
|
||||
«Форма: по зерну, не по цене запроса».
|
||||
|
||||
**Ключ шардирования — твое решение.** Ни одно ТЗ ключа не называет.
|
||||
Правило выбора и требование к донору — [дока
|
||||
хранилища](../architecture/storage.md), «Раскладка по шардам»: донор
|
||||
объявляется через `AS <цель>_rep` и шардируется тем же ключом, что цель,
|
||||
иначе замена партиции потеряет строку на одном шарде и задвоит на другом.
|
||||
Названный ключ надо уметь защитить: почему он не дает перекоса и почему
|
||||
более сложный тут ничего не добавляет. Образец такой защиты —
|
||||
`dm.revenue_daily_dist` в [`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
|
||||
|
||||
**Где лежит код.** DDL домашек — свой файл `sql/ddl/70-labs.sql`: он
|
||||
применяется после стендовых по порядку имени, и твои объекты не мешаются
|
||||
со стендовыми. У агрегатов рядом со стендовыми ложатся еще три запроса —
|
||||
`sql/dm/<витрина>_scope.sql` спрашивает у хранилища, какие дни пересобирает
|
||||
запуск, `_rebuild.sql` собирает их в донор, `_replace.sql` заменяет
|
||||
партицию, — и задача в
|
||||
[`dags/dm_transform.py`](../../dags/dm_transform.py). У разминочного
|
||||
представления нет ничего из этого: весь его код — одно `CREATE VIEW`.
|
||||
|
||||
**Образец на все случаи** — витрина `revenue_daily`: у нее есть все пять
|
||||
объектов, донор, сборка, замена дневных партиций и задача в конвейере.
|
||||
Списывать с нее устройство можно и нужно; списывать состав колонок — нет,
|
||||
он у каждой витрины свой.
|
||||
|
||||
## Самопроверка
|
||||
|
||||
Даг `homework_check` запускается рукой, расписания у него нет. Задача
|
||||
витрины красная, пока витрины нет или счет не сходится, и зеленая, когда
|
||||
собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет:
|
||||
это инструмент лабы, а не история качества стенда.
|
||||
|
||||
Проверка пяти заданий по витринам устроена одинаково и золотых констант не
|
||||
содержит: рядом с витриной независимым запросом собирается тот же ответ —
|
||||
прямо из источника витрины, минуя ее саму. У четырех заданий источник эталона
|
||||
тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания 4 это
|
||||
таблицы брака ODS, потому что в DDS у брака представительства нет. Обе стороны
|
||||
соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число строк,
|
||||
где стороны разошлись или одной стороны нет. Ноль — зеленый.
|
||||
Полное соединение здесь обязательно: при обычном пропавший день или
|
||||
пропавший ключ просто не попал бы в результат и остался бы незамечен.
|
||||
|
||||
Раздел «Как проверить себя» каждого ТЗ по витрине называет деловой ключ и
|
||||
сравниваемые величины. Тот же прием в стендовом исполнении — [даг
|
||||
`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
|
||||
|
||||
**Предупреждение.** Запросы самой проверки лежат в `sql/dq/` открыто, и
|
||||
имена называют витрину и роль сверки. Проверка на инвариантах неизбежно
|
||||
близка к ответу: чтобы сверить витрину, надо ее пересчитать. Прятать эти
|
||||
файлы никто не станет — но открыв их до того, как задание сделано, ты
|
||||
обменяешь урок на готовый SQL.
|
||||
Смотреть туда стоит после своей сборки: сравнить два решения полезно,
|
||||
списать одно — нет.
|
||||
|
||||
## Уборка
|
||||
|
||||
Черновики и опыты за собой сносить: правило проверок стенда —
|
||||
[дока тестирования](../architecture/testing.md). Снесенная витрина обязана
|
||||
снова покраснить свою задачу в `homework_check` — если не покраснила,
|
||||
проверка ничего и не проверяла.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Задание 4: `dm.dq_errors_daily`
|
||||
|
||||
**Чему учит:** у брака свой день и свой срок жизни. Витрина качества живет
|
||||
по времени загрузки, а не по модельному дню, и переживает собственный
|
||||
источник — поэтому «пересобрать все» здесь означает «стереть историю».
|
||||
|
||||
## Роль витрины
|
||||
|
||||
Сводка приемки: сколько строк за день не прошли разбор и по какой причине.
|
||||
Читают ее, когда качество потока просело: сначала смотрят, вырос ли класс
|
||||
брака, потом идут за сырым текстом в саму таблицу ошибок.
|
||||
|
||||
Витрина отвечает на вопрос «сколько и чего», а не «что именно сломалось».
|
||||
Разбирательство с конкретной строкой начинается в `ods.event_errors_dist` и
|
||||
`ods.order_errors_dist`, где рядом с классом лежат сырой текст и координаты
|
||||
доставки.
|
||||
|
||||
## Источник: единственное исключение из правила `_v`
|
||||
|
||||
Общее правило домашек — читать через публичный договор `_v`. У таблиц брака
|
||||
такого договора нет, и заводить его ради этой витрины не надо: брак — не
|
||||
сущность бизнес-модели, а диагностика слоя приема. Читать нужно
|
||||
распределенные таблицы напрямую:
|
||||
|
||||
- `ods.event_errors_dist` — брак разбора событий;
|
||||
- `ods.order_errors_dist` — брак разбора слепка заказов.
|
||||
|
||||
Именно `_dist`, а не `_rep`: локальная таблица вернет строки одного шарда, и
|
||||
витрина тихо покажет половину брака. Ошибки при этом не будет — ошибка
|
||||
появится позже, когда по витрине примут решение.
|
||||
|
||||
## Зерно и форма
|
||||
|
||||
Зерно — **день загрузки × источник × класс брака**. Зерно источника —
|
||||
строка брака, значит зерно сменилось и витрина материализуется:
|
||||
`dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
|
||||
публичное `dm.dq_errors_daily_v`. Ключ партиции — день загрузки.
|
||||
|
||||
День здесь — `toDate(_load_ts)`, то есть настоящее время, когда строка
|
||||
приехала в хранилище, а не модельный день мира. Модельного дня у брака нет
|
||||
и быть не может: он живет в содержимом, а содержимое как раз и не
|
||||
разобралось.
|
||||
|
||||
## Состав колонок
|
||||
|
||||
| Колонка | Тип | Что это |
|
||||
|---|---|---|
|
||||
| `load_date` | `Date` | день загрузки, в который приехал брак |
|
||||
| `source` | `LowCardinality(String)` | `event` или `order` |
|
||||
| `error_class` | `LowCardinality(String)` | класс брака из таблицы ошибок |
|
||||
| `error_rows` | `UInt64` | число забракованных строк |
|
||||
| `_load_id` | `String` | запуск, собравший день |
|
||||
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||
|
||||
Две похожие колонки рядом различаются намеренно: `load_date` — про день,
|
||||
когда приехал брак, `_load_ts` — про запуск, который собрал витрину. Первая
|
||||
деловая, вторая служебная, и совпадать они не обязаны.
|
||||
|
||||
Классы у двух источников свои и не пересекаются по смыслу, поэтому колонка
|
||||
`source` в зерне обязательна: без нее одинаковое имя класса у событий и у
|
||||
заказов слилось бы в одну строку.
|
||||
|
||||
## Ритм пересборки: витрина переживает источник
|
||||
|
||||
Таблицы брака живут месяц (`TTL`), сырье — трое суток. Витрина живет без
|
||||
срока: сводка «сколько брака было в марте» нужна и после того, как сами
|
||||
строки марта удалены.
|
||||
|
||||
Отсюда правило прогона: пересобираются **только те дни загрузки, которые
|
||||
еще есть в источнике**. Спроси у источника список живых дней и замени
|
||||
партиции ровно этих дней. Соблазн «собрать все заново в донор и подменить
|
||||
таблицу целиком» здесь стоит дороже обычного: истекшие по `TTL` дни исчезли
|
||||
бы из витрины навсегда, а вместе с ними — единственная оставшаяся память о
|
||||
них.
|
||||
|
||||
Текущий день загрузки не замерзает до конца суток: поток событий идет, и
|
||||
брак в него еще добавится. Это нормально, день просто пересоберется на
|
||||
следующем прогоне.
|
||||
|
||||
## Ключ шардирования
|
||||
|
||||
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||
шардам». Витрина крошечная: классов брака у событий и заказов вместе меньше
|
||||
десятка. Учти это в защите ключа — иногда правильный ответ звучит как
|
||||
«перекос здесь ничего не стоит, и вот почему».
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — пара `source` и `error_class` внутри `load_date`. Область
|
||||
проверки — дни загрузки, которые еще живы хотя бы в одной таблице ошибок:
|
||||
день за границей `TTL` сверять не с чем, и его отсутствие в источнике не
|
||||
ошибка.
|
||||
|
||||
Собери прямой счет по обеим таблицам ошибок, соедини с
|
||||
`dm.dq_errors_daily_v` полным внешним соединением по ключу и посчитай
|
||||
строки, где ключа нет с одной из сторон или где расходится `error_rows`.
|
||||
Ноль — зеленый.
|
||||
|
||||
Отдельно: сумма `error_rows` по живым дням равна общему числу строк в
|
||||
`ods.event_errors_dist` плюс `ods.order_errors_dist`. Если эта сумма вышла
|
||||
примерно вдвое меньше — витрина почти наверняка собрана с `_rep` вместо
|
||||
`_dist` и видит один шард из двух.
|
||||
|
||||
**Пустая витрина — верный ответ.** На чистом стенде брака нет, обе стороны
|
||||
пусты, расхождений ноль, задача зеленая. Проверка, которая требует хотя бы
|
||||
одной строки, на здоровом стенде краснела бы всегда и приучала бы себя
|
||||
игнорировать.
|
||||
|
||||
## Приемка
|
||||
|
||||
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||
ключом цели;
|
||||
- [ ] источники — `_dist`-таблицы обоих видов брака;
|
||||
- [ ] прогон заменяет партиции только живых дней источника и не трогает
|
||||
истекшие;
|
||||
- [ ] задача `dq_errors_daily` в `homework_check` зеленая — в том числе на
|
||||
чистом стенде, где брака нет;
|
||||
- [ ] задача витрины добавлена в `dm_transform`.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Задание 1: `dm.events_enriched_v`
|
||||
|
||||
**Чему учит:** слой — это контракт, а не копия данных. Витрина, которая не
|
||||
меняет зерно источника, остается представлением, и копии событий ради
|
||||
других имен колонок не заводит ни один боевой склад.
|
||||
|
||||
Это разминка. Строк она не хранит, в конвейер не встает, ключа шардирования
|
||||
у нее нет. Задача одна: собрать плоское событие для BI и не поддаться
|
||||
соблазну разложить его на диск.
|
||||
|
||||
## Роль витрины
|
||||
|
||||
Договор события в DDS широкий — 44 колонки, почти весь формат выгрузки:
|
||||
часть из них технические координаты источника, часть — параллельные массивы
|
||||
товаров. Аналитику в Superset нужен плоский набор деловых полей и пара
|
||||
производных, которые иначе каждый пишет заново в каждом запросе.
|
||||
|
||||
Витрина отвечает на вопрос «что вообще происходило»: с нее начинают, когда
|
||||
надо посмотреть на события глазами человека, а не разбирать широкую строку
|
||||
источника.
|
||||
|
||||
Источником для витрин следующих заданий она не служит. Витрина слоя DM
|
||||
читает публичный договор DDS — так устроены и стендовые витрины, и потому
|
||||
задания независимы: соседнюю домашку можно делать первой, а можно не делать
|
||||
вовсе.
|
||||
|
||||
## Зерно и форма
|
||||
|
||||
Зерно — **событие**: одна строка витрины равна одной строке `dds.event_v`.
|
||||
Зерно источника сохранено, значит форма — обычное представление
|
||||
`CREATE VIEW dm.events_enriched_v`, без физической таблицы, без донора и
|
||||
без задачи в `dm_transform`. Пересобирать нечего.
|
||||
|
||||
Источник — `dds.event_v`, и только он.
|
||||
|
||||
## Состав колонок
|
||||
|
||||
Явный список обязателен: договор не должен расширяться сам, когда в
|
||||
источник добавят колонку.
|
||||
|
||||
**Время и посетитель:** `event_date`, `event_time`, `event_hour`,
|
||||
`client_id`, `event_id`.
|
||||
|
||||
**Действие:** `event_type` (`pageview`, `add_to_cart`, `purchase`), `url`,
|
||||
`page_path`, `referer`, `referer_host`, `title`.
|
||||
|
||||
**Атрибуция:** `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`,
|
||||
`utm_term`.
|
||||
|
||||
**Паспорт визита:** `device_category`, `browser`, `operating_system`,
|
||||
`region_country`, `region_city`.
|
||||
|
||||
**Торговый блок, массивами как в источнике:** `product_id`, `product_name`,
|
||||
`product_category`, `product_price`, `product_quantity`,
|
||||
`product_event_type`, `purchase_id`, `purchase_revenue`, `purchase_coupon`.
|
||||
|
||||
Три колонки в источнике не лежат и считаются здесь:
|
||||
|
||||
| Колонка | Как считается | Зачем |
|
||||
|---|---|---|
|
||||
| `event_hour` | `toHour(event_time)` | суточный профиль спрашивают в каждом отчете |
|
||||
| `page_path` | путь `url` без домена и параметров | группировка по странице без разбора строки в каждом запросе |
|
||||
| `referer_host` | домен `referer` | «откуда пришли» одним значением, а не полным адресом |
|
||||
|
||||
Служебных `_load_id` и `_load_ts` у витрины нет, и это не забывчивость. Их
|
||||
нет и у `dds.event_v`: событие в DDS не грузят, а читают из ODS, поэтому
|
||||
координат собственной загрузки у него не бывает. Служебная колонка называет
|
||||
запуск, который положил строку на диск, — здесь на диск никто ничего не
|
||||
кладет.
|
||||
|
||||
## Ловушка задания
|
||||
|
||||
Массивы `product_*` тянет развернуть через `ARRAY JOIN` — так плоская
|
||||
таблица становится по-настоящему плоской. Разворачивать нельзя, и вреда
|
||||
здесь два.
|
||||
|
||||
Очевидный: у события с тремя позициями получилось бы три строки, зерно
|
||||
витрины перестало бы совпадать с зерном источника, а вместе с ним развалилось
|
||||
бы и правило формы — такую витрину уже пришлось бы материализовать.
|
||||
|
||||
Неочевидный и куда более разрушительный: `ARRAY JOIN` — соединение
|
||||
внутреннее, и строку с пустым массивом он выбрасывает. Торговый блок пуст у
|
||||
всех событий, кроме корзин и покупок, то есть у подавляющего большинства.
|
||||
Так «плоская таблица событий» превращается в таблицу товарных позиций и
|
||||
теряет девятнадцать строк из двадцати — а по имени и по составу колонок она
|
||||
по-прежнему выглядит таблицей событий. Ошибка не падает и ничего не
|
||||
подсвечивает.
|
||||
|
||||
Массивы едут в договор как есть; разворачивает их тот, кому нужна позиция
|
||||
заказа, — и делает это уже в своей витрине, с другим зерном и другим именем.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — `event_id` внутри дня. Собери прямой ответ из `dds.event_v`
|
||||
за тот же день, соедини с витриной полным внешним соединением и посчитай
|
||||
строки, где ключа нет с одной из сторон или где различаются значения
|
||||
деловых колонок. Ноль — зеленый.
|
||||
|
||||
Отдельно проверяются три производные колонки: `event_hour`, `page_path` и
|
||||
`referer_host` должны совпасть с тем же выражением, посчитанным по
|
||||
`dds.event_v` на лету.
|
||||
|
||||
Если строк в витрине оказалось в разы меньше, чем в источнике, а остались в
|
||||
ней одни корзины и покупки — это развернутые массивы: `ARRAY JOIN` выбросил
|
||||
все события с пустым торговым блоком.
|
||||
|
||||
## Приемка
|
||||
|
||||
- [ ] `dm.events_enriched_v` создано, физических таблиц под ним нет;
|
||||
- [ ] список колонок в представлении явный;
|
||||
- [ ] число строк и набор `event_id` совпадают с `dds.event_v` за каждый
|
||||
прожитый день;
|
||||
- [ ] задача `events_enriched` в `homework_check` зеленая;
|
||||
- [ ] задачи в `dm_transform` **нет** — и не должно быть.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Задание 3: `dm.session_overview`
|
||||
|
||||
**Чему учит:** агрегат хранит слагаемые, а не готовые средние и доли.
|
||||
Среднее по строкам витрины не складывается никогда, а уникальных нельзя
|
||||
складывать даже тогда, когда на сегодняшних данных сумма сходится.
|
||||
|
||||
## Роль витрины
|
||||
|
||||
Дневной портрет визитов в разрезе устройств: сколько было сессий, какие они
|
||||
были длинные, сколько страниц смотрели и как часто доходили до покупки.
|
||||
Вопрос витрины — «как люди ходят по магазину с телефона и с настольного
|
||||
компьютера, и чем эти визиты отличаются».
|
||||
|
||||
Сессия на стенде — расчетная сущность, ее уже нарезал слой DDS. Собирать
|
||||
визиты заново из событий не нужно и нельзя: это была бы вторая нарезка,
|
||||
которая тихо разойдется с первой.
|
||||
|
||||
Витрина с таким именем была и на прошлом стенде, но зерно у нее сменилось,
|
||||
и сменилось не по вкусу. Там визитов как сущности не было, и витрина
|
||||
нарезала их сама, строкой на визит. Здесь визит — сущность слоя DDS, и
|
||||
витрина, повторяющая его зерно, оказалась бы обычным представлением по
|
||||
[правилу формы](../architecture/dm.md): копией сессий с другими именами
|
||||
колонок. Чтобы задание осталось про дневной агрегат, витрина поднялась на
|
||||
зерно выше — до дня и среза.
|
||||
|
||||
## Зерно и форма
|
||||
|
||||
Зерно — **день сессии × категория устройства**. Источник —
|
||||
`dds.session_v`, у него зерно визита, значит зерно сменилось и витрина
|
||||
материализуется: `dm.session_overview_rep` / `_dist`, донор `_stage_rep` /
|
||||
`_stage_dist`, публичное `dm.session_overview_v`. Ключ партиции — день.
|
||||
|
||||
Категория устройства взята из сессии как есть: у куки она постоянна, и слой
|
||||
DDS уже перевел код источника в слово. Словарь расшифровки шире, чем мир
|
||||
стенда: слов в нем четыре, а встречаются три — `desktop`, `mobile` и
|
||||
`tablet`. Строку под `tv` заводить незачем, она возникнет сама, если такие
|
||||
визиты появятся.
|
||||
|
||||
## Состав колонок
|
||||
|
||||
| Колонка | Тип | Что это |
|
||||
|---|---|---|
|
||||
| `report_date` | `Date` | день сессии |
|
||||
| `device_category` | `LowCardinality(String)` | категория устройства |
|
||||
| `sessions` | `UInt64` | число визитов |
|
||||
| `visitors` | `UInt64` | число разных `client_id` |
|
||||
| `bounce_sessions` | `UInt64` | визиты ровно из одного события |
|
||||
| `purchase_sessions` | `UInt64` | визиты, в которых была хотя бы одна покупка |
|
||||
| `duration_seconds_total` | `UInt64` | суммарная длительность визитов |
|
||||
| `events_total` | `UInt64` | событий во всех визитах |
|
||||
| `pageviews` | `UInt64` | просмотров страниц |
|
||||
| `cart_adds` | `UInt64` | добавлений в корзину |
|
||||
| `purchases` | `UInt64` | покупок |
|
||||
| `_load_id` | `String` | запуск, собравший день |
|
||||
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||
|
||||
Средней длительности и доли отказов в витрине нет намеренно. Хранить
|
||||
`avg_duration_seconds` было бы удобнее ровно один раз — пока смотрят одну
|
||||
строку. Тот, кто захочет среднее по дню целиком, сложит средние по трем
|
||||
устройствам и получит неправду: средние не складываются, они взвешиваются.
|
||||
Пара «`duration_seconds_total` и `sessions`» дает верный ответ на любом
|
||||
срезе, а деление стоит потребителю один вызов. То же и с долей отказов:
|
||||
храним `bounce_sessions` и `sessions`, долю считает тот, кто спрашивает.
|
||||
|
||||
С `visitors` история тоньше, и в ней главная ловушка задания. Складывать
|
||||
уникальных по строкам нельзя: один и тот же посетитель, попавший в две
|
||||
строки, будет посчитан дважды. Проверь на стенде — и увидишь, что сумма
|
||||
`visitors` по устройствам за день **сходится** с `dm.daily_traffic_v`.
|
||||
|
||||
Это не отменяет правила, а показывает, откуда берется совпадение: в мире
|
||||
стенда устройство приписано куке на всю жизнь ([словарь
|
||||
понятий](../../CONTEXT.md), «Паспорт куки»), поэтому одна кука лежит ровно
|
||||
в одной строке дня, и пересекаться строкам не в чем.
|
||||
|
||||
Совпадение это — свойство мира, а не свойство витрины. Появятся визиты
|
||||
одной куки с двух устройств — сумма поедет, и ни одна проверка об этом не
|
||||
скажет: отчет просто начнет тихо завышать аудиторию. Отсюда правило,
|
||||
которое стоит унести с задания: складывать уникальных нельзя даже там, где
|
||||
сегодня получается. Число посетителей дня спрашивают у
|
||||
`dm.daily_traffic_v`, где оно посчитано на своем зерне.
|
||||
|
||||
## Ритм пересборки
|
||||
|
||||
Источник — сессии: день замерзает, как только прожит. Объем прогона тот же,
|
||||
что в задании 2, — хвост от первого несобранного прожитого дня; механика та
|
||||
же: донор и замена дневных партиций.
|
||||
|
||||
## Ключ шардирования
|
||||
|
||||
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||
шардам». Здесь у измерения всего три встречающихся значения, и распределены
|
||||
они неровно: планшеты — тонкая полоса, а телефоны и настольные машины держат
|
||||
основную массу. Прежде чем брать хеш от одного `device_category`, посчитай,
|
||||
сколько строк ляжет на каждый шард, и назови, чем тебя устраивает результат.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — `device_category` внутри дня. Собери прямой агрегат из
|
||||
`dds.session_v` за день, соедини с `dm.session_overview_v` полным внешним
|
||||
соединением и посчитай строки, где ключа нет с одной из сторон или где
|
||||
расходится любой счетчик. Ноль — зеленый.
|
||||
|
||||
Две проверки покрупнее ловят грубую ошибку сразу:
|
||||
|
||||
- сумма `sessions` за день равна числу строк `dds.session_v` этого дня:
|
||||
ни один визит не потерян и ни один не задвоен;
|
||||
- сумма `events_total` за день равна сумме `events_total` по сессиям того
|
||||
же дня.
|
||||
|
||||
Если суммы событий сходятся, а сумма сессий больше числа визитов — почти
|
||||
наверняка витрина собрана из событий, а не из сессий.
|
||||
|
||||
## Приемка
|
||||
|
||||
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||
ключом цели;
|
||||
- [ ] `dm.session_overview_v` перечисляет колонки явно;
|
||||
- [ ] источник витрины — `dds.session_v`, своей нарезки визитов в ней нет;
|
||||
- [ ] средних и долей в физической таблице нет;
|
||||
- [ ] задача `session_overview` в `homework_check` зеленая;
|
||||
- [ ] задача витрины добавлена в `dm_transform`.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Задание 6 (необязательное): свой дашборд в Superset
|
||||
|
||||
**Чему учит:** довести свои витрины до потребителя — завести в Superset
|
||||
датасеты поверх публичных представлений, подобрать графики под вопросы и
|
||||
собрать из них один связный экран.
|
||||
|
||||
Эта лаба идет после заданий 1–5 и опирается на их результат. Ее можно
|
||||
пропустить: основной предмет стенда — ClickHouse, а здесь вся работа — в
|
||||
интерфейсе BI.
|
||||
|
||||
## Роль дашборда
|
||||
|
||||
Собери обзор трафика и поведения посетителей. Он должен отвечать на три
|
||||
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
|
||||
каналы дали результат, были ли ошибки приема данных.
|
||||
|
||||
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
|
||||
собери из пяти витрин, которые ты построил в заданиях. Копировать раскладку
|
||||
стендового дашборда не нужно: выбери форму, в которой ответы читаются без
|
||||
пояснений рядом с экраном.
|
||||
|
||||
## Свои объекты — отдельно
|
||||
|
||||
Не добавляй графики в стендовый дашборд и не правь его датасеты. При
|
||||
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
|
||||
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
|
||||
таких объектов описан в [доке Superset](../architecture/superset.md), «Импорт
|
||||
при каждой инициализации».
|
||||
|
||||
Заведи отдельные датасеты и графики, собери из них свой дашборд. Назови его,
|
||||
например, «Мои витрины DM». Это то же правило, по которому DDL домашек лежит
|
||||
в `sql/ddl/70-labs.sql`: стенд привозит свои объекты, менти создает свои.
|
||||
|
||||
## Датасеты
|
||||
|
||||
Заведи в подключении `ClickHouse` пять физических датасетов схемы `dm`:
|
||||
|
||||
- `events_enriched_v`;
|
||||
- `top_pages_daily_v`;
|
||||
- `session_overview_v`;
|
||||
- `dq_errors_daily_v`;
|
||||
- `utm_effectiveness_v`.
|
||||
|
||||
## Состав дашборда
|
||||
|
||||
Каждая из пяти витрин должна появиться хотя бы в одном графике. Точный тип
|
||||
графика выбери сам. На дашборде покажи следующее:
|
||||
|
||||
| Витрина | Что показать |
|
||||
|---|---|
|
||||
| `events_enriched_v` | число событий по часу и типу события |
|
||||
| `top_pages_daily_v` | страницы с просмотрами, посетителями и добавлениями в корзину |
|
||||
| `session_overview_v` | сравнение устройств по визитам, отказам и визитам с покупкой |
|
||||
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
|
||||
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
|
||||
|
||||
Для долей используй слагаемые из витрин. Например, доля отказов считается
|
||||
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
|
||||
долям для такого графика не подходит — урок задания 3 остается в силе.
|
||||
|
||||
Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Эта лаба проверяется глазами. У расположения панелей и понятных подписей нет
|
||||
счетного инварианта, поэтому задачи для нее в `homework_check` нет.
|
||||
|
||||
Открой список дашбордов и проверь:
|
||||
|
||||
- «Бизнес-сюжеты» остался отдельным стендовым дашбордом;
|
||||
- твой дашборд открывается без ошибок и содержит график от каждого из пяти
|
||||
датасетов;
|
||||
- подписи, единицы измерения и легенды читаются без обращения к SQL;
|
||||
- прямой трафик не исчез из графика UTM, а заявленная выручка не выдана за
|
||||
серверную;
|
||||
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
|
||||
|
||||
Последний проход сделай как читатель: найди на экране самый заметный канал,
|
||||
страницу и устройство, затем проверь, понятно ли из подписей, что именно
|
||||
сравнивается. Если ответ приходится угадывать, поправь график или его название.
|
||||
|
||||
## Приемка
|
||||
|
||||
- [ ] пять датасетов читают публичные представления витрин заданий 1–5;
|
||||
- [ ] создан отдельный дашборд, стендовые объекты не изменены;
|
||||
- [ ] каждая витрина представлена хотя бы одним графиком;
|
||||
- [ ] дашборд проверен глазами по разделу выше.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Задание 2: `dm.top_pages_daily`
|
||||
|
||||
**Чему учит:** зерно сменилось — значит витрина материализуется, живет
|
||||
дневными партициями и встает в конвейер. Первая настоящая витрина: пять
|
||||
объектов, донор, замена партиции, задача в `dm_transform`.
|
||||
|
||||
## Роль витрины
|
||||
|
||||
Популярность страниц по дням: сколько раз страницу открыли, сколько разных
|
||||
посетителей ее видели и что на ней делали кроме просмотра.
|
||||
|
||||
Торговое событие садится на ту страницу, где случилось: корзина — на
|
||||
карточку товара, покупка — на страницу подтверждения заказа. Поэтому у
|
||||
карточки товара рядом стоят просмотры и корзины, и их отношение —
|
||||
конверсия карточки — главный вопрос к этой витрине: какие товары кладут в
|
||||
корзину, едва открыв, а какие смотрят и уходят.
|
||||
|
||||
Воронки «просмотр → корзина → покупка» здесь нет и быть не может: корзина и
|
||||
покупка живут на разных страницах, и связать их шаги в одну цепочку на
|
||||
зерне страницы не из чего. Путь одного человека собирают из событий или из
|
||||
сессий, а витрина страниц отвечает про страницу.
|
||||
|
||||
## Зерно и форма
|
||||
|
||||
Зерно — **день события × страница**. Источник — события, у него зерно
|
||||
события, значит зерно сменилось и витрина материализуется:
|
||||
`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
|
||||
публичное `dm.top_pages_daily_v`.
|
||||
|
||||
Страница — это `page_path`, путь `url` без домена и параметров запроса;
|
||||
считается он здесь же, из колонки `url` источника. Полный адрес в зерно не
|
||||
берется: с параметрами запроса одна и та же страница рассыпалась бы на
|
||||
сотни строк, и витрина перестала бы отвечать на свой вопрос.
|
||||
|
||||
Ключ партиции — день: он же единица пересборки.
|
||||
|
||||
Источник — `dds.event_v`, публичный договор события. Витрина задания 1
|
||||
источником не служит: витрина слоя DM читает договор DDS, как и стендовые
|
||||
витрины, и не зависит от того, выполнена ли соседняя домашка.
|
||||
|
||||
## Состав колонок
|
||||
|
||||
| Колонка | Тип | Что это |
|
||||
|---|---|---|
|
||||
| `report_date` | `Date` | день события |
|
||||
| `page_path` | `LowCardinality(String)` | путь страницы |
|
||||
| `pageviews` | `UInt64` | число событий `pageview` на странице |
|
||||
| `visitors` | `UInt64` | число разных `client_id` на странице |
|
||||
| `cart_adds` | `UInt64` | число событий `add_to_cart` на странице |
|
||||
| `purchases` | `UInt64` | число событий `purchase` на странице |
|
||||
| `_load_id` | `String` | запуск, собравший день |
|
||||
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||
|
||||
Счетчики условные: строку дают все три типа событий, а различает их
|
||||
`countIf`. Отфильтровать источник по `event_type = 'pageview'`, как
|
||||
подсказывает имя витрины, — значит получить витрину из одних просмотров:
|
||||
страницы останутся на месте, а `cart_adds` и `purchases` у всех строк
|
||||
обнулятся. Ошибка тихая: витрина выглядит собранной, и главный ее вопрос —
|
||||
конверсия карточки — молча отвечает «ноль везде».
|
||||
|
||||
## Ритм пересборки
|
||||
|
||||
Источник — события: день замерзает сразу, как только прожит. Значит
|
||||
пересобирать нужно только то, что еще не собрано: хвост дней, которых в
|
||||
витрине нет, — от первого несобранного прожитого дня до последнего
|
||||
прожитого. Готовые дни трогать незачем, а вот дыру, оставленную частичным
|
||||
отказом, объем прогона обязан подобрать: ищи не последний собранный день, а
|
||||
первый несобранный.
|
||||
|
||||
Механика — сборка отрезка в донор одной вставкой и замена дневных партиций
|
||||
по одной, как у `revenue_daily`.
|
||||
|
||||
## Ключ шардирования
|
||||
|
||||
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||
шардам». Подумай, чем эта витрина отличается от `revenue_daily`: там кроме
|
||||
дня было одно измерение из шести близких по объему значений, здесь —
|
||||
страницы, которых много и которые по популярности очень неровные.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — `page_path` внутри дня. Собери прямой агрегат из
|
||||
`dds.event_v` за день, соедини с `dm.top_pages_daily_v` полным внешним
|
||||
соединением по ключу и посчитай строки, где ключа нет с одной из сторон или
|
||||
где расходится любой из четырех счетчиков. Ноль — зеленый.
|
||||
|
||||
Две проверки попроще, ими удобно ловить грубую ошибку до полного
|
||||
соединения:
|
||||
|
||||
- сумма `pageviews` за день равна числу событий `pageview` этого дня в
|
||||
`dds.event_v`;
|
||||
- сумма `purchases` за день равна числу событий `purchase` этого дня.
|
||||
|
||||
Проверки ловят разное. Фильтр `WHERE event_type = 'pageview'` первая из них
|
||||
пропускает: отбор строк по типу не меняет счет событий того же типа.
|
||||
Расхождение покажет только вторая — вместе с `cart_adds` обнулятся и
|
||||
`purchases`. Проверку выбирают по тому, умеет ли она покраснеть на твоей
|
||||
ошибке, а не по тому, как быстро она пишется: [проверка, которая не умеет
|
||||
краснеть, бесполезна](../architecture/testing.md).
|
||||
|
||||
`visitors` складывать по строкам нельзя: один посетитель ходит по разным
|
||||
страницам и в сумме посчитается несколько раз. Число посетителей дня
|
||||
спрашивают у `dm.daily_traffic_v`, а не у этой витрины.
|
||||
|
||||
## Приемка
|
||||
|
||||
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||
ключом цели;
|
||||
- [ ] `dm.top_pages_daily_v` перечисляет колонки явно, включая `_load_id` и
|
||||
`_load_ts`;
|
||||
- [ ] объем прогона выводится из состояния витрины, а не задается руками;
|
||||
- [ ] задача `top_pages_daily` в `homework_check` зеленая;
|
||||
- [ ] задача витрины добавлена в `dm_transform`, повторный прогон конвейера
|
||||
результат не меняет.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Задание 5: `dm.utm_effectiveness`
|
||||
|
||||
**Чему учит:** приставка `declared_` в имени колонки — предупреждение, а не
|
||||
украшение. Она говорит: это заявка клиента, а не деньги отчетности. Урок
|
||||
здесь дает конфликт: суммы не сойдутся, и раскапывать почему предстоит
|
||||
самому.
|
||||
|
||||
Правило стенда: **поведение и атрибуцию считаем по трекеру, деньги — по
|
||||
бэкенду**. Эта витрина — единственное разрешенное исключение: атрибуции по
|
||||
UTM у бэкенда нет, метки живут только в кликстриме. Раз считать иначе
|
||||
нечем, клиентская сумма берется, но называется своим именем.
|
||||
|
||||
## Роль витрины
|
||||
|
||||
Эффективность рекламных каналов по дням: сколько трафика привел канал,
|
||||
сколько корзин и покупок в нем случилось и на какую сумму эти покупки, по
|
||||
словам клиента.
|
||||
|
||||
## Атрибуция: по событию, а не по визиту
|
||||
|
||||
Метки едут на каждом событии, и витрина группирует события по их
|
||||
собственным меткам. Модели «первое касание» или «последнее касание» здесь
|
||||
нет: их построение — отдельная тема, а это витрина трафика по меткам.
|
||||
|
||||
`dds.session_v` при этом несет UTM первого события визита, и ответ «какой
|
||||
канал привел» из сессии и из этой витрины будет разным. Это не
|
||||
ошибка ни одной из сторон — это два разных вопроса, и в отчете их надо
|
||||
различать.
|
||||
|
||||
## Прямой трафик из витрины не выбрасывается
|
||||
|
||||
Соблазн отфильтровать строки без меток велик: витрина же про рекламу.
|
||||
Отфильтровать нельзя по двум причинам. Первая деловая: прямой трафик —
|
||||
обычно самый крупный «канал» магазина, и отчет, где его нет, врет о доле
|
||||
рекламы. Вторая техническая: как только часть событий выпала, итог витрины
|
||||
перестал сходиться с итогом источника, и проверить сборку стало нечем.
|
||||
|
||||
Пустые метки остаются пустыми строками, как в источнике. Своего слова для
|
||||
«прямого захода» витрина не изобретает: чем меньше выдуманных значений, тем
|
||||
меньше расшифровывать читателю.
|
||||
|
||||
## Зерно и форма
|
||||
|
||||
Зерно — **день события × `utm_source` × `utm_medium` × `utm_campaign`**.
|
||||
Зерно источника — событие, значит витрина материализуется:
|
||||
`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
|
||||
публичное `dm.utm_effectiveness_v`. Ключ партиции — день.
|
||||
|
||||
Источник — `dds.event_v`, публичный договор события: витрина слоя DM читает
|
||||
договор DDS, а не соседнюю витрину.
|
||||
|
||||
## Состав колонок
|
||||
|
||||
| Колонка | Тип | Что это |
|
||||
|---|---|---|
|
||||
| `report_date` | `Date` | день события |
|
||||
| `utm_source` | `LowCardinality(String)` | источник кампании |
|
||||
| `utm_medium` | `LowCardinality(String)` | канал кампании |
|
||||
| `utm_campaign` | `String` | имя кампании |
|
||||
| `visitors` | `UInt64` | число разных `client_id` |
|
||||
| `pageviews` | `UInt64` | просмотров страниц |
|
||||
| `add_to_carts` | `UInt64` | добавлений в корзину |
|
||||
| `purchases` | `UInt64` | покупок |
|
||||
| `declared_revenue` | `Decimal(18, 2)` | выручка покупок глазами клиента |
|
||||
| `_load_id` | `String` | запуск, собравший день |
|
||||
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||
|
||||
`declared_revenue` складывает `purchase_revenue[1]` событий `purchase`.
|
||||
Индекс не описка: блок `purchase*` едет параллельными массивами, и у события
|
||||
покупки в них ровно один элемент — контракт источника обещает один номер
|
||||
заказа и одну объявленную сумму на событие. Тем же индексом берет свою
|
||||
сторону стендовая сверка `purchase_vs_orders`.
|
||||
|
||||
Приводить тип не нужно: в договоре `purchase_revenue` уже
|
||||
`Array(Decimal(18, 2))`. `Float64` эта сумма была только в выгрузке
|
||||
источника, и ODS перевел ее в точные деньги на приеме — сложение Decimal
|
||||
точное, и где стоит `CAST`, до или после `sum`, на результат не влияет.
|
||||
Витрина просто хранит то, что сложила, в `Decimal(18, 2)`.
|
||||
|
||||
`utm_campaign` оставлена обычной строкой: имен кампаний со временем
|
||||
становится много, и словарь `LowCardinality` на растущем множестве
|
||||
перестает окупаться.
|
||||
|
||||
## Ритм пересборки
|
||||
|
||||
Источник — события: день замерзает сразу. Объем прогона и механика те же,
|
||||
что в задании 2: хвост несобранных дней, донор, замена дневных партиций.
|
||||
|
||||
## Ключ шардирования
|
||||
|
||||
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||
шардам». Зерно здесь составное, из трех колонок, и решение «взять хеш от
|
||||
одной из них» надо защищать отдельно: назови, какая доля строк придется на
|
||||
самое частое значение выбранной колонки.
|
||||
|
||||
## Конфликт, ради которого витрина и написана
|
||||
|
||||
Собери за один и тот же день две суммы: `sum(declared_revenue)` из своей
|
||||
витрины и `sum(revenue)` из `dm.revenue_daily_v`. Они не сойдутся, и это
|
||||
верное поведение обеих витрин.
|
||||
|
||||
Базы у двух сумм при этом сравнимые: `revenue_daily` складывает цену позиций
|
||||
на количество и ни скидки, ни доставки в себя не берет — ровно то, что видит
|
||||
клиент. Значит все расхождение до копейки объясняется тем, что случилось с
|
||||
заказом, а не тем, как считали деньги. Ищи ответы среди таких причин:
|
||||
|
||||
- отмененный заказ из кликстрима никуда не девается: событие покупки
|
||||
случилось, а `revenue_daily` считает только оплаченные;
|
||||
- событие покупки может потеряться по дороге, а заказ при этом доехать —
|
||||
и наоборот, событие приехать дважды;
|
||||
- клиент назвал сумму, отличную от серверной суммы позиций: генератор
|
||||
делает это намеренно у одного-двух заказов из ста.
|
||||
|
||||
Как эти же расхождения выглядят на зерне заказа, показывает стендовая
|
||||
витрина `dm.purchase_vs_orders_v` с ее классами расхождений: это готовый
|
||||
ответ на вопрос «а сколько именно и из-за чего». Запиши свой вывод —
|
||||
в двух-трех фразах, своими словами.
|
||||
|
||||
Проверять сходимость этих двух сумм не нужно и нельзя: они и не должны
|
||||
сходиться.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — тройка `utm_source`, `utm_medium`, `utm_campaign` внутри
|
||||
дня. Собери прямой агрегат из `dds.event_v` за день, соедини с
|
||||
`dm.utm_effectiveness_v` полным внешним соединением и посчитай строки, где
|
||||
ключа нет с одной из сторон или где расходится любая из пяти величин. Ноль —
|
||||
зеленый.
|
||||
|
||||
Две проверки покрупнее ловят самые частые ошибки:
|
||||
|
||||
- сумма `pageviews`, `add_to_carts` и `purchases` за день равна числу
|
||||
событий соответствующего типа в `dds.event_v` за тот же день;
|
||||
- сумма `declared_revenue` за день равна сумме `purchase_revenue[1]` по всем
|
||||
событиям `purchase` этого дня.
|
||||
|
||||
Обе не сойдутся, если прямой трафик отфильтрован, и разойдутся ровно на его
|
||||
долю — по этому следу отфильтрованные строки и находят. `visitors`, как и в
|
||||
задании 3, по строкам не складывается: один посетитель приходит по разным
|
||||
меткам.
|
||||
|
||||
## Приемка
|
||||
|
||||
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||
ключом цели;
|
||||
- [ ] строки без UTM-меток в витрине остались;
|
||||
- [ ] `declared_revenue` хранится в `Decimal(18, 2)`;
|
||||
- [ ] расхождение с `dm.revenue_daily_v` объяснено письменно;
|
||||
- [ ] задача `utm_effectiveness` в `homework_check` зеленая;
|
||||
- [ ] задача витрины добавлена в `dm_transform`.
|
||||
@@ -126,6 +126,10 @@ parseDateTime64InJodaSyntaxOrNull(
|
||||
использовать и для типизированной строки, и для маршрутизации ошибки; нулевая
|
||||
дата не нужна.
|
||||
|
||||
> Актуальный стенд: версию `ods.order` задаёт `snapshot_date`
|
||||
> ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Следующий
|
||||
> абзац фиксирует исходный вывод исследования и не описывает текущий контракт.
|
||||
|
||||
`updated_at` допустим как колонка версии `ReplacingMergeTree`: ClickHouse
|
||||
явно разрешает для `ver` тип `DateTime64` и оставляет строку с максимальной
|
||||
версией
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
# Инвентаризация сюжетов лаб по слоям стенда
|
||||
|
||||
Это материал для спеки программы лаб (#202): полный список сюжетов без отбора. Спека решит, что оставить и в каком порядке поставить. Здесь для каждого участка стенда записано, чему на нем научится менти. Тикет — #201, карта — #182.
|
||||
|
||||
Каждый сюжет описан четырьмя строками: **урок**, **материал**, **форма** и **проверка**. Возможны три формы:
|
||||
|
||||
- *построить свое по ТЗ* — как в пяти домашних заданиях DM: рядом есть образец, но нет готового ответа;
|
||||
- *вычитание* — удалить SQL готовой трансформации и написать его заново; даги остаются без изменений (уточнение владельца в комментарии к #148);
|
||||
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а менти ищет причину и объясняет ее.
|
||||
|
||||
Существующие шесть заданий из `docs/labs/` входят в список как готовое ядро слоя DM. Идеи из тикетов #143, #145, #148, #149, #186, #187, #188 разнесены по подходящим участкам. В конце отдельно проверяется гипотеза «вычитание + DQ» для каждого потока нижних слоев.
|
||||
|
||||
## STG — прием и сырье
|
||||
|
||||
**1. Push против pull: два способа принять топик.**
|
||||
Урок: способ приема зависит от источника. Поток постоянно читает подписанная матвью, а слепок забирает отдельный запрос по команде.
|
||||
Материал: оба чтеца в `sql/ddl/10-stg-tables.sql` (у `orders_raw_kafka` нет ни матвью, ни `ON CLUSTER`, зато есть `kafka_commit_on_select`), матвью приема `sql/ddl/40-stg-views.sql`, забор `sql/stg/orders_raw_load.sql`, `dags/orders_ingest.py`; сравнение цены и наблюдаемости — заготовка спеки (`docs/specs/2026-07-30-stand-v2-realism.md`, раздел 12).
|
||||
Форма: расследование двух живых путей на одном стенде.
|
||||
Проверка: офсеты групп `clickstream_hits` и `clickstream_orders`, счет строк сырья по `_load_id`.
|
||||
|
||||
**2. Кто читал топик и куда легли данные.**
|
||||
Урок: нода, которая читает топик, может меняться от прогона к прогону. Раскладка данных не меняется: ее определяет ключ шардирования — свойство самих данных.
|
||||
Материал: колонка `consumer_host` в `stg.hits_raw_dist`; `docs/architecture/storage.md`, «Раскладка по шардам».
|
||||
Форма: расследование данных.
|
||||
Проверка: пересчет ключа шардирования по `hostName()` — строк на чужом шарде нет.
|
||||
|
||||
**3. Вторая consumer group — дубли.**
|
||||
Урок: на кластер нужна одна группа потребителей. Если завести вторую, каждая нода получит полную копию топика.
|
||||
Материал: `kafka_group_name` в `sql/ddl/10-stg-tables.sql`; сцена названа спекой (раздел 12).
|
||||
Форма: расследование-опыт «сломай — увидь — верни» на переигранном дне.
|
||||
Проверка: счет событий против описи мира (`make check-clickhouse`); уборка — снятием партиции `_load_ts` (правило из `docs/architecture/testing.md`).
|
||||
|
||||
**4. Дырка в офсетах.**
|
||||
Урок: сырье может сохранить не все сообщения, которые пришли из источника. Пустое сообщение или зазор после пересоздания матвью видны только по разрыву в `kafka_offset`, поэтому пропажу не нужно искать глазами.
|
||||
Материал: `docs/architecture/storage.md`, «Что проверено» (опыты 5 и 7 августа); колонка `kafka_offset` в сырье.
|
||||
Форма: расследование.
|
||||
Проверка: непрерывность офсетов внутри партиции.
|
||||
|
||||
**5. Две оси времени.**
|
||||
Урок: переобработка данных и очистка диска живут по разным часам. Сырье разбивается на части и удаляется по `_load_ts`, а модельный день появляется только в ODS. Поэтому TTL по модельной дате работал бы неверно.
|
||||
Материал: `sql/ddl/10-stg-tables.sql` (TTL трое суток, `ttl_only_drop_parts`), `docs/architecture/storage.md`, «Срок жизни сырья».
|
||||
Форма: расследование-вопрос (почему нельзя нарезать сырье по `EventDate`).
|
||||
Проверка: ответ сверяется с доводом доки; счетного инварианта нет.
|
||||
|
||||
## ODS — типизация и брак
|
||||
|
||||
**6. Предикат и его буквальное отрицание.**
|
||||
Урок: годные строки и брак должны делить поток без пропусков и пересечений. NULL в предикате незаметно теряет строку, а исключение роняет вставку и останавливает чтение топика.
|
||||
Материал: `sql/ddl/30-ods-views.sql` (пара матвью событий), `sql/ods/order_parse.sql` (макрос `order_wire_contract`), ADR 0005.
|
||||
Форма: расследование — подложить в топик по строке каждого класса брака и найти их в `ods.event_errors_dist` / `ods.order_errors_dist`.
|
||||
Проверка: «сырье среза = годные + брак» на управляемой пачке; строки опыта убрать за собой.
|
||||
|
||||
**7. Три времени и версия-наблюдение.**
|
||||
Урок: у строки заказа есть четыре временные координаты. Версию сущности задает дата слепка источника, а не время загрузки. `FINAL` живет ровно в одном месте — за представлением `_v`.
|
||||
Материал: `sql/ddl/20-ods-tables.sql`, `sql/ddl/30-ods-views.sql`, ADR 0010 и 0014, `docs/architecture/orders/ingestion.md` («Роль ODS»).
|
||||
Форма: расследование — физические версии `ods.order_dist` против `ods.order_v`.
|
||||
Проверка: эталон `argMax` по `updated_at` прямо из сырья (прием #165).
|
||||
|
||||
**8. Часовой пояс как способ смотреть на время.**
|
||||
Урок: у ночных событий `toDate(UTCEventTime)` может не совпадать с `EventDate`. Это нормальное свойство данных. День нужно считать по готовой колонке и явно называть часовой пояс, в котором его рассматривают.
|
||||
Материал: `docs/architecture/storage.md`, «Часовые пояса»; ADR 0015.
|
||||
Форма: расследование данных.
|
||||
Проверка: менти сам находит ночные события и объясняет расхождение.
|
||||
|
||||
**9. Сырое против разобранного: `ecommerce`.**
|
||||
Урок: типизированный разбор JSON может молча подменить ошибку значением. Например, `qty` со значением «oops» превратится в 0. Поэтому правильность разбора нужно подтверждать инвариантом источника.
|
||||
Материал: колонка `ecommerce` рядом с массивами `purchase_*` / `product_*` в `ods.event`; разбор `items` в `sql/dds/order_rebuild.sql`; спека, раздел 1.2 («кандидат будущей лабы»).
|
||||
Форма: построить свое по ТЗ — разобрать `ecommerce` запросом и сверить с готовыми массивами того же события.
|
||||
Проверка: построчная сверка с колонками-массивами.
|
||||
|
||||
## DDS — модель
|
||||
|
||||
**10. Лаба сессий: своя нарезка против эталона.**
|
||||
Урок: сессия — расчетная сущность, границы которой определяет логика в хранилище. Самостоятельная нарезка может разойтись с эталоном. Если расхождение невозможно, задание сводится к `GROUP BY` по готовому полю.
|
||||
Материал: `docs/architecture/dds/session.md`, `sql/dds/session_rebuild.sql` (два окна, полный порядок `(event_time, event_id)`, детерминированный чекан), эталон `VisitID` в `ods.event_v`.
|
||||
Форма: построить свое — менти собирает нарезку сам до знакомства со стендовым кодом; либо вычитание `session_rebuild.sql` (раздел о гипотезе ниже).
|
||||
Проверка: `dq_check` / `sessions_vs_reference` (зерно кука × день против `uniqExact(VisitID)`); затем сравнение со стендовой нарезкой по `session_id`.
|
||||
|
||||
**11. Поштучный разбор расхождения нарезки.**
|
||||
Урок: счетная сверка показывает, сколько сессий разошлось. Чтобы понять, где именно отличаются границы, нужно вручную соединить свою сессию с эталонной по времени.
|
||||
Материал: `sql/dq/sessions_vs_reference.sql`, `docs/architecture/dds/session.md`, «Сверка с эталоном» («поштучное сопоставление границ — материал лабы»).
|
||||
Форма: расследование.
|
||||
Проверка: найденная кука, день и пауза, на которой решения разошлись.
|
||||
|
||||
**12. Склейка идентичностей во времени (#145).**
|
||||
Урок: текущее знание о пользователе и знание на выбранную дату дают разные отчеты. Если использовать сведения из будущего, исторический график известной аудитории изменится.
|
||||
Материал: `dds.identity_map_v` (пары без временной колонки — решение `docs/architecture/dds/identity.md`), момент связи выводится как минимум времени `purchase`-события, соединенного с заказом.
|
||||
Форма: построить свое (запрос, без изменения стенда).
|
||||
Проверка: два способа на одном наборе данных; расхождение объясняется письменно.
|
||||
|
||||
**13. Справочник регионов (#143).**
|
||||
Урок: справочник содержит данные, которыми источник владеет вне хранилища. Если собрать словарь руками, можно разобраться в механике, которую готовый каталог стенда позволяет только использовать.
|
||||
Материал: образец `sql/ddl/05-dic-catalog.sql` и ADR 0012; зацепка в контракте — `region_city_id` / `region_country_id` отдаются как есть (`dds.event_v`).
|
||||
Форма: построить свое по образцу (свой CSV, подложка, словарь, `dictGet` в отчете).
|
||||
Проверка: обогащенный отчет; неатомарность обновления словаря на двух нодах — дополнительное наблюдение (спека, раздел 12).
|
||||
|
||||
**14. Инвариант позиций: недостающая проверка.**
|
||||
Урок: равенство «цена × количество = items_total» проверяет, не исказил ли молчаливый разбор данные. Сейчас это равенство проверяют вручную, хотя ему нужно постоянное место в контуре DQ.
|
||||
Материал: комментарий в `sql/dds/order_rebuild.sql` («постоянное место такой проверки — контур DQ этапа 4; пока она делается руками»), образцы `sql/dq/*_vs_dds.sql`, `dags/dq_check.py`.
|
||||
Форма: построить свое — новая проверка по образцу существующих пяти.
|
||||
Проверка: проверка обязана уметь краснеть — испортить позицию руками и увидеть красное (`docs/architecture/testing.md`).
|
||||
|
||||
**15. Сверка слоя с источником по дню.**
|
||||
Урок: трансформация выполняет заданное правило, но сама не замечает, что часть данных не приехала. Это обнаруживает отдельная сверка слоя с источником.
|
||||
Материал: `docs/architecture/etl.md`, «Чего конвейер о себе не знает» (три слепых пятна и лекарство: счет заказов и поздний `updated_at` с обеих сторон).
|
||||
Форма: построить свое — вторая самодельная DQ-проверка.
|
||||
Проверка: снести часть дня в ODS-окне — проверка краснеет, конвейер сам не замечал.
|
||||
|
||||
## DM — витрины
|
||||
|
||||
**16. Пять домашек DM (готовое ядро).**
|
||||
Урок: готовые ТЗ уже содержат пять отдельных уроков (`docs/labs/README.md`): `events_enriched_v` показывает, что слой задает контракт, а не копирует данные; `top_pages_daily` меняет зерно, материализует витрину и включает ее в конвейер; `session_overview` учит хранить слагаемые вместо средних и не складывать уникальные значения, даже если на текущих данных результат совпадает; `dq_errors_daily` показывает, что у брака есть собственный день и срок жизни, а витрина может пережить источник; `utm_effectiveness` объясняет, зачем в имени нужно предупреждение `declared_`.
|
||||
Материал: пять готовых ТЗ в `docs/labs/README.md`.
|
||||
Форма: построить свое по ТЗ.
|
||||
Проверка: `dags/homework_check.py` + `sql/dq/*_vs_dds.sql` (полное внешнее соединение с независимым ответом).
|
||||
|
||||
**17. Свой дашборд в Superset (готовая необязательная лаба).**
|
||||
Урок: пользовательские объекты BI хранятся отдельно от стендовых и переживают перезапуск. Стендовые объекты при импорте перезаписываются.
|
||||
Материал: `docs/labs/superset-dashboard.md`, `docs/architecture/superset.md`.
|
||||
Форма: построить свое.
|
||||
Проверка: глазами — счетного инварианта у раскладки панелей нет.
|
||||
|
||||
**18. Инкремент против пересборки (#149, повышенная сложность).**
|
||||
Урок: чтобы обновлять меньше данных, приходится хранить историю изменений. Полная перезагрузка эту историю стирает.
|
||||
Материал: `sql/dm/daily_traffic_rebuild.sql` (полный пересчет), `docs/architecture/dm.md`, «Ритм пересборки» и отклоненный вариант; момент `first_matched_at` — из сюжета 12.
|
||||
Форма: построить свое (инкрементальная альтернатива рядом со стендовой).
|
||||
Проверка: бесплатный контроль — результат обязан совпасть с эталонной витриной `dm.daily_traffic_v`.
|
||||
|
||||
**19. Вычитание стендовых витрин.**
|
||||
Урок: при сверке двух источников нужен `GLOBAL FULL OUTER JOIN` с подзапросами с обеих сторон, `join_use_nulls` и явный приоритет классов. Значение с отсутствующей стороны должно остаться `NULL`, а не превратиться в ноль.
|
||||
Материал: `sql/dm/purchase_vs_orders_rebuild.sql` (и соседние `revenue_daily_*`, `daily_traffic_*`), `docs/architecture/dm.md`.
|
||||
Форма: вычитание — с оговоркой: запросы `sql/dq/*_vs_dds.sql` лежат открыто и почти дословно повторяют рецепт витрины. Предупреждение из `docs/labs/README.md` («открыв их до того, как задание сделано, ты обменяешь урок на готовый SQL») здесь особенно важно.
|
||||
Проверка: `dq_check`, группа `marts_vs_dds` + `classes_vs_inventory`.
|
||||
|
||||
## BI — Superset (сюжеты сверх готовой лабы)
|
||||
|
||||
**20. Ловушка ноды 2.**
|
||||
Урок: забытый `ON CLUSTER` и представление над локальной таблицей могут проявиться только в дашборде. Superset намеренно читает ноду 2, а DDL запускается с ноды 1.
|
||||
Материал: `docs/architecture/superset.md`, «Устройство зоны»; спека, «Роли нод».
|
||||
Форма: расследование-опыт (создать объект без `ON CLUSTER`, открыть дашборд).
|
||||
Проверка: своя же находка и починка.
|
||||
|
||||
## Поперек слоев
|
||||
|
||||
### Airflow-переобработка по дневным партициям
|
||||
|
||||
**21. Переиграть день X.**
|
||||
Урок: объем повторного прогона определяется состоянием хранилища. После падения конвейеру не нужно помнить параметры прошлого запуска: он ищет первую несобранную дату, а не последнюю собранную.
|
||||
Материал: `sql/dds/order_scope.sql` и `session_scope.sql` (окно + хвост от первой дыры, `GLOBAL NOT IN`), `dags/dds_transform.py` (день — отдельная размноженная задача замены), `docs/architecture/etl.md`.
|
||||
Форма: расследование-опыт — снести партицию дня в `dds.session_rep`, запустить `etl_pipeline` без параметров, увидеть, как объем сам нашел дыру.
|
||||
Проверка: `dq_check` зеленый, день вернулся тем же (детерминированный чекан).
|
||||
|
||||
**22. Атомарность — пошардовая.**
|
||||
Урок: `REPLACE PARTITION` заменяет данные в локальных таблицах по одному шарду. Если одна из двух замен завершится ошибкой, половина дня останется старой. Сам конвейер этого не заметит, поэтому нужна отдельная сверка.
|
||||
Материал: `sql/dds/order_replace.sql` (комментарий о границе обещания), `docs/architecture/etl.md`, «Чего конвейер о себе не знает»; правило донора — `sql/ddl/45-dds-tables.sql`.
|
||||
Форма: расследование (в паре с сюжетом 15).
|
||||
Проверка: `purchase_vs_orders_vs_dds` или самодельная сверка по дню.
|
||||
|
||||
### Сверка purchase vs orders (классы A–D)
|
||||
|
||||
**23. Воронка подтверждений и недосчет трекера (#186, класс B).**
|
||||
Урок: со стороны трекера потерянное событие можно обнаружить только по разрыву воронки между `/confirmation` и `purchase`. Отсюда правило: поведение считаем по трекеру, деньги — по бэкенду.
|
||||
Материал: `lost_event` в `dm.purchase_vs_orders_v`, события дня в `dds.event_v`; механика — `docs/architecture/orders/fate.md` («потеря точечная»).
|
||||
Форма: расследование.
|
||||
Проверка: оцененный недосчет сходится с долей `lost_event` закрытых дней.
|
||||
|
||||
**24. Бизнес-дубль и наивная выручка (#187, класс D).**
|
||||
Урок: движок удаляет технические дубли, а бизнес-дубли приходится определять самим — по `purchaseID`, а не по совпадению строк. `ReplacingMergeTree` не удалит дубль с новым `WatchID`, и это правильное поведение.
|
||||
Материал: `duplicate_event`, свертка по `purchase_id[1]` в `sql/dm/purchase_vs_orders_rebuild.sql`; `fate.md` («дубль сюжетный»).
|
||||
Форма: расследование — наивный `SUM` против дедуплицированного.
|
||||
Проверка: разница равна вкладу дублей; сверка с `dm.revenue_daily_v`.
|
||||
|
||||
**25. Дельта суммы объясняется сравнением позиций (класс C).**
|
||||
Урок: `amount_delta` обозначает конкретное расхождение, а не случайный шум. Его можно до копейки объяснить вычеркнутой позицией, если разобрать вложенный JSON и применить `ARRAY JOIN`. Ради этого навыка позиции и разбираются.
|
||||
Материал: `amount_delta` в сверке, массивы `product_*` события против `item_*` заказа; `fate.md` («дельта — вычеркнутая позиция»).
|
||||
Форма: расследование.
|
||||
Проверка: найденная позиция и ее полная стоимость равны дельте.
|
||||
|
||||
**26. Две дороги отмены и история версий (класс A).**
|
||||
Урок: текущее состояние заказа не сохраняет его историю. Различить случаи «оплачен и отменен» и «отменен неоплаченным» можно только по следам: физическим версиям ODS до слияния и сырью STG, пока оно хранится трое суток.
|
||||
Материал: `fate.md`, «Что из двух дорог видно»; `ods.order_dist`, `stg.orders_raw_dist`; выручка дня, которая выросла и убыла.
|
||||
Форма: расследование с ограничением по времени — часть случаев неразличима принципиально, и признать это тоже урок.
|
||||
Проверка: списки заказов по дорогам, сверенные с историей версий.
|
||||
|
||||
### Опоздания и закрытие дня
|
||||
|
||||
**27. Опоздавшие заказы и закрытое окно (#188).**
|
||||
Урок: отчет за день зависит от момента расчета. День можно считать закрытым, когда источник уже не способен его изменить. Поэтому опись хранит счетчики классов только для закрытых дней.
|
||||
Материал: `awaiting_order` в `dm.purchase_vs_orders_v`, задержка слепков D+1/D+2 (`fate.md`, «Опоздание»), `data/world-inventory.json` (счетчики `orders` только у дней с закрытым окном), возврат `awaiting_order`-дней в объем — `sql/dm/purchase_vs_orders_scope.sql`.
|
||||
Форма: расследование по ходу проживаемых дней.
|
||||
Проверка: вчерашний отчет, не сошедшийся утром, сходится завтра; разность слепков называет опоздавших.
|
||||
|
||||
**28. Дыхание выручки.**
|
||||
Урок: значение выручки за день меняется, пока день внутри окна изменяемости, а после выхода из окна фиксируется. Эти изменения видны как повторные пересборки партиции.
|
||||
Материал: `dm.revenue_daily_v` по ходу дней, окно из ярлыка `snapshot_date` (`sql/dm/revenue_daily_scope.sql`), K = 7 — константа мира.
|
||||
Форма: расследование-наблюдение (снять один день несколько раз).
|
||||
Проверка: за окном значение больше не меняется.
|
||||
|
||||
### Мониторинг и runbook
|
||||
|
||||
В репозитории пока нет runbook для случаев «keeper упал / DDL повис». Спека оставляет его в хвосте этапа 9 вместе с `system.text_log` (ADR 0002), потому что такой документ должен опираться на реальный опыт. Поэтому менти может написать runbook сам: по шагам сломать сценарий, записать наблюдения и оставить стенду документ, которого сейчас не хватает.
|
||||
|
||||
**29. Keeper — общая память кластера.**
|
||||
Урок: без keeper останавливаются DDL и репликация. На дашборде это бывает видно раньше, чем в ошибках запросов. Читаемый путь в keeper помогает понять, какие данные он хранит и за что отвечает.
|
||||
Материал: панель keeper и очередь DDL в `docs/architecture/monitoring/cluster.md`, `system.zookeeper` (читаемый путь — `docs/architecture/storage.md`, «Путь реплицированных таблиц»), `make smoke` / `make check-clickhouse`.
|
||||
Форма: расследование-опыт «посмотрели — сломали — увидели — вернули» (остановить keeper), итог — свой runbook.
|
||||
Проверка: возврат к зеленым целям `make`; шаги записаны.
|
||||
|
||||
**30. Повисший DDL.**
|
||||
Урок: каждый запрос `ON CLUSTER` проходит через keeper. Очередь распределенных DDL показывает, дошел ли запрос до обеих нод и что ответила каждая из них.
|
||||
Материал: `system.distributed_ddl_queue`, панель «Распределенный DDL» (`cluster.md`: единственная панель без `clusterAllReplicas` — и почему).
|
||||
Форма: расследование-опыт (DDL при недоступной второй ноде, таймаут `ON CLUSTER`).
|
||||
Проверка: запись в очереди со статусом по нодам; починка доводит до `Finished`.
|
||||
|
||||
**31. Мониторинг сам стоит денег.**
|
||||
Урок: запросы Grafana попадают в `system.query_log` вместе с запросами людей. Самый частый клиент хранилища — сам мониторинг. Если отфильтровать его запросы, стоимость наблюдаемости станет невидимой.
|
||||
Материал: `docs/architecture/monitoring/queries.md` (панель «Кто спрашивает», строки начала и конца, куски шардов в панели ошибок).
|
||||
Форма: расследование.
|
||||
Проверка: собственное объяснение верха списка тяжелых запросов.
|
||||
|
||||
**32. Свежесть второго источника — пила, а не линия.**
|
||||
Урок: возраст заказов растет в течение дня и резко уменьшается после нового слепка. Так устроен источник; это не признак деградации. Для панелей свежести, потока и брака выбираются разные слои данных. Если выбрать неверный слой, панель даст правдоподобный, но ложный ответ.
|
||||
Материал: `docs/architecture/monitoring/data.md` (почему свежесть — ODS, поток и брак — сырье, окно доли брака).
|
||||
Форма: расследование.
|
||||
Проверка: предсказать форму графика до взгляда на него.
|
||||
|
||||
### За границей лаб — опорные точки лекций
|
||||
|
||||
Спека (раздел 12) оставляет несколько сюжетов для лекций и не включает их в лабораторные работы: `Sign` и CollapsingMergeTree (почему на стенде `sum(Sign)` равен `count()`, а в бою нет), «как выбирают топологии в бою», идентичность «как в бою» (`setUserID`, вероятностная склейка, identity graph), анти-паттерны ключа шардирования. Последний сюжет уже входит в домашние задания DM как требование защитить выбранный ключ (`docs/labs/README.md`).
|
||||
|
||||
## Гипотеза «вычитание + DQ» для нижних слоев
|
||||
|
||||
В #148 предложено удалить SQL трансформаций потока и написать его заново. Владелец уточнил, что даги менять не нужно: это несущая инфраструктура. Обратную связь дают инварианты, которые не зависят от реализации слоя. Ниже каждый поток проверяется отдельно: что можно отдать на переписывание, что должно остаться, чем проверять результат и где заканчивается эталон.
|
||||
|
||||
На всю таблицу влияют два общих факта:
|
||||
|
||||
- `homework_check` покрывает только пять витрин DM. `dq_check` содержит пять проверок, но внешний эталон есть лишь у двух: `sessions_vs_reference` использует чужой чекан `VisitID` из `ods.event_v`, а `classes_vs_inventory` — опись мира. Три проверки `*_vs_dds` пересчитывают витрину из DDS по тому же рецепту. Они находят ошибку в сборке витрины, но не ошибку в DDS, потому что обе стороны читают один слой.
|
||||
- Все сверки читают таблицы `_dist`, поэтому их агрегаты не зависят от раскладки (спека, раздел 6). Если строка попала не на свой шард, счетные проверки останутся зелеными. Это ловит только пересчет ключа шардирования по `hostName()` (прием из `docs/architecture/storage.md`, замер #137). Такой пересчет нужно явно включать в ТЗ каждого вычитания.
|
||||
|
||||
### По потокам
|
||||
|
||||
**События STG (прием).** На переписывание этот участок не отдается. Здесь почти нет трансформации: `stg.hits_raw_mv` перекладывает колонки (`sql/ddl/40-stg-views.sql`). При этом пересоздание матвью на работающем стенде незаметно пропускает ближайшие сообщения (замер в `storage.md`, «Что проверено»). Чтец, таблицы сырья и матвью остаются несущей стеной. Урок этого участка раскрывают расследования из сюжетов 1–5.
|
||||
|
||||
**События STG → ODS (разбор).** Вся логика — в DDL, в паре матвью `sql/ddl/30-ods-views.sql`; отдельных файлов событий в `sql/ods/` нет. Удаление и пересоздание этих матвью остановит прием и снова создаст зазор с пропущенными сообщениями. Поэтому обычное вычитание здесь не подходит. Рабочая форма — **«собери рядом»**: выполнить пакетный `INSERT SELECT` из сырья в собственную таблицу по срезу `_load_ts`, а затем построчно сверить результат с нетронутым `ods.event_v`. Эталоном служат стендовый ODS и подневный счет против описи (`make check-clickhouse`). Однако опись проверяет только количество и только для покрытых ею дней. Содержимое неопорных колонок остается без независимой проверки: неверно разобранные UTM или браузер нигде не покраснеют, потому что витрины сверяются с DDS, а DDS читает ODS. Срок работы тоже ограничен: сырье хранится трое суток.
|
||||
|
||||
**Заказы STG (забор, `sql/stg/orders_raw_load.sql`).** Формально трансформация вынесена в отдельный файл, но ее учебный смысл сводится к перекладке колонок, а цена ошибки велика. Офсеты коммитятся при чтении, поэтому неверный повтор унесет слепок до следующего переигрыша дня через пульт. Этот участок остается стеной. Эталон описи — хеш байтов слепка — проверяет топик, но не сам забор.
|
||||
|
||||
**Заказы STG → ODS (`sql/ods/order_parse.sql`).** Вычитание возможно с оговоркой. Это пакетный шаг, идемпотентный по `_load_id`: повтор не меняет `ods.order_v` («Что проверено» в `orders/ingestion.md`). Брак обрабатывается отдельной целью, а слепок можно вернуть переигрышем дня. Обратную связь менти собирает сам: проверяет равенство «сырье среза = годные + брак» по `_load_id`, а текущее состояние сверяет с `argMax` по `updated_at` из сырья. Ниже по течению результат проверяет `classes_vs_inventory` через DDS и витрину. Слабое место — ветвь брака. Чистый мир не производит брак, поэтому отрицание предиката останется непроверенным, пока менти сам не подложит по строке каждого класса. Готовый рецепт есть в `orders/ingestion.md`, «Риски и проверка»; после опыта данные нужно обязательно убрать.
|
||||
|
||||
**DDS сессия (`session_scope` / `rebuild` / `replace`).** Это лучший поток для вычитания: здесь гипотеза подтверждается без дополнительных условий. Эталон `VisitID` внешний, а `sessions_vs_reference.sql` считает чужой чекан и не повторяет алгоритм нарезки. Ошибиться можно в важных местах: в полном порядке окон, границе суток в `PARTITION BY`, детерминированном чекане и расчете объема из позиции мира. Эталон заканчивается на атрибутах строки. Сверка работает на зерне кука × день и проверяет только число сессий. Поэтому неверные границы, длительность, вход, выход или UTM могут пройти зеленым. `session_overview` из `homework_check` тоже согласится, потому что сверяет витрину с тем же слоем. Вторая ступень обратной связи — поштучная сверка границ по `VisitID` из сюжета 11, которую менти пишет сам.
|
||||
|
||||
**DDS заказ (`order_scope` / `rebuild` / `replace`).** Вычитание возможно при условии: сначала нужно построить недостающую проверку. Косвенный эталон уже есть. `classes_vs_inventory` ловит потерянные заказы и неверные статусы закрытых дней, а `purchase_vs_orders` проверяет `items_total`. Но независимо не проверяются разбор массивов `item_*` и время в поясе `Europe/Samara`. Выручка сойдется сама с собой, а счетчиков выручки в описи намеренно нет (спека, раздел 8). Стенд уже подсказывает решение: до вычитания нужно реализовать инвариант «цена × количество = items_total» из сюжета 14. Последовательность «сначала сторож, потом ломать» сама становится частью урока.
|
||||
|
||||
**DDS карта идентичностей (`identity_map_rebuild.sql`).** Для чистого вычитания этот поток не подходит: независимого эталона нет. `daily_traffic_vs_dds` читает карту с обеих сторон, поэтому пустая или задвоенная карта пройдет проверку. Счетчики идентичности в опись не входят (спека, раздел 8). Менти может сам написать слабые инварианты: `known_users <= visitors` и «каждая пара подтверждена покупкой и заказом». Они проверят допустимость пар, но не их полноту. Подходящие формы — «собери рядом и сверь со стендовой картой» либо вычитание вместе с самодельной проверкой, которая честно названа неполной.
|
||||
|
||||
**DM стендовые витрины.** Формально это самый защищенный участок: `dq_check` независимо пересчитывает каждую витрину. На деле проверка содержит готовый ответ: `revenue_daily_vs_dds.sql` дословно повторяет рецепт витрины. Вычитание возможно как сюжет 19, но только с предупреждением из `docs/labs/README.md` о цене подглядывания.
|
||||
|
||||
### Сводная таблица
|
||||
|
||||
| Поток | Отдать на переписывание | Несущая стена | Обратная связь | Где кончается эталон |
|
||||
|---|---|---|---|---|
|
||||
| События STG | ничего | чтец, сырье, `hits_raw_mv` | опись (счет) | все, кроме счета |
|
||||
| События STG→ODS | «собери рядом», не вычитание | обе матвью, `*_errors` | стендовый ODS рядом, опись | содержимое неопорных колонок; окно сырья 3 суток |
|
||||
| Заказы STG | ничего | чтец, забор | хеш слепка в описи | сам забор |
|
||||
| Заказы STG→ODS | `order_parse.sql` | `*_errors`, чтец | баланс среза по `_load_id`, `argMax(updated_at)`, опись классов | ветвь брака — пока не подложен брак руками |
|
||||
| DDS сессия | `session_*` (все три) | донор, DDL, даг | `sessions_vs_reference` (внешний эталон) | атрибуты строки — нужна поштучная сверка |
|
||||
| DDS заказ | `order_*` (все три) | донор, DDL, даг | опись классов, `items_total` в сверке | разбор позиций и пояс — сначала сюжет 14 |
|
||||
| DDS карта | только с оговорками | DDL, даг | нет независимой | почти сразу; форма — «собери рядом» |
|
||||
| DM витрины | `revenue/pvo/traffic` | доноры, DDL, даг | `dq_check` целиком | ответ лежит в самой проверке |
|
||||
|
||||
### Вывод
|
||||
|
||||
Гипотеза «вычитание + DQ» подходит не для всех нижних слоев. Без оговорок она работает только для сессий DDS: там есть внешний эталон, настоящие места для ошибок и проверка, которая не содержит готового решения.
|
||||
|
||||
Для заказов DDS и разбора заказов ODS нужна последовательность «сначала построй сторожа, потом ломай». Недостающие инварианты — сюжеты 14 и 15, а также баланс среза — сами входят в учебный материал. Их нужно построить до вычитания.
|
||||
|
||||
STG и прием событий нельзя отдавать в форме «удали и перепиши»: код там служит несущей частью стенда. Для этих участков подходят расследования и форма «собери рядом».
|
||||
|
||||
У всех счетных проверок остается общая дыра — раскладка по шардам. Поэтому пересчет ключа по `hostName()` должен входить в ТЗ каждого вычитания отдельной строкой.
|
||||
@@ -138,8 +138,9 @@ Ecommerce (заполнены только у торговых событий):
|
||||
| `ecommerce` | String — сырой JSON события, как отдаёт Метрика (кандидат будущей лабы: сырое против разобранного) |
|
||||
|
||||
`add_to_cart` несёт массивы `product*` с одним товаром; `purchase` — состав
|
||||
заказа и блок `purchase*`. Выручка у клиента — во Float64, как у Метрики:
|
||||
это не недосмотр, а часть урока о расхождениях (см. раздел 4).
|
||||
заказа и блок `purchase*`. Выручка приходит JSON-числом Float64, как у
|
||||
Метрики. ODS один раз нормализует деньги в `Array(Decimal(18, 2))`, не меняя
|
||||
формат источника; DDS наследует тип хранения без нового вычисления.
|
||||
|
||||
### 1.3 Ключи и движки
|
||||
|
||||
@@ -210,9 +211,9 @@ Ecommerce (заполнены только у торговых событий):
|
||||
ключей совпадает с порядком полей в таблице контракта, у позиции — `sku`,
|
||||
`qty`, `price`: так байты воспроизводимы без сортировки ключей. Разница с
|
||||
кликстримом намеренна: там значения `purchaseRevenue` приезжают JSON-числами
|
||||
и разбираются как `Array(Float64)`, а бэкенд передаёт деньги строками для
|
||||
точного `Decimal`. Это показывает расхождение представлений денег в двух
|
||||
источниках.
|
||||
Float64, а бэкенд передает деньги строками. На границе ODS оба источника
|
||||
становятся `Decimal(18, 2)`: клиентское число разбирается сразу, без
|
||||
промежуточного Float64 в хранилище.
|
||||
Запись собирает явная функция существующего канонического сериализатора:
|
||||
один словарь с вложенным списком и один `orjson.dumps`, без универсального
|
||||
слоя кодеков.
|
||||
@@ -220,10 +221,12 @@ Ecommerce (заполнены только у торговых событий):
|
||||
[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md).
|
||||
|
||||
- `ods.order` принимает версии заказа в
|
||||
`ReplacingMergeTree(updated_at)`: `ORDER BY order_id`, партиция по дню
|
||||
неизменного `created_at`, шардирование по `cityHash64(order_id)`.
|
||||
`snapshot_date` остаётся датой наблюдения источника, но не задаёт публикацию
|
||||
или идемпотентность. Физическое чтение может видеть несколько версий;
|
||||
`ReplacingMergeTree(snapshot_date)`: `ORDER BY order_id`, партиция по дню
|
||||
неизменного `created_at`, шардирование по `cityHash64(order_id)`. Дата
|
||||
слепка служит колонкой версии: выживает строка с максимальной
|
||||
`snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)).
|
||||
Дата не доказывает полноту принятой порции, поэтому ODS не заменяет партицию
|
||||
слепка целиком. Физическое чтение может видеть несколько версий;
|
||||
`ods.order_v` возвращает точное текущее состояние. В DDS одна строка хранит
|
||||
один заказ с позициями в типизированных массивах; физическая модель и
|
||||
пакетное наполнение описаны в [доке заказа](../architecture/dds/order.md).
|
||||
@@ -262,7 +265,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
| | Расхождение | Механика в генераторе | Ориентир доли |
|
||||
|---|---|---|---|
|
||||
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе |
|
||||
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
|
||||
| B | Потерянное событие | заказ есть, `purchase` не доехал | бросок 3%, наблюдаемых ~1,8%: часть маячков защищена парами склейки, часть уходит в отмену приоритетом |
|
||||
| C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% |
|
||||
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
|
||||
|
||||
@@ -271,8 +274,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
|
||||
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
||||
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
||||
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
|
||||
опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
|
||||
ориентир ~10%. Точные доли зафиксированы замером на каноническом мире
|
||||
(28 дней, 2026-08-30, #194) — запись в «Что проверено»
|
||||
[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
|
||||
только по дням с закрытым окном (раздел 8).
|
||||
|
||||
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
||||
@@ -283,7 +287,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
под именем `declared_*` там, где атрибуция без трекера невозможна (UTM);
|
||||
слово `declared` в имени — сигнал «это заявка клиента, не деньги
|
||||
отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам
|
||||
раскопает почему (Float64 против Decimal, промокод, доставка, отмены).
|
||||
раскопает почему (состав позиций, промокод, доставка, отмены).
|
||||
|
||||
## 5. Анонимность и склейка идентичностей
|
||||
|
||||
@@ -365,8 +369,10 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы,
|
||||
опечатка иначе не падает.
|
||||
- **Политика соединений**: по ключу ко-локации — обычное соединение с
|
||||
комментарием, почему локальный результат корректен; по любому другому ключу
|
||||
— только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка
|
||||
комментарием, почему локальный результат корректен; если публичные
|
||||
представления скрывают ко-локацию — решение записывается рядом с витриной.
|
||||
По любому другому ключу — только явный GLOBAL; `NOT IN` — только
|
||||
`GLOBAL NOT IN`. Сверка
|
||||
`purchase`↔заказ —
|
||||
легитимная GLOBAL-витрина (заказы малы).
|
||||
- **Конвейер без TRUNCATE**: поток версий — в ReplacingMergeTree, точное чтение
|
||||
@@ -452,17 +458,13 @@ README.
|
||||
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
|
||||
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска.
|
||||
Один следующий `task_id` двумя последовательными запросами пишет годные строки
|
||||
и ошибки из того же среза. `ods.order` хранит версии по `updated_at`,
|
||||
а не публикует партицию `snapshot_date`. Слой сырья остаётся точкой повтора и
|
||||
разбора одной принятой порции.
|
||||
|
||||
Стенд получает от этого два режима приёма рядом, поток и слепок, и сравнение
|
||||
из опорных точек раздела 12 переформулировано под них.
|
||||
и ошибки из того же среза. `ods.order` выбирает текущую версию по максимальной
|
||||
`snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Слой
|
||||
сырья остаётся точкой повтора и разбора одной принятой порции.
|
||||
|
||||
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
||||
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но у
|
||||
заказа бизнес-версию задаёт `updated_at`; `_load_id` проходит через STG и обе
|
||||
цели ODS. Таблицы
|
||||
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию
|
||||
заказа задаёт дата слепка; `_load_id` проходит через STG и обе цели ODS. Таблицы
|
||||
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
|
||||
без них урок «какая нода читала топик» ненаблюдаем.
|
||||
Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое
|
||||
@@ -507,13 +509,20 @@ Kafka день переигрывается генератором заново:
|
||||
счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется
|
||||
`declared_revenue` по UTM.
|
||||
- **`daily_traffic_v`** — расширяется парой «посетители» / «известные
|
||||
пользователи» (обогащение через `dds.identity_map_v`, локальное соединение
|
||||
по ключу ко-локации).
|
||||
пользователи». Обогащение идет явным `GLOBAL LEFT JOIN` через
|
||||
`dds.identity_map_v`: опыт на ClickHouse 26.3 показал, что публичные `_v`
|
||||
скрывают ко-локацию от локальной подстановки.
|
||||
- `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`,
|
||||
`dq_errors_daily_v` — переезжают на новую модель без смены роли: источник —
|
||||
`dds.event_v`, не `ods.event`.
|
||||
- `dm.dq_summary` переводится с TRUNCATE+INSERT на партиционную замену
|
||||
(политика «без TRUNCATE»).
|
||||
`dq_errors_daily_v` — переезжают на новую модель без смены роли. Источник
|
||||
и зерно у двух из них меняет сама новая модель, и это зафиксировано ТЗ
|
||||
домашек ([`docs/labs/`](../labs/README.md)):
|
||||
`session_overview_v` читает `dds.session_v` и поднимается на зерно «день ×
|
||||
срез», потому что визит стал сущностью DDS и витрина его зерна была бы
|
||||
копией сессий; `dq_errors_daily_v` читает таблицы `ods.*_errors`, у
|
||||
которых в DDS представительства нет. Остальные две читают `dds.event_v`,
|
||||
а не `ods.event`.
|
||||
- `dm.dq_summary` хранит текущее качество дня и обновляется партиционной
|
||||
заменой (политика «без TRUNCATE»).
|
||||
|
||||
Дашборд Superset получает три новых сюжета: выручка по дням и категориям,
|
||||
таблица сверки с классами расхождений, пара посетители/известные.
|
||||
@@ -524,10 +533,11 @@ Kafka день переигрывается генератором заново:
|
||||
|
||||
## 8. Эталонный мир и опись
|
||||
|
||||
В git хранится только опись эталонного мира; сам снимок (14 модельных
|
||||
дней) генерируется на месте — при `make up` и при проверках (решение
|
||||
развилки «Производительность» карты #26, подробности —
|
||||
[спека генератора](2026-08-01-generator.md), раздел 5). Опись несёт
|
||||
В git хранится только опись эталонного мира; сам снимок генерируется на
|
||||
месте — при `make up` и при проверках (решение развилки «Производительность»
|
||||
карты #26, подробности — [спека генератора](2026-08-01-generator.md),
|
||||
раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней
|
||||
снято на этапе 7. Опись несёт
|
||||
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
|
||||
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
||||
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
|
||||
@@ -547,8 +557,8 @@ Kafka день переигрывается генератором заново:
|
||||
названного читателя в опись не кладётся
|
||||
([что хранит опись](../architecture/orders/inventory.md)).
|
||||
|
||||
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
|
||||
при пересборке.
|
||||
Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
|
||||
[карты проверок](../architecture/testing.md).
|
||||
|
||||
## 9. Оценка объёма исполнения
|
||||
|
||||
@@ -610,8 +620,8 @@ v2 стартует пустым, поэтому объём ниже — это
|
||||
выручка, сверка A+C.
|
||||
5. Airflow: `etl_pipeline` (партиционная переобработка).
|
||||
6. Расхождения B+D и опоздания; счётчики описи.
|
||||
7. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация
|
||||
на amd64 и arm64.
|
||||
7. Эталонный мир: опись и пересборка снимка, чек-скрипты. CI-генерация
|
||||
снята (спека генератора, раздел 5).
|
||||
8. Superset-дашборд v2.
|
||||
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
||||
и границы — ADR 0002. Этап идёт не подряд, см. ниже.
|
||||
@@ -648,9 +658,15 @@ ADR 0002 с приёмочными совпадают почти целиком.
|
||||
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
|
||||
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
|
||||
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
|
||||
переносится. Спека даёт будущим лабам только опорные точки — контрольные
|
||||
числа описи (сверка, идентичность). Явное следствие: после этапа 9
|
||||
стенд работает, но учебного пути на нём ещё нет.
|
||||
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг
|
||||
самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и
|
||||
конвейер, в который она встает, проектируются вместе. Сюда же —
|
||||
необязательная лаба «свой дашборд в Superset» (#197): она стоит на
|
||||
витринах домашек DM и продумана до готового ТЗ в этапе 8, тогда как
|
||||
остальные лабы пока на уровне идей. Единый стиль блока
|
||||
домашек — за работой учебного пути. Спека даёт будущим лабам только
|
||||
опорные точки — контрольные числа описи (сверка, идентичность). Явное
|
||||
следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет.
|
||||
- Инкрементальный ETL (#8) — свой issue.
|
||||
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
|
||||
- TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные
|
||||
@@ -686,10 +702,6 @@ Kafka Engine на двух нодах снят с этого списка при
|
||||
`ReplacingMergeTree`. Проверять то, что заведомо случается и заведомо
|
||||
обезврежено, — работа без ответа на конце.
|
||||
|
||||
- Точную форму локального обогащения `daily_traffic_v` через публичные `_v` —
|
||||
эмпирически при проектировании витрин #135. Общее поведение соединения двух
|
||||
Distributed-таблиц уже проверено в
|
||||
[исследовании](../research/2026-08-22-distributed-join.md).
|
||||
- Размер артефакта эталонного мира после пересборки.
|
||||
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
|
||||
сверять через MCP Context7 (правило AGENTS.md).
|
||||
|
||||
+431
-526
File diff suppressed because it is too large.
Load diff
@@ -20,8 +20,9 @@ DDL и двумя одноразовыми службами заливал во
|
||||
|
||||
**Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат
|
||||
прикладная схема, данные мира и позиция модельного времени, — и Compose,
|
||||
который держит только инфраструктуру. Зелёный `world_initialize` значит ровно
|
||||
одно: схема создана, оба источника приняты, следующий день известен.
|
||||
который держит только инфраструктуру. Зеленая ветка создания в
|
||||
`world_initialize` подтверждает: схема создана, оба источника приняты,
|
||||
следующий день известен, слои DDS и DM собраны.
|
||||
|
||||
## Решение
|
||||
|
||||
@@ -106,11 +107,16 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
|
||||
приехало» условием не считается.
|
||||
7. Отправляет слепки заказов вторым прогоном образа — со сдвигом, дни 0…6
|
||||
([заказы в стартовом мире](../architecture/orders/start-world.md)).
|
||||
8. Дёргает `orders_ingest` ждущим триггером и зеленеет только вслед за ним.
|
||||
9. Последней успешной задачей ставит позицию в `WORLD_STARTING_DAYS`.
|
||||
8. Дергает `orders_ingest` ждущим триггером и продолжает только после его
|
||||
успеха.
|
||||
9. Ставит позицию в `WORLD_STARTING_DAYS`: источники приняты, мир уже создан.
|
||||
10. Последним шагом дергает `etl_pipeline` ждущим триггером. Конвейер
|
||||
последовательно ждет `dds_transform` и `dm_transform`; отказ слоя красит
|
||||
инициализацию.
|
||||
|
||||
Порядок задач связывает проверки: каждый шаг подтверждён предыдущим, а красная
|
||||
задача называет место остановки.
|
||||
задача называет место остановки. Ветка "мир уже существует" конвейер не
|
||||
запускает: обычный повтор `make up` по-прежнему ничего не меняет.
|
||||
|
||||
### `world_recreate` — снести мир и создать заново
|
||||
|
||||
@@ -163,9 +169,15 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
|
||||
недействителен и пересборку надо повторить. Стенд обязан назвать незавершённую
|
||||
очистку вслух и не продолжать работу молча.
|
||||
|
||||
**Очистка прошла, потомок не доработал.** Позиция остаётся `0`. Инициализация
|
||||
детерминирована, поэтому следующий `make up` просто пробует её заново — с
|
||||
того же места и с тем же результатом.
|
||||
**Очистка прошла, создание остановилось до установки позиции.** Позиция
|
||||
остаётся `0`. Инициализация детерминирована, поэтому следующий `make up` просто
|
||||
пробует её заново — с того же места и с тем же результатом.
|
||||
|
||||
**ETL упал после установки позиции.** Мир и оба источника уже готовы, поэтому
|
||||
позиция остается в `WORLD_STARTING_DAYS`. После исправления запускают рукой
|
||||
`etl_pipeline`: он сам выводит объем из состояния данных. Повторный
|
||||
`world_initialize` здесь не поможет: при готовой позиции инициализация уйдет в
|
||||
короткую ветку без изменений.
|
||||
|
||||
**Позицию в `WORLD_STARTING_DAYS` ставит только успех обоих источников.**
|
||||
События доехали, слепки приняты, `orders_ingest` зелёный — тогда и только тогда
|
||||
@@ -181,10 +193,11 @@ ODS истинно сразу — этим оно и ограничено, об
|
||||
заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти
|
||||
делает себе сам и тут же видит.
|
||||
|
||||
**Слоты Airflow.** Их четыре, и вложенные ожидания в них укладываются, пока
|
||||
пересборка не наложилась на движение мира: `world_recreate` ждёт
|
||||
`world_initialize`, тот ждёт `orders_ingest` — три слота из четырёх. Одновременно
|
||||
работающий выключатель добавляет свои и загоняет стенд в тупик ожиданий.
|
||||
**Слоты Airflow.** Их четыре. При пересборке `world_recreate` ждет
|
||||
`world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом
|
||||
режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline`
|
||||
и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот.
|
||||
Одновременная пересборка добавляет ожидания и загоняет стенд в тупик.
|
||||
Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live`
|
||||
на паузу и даёт работникам доиграть. Предусловие остаётся в документации;
|
||||
механизм блокировок стоил бы дороже этой строки.
|
||||
@@ -214,8 +227,7 @@ ODS истинно сразу — этим оно и ограничено, об
|
||||
мир уже есть.
|
||||
- **`make rebuild-storage`** возвращает канонический мир: позиция проходит
|
||||
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы
|
||||
заново, офсеты групп идут с нуля. Цена прогона называется числом и сверяется
|
||||
с прежними 2 м 25 с.
|
||||
заново, офсеты групп идут с нуля, DDS и DM собраны.
|
||||
- **Красные пути проверяются своими отказами.** Сторож работника при
|
||||
отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`;
|
||||
оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки,
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# Программа учебных лаб
|
||||
|
||||
Эта спека собирает программу лаб из
|
||||
[инвентаризации сюжетов](../research/2026-08-31-labs-inventory.md), выполненной
|
||||
в тикете #201. Тикет спеки — #202, карта — #182.
|
||||
|
||||
Статус: спека принята владельцем; судьба идей-кандидатов решена здесь поименно.
|
||||
|
||||
## 1. Кому и зачем
|
||||
|
||||
Менти уже прошел большой теоретический курс ClickHouse объемом около 60 часов.
|
||||
Он знает устройство системы в теории, но почти ничего не делал своими руками.
|
||||
|
||||
Поэтому в программе нет вводного блока с теорией. Здесь теория становится
|
||||
ремеслом: менти руками осваивает слои хранилища, DQ, оркестрацию и
|
||||
идемпотентность.
|
||||
|
||||
Обещание программы: менти проходит конвейер стенда автором, а не читателем.
|
||||
|
||||
## 2. Устройство программы: основная программа и факультативы
|
||||
|
||||
У частей программы два статуса, третьего нет.
|
||||
|
||||
**Основная программа** — обязательная часть. Менти проходит ее целиком и по
|
||||
порядку, потому что каждая следующая ступень опирается на предыдущую. От
|
||||
ступени к ступени растет самостоятельность: сначала менти получает готовые ТЗ с
|
||||
готовыми сторожами (DQ-проверками), а затем строит сторожей сам.
|
||||
|
||||
**Факультативы** — необязательные лабы. Они собраны в тематические разделы,
|
||||
чтобы менти мог одним взглядом выбрать интересное. Для каждого раздела указано,
|
||||
после какой ступени основной программы он открывается.
|
||||
|
||||
В программе используются четыре формы заданий:
|
||||
|
||||
- *построить свое по ТЗ* — рядом есть образец, но готового ответа нет;
|
||||
- *вычитание* — SQL готовой трансформации удаляется и пишется заново, а даги
|
||||
остаются как несущая инфраструктура;
|
||||
- *собери рядом* — менти строит свою копию результата рядом с нетронутым
|
||||
стендовым и сверяет их построчно;
|
||||
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а
|
||||
менти ищет причину и объясняет ее.
|
||||
|
||||
## 3. Основная программа — семь ступеней
|
||||
|
||||
### Ступень 1. Построй пять витрин слоя DM
|
||||
|
||||
Первая часть программы опирается на шесть существующих заданий из
|
||||
[`docs/labs/`](../labs/README.md). Пять ТЗ по витринам входят в первую ступень
|
||||
основной программы и выполняются как есть. Их точечный пересмотр — работа
|
||||
реализации. Шестое задание, про свой дашборд в Superset, остается факультативом
|
||||
в разделе «Superset и BI». Набор уроков пяти витрин уже записан в их общем
|
||||
описании: от слоя как договора до честных имен показателей.
|
||||
|
||||
Форма — построить свое по ТЗ. Обратную связь дает даг `homework_check`.
|
||||
|
||||
### Ступень 2. Свой отчет в Superset
|
||||
|
||||
Менти строит один график в Superset поверх витрины, которую сделал на первой
|
||||
ступени. Урок — увидеть, как собственный результат из хранилища становится
|
||||
отчетом в BI, не превращая знакомство с инструментом в отдельный большой
|
||||
проект.
|
||||
|
||||
Форма — построить свое по ТЗ. Результат проверяется глазами. ТЗ для этой новой
|
||||
ступени предстоит написать при реализации программы. Полный собственный дашборд
|
||||
остается факультативом в разделе «Superset и BI».
|
||||
|
||||
### Ступень 3. Нарежь сессии сам
|
||||
|
||||
До того как открыть стендовый SQL нарезки сессий в DDS, менти самостоятельно
|
||||
делит события на визиты. Сначала он сравнивает число своих сессий с эталоном
|
||||
`VisitID`. Затем вручную соединяет свою сессию с эталонной и поштучно разбирает
|
||||
хотя бы одно расхождение границ. Урок — счет называет проблему, но причину
|
||||
приходится искать в отдельных строках.
|
||||
|
||||
Форма — построить свое по ТЗ и провести расследование. Обратная связь состоит
|
||||
из счетной сверки с `VisitID` и ручного сопоставления границ.
|
||||
|
||||
### Ступень 4. Перепиши нарезку стенда
|
||||
|
||||
Менти удаляет и заново пишет SQL нарезки сессий DDS: расчет объема, пересборку
|
||||
и замену. Урок — изменить расчетную сущность внутри готового конвейера и
|
||||
проверить ее внешним эталоном. Это единственный поток, для которого
|
||||
инвентаризация подтвердила вычитание без оговорок: здесь есть настоящие места
|
||||
для ошибок, а проверка не содержит готового ответа.
|
||||
|
||||
Форма — вычитание. Обратную связь дают `dq_check` и поштучная сверка,
|
||||
построенная на третьей ступени.
|
||||
|
||||
### Ступень 5. Сначала сторож, потом ломай: заказы
|
||||
|
||||
Сначала менти строит две недостающие DQ-проверки. Первая утверждает равенство
|
||||
«цена × количество = items_total». Вторая сверяет слой с источником по дням. Их
|
||||
менти строит по образцу пяти стендовых проверок в `sql/dq/`. Обе проверки
|
||||
должны покраснеть на управляемой ошибке. Затем менти вручную подкладывает по
|
||||
строке каждого класса брака, проверяет разбор и убирает эти строки за собой.
|
||||
|
||||
Только после этого он вычитает разбор заказов ODS `order_parse` и трансформацию
|
||||
заказа DDS. Урок — сторожа строят до рискованного изменения, а не после первой
|
||||
поломки.
|
||||
|
||||
Форма — построить свои проверки по ТЗ, провести управляемое расследование и
|
||||
выполнить вычитание. Обратную связь дают две новые проверки, баланс годных и
|
||||
ошибочных строк по срезу и существующие сверки заказов.
|
||||
|
||||
### Ступень 6. Разбери сырые события и сверь результат со стендом
|
||||
|
||||
Менти пакетно разбирает срез сырья событий в собственную таблицу и построчно
|
||||
сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока
|
||||
отдельно от живого приема и использовать стендовый результат как образец для
|
||||
сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет
|
||||
сообщения.
|
||||
|
||||
Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее
|
||||
границы называются прямо: срок работы ограничен окном хранения сырья, а
|
||||
содержимое неопорных колонок можно сравнить только со стендовым разбором, не с
|
||||
независимым эталоном.
|
||||
|
||||
### Ступень 7. Мониторинг: куда смотреть
|
||||
|
||||
Сначала менти знакомится с панелями стенда. Затем проводит два безопасных
|
||||
расследования. В первом он находит в `query_log`, что верх списка тяжелых
|
||||
запросов занимает сам мониторинг. Во втором объясняет, почему свежесть второго
|
||||
источника выглядит как пила, а не как линия, и выбирает правильный слой данных
|
||||
для панели.
|
||||
|
||||
Форма — расследование. Обратная связь — найденные запросы и верное объяснение
|
||||
формы графика и выбора слоя. Ломательные опыты остаются в факультативе «Что
|
||||
делать, когда стенд сломался». Урок ступени — куда и на что смотреть в живой
|
||||
системе.
|
||||
|
||||
**Общее правило вычитания:** ТЗ каждого вычитания на ступенях 4 и 5 включает
|
||||
пересчет ключа шардирования по `hostName()`. Счетные сверки читают `_dist`,
|
||||
поэтому учитывают строку даже на чужом шарде и остаются зелеными: они не видят
|
||||
саму ошибку раскладки.
|
||||
|
||||
## 4. Факультативы — пять разделов
|
||||
|
||||
Ниже перечислены только названия сюжетов и их уроки. Материал, формы и способы
|
||||
проверки остаются в [инвентаризации](../research/2026-08-31-labs-inventory.md).
|
||||
|
||||
### Почему выручка не сходится
|
||||
|
||||
Этот раздел рекомендован первым из факультативов. Он открывается после ступени
|
||||
1 основной программы: материал живет на стенде с этапа 6.
|
||||
|
||||
- **Почему трекер недосчитывает покупки (#186).** Разрыв воронки подтверждений
|
||||
показывает потерянные события и границу доверия к двум источникам.
|
||||
- **Почему повтор покупки завышает выручку (#187).** Движок убирает технический
|
||||
дубль строки, а повтор одной покупки приходится находить по деловому ключу.
|
||||
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
|
||||
разложить до конкретной вычеркнутой позиции и объяснить до копейки.
|
||||
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
|
||||
некоторые различия после истечения срока сырья уже не восстановить.
|
||||
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день
|
||||
становится окончательным лишь после того, как источник больше не может
|
||||
изменить этот день.
|
||||
- **Почему выручка за день еще меняется.** Внутри окна изменяемости выручка
|
||||
движется, а после закрытия дня фиксируется.
|
||||
|
||||
### Тонкий анализ данных
|
||||
|
||||
Открывается после ступени 1 основной программы.
|
||||
|
||||
- **Что мы знали о пользователе в тот день (#145).** Текущее знание и знание на
|
||||
дату дают разные отчеты; сведения из будущего меняют прошлое.
|
||||
- **Добавь названия регионов в отчет (#143).** Менти собирает свой словарь по
|
||||
образцу каталога и обогащает отчет через `dictGet`.
|
||||
|
||||
### Superset и BI
|
||||
|
||||
Открывается после ступени 2 основной программы.
|
||||
|
||||
- **Свой дашборд.** Готовая лаба
|
||||
[`docs/labs/superset-dashboard.md`](../labs/superset-dashboard.md) учит
|
||||
хранить пользовательские объекты отдельно от стендовых.
|
||||
- **Почему дашборд не видит объект на второй ноде.** Забытый `ON CLUSTER` может
|
||||
остаться незаметным до тех пор, пока дашборд не обратится ко второй ноде.
|
||||
|
||||
### Инженерия стенда
|
||||
|
||||
Открывается после ступени 1 основной программы. Сюжеты про переигрывание дня и
|
||||
пошардовую атомарность раскроются полнее после ступени 4, когда менти уже
|
||||
спускался в DDS.
|
||||
|
||||
- **Как принимать данные из Kafka: потоком или слепком.** Поток и слепок
|
||||
требуют разных способов приема и дают разную наблюдаемость.
|
||||
- **Почему данные попадают на свои шарды независимо от ноды чтения.**
|
||||
Нода-потребитель может меняться, а раскладку строк определяет ключ
|
||||
шардирования.
|
||||
- **Почему вторая группа потребителей дублирует поток.** Еще одна группа
|
||||
потребителей создает еще одну полную копию потока.
|
||||
- **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах
|
||||
показывает потерю, которую трудно заметить по самим данным.
|
||||
- **Почему сырье удаляется по времени загрузки.** Очистка сырья живет по
|
||||
времени загрузки, а не по модельному дню.
|
||||
- **Куда попадают битые строки.** Годные строки и брак делят поток без
|
||||
пропусков и пересечений; менти подкладывает по строке каждого класса брака
|
||||
событий и находит их в `ods.event_errors_dist`.
|
||||
- **Какая версия заказа считается текущей.** Версию заказа задает слепок
|
||||
источника, а `FINAL` скрывается за представлением.
|
||||
- **Почему один момент попадает в разные дни.** Один момент дает разные дни в
|
||||
разных поясах, поэтому день и пояс нужно называть явно.
|
||||
- **Проверь, как стенд разобрал JSON в `ecommerce`.** Типизированный разбор
|
||||
может молча подменить ошибку значением, поэтому менти сам разбирает сырой
|
||||
JSON и сравнивает результат с массивами стенда.
|
||||
- **Как конвейер восстанавливает пропущенный день.** Конвейер сам находит дыру
|
||||
и определяет объем повторной обработки по состоянию хранилища.
|
||||
- **Что будет, если день заменился не на всех шардах.** Замена дня завершается
|
||||
отдельно на каждом шарде, поэтому целостность результата подтверждает
|
||||
отдельная сверка.
|
||||
- **Как обновлять витрину без полной пересборки (#149) — повышенная
|
||||
сложность.** Экономия на пересчете требует хранить историю изменений, которую
|
||||
полная пересборка стирает.
|
||||
|
||||
### Что делать, когда стенд сломался
|
||||
|
||||
Открывается после ступени 7 основной программы; весь раздел — повышенная
|
||||
сложность.
|
||||
|
||||
- **Что ломается вместе с keeper.** Остановка keeper связывает состояние DDL и
|
||||
репликации с тем, что видно на панелях и в системных таблицах.
|
||||
- **Где искать запрос DDL, который не завершился.** Очередь распределенного DDL
|
||||
показывает путь запроса и ответ каждой ноды.
|
||||
|
||||
Итог раздела — runbook, которого сейчас не хватает стенду. Менти пишет его по
|
||||
результатам опытов, и документ остается в проекте.
|
||||
|
||||
## 5. Границы вычитания по потокам
|
||||
|
||||
Подробное сравнение потоков и таблица доказательств остаются в
|
||||
[инвентаризации](../research/2026-08-31-labs-inventory.md). Спека берет это
|
||||
сравнение за основу и проводит свои границы. Без оговорок вычитанию отдаются
|
||||
только сессии DDS. Заказы можно вычитать лишь после самодельных сторожей. Для
|
||||
событий ODS выбрана форма «собери рядом»; вычитание здесь отклонено. Прием STG
|
||||
и забор заказов остаются несущей стеной и доступны только для расследований.
|
||||
Карту идентичностей спека решает не отдавать вычитанию; причины этого выбора
|
||||
названы в следующем разделе.
|
||||
|
||||
## 6. Что не вошло
|
||||
|
||||
Вычитание стендовых витрин DM не входит в программу. Оно повторяет урок
|
||||
готового ядра DM, а открытые сверочные запросы в `sql/dq/` почти дословно
|
||||
содержат ответ.
|
||||
|
||||
Форма «собери рядом» для карты идентичностей тоже не входит. Независимого
|
||||
эталона нет, а слабые инварианты проверяют допустимость связей, но не полноту.
|
||||
Аналитический урок идентичности уже несет склейка во времени (#145) в
|
||||
факультативе «Тонкий анализ данных».
|
||||
|
||||
Сюжеты, которые инвентаризация относит к лекциям, остаются лекциями: `Sign` и
|
||||
CollapsingMergeTree, топологии в бою и идентичность «как в бою». Эта спека
|
||||
оставляет их в
|
||||
[списке опорных точек раздела 12 спеки v2](2026-07-30-stand-v2-realism.md#опорные-точки-для-будущих-лекций-и-лаб).
|
||||
|
||||
## 7. Судьба идей-кандидатов
|
||||
|
||||
Эти решения закрывают тикеты после приемки спеки:
|
||||
|
||||
- #143, справочник регионов — вошла в факультатив «Тонкий анализ данных».
|
||||
- #145, склейка во времени — вошла в факультатив «Тонкий анализ данных».
|
||||
- #148, домашки ниже DM и вычитание — вошла в основную программу как ступени
|
||||
4–6. Форму для каждого потока уточнила инвентаризация #201: вычитание,
|
||||
«сначала сторож» или «собери рядом».
|
||||
- #149, инкрементальный `daily_traffic` — вошла в факультатив «Инженерия
|
||||
стенда» с пометкой «повышенная сложность».
|
||||
- #186, воронка подтверждений — вошла в факультатив «Почему выручка не
|
||||
сходится».
|
||||
- #187, бизнес-дубль — вошла в факультатив «Почему выручка не сходится».
|
||||
- #188, опоздавшие заказы — вошла в факультатив «Почему выручка не сходится».
|
||||
|
||||
## 8. Отклоненные варианты оси
|
||||
|
||||
- **Чистое «снизу вверх»: STG → ODS → DDS → DM.** Оно начинает программу с
|
||||
самого тяжелого по инфраструктуре участка. Внизу быстро кончается эталон
|
||||
обратной связи, а вычитание вслепую заставляет переписывать слой, которым
|
||||
менти еще не пользовался.
|
||||
- **Два прохода: сначала расследования снизу вверх, потом постройка сверху
|
||||
вниз.** Пролог из восьми расследований откладывает первый собственный
|
||||
результат. Вместо этого расследования разнесены по факультативам и
|
||||
открываются по мере прохождения основной программы.
|
||||
- **Сюжетами поперек слоев.** Такой порядок ломает принятое решение, по
|
||||
которому готовые задания открывают программу, и превращает путь в сетку
|
||||
зависимостей.
|
||||
|
||||
## 9. Границы спеки и что дальше
|
||||
|
||||
Спека не переписывает существующие ТЗ в `docs/labs/` и общий README; точечный
|
||||
пересмотр относится к реализации.
|
||||
|
||||
Реализация программы тоже находится за границей спеки. В нее входят новые ТЗ
|
||||
для ступеней 2–7 основной программы и факультативов, а также отражение порядка
|
||||
программы в `docs/labs/README.md`. Карту #182 приемка этой спеки закрыла;
|
||||
исполнение ведет отдельная карта — тикет #206 (решение тикета #203).
|
||||
+6
-1
@@ -1,4 +1,4 @@
|
||||
.PHONY: lint typecheck test docs inventory
|
||||
.PHONY: lint typecheck test demo docs inventory
|
||||
|
||||
# --- Проверки ---
|
||||
|
||||
@@ -12,6 +12,11 @@ typecheck:
|
||||
test:
|
||||
uv run pytest
|
||||
|
||||
# --- Пример из путеводителя ---
|
||||
|
||||
demo:
|
||||
uv run python -m clickstream_generator.demo
|
||||
|
||||
# --- Сборка из кода ---
|
||||
|
||||
docs:
|
||||
|
||||
+6
-25
@@ -13,31 +13,11 @@
|
||||
|
||||
## Как это работает
|
||||
|
||||
> Раздел черновой. По сути он верен, но на понятность читателем со стороны не
|
||||
> выверен: пишет его тот, кто держит устройство генератора в голове, а это
|
||||
> худший судья понятности. Выверка — вместе с путеводителем, тикет #48.
|
||||
|
||||
Цепочка одна и всегда та же: корневое зерно → подпотоки по позиции в дереве
|
||||
→ когорта дня (кто впервые пришёл, сколько раз вернётся, чей паспорт какой)
|
||||
→ аудитория дня, то есть когорта плюс возвраты когорт окна → визиты этих кук
|
||||
→ строки событий. Мир при этом ленив: когорта считается по требованию, а до
|
||||
D0 живёт предыстория, поэтому любой день собирается сам по себе — прожитая
|
||||
история для него не нужна и на него не влияет.
|
||||
|
||||
Главное правило для того, кто придёт следующим: **внутри подпотока порядок
|
||||
бросков — часть контракта.** Броски раздаёт один генератор подряд, и k-й
|
||||
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
|
||||
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
|
||||
значит сдвинуть все броски после него, а с ними и весь мир: события того же
|
||||
дня станут другими, счётчики канонического мира разойдутся с описью, и
|
||||
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
|
||||
`plan.cohort` брошены последними, а личность человека приписана уже за ними:
|
||||
куки, пары и паспорта остались теми же до байта.
|
||||
|
||||
Случайность целочисленная — только диапазоны и выбор по целым весам: готовые
|
||||
распределения numpy расходятся между версиями и архитектурами, а обещано
|
||||
побайтовое совпадение (спека, раздел 2). Отсюда `weights.py` вместо
|
||||
`rng.choice` с вероятностями.
|
||||
Корневое зерно ветвится на подпотоки, из них складываются когорты, аудитория
|
||||
дня, визиты, строки событий и заказы. План поднимает состав мира по требованию,
|
||||
а день-функция проживает один день без накопленного состояния. Код этой
|
||||
цепочки, ее схема и запуск D0 собраны в
|
||||
[путеводителе](../docs/architecture/generator/README.md).
|
||||
|
||||
## Что где лежит
|
||||
|
||||
@@ -104,6 +84,7 @@ D0 живёт предыстория, поэтому любой день соб
|
||||
- `make test` — тесты генератора;
|
||||
- `make lint` — ruff: проверка и формат;
|
||||
- `make typecheck` — ty: проверка типов;
|
||||
- `make demo` — пройти стадии канонического D0 на одном экране;
|
||||
- `make docs` — пересобрать описание выгрузки;
|
||||
- `make inventory` — пересобрать опись мира.
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
@@ -160,6 +161,14 @@ class Purchases:
|
||||
return self.person_id.size
|
||||
|
||||
|
||||
class EventOutcome(IntEnum):
|
||||
"""Что случилось с маячком purchase после создания заказа."""
|
||||
|
||||
DELIVERED = 0
|
||||
LOST = 1
|
||||
DUPLICATED = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Events:
|
||||
"""Строки одного вида торговых событий, готовые встать в поток.
|
||||
@@ -177,9 +186,18 @@ class _Events:
|
||||
basket: NDArray[np.int64]
|
||||
|
||||
|
||||
# Что торговая половина отдаёт дню: поток целиком — колонки, страницы и
|
||||
# товары — и покупки дня вторым выходом.
|
||||
Woven = tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], Purchases]
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Woven:
|
||||
"""Что торговая половина отдает дню после вплетения событий."""
|
||||
|
||||
columns: dict[str, NDArray[Any]]
|
||||
page: NDArray[np.uint8]
|
||||
product: NDArray[np.int64]
|
||||
purchases: Purchases
|
||||
event_outcome: NDArray[np.int8]
|
||||
# Для каждой покупки: назначен ли её заказ планом.
|
||||
assigned_order: NDArray[np.bool_]
|
||||
|
||||
|
||||
# День без единой корзины: покупок в нём нет, а форма у рядов есть.
|
||||
_NO_PURCHASES = Purchases(
|
||||
@@ -191,6 +209,8 @@ _NO_PURCHASES = Purchases(
|
||||
revenue=np.empty(0, dtype=np.int64),
|
||||
moment=np.empty(0, dtype="datetime64[s]"),
|
||||
)
|
||||
_NO_EVENT_OUTCOMES = np.empty(0, dtype=np.int8)
|
||||
_NO_ASSIGNED_ORDERS = np.empty(0, dtype=np.bool_)
|
||||
|
||||
|
||||
def weave(
|
||||
@@ -200,6 +220,7 @@ def weave(
|
||||
page: NDArray[np.uint8],
|
||||
product: NDArray[np.int64],
|
||||
person: NDArray[np.uint64],
|
||||
assigned_order: NDArray[np.bool_],
|
||||
) -> Woven:
|
||||
"""Вплетает торговые события в поток и отдаёт поток и покупки дня.
|
||||
|
||||
@@ -207,14 +228,21 @@ def weave(
|
||||
события встают между ними, и поток пересобирается одним порядком. Второй
|
||||
выход — покупки дня: то же самое, чем они уехали в события.
|
||||
|
||||
`person` — человек за кукой каждой строки, выровненный по входящему
|
||||
потоку: личность приходит от плана состава, а не выводится из трекера
|
||||
(docs/architecture/orders/identity.md).
|
||||
`person` и `assigned_order` выровнены по входящему потоку. Оба
|
||||
признака приходят из плана состава: личность дает заказу `user_id`,
|
||||
а назначение защищает мост склейки от потери purchase.
|
||||
"""
|
||||
rng = day_stream(seed, day, Component.COMMERCE)
|
||||
baskets = _baskets(rng, page, product, columns["VisitID"])
|
||||
if not len(baskets):
|
||||
return columns, page, product, _NO_PURCHASES
|
||||
return Woven(
|
||||
columns=columns,
|
||||
page=page,
|
||||
product=product,
|
||||
purchases=_NO_PURCHASES,
|
||||
event_outcome=_NO_EVENT_OUTCOMES,
|
||||
assigned_order=_NO_ASSIGNED_ORDERS,
|
||||
)
|
||||
|
||||
goods = catalog.catalog()
|
||||
draws = _draws(rng, baskets)
|
||||
@@ -225,8 +253,20 @@ def weave(
|
||||
_order_events(columns, baskets, goods, draws, revenue, coupons),
|
||||
)
|
||||
columns, page, product, basket = _stream(rng, columns, page, product, events)
|
||||
purchases = _purchases(columns, basket, baskets, draws, revenue, coupons, person)
|
||||
return columns, page, product, purchases
|
||||
purchases, assigned = _purchases(
|
||||
columns, basket, baskets, draws, revenue, coupons, person, assigned_order
|
||||
)
|
||||
columns, page, product, event_outcome = _corrupt_events(
|
||||
seed, day, columns, page, product, purchases, assigned
|
||||
)
|
||||
return Woven(
|
||||
columns=columns,
|
||||
page=page,
|
||||
product=product,
|
||||
purchases=purchases,
|
||||
event_outcome=event_outcome,
|
||||
assigned_order=assigned,
|
||||
)
|
||||
|
||||
|
||||
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
|
||||
@@ -653,7 +693,8 @@ def _purchases(
|
||||
revenue: NDArray[np.int64],
|
||||
coupons: NDArray[np.object_],
|
||||
person: NDArray[np.uint64],
|
||||
) -> Purchases:
|
||||
assigned_order: NDArray[np.bool_],
|
||||
) -> tuple[Purchases, NDArray[np.bool_]]:
|
||||
"""Покупки дня — то же, чем они уехали в события, только структурой.
|
||||
|
||||
Порядок здесь — порядок строк потока, он же порядок номеров заказа:
|
||||
@@ -664,7 +705,7 @@ def _purchases(
|
||||
here = np.flatnonzero(rows["EventType"] == PURCHASE)
|
||||
mine = basket[here]
|
||||
bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()]
|
||||
return Purchases(
|
||||
purchases = Purchases(
|
||||
order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()),
|
||||
person_id=person[baskets.confirmation[mine]],
|
||||
product=tuple(baskets.product[group] for group in bought),
|
||||
@@ -673,6 +714,118 @@ def _purchases(
|
||||
revenue=revenue[mine],
|
||||
moment=rows["UTCEventTime"][here],
|
||||
)
|
||||
return purchases, assigned_order[baskets.confirmation[mine]]
|
||||
|
||||
|
||||
def _corrupt_events(
|
||||
seed: int,
|
||||
day: int,
|
||||
rows: dict[str, NDArray[Any]],
|
||||
page: NDArray[np.uint8],
|
||||
product: NDArray[np.int64],
|
||||
purchases: Purchases,
|
||||
assigned: NDArray[np.bool_],
|
||||
) -> tuple[
|
||||
dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], NDArray[np.int8]
|
||||
]:
|
||||
"""Теряет purchase или повторяет пару подтверждения.
|
||||
|
||||
Заказы уже собраны, а номера покупок уже вписаны в строки. Поэтому
|
||||
порча меняет только событийную сторону. Все броски делаются на полную
|
||||
длину покупок дня, чтобы отбор одного класса не сдвигал другой.
|
||||
"""
|
||||
count = len(purchases)
|
||||
if not count:
|
||||
return rows, page, product, _NO_EVENT_OUTCOMES
|
||||
|
||||
rng = day_stream(seed, day, Component.BEACONS)
|
||||
lost = (rng.integers(0, 100, count) < world.LOST_PURCHASE_PERCENT) & ~assigned
|
||||
duplicate_draw = rng.integers(0, 100, count) < world.DUPLICATE_PURCHASE_PERCENT
|
||||
delay = rng.integers(*world.DUPLICATE_DELAY_SECONDS, count)
|
||||
watch = ids.unique_apart_from(rng, 2 * count, rows["WatchID"]).reshape(count, 2)
|
||||
|
||||
purchase_rows = np.flatnonzero(rows["EventType"] == PURCHASE)
|
||||
confirmation_rows = _confirmation_rows(rows, page, purchase_rows)
|
||||
eligible = _duplicate_eligibility(rows, purchase_rows, delay)
|
||||
duplicated = ~lost & duplicate_draw & eligible
|
||||
|
||||
outcome = np.full(count, EventOutcome.DELIVERED, dtype=np.int8)
|
||||
outcome[lost] = EventOutcome.LOST
|
||||
outcome[duplicated] = EventOutcome.DUPLICATED
|
||||
|
||||
keep = np.ones(page.size, dtype=bool)
|
||||
keep[purchase_rows[lost]] = False
|
||||
sources = np.column_stack(
|
||||
(confirmation_rows[duplicated], purchase_rows[duplicated])
|
||||
)
|
||||
selected = np.flatnonzero(duplicated)
|
||||
if selected.size:
|
||||
copies = sources.ravel()
|
||||
shifted = np.repeat(delay[selected], 2).astype("timedelta64[s]")
|
||||
additions = {name: values[copies] for name, values in rows.items()}
|
||||
additions["UTCEventTime"] = additions["UTCEventTime"] + shifted
|
||||
additions["WatchID"] = watch[selected].ravel()
|
||||
# Первая строка пары — обновление confirmation: ее предыдущая
|
||||
# страница — она же, а не checkout из первого просмотра.
|
||||
additions["Referer"] = additions["URL"].copy()
|
||||
rows = {
|
||||
name: np.concatenate((values[keep], additions[name]))
|
||||
for name, values in rows.items()
|
||||
}
|
||||
page = np.concatenate((page[keep], page[copies]))
|
||||
product = np.concatenate((product[keep], product[copies]))
|
||||
else:
|
||||
rows = {name: values[keep] for name, values in rows.items()}
|
||||
page = page[keep]
|
||||
product = product[keep]
|
||||
|
||||
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
|
||||
return (
|
||||
{name: values[order] for name, values in rows.items()},
|
||||
page[order],
|
||||
product[order],
|
||||
outcome,
|
||||
)
|
||||
|
||||
|
||||
def _confirmation_rows(
|
||||
rows: dict[str, NDArray[Any]],
|
||||
page: NDArray[np.uint8],
|
||||
purchase_rows: NDArray[np.int64],
|
||||
) -> NDArray[np.int64]:
|
||||
"""Находит просмотр, с которым purchase образует пару."""
|
||||
confirmation = (rows["EventType"] == "pageview") & (page == Page.CONFIRMATION)
|
||||
by_visit = {
|
||||
int(rows["VisitID"][row]): row for row in np.flatnonzero(confirmation).tolist()
|
||||
}
|
||||
return np.array(
|
||||
[by_visit[int(rows["VisitID"][row])] for row in purchase_rows.tolist()],
|
||||
dtype=np.int64,
|
||||
)
|
||||
|
||||
|
||||
def _duplicate_eligibility(
|
||||
rows: dict[str, NDArray[Any]],
|
||||
purchase_rows: NDArray[np.int64],
|
||||
delay: NDArray[np.int64],
|
||||
) -> NDArray[np.bool_]:
|
||||
"""Проверяет полночь и запас до следующего визита куки."""
|
||||
moment = rows["UTCEventTime"][purchase_rows]
|
||||
shifted = moment + delay.astype("timedelta64[s]")
|
||||
before_midnight = shifted < _midnight(rows) + np.timedelta64(1, "D")
|
||||
eligible = before_midnight.copy()
|
||||
for number, row in enumerate(purchase_rows.tolist()):
|
||||
later_visit = (
|
||||
(rows["ClientID"] == rows["ClientID"][row])
|
||||
& (rows["VisitID"] != rows["VisitID"][row])
|
||||
& (rows["UTCEventTime"] > moment[number])
|
||||
)
|
||||
if np.any(later_visit):
|
||||
next_start = rows["UTCEventTime"][later_visit].min()
|
||||
eligible[number] &= next_start - moment[number] > np.timedelta64(
|
||||
world.VISIT_TIMEOUT_SECONDS + int(delay[number]), "s"
|
||||
)
|
||||
return eligible
|
||||
|
||||
|
||||
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
|
||||
|
||||
@@ -100,6 +100,8 @@ class Day:
|
||||
page: NDArray[np.uint8]
|
||||
product: NDArray[np.int64]
|
||||
orders: orders.Orders
|
||||
# Событийный исход каждого заказа, в том же порядке.
|
||||
purchase_outcome: NDArray[np.int8]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.page.size
|
||||
@@ -162,20 +164,23 @@ def stream(seed: int, day: int) -> Day:
|
||||
# целиком: в нём же они и упорядочиваются. Вторым выходом приходят
|
||||
# покупки дня — из них заказная сторона собирает заказы.
|
||||
person = np.repeat(audience.person_id[visits.cookie], visits.pages)
|
||||
columns, page, product, purchases = commerce.weave(
|
||||
assigned_order = np.repeat(visits.ordering, visits.pages)
|
||||
woven = commerce.weave(
|
||||
seed,
|
||||
day,
|
||||
{name: value[order] for name, value in columns.items()},
|
||||
page[alive][order],
|
||||
product[alive][order],
|
||||
person[alive][order],
|
||||
assigned_order[alive][order],
|
||||
)
|
||||
return Day(
|
||||
day=day,
|
||||
columns=columns,
|
||||
page=page,
|
||||
product=product,
|
||||
orders=orders.of_day(seed, day, purchases),
|
||||
columns=woven.columns,
|
||||
page=woven.page,
|
||||
product=woven.product,
|
||||
orders=orders.of_day(seed, day, woven.purchases, woven.assigned_order),
|
||||
purchase_outcome=woven.event_outcome,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Короткий проход по цепочке генератора на каноническом дне D0."""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
import numpy as np
|
||||
|
||||
from clickstream_generator import day, plan, seeds, world
|
||||
|
||||
DAY = 0
|
||||
|
||||
|
||||
def _number(value: int) -> str:
|
||||
"""Разделяет разряды числа для чтения с экрана."""
|
||||
return f"{value:,}".replace(",", " ")
|
||||
|
||||
|
||||
def render() -> str:
|
||||
"""Собирает D0 и показывает его стадии без числового эталона."""
|
||||
seed = seeds.CANONICAL_SEED
|
||||
cohort = plan.cohort(seed, DAY)
|
||||
audience = plan.audience(seed, DAY)
|
||||
events = day.stream(seed, DAY)
|
||||
|
||||
_, rows_per_visit = np.unique(events.columns["VisitID"], return_counts=True)
|
||||
event_types = Counter(str(value) for value in events.columns["EventType"])
|
||||
smallest_visit = int(rows_per_visit.min())
|
||||
largest_visit = int(rows_per_visit.max())
|
||||
|
||||
return "\n".join(
|
||||
(
|
||||
f"Зерно: {seed}",
|
||||
"Подпотоки D0: состав мира → когорта; день → трафик, торговля, заказы",
|
||||
(
|
||||
f"Когорта D0: людей — {_number(cohort.people)}; "
|
||||
f"кук — {_number(len(cohort.client_id))}"
|
||||
),
|
||||
(
|
||||
f"Аудитория D0: кук — {_number(len(audience.client_id))}; "
|
||||
f"когорты D-{world.RETURN_TAIL_DAYS}...D0"
|
||||
),
|
||||
(
|
||||
f"Визиты по VisitID: групп — {_number(len(rows_per_visit))}; "
|
||||
f"строк в группе — {smallest_visit}–{largest_visit}"
|
||||
),
|
||||
(
|
||||
f"Строки событий: {_number(len(events))} "
|
||||
f"(pageview — {_number(event_types['pageview'])}; "
|
||||
f"add_to_cart — {_number(event_types['add_to_cart'])}; "
|
||||
f"purchase — {_number(event_types['purchase'])}); "
|
||||
f"заказов — {_number(len(events.orders))}"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Печатает проход по D0."""
|
||||
print(render())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
|
||||
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
|
||||
Эталонный снимок в четырнадцать дней придёт на этапе 7 и станет продолжением
|
||||
этой же описи, а не вторым файлом.
|
||||
Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 —
|
||||
у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот
|
||||
же файл, а не заведет второй.
|
||||
|
||||
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
|
||||
это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир
|
||||
@@ -119,17 +120,38 @@ def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]
|
||||
|
||||
|
||||
def _order_class_counts(today: day_module.Day) -> dict[str, int]:
|
||||
"""Заказы дня по итоговому классу: отмена перевешивает дельту суммы."""
|
||||
"""Заказы дня по итоговому классу по приоритету спеки."""
|
||||
purchase = today.columns["EventType"] == commerce.PURCHASE
|
||||
declared = today.columns["purchaseRevenue"][purchase]
|
||||
counts = {"match": 0, "cancelled": 0, "amount_delta": 0}
|
||||
declared = {
|
||||
purchase_id[0]: round(revenue[0] * commerce.KOPECKS)
|
||||
for purchase_id, revenue in zip(
|
||||
today.columns["purchaseID"][purchase],
|
||||
today.columns["purchaseRevenue"][purchase],
|
||||
strict=True,
|
||||
)
|
||||
}
|
||||
counts = {
|
||||
"match": 0,
|
||||
"cancelled": 0,
|
||||
"lost_event": 0,
|
||||
"duplicate_event": 0,
|
||||
"amount_delta": 0,
|
||||
}
|
||||
|
||||
for outcome, items_total, revenue in zip(
|
||||
today.orders.outcome, today.orders.items_total, declared, strict=True
|
||||
for order_id, order_outcome, event_outcome, items_total in zip(
|
||||
today.orders.order_id,
|
||||
today.orders.outcome,
|
||||
today.purchase_outcome,
|
||||
today.orders.items_total,
|
||||
strict=True,
|
||||
):
|
||||
if outcome != orders_module.OrderOutcome.PAID:
|
||||
if order_outcome != orders_module.OrderOutcome.PAID:
|
||||
counts["cancelled"] += 1
|
||||
elif items_total != round(revenue[0] * commerce.KOPECKS):
|
||||
elif event_outcome == commerce.EventOutcome.LOST:
|
||||
counts["lost_event"] += 1
|
||||
elif event_outcome == commerce.EventOutcome.DUPLICATED:
|
||||
counts["duplicate_event"] += 1
|
||||
elif items_total != declared[order_id]:
|
||||
counts["amount_delta"] += 1
|
||||
else:
|
||||
counts["match"] += 1
|
||||
|
||||
@@ -24,11 +24,13 @@
|
||||
одной доли перебрасывала бы весь подпоток после себя
|
||||
(docs/architecture/orders/fate.md).
|
||||
|
||||
**Судьба заказа — исход и его моменты**: из окна изменяемости заказ выходит
|
||||
оплаченным или отменённым, а когда именно это случилось, сказано смещением в
|
||||
секундах от рождения заказа. Момента, которого у исхода нет, нет и в данных:
|
||||
его место занимает −1, а не ноль, — иначе «оплатили в секунду рождения» было
|
||||
бы не отличить от «не оплатили вовсе».
|
||||
**Судьба заказа — исход, его моменты и задержка выгрузки**. Из окна
|
||||
изменяемости заказ выходит оплаченным или отменённым, а когда именно это
|
||||
случилось, сказано смещением в секундах от рождения заказа. Момента, которого
|
||||
у исхода нет, нет и в данных: его место занимает −1, а не ноль, — иначе
|
||||
«оплатили в секунду рождения» было бы не отличить от «не оплатили вовсе».
|
||||
Задержка говорит, сколько ранних слепков заказ пропустит; назначенный планом
|
||||
приезжает сразу.
|
||||
|
||||
**Состояние на границе суток** — чтение готовой судьбы, а не накопление:
|
||||
слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет
|
||||
@@ -62,6 +64,7 @@ _DELIVERY_CUMULATIVE = np.cumsum(
|
||||
)
|
||||
_OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS)
|
||||
_MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS)
|
||||
_SNAPSHOT_DELAY_CUMULATIVE = np.cumsum(world.ORDER_SNAPSHOT_DELAY_WEIGHTS)
|
||||
|
||||
|
||||
class OrderOutcome(IntEnum):
|
||||
@@ -103,12 +106,18 @@ class Orders:
|
||||
outcome: NDArray[np.int64]
|
||||
paid_after: NDArray[np.int64]
|
||||
cancelled_after: NDArray[np.int64]
|
||||
snapshot_delay: NDArray[np.int64]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.user_id.size
|
||||
|
||||
|
||||
def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
|
||||
def of_day(
|
||||
seed: int,
|
||||
day: int,
|
||||
purchases: Purchases,
|
||||
assigned_order: NDArray[np.bool_],
|
||||
) -> Orders:
|
||||
"""Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги."""
|
||||
rng = day_stream(seed, day, Component.ORDERS)
|
||||
delivery = _delivery(rng, len(purchases))
|
||||
@@ -116,6 +125,7 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
|
||||
product, quantity, items_total = _delta(rng, purchases)
|
||||
created_at = _created_at(rng, purchases)
|
||||
discount = _discount(purchases, items_total)
|
||||
snapshot_delay = _snapshot_delay(rng, assigned_order)
|
||||
return Orders(
|
||||
day=day,
|
||||
order_id=purchases.order_id,
|
||||
@@ -130,6 +140,7 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
|
||||
outcome=outcome,
|
||||
paid_after=paid_after,
|
||||
cancelled_after=cancelled_after,
|
||||
snapshot_delay=snapshot_delay,
|
||||
)
|
||||
|
||||
|
||||
@@ -166,6 +177,11 @@ def at_boundary(rows: Orders, day: int) -> tuple[list[str], NDArray[np.datetime6
|
||||
return status.tolist(), updated
|
||||
|
||||
|
||||
def arrived(rows: Orders, day: int) -> NDArray[np.bool_]:
|
||||
"""Какие заказы уже попали в выгрузку к слепку дня `day`."""
|
||||
return rows.day + rows.snapshot_delay <= day
|
||||
|
||||
|
||||
def _boundary(day: int) -> np.datetime64:
|
||||
"""Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика.
|
||||
|
||||
@@ -218,6 +234,15 @@ def _created_at(
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_delay(
|
||||
rng: np.random.Generator,
|
||||
assigned_order: NDArray[np.bool_],
|
||||
) -> NDArray[np.int64]:
|
||||
"""Сколько ранних слепков пропустит заказ; назначенный приезжает сразу."""
|
||||
delay = pick(rng, _SNAPSHOT_DELAY_CUMULATIVE, assigned_order.size)
|
||||
return np.where(assigned_order, 0, delay)
|
||||
|
||||
|
||||
def _moment(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
|
||||
"""Момент внутри окна: час по таблице весов и равномерная секунда в нём.
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
- `name` — имя источника, как в облачной выгрузке Метрики; сырой слой хранит
|
||||
его без изменений.
|
||||
- `clickhouse_type` — тип в хранилище ровно в той записи, в какой его вернёт
|
||||
`system.columns`: параметры входят в имя типа целиком, без сокращений
|
||||
(`LowCardinality(String)`, `Array(Float64)`). Сверено 2026-08-01 —
|
||||
по документации ClickHouse через Context7 и запросом к узлу стенда
|
||||
(26.3.17.56). Запись важна потому, что по ней человек пишет DDL: тип,
|
||||
сокращённый здесь, приедет в таблицу сокращённым же.
|
||||
- `clickhouse_type` — тип колонки у источника в той записи, которой пользуется
|
||||
ClickHouse: параметры входят в имя целиком, без сокращений
|
||||
(`LowCardinality(String)`, `Array(Float64)`). Обычно ODS берет его без
|
||||
изменений. Исключение одно: денежный `purchaseRevenue` приезжает как
|
||||
Float64, а на границе хранения разбирается прямо в Decimal. Поэтому поле
|
||||
описывает выгрузку, а локальный тип ODS назван в документации хранилища.
|
||||
- `numpy_dtype` — чем колонка представлена внутри генератора; у массивов это
|
||||
тип элемента. Строки живут в `object`-массивах: numpy-строки фиксированной
|
||||
длины стенду ничего не дают.
|
||||
@@ -371,8 +371,8 @@ COLUMNS: tuple[Column, ...] = (
|
||||
numpy_dtype="float64",
|
||||
normalized_name="purchase_revenue",
|
||||
group=ColumnGroup.ECOMMERCE,
|
||||
comment="выручка заказа глазами клиента; Float64, как у Метрики —"
|
||||
" на этом держится урок о расхождениях с бэкендом",
|
||||
comment="выручка заказа глазами клиента; источник объявляет Float64,"
|
||||
" ODS хранит точный Decimal",
|
||||
),
|
||||
Column(
|
||||
name="purchaseCurrency",
|
||||
|
||||
@@ -27,10 +27,12 @@ PREAMBLE = """# Описание выгрузки: событие кликстр
|
||||
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
|
||||
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
|
||||
|
||||
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
|
||||
нормализует у себя: то же имя в нашем стиле ждёт в столбце «Нормализованное
|
||||
имя». Это имя источника, приведённое к snake_case, а не имя атрибута в
|
||||
модели данных: слой DDS складывает свою модель и называет атрибуты по ней.
|
||||
Имена и типы колонок описывают сторону источника. Обычно ODS принимает их без
|
||||
изменений. Исключение — `purchaseRevenue`: источник объявляет Float64, а ODS
|
||||
хранит деньги в Decimal. Точный локальный тип назван в документации
|
||||
хранилища. Имя в нашем стиле ждет в столбце «Нормализованное имя». Это имя
|
||||
источника, приведенное к snake_case, а не имя атрибута в модели данных: слой
|
||||
DDS складывает свою модель и называет атрибуты по ней.
|
||||
Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора;
|
||||
у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан
|
||||
контрактом.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
│ ├── ось → номер дня: когорта этого дня
|
||||
│ └── предыстория → глубина: когорта дня до D0
|
||||
└── дни
|
||||
└── номер дня → трафик, торговля, заказная сторона, опоздания
|
||||
└── номер дня → трафик, торговля, заказы, маячки счетчика
|
||||
|
||||
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
|
||||
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
|
||||
@@ -40,10 +40,8 @@ class Component(IntEnum):
|
||||
# каждого заказа читает из его собственного дня
|
||||
# (docs/architecture/orders/fate.md).
|
||||
ORDERS = 2
|
||||
# Событийную сторону — потерю и дубль события — заведёт этап 6; до тех
|
||||
# пор позиция занята мёртвым именем прежней нарезки, и из него никто не
|
||||
# бросает.
|
||||
LATECOMERS = 3
|
||||
# Маячки счетчика: потеря и повторная отправка событий.
|
||||
BEACONS = 3
|
||||
|
||||
|
||||
class _Branch(IntEnum):
|
||||
|
||||
@@ -45,7 +45,7 @@ from numpy.typing import NDArray
|
||||
|
||||
from clickstream_generator import catalog, schema, world
|
||||
from clickstream_generator.day import Day
|
||||
from clickstream_generator.orders import Orders, at_boundary
|
||||
from clickstream_generator.orders import Orders, arrived, at_boundary
|
||||
|
||||
_ARRAY_PREFIX = "Array("
|
||||
|
||||
@@ -72,9 +72,10 @@ def events(day: Day, limit: int | None = None) -> list[bytes]:
|
||||
def orders(window: Sequence[Orders], day: int) -> list[bytes]:
|
||||
"""Канонические байты слепка дня `day`: по документу JSON на заказ.
|
||||
|
||||
`window` — заказы дней окна, от раннего дня к позднему: слепок несёт их
|
||||
подряд, и порядок строк выходит порядком рождения заказов, он же
|
||||
возрастание `order_id`. Какие это дни, решает `orders.window`.
|
||||
`window` — заказы дней окна, от раннего дня к позднему. Уже приехавшие
|
||||
заказы слепок несёт подряд, поэтому порядок строк остаётся порядком
|
||||
рождения, он же возрастание `order_id`. Какие это дни, решает
|
||||
`orders.window`.
|
||||
|
||||
Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они
|
||||
станут `Decimal`, и дробь двоичного числа была бы потерей точности до
|
||||
@@ -97,7 +98,8 @@ def orders(window: Sequence[Orders], day: int) -> list[bytes]:
|
||||
delivery = rows.delivery.tolist()
|
||||
total = rows.total.tolist()
|
||||
|
||||
for number, order_id in enumerate(rows.order_id):
|
||||
for number in np.flatnonzero(arrived(rows, day)).tolist():
|
||||
order_id = rows.order_id[number]
|
||||
payloads.append(
|
||||
orjson.dumps(
|
||||
{
|
||||
|
||||
@@ -169,6 +169,16 @@ BUYER_CHECKOUT_OF_CART_PERCENT = 60
|
||||
# страницу, на которой посетитель его нажал.
|
||||
TRADE_DELAY_SECONDS = (2, 8)
|
||||
|
||||
# Событийная сторона сверки: потерянные маячки purchase и повторная
|
||||
# отправка пары «просмотр подтверждения + purchase». Замер (28 дней,
|
||||
# канонический мир, 2026-08-30): дубль 1,89% — в пределах ориентира ~2%;
|
||||
# потеря 1,78% при броске 3% — так и задумано: назначенные планом покупки
|
||||
# из броска исключены (мост склейки), часть съедает приоритет отмены;
|
||||
# наблюдаемая доля принята решением владельца (#194).
|
||||
LOST_PURCHASE_PERCENT = 3
|
||||
DUPLICATE_PURCHASE_PERCENT = 2
|
||||
DUPLICATE_DELAY_SECONDS = (30, 301)
|
||||
|
||||
# Кто и что кладёт в корзину: доля открытых карточек, которая до неё дошла.
|
||||
# Оба ряда читаются по уровням спроса товара (`catalog.DEMAND_LEVELS`):
|
||||
# магнит, обычный, залёживается.
|
||||
@@ -238,8 +248,8 @@ GOAL_PURCHASE_ID = 42150002
|
||||
# обычная курьерская, срочная. Это единственные деньги заказа, которых нет
|
||||
# ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду
|
||||
# считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок
|
||||
# заказной стороны. Числа черновые: калибровка — пересборка эталонного
|
||||
# мира, этап 7.
|
||||
# заказной стороны. Замер (28 дней, канонический мир, 2026-08-30): 35,5% /
|
||||
# 49,2% / 15,4% — совпадает с весами.
|
||||
DELIVERY_KOPECKS_WEIGHTS = (
|
||||
(0, 35),
|
||||
(29_900, 50),
|
||||
@@ -248,12 +258,13 @@ DELIVERY_KOPECKS_WEIGHTS = (
|
||||
|
||||
# Чем кончается окно изменяемости заказа: веса трёх исходов, в порядке
|
||||
# `orders.OrderOutcome`. «Создан» навсегда мир не допускает — из окна заказ
|
||||
# выходит либо оплаченным, либо отменённым. Доли черновые: калибровка —
|
||||
# пересборка эталонного мира, этап 7.
|
||||
# выходит либо оплаченным, либо отменённым. Замер (28 дней, 2026-08-30):
|
||||
# отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12% отменен
|
||||
# неоплаченным; ориентир мастер-спеки ~5%.
|
||||
ORDER_OUTCOME_WEIGHTS = (
|
||||
95, # оплачен
|
||||
3, # оплачен, потом отменён
|
||||
2, # отменён неоплаченным
|
||||
2, # отменен неоплаченным
|
||||
)
|
||||
|
||||
# Когда внутри окна случается момент заказа — оплата или отмена: веса часов
|
||||
@@ -261,8 +272,8 @@ ORDER_OUTCOME_WEIGHTS = (
|
||||
# с каким весом»; часов в сумме ровно 144 — самый узкий край окна, у заказа,
|
||||
# рождённого в конце суток (docs/architecture/orders/fate.md). Почти всё в
|
||||
# первые сутки, дальше затухание до края:
|
||||
# профиль гаснет, поэтому обрыв окна в данных не виден. Веса черновые, как и
|
||||
# доли исходов.
|
||||
# профиль гаснет, поэтому обрыв окна в данных не виден. Веса зафиксированы
|
||||
# вместе с долями исходов — тем же замером выше.
|
||||
ORDER_MOMENT_HOUR_WEIGHTS = tuple(
|
||||
weight
|
||||
for hours, weight in ((6, 100), (18, 60), (24, 25), (48, 8), (48, 2))
|
||||
@@ -273,6 +284,12 @@ ORDER_MOMENT_HOUR_WEIGHTS = tuple(
|
||||
# наличии, и заказ приезжает на строку короче клиентской корзины. Бросается
|
||||
# доля на все заказы дня, а достаётся дельта не всем — однопозиционному заказу
|
||||
# терять нечего, и таких больше половины, — поэтому в сверке наблюдаемая доля
|
||||
# выходит вдвое-втрое ниже броска, около ориентира мастер-спеки в 1–2%. Число
|
||||
# черновое: калибровка — пересборка эталонного мира, этап 7.
|
||||
# выходит вдвое-втрое ниже броска, около ориентира мастер-спеки в 1–2%. Замер
|
||||
# (28 дней, 2026-08-30): наблюдаемая доля 1,32% — в пределах ориентира.
|
||||
ORDER_DELTA_PERCENT = 4
|
||||
|
||||
# На сколько первых дневных слепков заказ опоздает: веса δ = 0, 1, 2.
|
||||
# Назначенные планом заказы после броска получают 0, чтобы мост склейки
|
||||
# появился в стартовом мире. Замер (28 дней, 2026-08-30): доля опоздавших
|
||||
# (δ>0) 8,39% — в пределах ориентира мастер-спеки ~10%.
|
||||
ORDER_SNAPSHOT_DELAY_WEIGHTS = (90, 5, 5)
|
||||
@@ -21,7 +21,7 @@ from clickstream_generator.seeds import CANONICAL_SEED, Component
|
||||
|
||||
WEEKDAY = 2
|
||||
WEEK = 7
|
||||
# Окно эталонного снимка: доли по уровням спроса меряются на нём целиком —
|
||||
# Окно замера — две недели: доли по уровням спроса меряются на нем целиком —
|
||||
# на одном дне у самого редкого уровня набирается слишком мало карточек.
|
||||
FORTNIGHT = 14
|
||||
|
||||
@@ -133,15 +133,15 @@ def opened_cards(events: day.Day) -> set[tuple[int, int]]:
|
||||
)
|
||||
|
||||
|
||||
def test_a_confirmation_in_the_stream_always_has_its_purchase(weekday: day.Day):
|
||||
"""Клиентская сторона честная: подтверждение без покупки — это потеря.
|
||||
|
||||
Потери и дубли стенд заводит намеренно и позже (этапы 4 и 6); здесь их
|
||||
быть не должно ни одной. Обратное тоже верно: покупка без подтверждения
|
||||
в потоке — заказ ниоткуда.
|
||||
"""
|
||||
def test_only_a_lost_purchase_leaves_a_confirmation_without_purchase(
|
||||
weekday: day.Day,
|
||||
):
|
||||
"""Потеря выбрасывает purchase, но оставляет просмотр confirmation."""
|
||||
bought = set(rows_of(weekday, commerce.PURCHASE)["VisitID"].tolist())
|
||||
assert confirmed_visits(weekday) == bought
|
||||
missing = confirmed_visits(weekday) - bought
|
||||
lost = int((weekday.purchase_outcome == commerce.EventOutcome.LOST).sum())
|
||||
assert len(missing) == lost > 0
|
||||
assert bought <= confirmed_visits(weekday)
|
||||
|
||||
|
||||
def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
|
||||
@@ -159,7 +159,10 @@ def test_the_day_boundary_takes_the_confirmation_together_with_its_purchase(
|
||||
events = day.stream(CANONICAL_SEED, WEEKDAY)
|
||||
bought = set(rows_of(events, commerce.PURCHASE)["VisitID"].tolist())
|
||||
assert bought
|
||||
assert confirmed_visits(events) == bought
|
||||
missing = confirmed_visits(events) - bought
|
||||
lost = int((events.purchase_outcome == commerce.EventOutcome.LOST).sum())
|
||||
assert len(missing) == lost
|
||||
assert bought <= confirmed_visits(events)
|
||||
|
||||
|
||||
def test_a_purchase_carries_exactly_one_order_number(weekday: day.Day):
|
||||
@@ -525,13 +528,15 @@ def test_the_goals_repeat_the_trade_events(weekday: day.Day):
|
||||
|
||||
def test_the_order_number_reads_as_the_day_and_the_count(weekday: day.Day):
|
||||
"""Номер — день и порядковый номер покупки в нём; он же `order_id` бэкенда."""
|
||||
orders = rows_of(weekday, commerce.PURCHASE)
|
||||
numbers = [cell[0] for cell in orders["purchaseID"]]
|
||||
events = rows_of(weekday, commerce.PURCHASE)
|
||||
numbers = [cell[0] for cell in events["purchaseID"]]
|
||||
date = str(weekday.columns["EventDate"][0]).replace("-", "")
|
||||
assert numbers == [f"{date}-{place:04d}" for place in range(1, len(numbers) + 1)]
|
||||
assert len(set(numbers)) == len(numbers)
|
||||
# Нумерация идёт в порядке событий: строки дня уже упорядочены по времени.
|
||||
assert list(orders["UTCEventTime"]) == sorted(orders["UTCEventTime"])
|
||||
expected = [f"{date}-{place:04d}" for place in range(1, len(weekday.orders) + 1)]
|
||||
# Порча идет после нумерации: потеря оставляет дыру, а дубль
|
||||
# повторяет готовый номер и не двигает соседей.
|
||||
assert list(weekday.orders.order_id) == expected
|
||||
assert set(numbers) <= set(expected)
|
||||
assert list(events["UTCEventTime"]) == sorted(events["UTCEventTime"])
|
||||
|
||||
|
||||
def test_the_coupon_comes_from_the_world_table_and_does_not_touch_the_revenue(
|
||||
@@ -599,7 +604,23 @@ def test_the_trade_flow_draws_from_its_own_named_branch(
|
||||
|
||||
monkeypatch.setattr(commerce, "day_stream", spy)
|
||||
day.stream(CANONICAL_SEED, WEEKDAY)
|
||||
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.COMMERCE)]
|
||||
assert asked == [
|
||||
(CANONICAL_SEED, WEEKDAY, Component.COMMERCE),
|
||||
(CANONICAL_SEED, WEEKDAY, Component.BEACONS),
|
||||
]
|
||||
|
||||
|
||||
def test_an_assigned_purchase_is_never_lost(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Гарантия плана сильнее порчи: мост склейки не теряется."""
|
||||
monkeypatch.setattr(world, "LOST_PURCHASE_PERCENT", 100)
|
||||
monkeypatch.setattr(world, "DUPLICATE_PURCHASE_PERCENT", 0)
|
||||
events = day.stream(CANONICAL_SEED, WEEKDAY)
|
||||
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
|
||||
|
||||
promised = set(audience.client_id[audience.assigned_order].tolist())
|
||||
bought = set(rows_of(events, commerce.PURCHASE)["ClientID"].tolist())
|
||||
assert promised
|
||||
assert bought == promised
|
||||
|
||||
|
||||
def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
|
||||
@@ -627,6 +648,78 @@ def test_the_promised_orders_of_a_pair_become_purchases(week: list[day.Day]):
|
||||
assert pairs == plan.counters(CANONICAL_SEED, WEEK).pairs
|
||||
|
||||
|
||||
def test_a_duplicate_repeats_the_confirmation_story(weekday: day.Day):
|
||||
"""Обновление повторяет просмотр и purchase в том же визите."""
|
||||
purchases = rows_of(weekday, commerce.PURCHASE)
|
||||
ids_by_order: dict[str, list[int]] = {}
|
||||
for row, cell in enumerate(purchases["purchaseID"]):
|
||||
ids_by_order.setdefault(str(cell[0]), []).append(row)
|
||||
duplicated = [rows for rows in ids_by_order.values() if len(rows) == 2]
|
||||
|
||||
expected = int((weekday.purchase_outcome == commerce.EventOutcome.DUPLICATED).sum())
|
||||
assert len(duplicated) == expected > 0
|
||||
for pair in duplicated:
|
||||
first, second = sorted(pair, key=lambda row: purchases["UTCEventTime"][row])
|
||||
delay = (
|
||||
(purchases["UTCEventTime"][second] - purchases["UTCEventTime"][first])
|
||||
.astype("timedelta64[s]")
|
||||
.astype(int)
|
||||
)
|
||||
assert world.DUPLICATE_DELAY_SECONDS[0] <= delay
|
||||
assert delay < world.DUPLICATE_DELAY_SECONDS[1]
|
||||
assert delay < world.VISIT_TIMEOUT_SECONDS
|
||||
assert purchases["VisitID"][first] == purchases["VisitID"][second]
|
||||
assert purchases["WatchID"][first] != purchases["WatchID"][second]
|
||||
for name in (
|
||||
"purchaseID",
|
||||
"purchaseRevenue",
|
||||
"productID",
|
||||
"productQuantity",
|
||||
"ecommerce",
|
||||
):
|
||||
left, right = purchases[name][first], purchases[name][second]
|
||||
if isinstance(left, np.ndarray):
|
||||
assert np.array_equal(left, right), name
|
||||
else:
|
||||
assert left == right, name
|
||||
|
||||
visit = purchases["VisitID"][first]
|
||||
confirmation = (
|
||||
(weekday.columns["EventType"] == "pageview")
|
||||
& (weekday.page == Page.CONFIRMATION)
|
||||
& (weekday.columns["VisitID"] == visit)
|
||||
)
|
||||
views = np.flatnonzero(confirmation)
|
||||
assert views.size == 2
|
||||
assert len(set(weekday.columns["WatchID"][views].tolist())) == 2
|
||||
view_delay = (
|
||||
(
|
||||
weekday.columns["UTCEventTime"][views].max()
|
||||
- weekday.columns["UTCEventTime"][views].min()
|
||||
)
|
||||
.astype("timedelta64[s]")
|
||||
.astype(int)
|
||||
)
|
||||
assert view_delay == delay
|
||||
|
||||
cookie = purchases["ClientID"][first]
|
||||
later_visit = (
|
||||
(weekday.columns["ClientID"] == cookie)
|
||||
& (weekday.columns["VisitID"] != visit)
|
||||
& (weekday.columns["UTCEventTime"] > purchases["UTCEventTime"][second])
|
||||
)
|
||||
if np.any(later_visit):
|
||||
gap = (
|
||||
(
|
||||
weekday.columns["UTCEventTime"][later_visit].min()
|
||||
- purchases["UTCEventTime"][second]
|
||||
)
|
||||
.astype("timedelta64[s]")
|
||||
.astype(int)
|
||||
)
|
||||
assert gap > world.VISIT_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def paired_cookies() -> set[int]:
|
||||
"""Куки двухкуковых пар: их заказы обещаны планом, а не решены днём."""
|
||||
cookies: set[int] = set()
|
||||
@@ -642,21 +735,26 @@ def test_flagged_buyers_buy_again_much_more_often(week: list[day.Day]):
|
||||
«Постоянный покупатель» читается только так — как кука, которая ходит
|
||||
неделями и покупает не раз: метки в событии нет и не будет.
|
||||
|
||||
Куки двухкуковых пар из счёта исключены: их заказы назначил план, и
|
||||
вместе с ними лифт мерил бы гарантию, а не два новых рычага. Остаются
|
||||
те помеченные, чьи покупки решил день: повторно покупают 4,6% из них
|
||||
против 1,5% прочих — втрое чаще при пороге «вдвое».
|
||||
Куки двухкуковых пар из счета исключены: их заказы назначил план, и
|
||||
вместе с ними лифт мерил бы гарантию, а не два новых рычага. Даже после
|
||||
событийной порчи оставшиеся помеченные покупают повторно более чем вдвое
|
||||
чаще прочих.
|
||||
"""
|
||||
purchases: dict[int, int] = {}
|
||||
purchases: dict[int, set[str]] = {}
|
||||
flagged: set[int] = set()
|
||||
for number, events in enumerate(week):
|
||||
audience = plan.audience(CANONICAL_SEED, number)
|
||||
flagged |= set(audience.client_id[audience.buyer].tolist())
|
||||
for cookie in rows_of(events, commerce.PURCHASE)["ClientID"].tolist():
|
||||
purchases[cookie] = purchases.get(cookie, 0) + 1
|
||||
rows = rows_of(events, commerce.PURCHASE)
|
||||
for cookie, order_id in zip(
|
||||
rows["ClientID"].tolist(),
|
||||
(str(cell[0]) for cell in rows["purchaseID"]),
|
||||
strict=True,
|
||||
):
|
||||
purchases.setdefault(cookie, set()).add(order_id)
|
||||
|
||||
def repeat_share(cookies: set[int]) -> float:
|
||||
again = sum(1 for cookie in cookies if purchases[cookie] > 1)
|
||||
again = sum(1 for cookie in cookies if len(purchases[cookie]) > 1)
|
||||
return again / len(cookies)
|
||||
|
||||
buyers = set(purchases)
|
||||
|
||||
@@ -68,6 +68,7 @@ def orders(events: day.Day) -> list[list[Any]]:
|
||||
int(theirs.outcome[number]),
|
||||
int(theirs.paid_after[number]),
|
||||
int(theirs.cancelled_after[number]),
|
||||
int(theirs.snapshot_delay[number]),
|
||||
]
|
||||
for number in range(len(theirs))
|
||||
]
|
||||
@@ -88,6 +89,7 @@ def snapshot(events: day.Day) -> dict[str, Any]:
|
||||
"page": events.page.tolist(),
|
||||
"product": events.product.tolist(),
|
||||
"orders": orders(events),
|
||||
"purchase_outcome": events.purchase_outcome.tolist(),
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +117,7 @@ def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
|
||||
page=weekday.page,
|
||||
product=weekday.product,
|
||||
orders=weekday.orders,
|
||||
purchase_outcome=weekday.purchase_outcome,
|
||||
)
|
||||
assert snapshot(reordered) != original
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Запускаемый путеводитель остается коротким и проходит всю цепочку."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_demo_runs_and_shows_the_whole_chain() -> None:
|
||||
"""Сторожит команду и стадии, а числа мира оставляет описи."""
|
||||
finished = subprocess.run(
|
||||
["make", "--silent", "demo"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
cwd=Path(__file__).parents[1],
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert finished.returncode == 0, finished.stderr
|
||||
lines = finished.stdout.splitlines()
|
||||
assert [line.partition(":")[0] for line in lines] == [
|
||||
"Зерно",
|
||||
"Подпотоки D0",
|
||||
"Когорта D0",
|
||||
"Аудитория D0",
|
||||
"Визиты по VisitID",
|
||||
"Строки событий",
|
||||
]
|
||||
assert "когорты D-" in lines[3] and "...D0" in lines[3]
|
||||
assert "строк в группе" in lines[4]
|
||||
assert all(
|
||||
label in lines[5]
|
||||
for label in ("pageview", "add_to_cart", "purchase", "заказов")
|
||||
)
|
||||
@@ -27,6 +27,9 @@ INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory
|
||||
# отправляемых; этот дешевле прочих — его окно короче.
|
||||
RUN_DAY = 2
|
||||
|
||||
# День любой: хеш дня не зависит ни от соседей, ни от края описи.
|
||||
REPLAY_DAY = 4
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def inventory() -> dict:
|
||||
@@ -48,7 +51,15 @@ def test_order_class_counters_cover_only_closed_days(inventory: dict):
|
||||
rows = [row for row in inventory["days"] if "orders" in row]
|
||||
|
||||
assert [row["day"] for row in rows] == [0]
|
||||
assert set(rows[0]["orders"]) == {"match", "cancelled", "amount_delta"}
|
||||
assert set(rows[0]["orders"]) == {
|
||||
"match",
|
||||
"cancelled",
|
||||
"lost_event",
|
||||
"duplicate_event",
|
||||
"amount_delta",
|
||||
}
|
||||
assert rows[0]["orders"]["lost_event"] > 0
|
||||
assert rows[0]["orders"]["duplicate_event"] > 0
|
||||
today = day_module.stream(CANONICAL_SEED, rows[0]["day"])
|
||||
assert sum(rows[0]["orders"].values()) == len(today.orders)
|
||||
|
||||
@@ -72,3 +83,17 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm
|
||||
|
||||
row = next(row for row in inventory["snapshots"] if row["day"] == RUN_DAY - 1)
|
||||
assert row["sha256"] == sent
|
||||
|
||||
|
||||
def test_a_replayed_day_matches_its_inventory_hash(tmp_path):
|
||||
"""Опись сверяется с байтами, вышедшими из CLI, а не с собранной тут же:
|
||||
пересчет сравнивался бы сам с собой.
|
||||
"""
|
||||
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
||||
path = tmp_path / "day.jsonl"
|
||||
|
||||
assert cli.main(["batch", "--day", str(REPLAY_DAY), "--file", str(path)]) == 0
|
||||
|
||||
sent = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
row = next(row for row in stored["days"] if row["day"] == REPLAY_DAY)
|
||||
assert row["sha256"] == sent
|
||||
@@ -58,17 +58,52 @@ def user_id_by_order(events: day.Day) -> dict[str, int]:
|
||||
)
|
||||
|
||||
|
||||
def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
|
||||
"""Заказ — вторая проекция покупки, а не второе порождение.
|
||||
def purchase_row_by_order(
|
||||
events: day.Day,
|
||||
) -> tuple[dict[str, NDArray[Any]], dict[str, int]]:
|
||||
"""Первая строка purchase каждого доехавшего заказа."""
|
||||
rows = purchases_of(events)
|
||||
index: dict[str, int] = {}
|
||||
for row, cell in enumerate(rows["purchaseID"]):
|
||||
index.setdefault(str(cell[0]), row)
|
||||
return rows, index
|
||||
|
||||
У всякого заказа ровно одно событие `purchase`, и номер у них один:
|
||||
сойтись двум источникам больше негде — сверка стенда соединяется
|
||||
именно по нему.
|
||||
"""
|
||||
|
||||
def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
|
||||
"""Заказ рождается до потери или дубля его событийной проекции."""
|
||||
events = purchases_of(weekday)
|
||||
numbers = [cell[0] for cell in events["purchaseID"]]
|
||||
assert len(numbers) > 100
|
||||
assert list(weekday.orders.order_id) == numbers
|
||||
lost = {
|
||||
order_id
|
||||
for order_id, outcome in zip(
|
||||
weekday.orders.order_id, weekday.purchase_outcome, strict=True
|
||||
)
|
||||
if outcome == commerce.EventOutcome.LOST
|
||||
}
|
||||
assert set(numbers) == set(weekday.orders.order_id) - lost
|
||||
assert len(numbers) - len(set(numbers)) == int(
|
||||
(weekday.purchase_outcome == commerce.EventOutcome.DUPLICATED).sum()
|
||||
)
|
||||
|
||||
|
||||
def test_an_assigned_order_never_waits_for_a_later_snapshot(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Гарантия плана сильнее опоздания: мост склейки приезжает сразу."""
|
||||
# Все неназначенные заказы опаздывают на два дня. Нулевую задержку после
|
||||
# такого броска могут получить только заказы, защищённые планом.
|
||||
monkeypatch.setattr(
|
||||
orders, "_SNAPSHOT_DELAY_CUMULATIVE", np.array([0, 0, 1], dtype=np.int64)
|
||||
)
|
||||
events = day.stream(CANONICAL_SEED, WEEKDAY)
|
||||
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
|
||||
|
||||
promised = set(audience.person_id[audience.assigned_order].tolist())
|
||||
immediate = set(events.orders.user_id[events.orders.snapshot_delay == 0].tolist())
|
||||
assert promised
|
||||
assert immediate == promised
|
||||
assert (events.orders.snapshot_delay == 2).any()
|
||||
|
||||
|
||||
def dropped_line(client: list[tuple[Any, int]], order: list[tuple[Any, int]]):
|
||||
@@ -88,16 +123,18 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
|
||||
"""
|
||||
goods = catalog.catalog()
|
||||
price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True))
|
||||
events = purchases_of(weekday)
|
||||
events, row_by_order = purchase_row_by_order(weekday)
|
||||
money = weekday.orders
|
||||
deltas = 0
|
||||
|
||||
for number, order in enumerate(weekday.orders.order_id):
|
||||
assert order == events["purchaseID"][number][0]
|
||||
if weekday.purchase_outcome[number] == commerce.EventOutcome.LOST:
|
||||
continue
|
||||
row = row_by_order[order]
|
||||
client = list(
|
||||
zip(
|
||||
events["productID"][number].tolist(),
|
||||
events["productQuantity"][number].tolist(),
|
||||
events["productID"][row].tolist(),
|
||||
events["productQuantity"][row].tolist(),
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
@@ -110,7 +147,7 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
|
||||
)
|
||||
# Выручка клиента и `items_total` бэкенда — одно число: в событии
|
||||
# оно дробное, у заказа целое в копейках.
|
||||
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS)
|
||||
revenue = round(events["purchaseRevenue"][row][0] * commerce.KOPECKS)
|
||||
if mine == client:
|
||||
assert money.items_total[number] == revenue
|
||||
continue
|
||||
@@ -136,15 +173,18 @@ def test_the_discount_comes_from_the_coupon_of_the_event(
|
||||
|
||||
for today in start_world:
|
||||
money = today.orders
|
||||
events = purchases_of(today)
|
||||
codes = [cell[0] for cell in events["purchaseCoupon"]]
|
||||
for number, code in enumerate(codes):
|
||||
events, row_by_order = purchase_row_by_order(today)
|
||||
for number, order_id in enumerate(money.order_id):
|
||||
if today.purchase_outcome[number] == commerce.EventOutcome.LOST:
|
||||
continue
|
||||
row = row_by_order[order_id]
|
||||
code = events["purchaseCoupon"][row][0]
|
||||
items_total = money.items_total[number]
|
||||
expected = items_total * percent[code] // 100 if code else 0
|
||||
assert money.discount[number] == expected, money.order_id[number]
|
||||
assert money.discount[number] < items_total
|
||||
|
||||
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS)
|
||||
revenue = round(events["purchaseRevenue"][row][0] * commerce.KOPECKS)
|
||||
discounted_delta += int(bool(code) and items_total != revenue)
|
||||
|
||||
# Иначе проверка не отличила бы новую базу скидки от прежней.
|
||||
@@ -255,8 +295,13 @@ def test_the_user_id_is_the_person_behind_the_cookie(weekday: day.Day):
|
||||
zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True)
|
||||
)
|
||||
events = purchases_of(weekday)
|
||||
for number, cookie in enumerate(events["ClientID"].tolist()):
|
||||
assert weekday.orders.user_id[number] == person[cookie]
|
||||
by_order = user_id_by_order(weekday)
|
||||
for order_id, cookie in zip(
|
||||
(cell[0] for cell in events["purchaseID"]),
|
||||
events["ClientID"].tolist(),
|
||||
strict=True,
|
||||
):
|
||||
assert by_order[order_id] == person[cookie]
|
||||
assert np.all(weekday.orders.user_id > 0)
|
||||
assert weekday.orders.user_id.max() < 2**53
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import pytest
|
||||
from clickstream_generator import plan, reference, world
|
||||
from clickstream_generator.seeds import CANONICAL_SEED
|
||||
|
||||
# Горизонт эталонного снимка — две недели (спека, раздел 5).
|
||||
SNAPSHOT_DAYS = 14
|
||||
# Окно замера плана — две недели: два цикла недельной сезонности.
|
||||
WINDOW_DAYS = 14
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -54,9 +54,9 @@ def test_the_plan_is_a_pure_function_of_the_seed():
|
||||
|
||||
|
||||
def test_counters_are_a_pure_function_of_the_seed():
|
||||
first = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||
first = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||
plan.cohort.cache_clear()
|
||||
assert first == plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||
assert first == plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||
|
||||
|
||||
def test_a_remembered_cohort_cannot_be_spoiled_from_outside():
|
||||
@@ -216,12 +216,12 @@ def test_the_passport_points_into_the_directories():
|
||||
|
||||
def test_the_daily_audience_matches_the_spec_band():
|
||||
"""6–8 тыс. посетителей в день — правдоподобный средний магазин."""
|
||||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||
counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||
assert all(6_000 <= size <= 8_000 for size in counters.audience)
|
||||
|
||||
|
||||
def test_the_influx_holds_its_daily_number():
|
||||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||
counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||
average = sum(counters.new_cookies) / len(counters.new_cookies)
|
||||
assert abs(average - world.DAILY_INFLUX) < world.DAILY_INFLUX // 10
|
||||
|
||||
@@ -232,7 +232,7 @@ def test_the_influx_breathes_with_the_week():
|
||||
def mean(numbers: tuple[int, ...] | list[int]) -> float:
|
||||
return sum(numbers) / len(numbers)
|
||||
|
||||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||
counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||
weekdays = [size for day, size in enumerate(counters.new_cookies) if day % 7 < 5]
|
||||
weekend = [size for day, size in enumerate(counters.new_cookies) if day % 7 >= 5]
|
||||
expected = mean(world.WEEKLY_PROFILE_PERCENT[5:]) / mean(
|
||||
@@ -250,7 +250,7 @@ def test_the_influx_differs_even_on_the_same_weekday():
|
||||
def test_the_influx_is_visible_on_the_plan():
|
||||
"""Накопленная аудитория растёт с горизонтом и с дневной не сходится."""
|
||||
week = plan.counters(CANONICAL_SEED, 7)
|
||||
fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||
fortnight = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||
assert week.visitors < fortnight.visitors
|
||||
assert fortnight.visitors > 2 * max(fortnight.audience)
|
||||
|
||||
@@ -258,21 +258,21 @@ def test_the_influx_is_visible_on_the_plan():
|
||||
def test_the_horizon_is_a_prefix_not_another_world():
|
||||
"""Продление истории днём N+1 не трогает дни 1…N."""
|
||||
week = plan.counters(CANONICAL_SEED, 7)
|
||||
fortnight = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||
fortnight = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||
assert fortnight.audience[:7] == week.audience
|
||||
assert fortnight.new_cookies[:7] == week.new_cookies
|
||||
|
||||
|
||||
def test_counters_count_the_same_audience_that_the_day_gets():
|
||||
"""Счётчики и дня-функция спрашивают один план — расходиться им негде."""
|
||||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||
for day in (0, 7, SNAPSHOT_DAYS - 1):
|
||||
counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||
for day in (0, 7, WINDOW_DAYS - 1):
|
||||
actual = plan.audience(CANONICAL_SEED, day).client_id.size
|
||||
assert actual == counters.audience[day]
|
||||
|
||||
|
||||
def test_counters_know_the_pairs_before_a_single_event():
|
||||
counters = plan.counters(CANONICAL_SEED, SNAPSHOT_DAYS)
|
||||
counters = plan.counters(CANONICAL_SEED, WINDOW_DAYS)
|
||||
assert counters.pairs > 0
|
||||
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ def test_day_components_do_not_share_randomness():
|
||||
Component.TRAFFIC,
|
||||
Component.COMMERCE,
|
||||
Component.ORDERS,
|
||||
Component.LATECOMERS,
|
||||
Component.BEACONS,
|
||||
)
|
||||
assert len({int(component) for component in components}) == 4
|
||||
draws = {
|
||||
|
||||
@@ -128,7 +128,9 @@ def test_the_record_matches_the_wire_contract(days: list[day_module.Day]):
|
||||
)
|
||||
|
||||
if _born(record) == _date(SHORT_WINDOW_DAY):
|
||||
assert record["created_at"][:19] + "Z" == bought[record["order_id"]]
|
||||
# Потерянного purchase в трекере нет, но заказ остается.
|
||||
if record["order_id"] in bought:
|
||||
assert record["created_at"][:19] + "Z" == bought[record["order_id"]]
|
||||
|
||||
|
||||
def test_negative_money_does_not_leave_the_source(days: list[day_module.Day]):
|
||||
@@ -174,6 +176,63 @@ def test_the_rows_go_in_the_order_of_birth(days: list[day_module.Day]):
|
||||
assert len(set(numbers)) == len(numbers)
|
||||
|
||||
|
||||
def test_lateness_changes_only_the_rows_present_in_a_snapshot(
|
||||
days: list[day_module.Day],
|
||||
):
|
||||
"""Неопоздавшие строки остаются прежними байтами и в прежнем порядке."""
|
||||
number = SHORT_WINDOW_DAY
|
||||
window = [today.orders for today in days[: number + 1]]
|
||||
eager = serialize.orders(
|
||||
[
|
||||
replace(rows, snapshot_delay=np.zeros_like(rows.snapshot_delay))
|
||||
for rows in window
|
||||
],
|
||||
number,
|
||||
)
|
||||
actual = serialize.orders(window, number)
|
||||
actual_ids = {json.loads(payload)["order_id"] for payload in actual}
|
||||
expected = [
|
||||
payload for payload in eager if json.loads(payload)["order_id"] in actual_ids
|
||||
]
|
||||
|
||||
assert len(actual) < len(eager)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
def test_a_two_day_late_order_first_arrives_in_its_lived_state(
|
||||
days: list[day_module.Day],
|
||||
):
|
||||
"""Заказ δ = 2 пропускает D и D+1 и догоняет жизнь, не меняя `created_at`."""
|
||||
honest = days[0].orders
|
||||
target = 0
|
||||
delay = np.zeros_like(honest.snapshot_delay)
|
||||
delay[target] = 2
|
||||
cancelled_after = honest.cancelled_after.copy()
|
||||
cancelled_after[target] = 30 * 3600
|
||||
late = replace(
|
||||
honest,
|
||||
snapshot_delay=delay,
|
||||
cancelled_after=cancelled_after,
|
||||
)
|
||||
order_id = late.order_id[target]
|
||||
|
||||
for number in (0, 1):
|
||||
assert order_id not in {
|
||||
json.loads(payload)["order_id"]
|
||||
for payload in serialize.orders([late], number)
|
||||
}
|
||||
|
||||
arrived = {
|
||||
record["order_id"]: record
|
||||
for record in map(json.loads, serialize.orders([late], 2))
|
||||
}[order_id]
|
||||
created_at = np.datetime_as_string(
|
||||
late.created_at[target], unit="ms", timezone="UTC"
|
||||
)
|
||||
assert arrived["status"] == "cancelled"
|
||||
assert arrived["created_at"] == created_at
|
||||
|
||||
|
||||
def test_the_state_is_read_at_the_boundary_of_the_day(days: list[day_module.Day]):
|
||||
"""Дыхание окна: оплаченный назавтра заказ в сегодняшнем слепке `created`.
|
||||
|
||||
@@ -325,7 +384,10 @@ def _purchase_times(today: day_module.Day) -> dict[str, str]:
|
||||
times = np.datetime_as_string(
|
||||
today.columns["UTCEventTime"][here], unit="s", timezone="UTC"
|
||||
)
|
||||
return dict(zip(numbers, times.tolist(), strict=True))
|
||||
result: dict[str, str] = {}
|
||||
for number, moment in zip(numbers, times.tolist(), strict=True):
|
||||
result.setdefault(number, moment)
|
||||
return result
|
||||
|
||||
|
||||
def _born(record: dict) -> date:
|
||||
|
||||
@@ -122,6 +122,11 @@ def test_the_trade_event_never_overtakes_the_next_page():
|
||||
assert max(world.TRADE_DELAY_SECONDS) <= min(world.PAGE_PAUSE_SECONDS)
|
||||
|
||||
|
||||
def test_the_duplicate_delay_stays_inside_a_visit_timeout():
|
||||
assert 0 < min(world.DUPLICATE_DELAY_SECONDS)
|
||||
assert max(world.DUPLICATE_DELAY_SECONDS) <= world.VISIT_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def conversion(cart: int, checkout_of_cart: int) -> float:
|
||||
"""Конверсия визита в заказ, проценты: произведение трёх шагов воронки."""
|
||||
return cart * checkout_of_cart * world.CONFIRMATION_OF_CHECKOUT_PERCENT / 100**2
|
||||
|
||||
@@ -6,6 +6,8 @@ FROM grafana/grafana:13.1.1
|
||||
USER root
|
||||
ENV GF_PATHS_PLUGINS=/usr/share/grafana/plugins \
|
||||
GF_PLUGINS_PREINSTALL_DISABLED=true
|
||||
ARG CLICKHOUSE_PLUGIN_VERSION=4.20.0
|
||||
RUN grafana cli --pluginsDir "$GF_PATHS_PLUGINS" \
|
||||
plugins install grafana-clickhouse-datasource 4.20.0
|
||||
--pluginUrl "https://github.com/grafana/clickhouse-datasource/releases/download/v${CLICKHOUSE_PLUGIN_VERSION}/grafana-clickhouse-datasource-${CLICKHOUSE_PLUGIN_VERSION}.zip" \
|
||||
plugins install grafana-clickhouse-datasource "$CLICKHOUSE_PLUGIN_VERSION"
|
||||
USER grafana
|
||||
@@ -0,0 +1,31 @@
|
||||
slice_name: Сверка purchase и заказов
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: pivot_table_v2
|
||||
params:
|
||||
viz_type: pivot_table_v2
|
||||
groupbyRows:
|
||||
- order_day
|
||||
groupbyColumns:
|
||||
- mismatch_class
|
||||
metrics:
|
||||
- order_count
|
||||
adhoc_filters:
|
||||
- expressionType: SIMPLE
|
||||
subject: order_day
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: No filter
|
||||
clause: WHERE
|
||||
aggregateFunction: Sum
|
||||
metricsLayout: COLUMNS
|
||||
rowOrder: key_a_to_z
|
||||
colOrder: key_a_to_z
|
||||
order_desc: true
|
||||
row_limit: 10000
|
||||
valueFormat: SMART_NUMBER
|
||||
date_format: smart_date
|
||||
cache_timeout: null
|
||||
uuid: 19600000-0000-4000-8000-000000000102
|
||||
version: 1.0.0
|
||||
dataset_uuid: 19600000-0000-4000-8000-000000000002
|
||||
@@ -0,0 +1,30 @@
|
||||
slice_name: Выручка по дням и категориям
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: echarts_timeseries_bar
|
||||
params:
|
||||
viz_type: echarts_timeseries_bar
|
||||
x_axis: report_date
|
||||
metrics:
|
||||
- revenue
|
||||
groupby:
|
||||
- product_category
|
||||
adhoc_filters:
|
||||
- expressionType: SIMPLE
|
||||
subject: report_date
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: No filter
|
||||
clause: WHERE
|
||||
row_limit: 10000
|
||||
stack: Stack
|
||||
orientation: vertical
|
||||
show_legend: true
|
||||
rich_tooltip: true
|
||||
show_value: false
|
||||
y_axis_format: ',.2f'
|
||||
x_axis_time_format: smart_date
|
||||
cache_timeout: null
|
||||
uuid: 19600000-0000-4000-8000-000000000101
|
||||
version: 1.0.0
|
||||
dataset_uuid: 19600000-0000-4000-8000-000000000001
|
||||
@@ -0,0 +1,29 @@
|
||||
slice_name: Посетители и известные пользователи
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: echarts_timeseries_line
|
||||
params:
|
||||
viz_type: echarts_timeseries_line
|
||||
x_axis: report_date
|
||||
metrics:
|
||||
- visitors
|
||||
- known_users
|
||||
groupby: []
|
||||
adhoc_filters:
|
||||
- expressionType: SIMPLE
|
||||
subject: report_date
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: No filter
|
||||
clause: WHERE
|
||||
row_limit: 10000
|
||||
seriesType: echarts_timeseries_line
|
||||
show_legend: true
|
||||
rich_tooltip: true
|
||||
show_value: false
|
||||
y_axis_format: ',d'
|
||||
x_axis_time_format: smart_date
|
||||
cache_timeout: null
|
||||
uuid: 19600000-0000-4000-8000-000000000103
|
||||
version: 1.0.0
|
||||
dataset_uuid: 19600000-0000-4000-8000-000000000003
|
||||
@@ -0,0 +1,105 @@
|
||||
dashboard_title: Бизнес-сюжеты
|
||||
description: null
|
||||
css: null
|
||||
slug: business-stories
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
published: true
|
||||
uuid: 19600000-0000-4000-8000-000000000201
|
||||
position:
|
||||
DASHBOARD_VERSION_KEY: v2
|
||||
ROOT_ID:
|
||||
id: ROOT_ID
|
||||
type: ROOT
|
||||
children:
|
||||
- GRID_ID
|
||||
GRID_ID:
|
||||
id: GRID_ID
|
||||
type: GRID
|
||||
parents:
|
||||
- ROOT_ID
|
||||
children:
|
||||
- ROW-revenue
|
||||
- ROW-pvo
|
||||
- ROW-traffic
|
||||
ROW-revenue:
|
||||
id: ROW-revenue
|
||||
type: ROW
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
children:
|
||||
- CHART-revenue
|
||||
meta:
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
CHART-revenue:
|
||||
id: CHART-revenue
|
||||
type: CHART
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
- ROW-revenue
|
||||
children: []
|
||||
meta:
|
||||
chartId: 1
|
||||
height: 50
|
||||
width: 12
|
||||
sliceName: Выручка по дням и категориям
|
||||
uuid: 19600000-0000-4000-8000-000000000101
|
||||
ROW-pvo:
|
||||
id: ROW-pvo
|
||||
type: ROW
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
children:
|
||||
- CHART-pvo
|
||||
meta:
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
CHART-pvo:
|
||||
id: CHART-pvo
|
||||
type: CHART
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
- ROW-pvo
|
||||
children: []
|
||||
meta:
|
||||
chartId: 2
|
||||
height: 50
|
||||
width: 12
|
||||
sliceName: Сверка purchase и заказов
|
||||
uuid: 19600000-0000-4000-8000-000000000102
|
||||
ROW-traffic:
|
||||
id: ROW-traffic
|
||||
type: ROW
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
children:
|
||||
- CHART-traffic
|
||||
meta:
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
CHART-traffic:
|
||||
id: CHART-traffic
|
||||
type: CHART
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
- ROW-traffic
|
||||
children: []
|
||||
meta:
|
||||
chartId: 3
|
||||
height: 50
|
||||
width: 12
|
||||
sliceName: Посетители и известные пользователи
|
||||
uuid: 19600000-0000-4000-8000-000000000103
|
||||
metadata:
|
||||
color_scheme: supersetColors
|
||||
cross_filters_enabled: false
|
||||
chart_configuration: {}
|
||||
expanded_slices: {}
|
||||
default_filters: '{}'
|
||||
native_filter_configuration: []
|
||||
theme_uuid: null
|
||||
version: 1.0.0
|
||||
@@ -0,0 +1,106 @@
|
||||
table_name: daily_traffic_v
|
||||
main_dttm_col: report_date
|
||||
currency_code_column: null
|
||||
description: null
|
||||
default_endpoint: null
|
||||
offset: 0
|
||||
cache_timeout: null
|
||||
catalog: null
|
||||
schema: dm
|
||||
sql: null
|
||||
params: null
|
||||
template_params: null
|
||||
filter_select_enabled: true
|
||||
fetch_values_predicate: null
|
||||
extra: null
|
||||
normalize_columns: false
|
||||
always_filter_main_dttm: false
|
||||
folders: null
|
||||
uuid: 19600000-0000-4000-8000-000000000003
|
||||
metrics:
|
||||
- metric_name: known_users
|
||||
verbose_name: Известные пользователи
|
||||
metric_type: null
|
||||
expression: SUM(known_users)
|
||||
description: null
|
||||
d3format: ',d'
|
||||
currency: null
|
||||
extra: null
|
||||
warning_text: null
|
||||
- metric_name: visitors
|
||||
verbose_name: Посетители
|
||||
metric_type: null
|
||||
expression: SUM(visitors)
|
||||
description: null
|
||||
d3format: ',d'
|
||||
currency: null
|
||||
extra: null
|
||||
warning_text: null
|
||||
columns:
|
||||
- column_name: _load_ts
|
||||
verbose_name: Время загрузки
|
||||
is_dttm: true
|
||||
is_active: true
|
||||
type: DATETIME64(3, 'UTC')
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: known_users
|
||||
verbose_name: Известные пользователи
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: UINT64
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: _load_id
|
||||
verbose_name: Запуск загрузки
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: STRING
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: visitors
|
||||
verbose_name: Посетители
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: UINT64
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: report_date
|
||||
verbose_name: Дата
|
||||
is_dttm: true
|
||||
is_active: true
|
||||
type: DATE
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
version: 1.0.0
|
||||
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
|
||||
@@ -0,0 +1,136 @@
|
||||
table_name: purchase_vs_orders_v
|
||||
main_dttm_col: order_day
|
||||
currency_code_column: null
|
||||
description: null
|
||||
default_endpoint: null
|
||||
offset: 0
|
||||
cache_timeout: null
|
||||
catalog: null
|
||||
schema: dm
|
||||
sql: null
|
||||
params: null
|
||||
template_params: null
|
||||
filter_select_enabled: true
|
||||
fetch_values_predicate: null
|
||||
extra: null
|
||||
normalize_columns: false
|
||||
always_filter_main_dttm: false
|
||||
folders: null
|
||||
uuid: 19600000-0000-4000-8000-000000000002
|
||||
metrics:
|
||||
- metric_name: order_count
|
||||
verbose_name: Количество заказов
|
||||
metric_type: null
|
||||
expression: COUNT(order_id)
|
||||
description: null
|
||||
d3format: ',d'
|
||||
currency: null
|
||||
extra: null
|
||||
warning_text: null
|
||||
columns:
|
||||
- column_name: items_total
|
||||
verbose_name: Сумма позиций заказа
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: NULLABLE(DECIMAL(18, 2))
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: _load_ts
|
||||
verbose_name: Время загрузки
|
||||
is_dttm: true
|
||||
is_active: true
|
||||
type: DATETIME64(3, 'UTC')
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: declared_revenue
|
||||
verbose_name: Выручка purchase
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: NULLABLE(DECIMAL(18, 2))
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: status
|
||||
verbose_name: Статус заказа
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: LOWCARDINALITY(NULLABLE(STRING))
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: mismatch_class
|
||||
verbose_name: Класс расхождения
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: LOWCARDINALITY(STRING)
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: order_day
|
||||
verbose_name: День заказа
|
||||
is_dttm: true
|
||||
is_active: true
|
||||
type: DATE
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: _load_id
|
||||
verbose_name: Запуск загрузки
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: STRING
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: order_id
|
||||
verbose_name: Заказ
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: STRING
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
version: 1.0.0
|
||||
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
|
||||
@@ -0,0 +1,138 @@
|
||||
table_name: revenue_daily_v
|
||||
main_dttm_col: report_date
|
||||
currency_code_column: null
|
||||
description: null
|
||||
default_endpoint: null
|
||||
offset: 0
|
||||
cache_timeout: null
|
||||
catalog: null
|
||||
schema: dm
|
||||
sql: null
|
||||
params: null
|
||||
template_params: null
|
||||
filter_select_enabled: true
|
||||
fetch_values_predicate: null
|
||||
extra: null
|
||||
normalize_columns: false
|
||||
always_filter_main_dttm: false
|
||||
folders: null
|
||||
uuid: 19600000-0000-4000-8000-000000000001
|
||||
metrics:
|
||||
- metric_name: revenue
|
||||
verbose_name: Выручка
|
||||
metric_type: null
|
||||
expression: SUM(revenue)
|
||||
description: null
|
||||
d3format: ',.2f'
|
||||
currency:
|
||||
symbol: ₽
|
||||
symbolPosition: suffix
|
||||
extra: null
|
||||
warning_text: null
|
||||
columns:
|
||||
- column_name: _load_ts
|
||||
verbose_name: Время загрузки
|
||||
is_dttm: true
|
||||
is_active: true
|
||||
type: DATETIME64(3, 'UTC')
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: aov
|
||||
verbose_name: Средний чек
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: DECIMAL(18, 2)
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: revenue
|
||||
verbose_name: Выручка
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: DECIMAL(18, 2)
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: product_category
|
||||
verbose_name: Категория товара
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: LOWCARDINALITY(STRING)
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: _load_id
|
||||
verbose_name: Запуск загрузки
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: STRING
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: orders
|
||||
verbose_name: Заказы
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: UINT64
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: units
|
||||
verbose_name: Товары
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: UINT64
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: report_date
|
||||
verbose_name: Дата
|
||||
is_dttm: true
|
||||
is_active: true
|
||||
type: DATE
|
||||
advanced_data_type: null
|
||||
groupby: true
|
||||
filterable: true
|
||||
expression: null
|
||||
description: null
|
||||
python_date_format: null
|
||||
datetime_format: null
|
||||
extra: null
|
||||
version: 1.0.0
|
||||
database_uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
|
||||
@@ -1,3 +1 @@
|
||||
version: 1.0.0
|
||||
type: Database
|
||||
timestamp: '2026-07-30T00:00:00+00:00'
|
||||
+51
-23
@@ -237,35 +237,42 @@ check_airflow_probes() {
|
||||
}
|
||||
|
||||
check_superset() {
|
||||
local clickhouse_password
|
||||
local access_token
|
||||
local available_schemas
|
||||
local clickhouse_databases
|
||||
local config
|
||||
local connection_count
|
||||
local connection_details
|
||||
local connection_id
|
||||
local database_list
|
||||
local login
|
||||
local metadata_tables
|
||||
local password
|
||||
local port
|
||||
local metadata_engine
|
||||
local stored_config
|
||||
local stored_uri
|
||||
local schemas
|
||||
local stored_safe_uri
|
||||
local stored_uuid
|
||||
local superset_password
|
||||
local superset_user
|
||||
local superset_url
|
||||
local user
|
||||
|
||||
config="$(compose config --format json 2>/dev/null || true)"
|
||||
user="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_USER // empty' <<<"$config")"
|
||||
password="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_PASSWORD // empty' <<<"$config")"
|
||||
clickhouse_password="$(jq -r '.services.superset.environment.CLICKHOUSE_BI_PASSWORD // empty' <<<"$config")"
|
||||
superset_user="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_USER // empty' <<<"$config")"
|
||||
superset_password="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_PASSWORD // empty' <<<"$config")"
|
||||
port="$(published_port superset 8088)"
|
||||
superset_url="http://127.0.0.1:${port}"
|
||||
|
||||
login="$(curl -sf --max-time 10 -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -cn --arg username "$user" --arg password "$password" \
|
||||
'{username: $username, password: $password, provider: "db", refresh: true}')" \
|
||||
"http://127.0.0.1:${port}/api/v1/security/login" 2>/dev/null || true)"
|
||||
if curl -sf --max-time 10 "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && \
|
||||
jq -e '.access_token | length > 0' >/dev/null 2>&1 <<<"$login"; then
|
||||
"${superset_url}/api/v1/security/login" 2>/dev/null || true)"
|
||||
access_token="$(jq -r '.access_token // empty' <<<"$login")"
|
||||
if curl -sf --max-time 10 "${superset_url}/health" >/dev/null 2>&1 && \
|
||||
[[ -n "$access_token" ]]; then
|
||||
pass 'интерфейс Superset отвечает и принимает подготовленные учётные данные администратора'
|
||||
else
|
||||
fail 'интерфейс Superset не отвечает или не принимает учётные данные администратора'
|
||||
@@ -276,28 +283,49 @@ check_superset() {
|
||||
psql -U "$superset_user" -d superset -tAc \
|
||||
"SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ab_user';" \
|
||||
2>/dev/null || true)"
|
||||
stored_config="$(timeout 30s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \
|
||||
exec -T superset python -c \
|
||||
"from superset.app import create_app; app = create_app(); app.app_context().push(); from superset.extensions import db; from superset.models.core import Database; url = db.engine.url; database = db.session.query(Database).filter_by(database_name='ClickHouse').one(); print('SMOKE_METADATA=' + '|'.join([url.get_backend_name(), url.username or '', url.host or '', str(url.port or ''), url.database or ''])); print('SMOKE_UUID=' + str(database.uuid)); print('SMOKE_URI=' + database.sqlalchemy_uri_decrypted)" \
|
||||
2>/dev/null)"
|
||||
metadata_engine="$(sed -n 's/^SMOKE_METADATA=//p' <<<"$stored_config" | tail -n 1)"
|
||||
stored_uuid="$(sed -n 's/^SMOKE_UUID=//p' <<<"$stored_config" | tail -n 1)"
|
||||
stored_uri="$(sed -n 's/^SMOKE_URI=//p' <<<"$stored_config" | tail -n 1)"
|
||||
|
||||
# Superset уже собрал приложение. Его REST API читает ту же сохранённую
|
||||
# модель Database без второго тяжёлого запуска Python.
|
||||
database_list="$(curl -sf --max-time 10 \
|
||||
-H "Authorization: Bearer ${access_token}" \
|
||||
"${superset_url}/api/v1/database/" 2>/dev/null || true)"
|
||||
clickhouse_databases="$(jq -c \
|
||||
'[.result[]? | select(.database_name == "ClickHouse")]' \
|
||||
<<<"$database_list")"
|
||||
connection_count="$(jq -r 'length' <<<"$clickhouse_databases")"
|
||||
connection_id="$(jq -r 'if length == 1 then .[0].id else empty end' \
|
||||
<<<"$clickhouse_databases")"
|
||||
stored_uuid="$(jq -r 'if length == 1 then .[0].uuid else empty end' \
|
||||
<<<"$clickhouse_databases")"
|
||||
|
||||
connection_details=''
|
||||
if [[ -n "$connection_id" ]]; then
|
||||
connection_details="$(curl -sf --max-time 10 \
|
||||
-H "Authorization: Bearer ${access_token}" \
|
||||
"${superset_url}/api/v1/database/${connection_id}/connection" \
|
||||
2>/dev/null || true)"
|
||||
fi
|
||||
stored_safe_uri="$(jq -r '.result.sqlalchemy_uri // empty' \
|
||||
<<<"$connection_details")"
|
||||
|
||||
if [[ "$metadata_tables" == '1' ]] && \
|
||||
[[ "$metadata_engine" == "postgresql|${superset_user}|postgres-metadata|5432|superset" ]] && \
|
||||
[[ "$stored_uuid" == '4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d' ]] && \
|
||||
[[ "$stored_uri" == "clickhousedb://bi:${clickhouse_password}@clickhouse-02:8123/default" ]]; then
|
||||
pass 'метаданные Superset живут в Postgres, подготовленное подключение указывает на clickhouse-02'
|
||||
[[ "$stored_safe_uri" == 'clickhousedb://bi@clickhouse-02:8123/default' ]]; then
|
||||
pass 'Postgres для метаданных Superset подготовлен, подключение указывает на clickhouse-02'
|
||||
else
|
||||
fail "Superset не подтвердил Postgres и подготовленное подключение bi к ноде 2: таблицы=${metadata_tables:-нет}, движок=${metadata_engine:-нет}, UUID=${stored_uuid:-нет}"
|
||||
fail "Postgres или подключение Superset не подготовлены: таблицы=${metadata_tables:-нет}, подключений=${connection_count:-нет}, UUID=${stored_uuid:-нет}, URI=${stored_safe_uri:-нет}"
|
||||
return
|
||||
fi
|
||||
|
||||
if printf 'n\n' | timeout 60s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \
|
||||
exec -T superset superset test-db "$stored_uri" >/dev/null 2>&1; then
|
||||
pass 'Superset успешно проверил извлечённое из метаданных подключение ClickHouse'
|
||||
schemas="$(curl -sf --max-time 10 \
|
||||
-H "Authorization: Bearer ${access_token}" \
|
||||
"${superset_url}/api/v1/database/${connection_id}/schemas/" \
|
||||
2>/dev/null || true)"
|
||||
available_schemas="$(jq -r '[.result[]?] | join(", ")' <<<"$schemas")"
|
||||
if jq -e '.result | index("dm") != null' >/dev/null 2>&1 <<<"$schemas"; then
|
||||
pass 'сохранённое подключение Superset видит схему витрин dm'
|
||||
else
|
||||
fail 'Superset не смог проверить извлечённое из метаданных подключение ClickHouse'
|
||||
fail "сохранённое подключение Superset не видит схему витрин dm: схемы=${available_schemas:-нет}"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ CREATE DATABASE IF NOT EXISTS ods ON CLUSTER clickstream_cluster;
|
||||
-- База DDS заводится здесь же по той же причине, что и ODS.
|
||||
CREATE DATABASE IF NOT EXISTS dds ON CLUSTER clickstream_cluster;
|
||||
|
||||
-- База витрин завершает цепочку STG → ODS → DDS → DM.
|
||||
CREATE DATABASE IF NOT EXISTS dm ON CLUSTER clickstream_cluster;
|
||||
|
||||
-- Справочники стоят вне цепочки STG → ODS → DDS → DM: в хранилище их никто не
|
||||
-- производит, а читают их несколько слоёв — ADR 0012. Порядок слоёв к зоне не
|
||||
-- применяется, её файл идёт сразу за этим.
|
||||
|
||||
+13
-11
@@ -83,7 +83,7 @@ CREATE TABLE IF NOT EXISTS ods.event_rep ON CLUSTER clickstream_cluster
|
||||
GoalsReached Array(UInt32),
|
||||
ParsedParamsKey1 Array(String),
|
||||
purchaseID Array(String),
|
||||
purchaseRevenue Array(Float64),
|
||||
purchaseRevenue Array(Decimal(18, 2)),
|
||||
purchaseCurrency Array(String),
|
||||
purchaseCoupon Array(String),
|
||||
productID Array(String),
|
||||
@@ -156,17 +156,19 @@ ENGINE = Distributed('clickstream_cluster', 'ods', 'event_errors_rep', cityHash6
|
||||
-- Слепок привозит состояние заказов окна изменяемости, а не поток изменений,
|
||||
-- и одна и та же сущность приезжает в нём много дней подряд. Поэтому строка
|
||||
-- здесь — версия заказа, а не запись слепка: ключ сущности order_id, колонка
|
||||
-- версии updated_at (время последнего изменения строки в источнике). Порядок
|
||||
-- версий решает источник, а не хранилище.
|
||||
-- версии snapshot_date — день, состояние которого снял источник. У полного
|
||||
-- слепка версия и есть слепок: истинно то, что источник сказал последним
|
||||
-- (ADR 0014).
|
||||
--
|
||||
-- Четыре координаты отвечают на разные вопросы, и путать их нельзя:
|
||||
-- updated_at — какая бизнес-версия новее; snapshot_date — в слепке какого
|
||||
-- модельного дня источник показал строку; _load_id — какой запуск Airflow её
|
||||
-- принял; _load_ts — когда она приехала в хранилище. Ключом сущности не
|
||||
-- становится ни одна из трёх последних: они про наблюдение и загрузку. Версию
|
||||
-- выбирает только updated_at, поэтому у выжившей после схлопывания строки
|
||||
-- snapshot_date — один из дней, когда источник показывал эту версию, не
|
||||
-- обязательно последний (docs/architecture/orders/ingestion.md).
|
||||
-- snapshot_date — в слепке какого модельного дня источник показал строку;
|
||||
-- updated_at — когда строку последний раз меняли в источнике; _load_id —
|
||||
-- какой запуск Airflow её принял; _load_ts — когда она приехала в хранилище.
|
||||
-- Ключом сущности не становится ни одна: заказ один, а дней наблюдения и
|
||||
-- версий у него много. Схлопывание оставляет строку позднейшего слепка,
|
||||
-- поэтому snapshot_date выжившей — последний день, когда источник показывал
|
||||
-- заказ, и по нему DDS читает объём пересборки
|
||||
-- (docs/architecture/orders/ingestion.md).
|
||||
--
|
||||
-- PARTITION BY toDate(created_at) — по дню создания строки в источнике. Он
|
||||
-- неизменен у всех версий заказа, поэтому версии лежат в одной партиции и
|
||||
@@ -194,7 +196,7 @@ CREATE TABLE IF NOT EXISTS ods.order_rep ON CLUSTER clickstream_cluster
|
||||
_load_id String,
|
||||
_load_ts DateTime64(3, 'UTC')
|
||||
)
|
||||
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', updated_at)
|
||||
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}', snapshot_date)
|
||||
PARTITION BY toDate(created_at)
|
||||
ORDER BY order_id;
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ FROM ods.event_dist FINAL;
|
||||
-- ODS: каноническое чтение текущего состояния заказов.
|
||||
--
|
||||
-- То же, что у событий, но версию здесь задаёт источник: физическая таблица
|
||||
-- хранит все приехавшие версии заказа, а FINAL оставляет ту, у которой
|
||||
-- updated_at позже. Читать заказы голым SELECT по _dist значит зависеть от
|
||||
-- того, сколько фоновых слияний успело пройти.
|
||||
-- хранит все приехавшие версии заказа, а FINAL оставляет приехавшую
|
||||
-- позднейшим слепком — колонка версии здесь snapshot_date. Читать заказы
|
||||
-- голым SELECT по _dist значит зависеть от того, сколько фоновых слияний
|
||||
-- успело пройти.
|
||||
--
|
||||
-- Поля остаются языком источника, обогащения нет: DDS читает это
|
||||
-- представление и отдельно решает, какой будет его модель (ADR 0010).
|
||||
@@ -153,7 +154,11 @@ SELECT
|
||||
JSONExtract(raw, 'GoalsReached', 'Array(UInt32)') AS GoalsReached,
|
||||
JSONExtract(raw, 'ParsedParamsKey1', 'Array(String)') AS ParsedParamsKey1,
|
||||
JSONExtract(raw, 'purchaseID', 'Array(String)') AS purchaseID,
|
||||
JSONExtract(raw, 'purchaseRevenue', 'Array(Float64)') AS purchaseRevenue,
|
||||
-- Выгрузка несет JSON-числа типа Float64, но деньги хранятся в Decimal.
|
||||
-- JSONExtract разбирает их сразу, без промежуточного двоичного числа:
|
||||
-- синтаксис подтвержден документацией ClickHouse через Context7 и
|
||||
-- запросом к стендовому ClickHouse 26.3 при исполнении #155.
|
||||
JSONExtract(raw, 'purchaseRevenue', 'Array(Decimal(18, 2))') AS purchaseRevenue,
|
||||
JSONExtract(raw, 'purchaseCurrency', 'Array(String)') AS purchaseCurrency,
|
||||
JSONExtract(raw, 'purchaseCoupon', 'Array(String)') AS purchaseCoupon,
|
||||
JSONExtract(raw, 'productID', 'Array(String)') AS productID,
|
||||
|
||||
+113
-10
@@ -1,18 +1,14 @@
|
||||
-- DDS: физическая модель заказа и донор её партиций.
|
||||
-- DDS: физические модели слоя и доноры партиций.
|
||||
--
|
||||
-- Заказ и сессия гасят повтор заменой дневной партиции, без колонки версии.
|
||||
-- Карта идентичностей пересчитывается целиком и пишет новую версию пары.
|
||||
|
||||
-- Локальная таблица заказа.
|
||||
--
|
||||
-- Зерно — заказ в текущем состоянии, ключ order_id. Историю версий слой не
|
||||
-- хранит: у неё нет читателя ни в одной витрине, а ODS её и не обещает
|
||||
-- (docs/architecture/dds/order.md, «Зерно»).
|
||||
--
|
||||
-- Идемпотентность здесь устроена иначе, чем в приёме, и это главный урок
|
||||
-- сущности. В ODS повтор гасит колонка версии: ReplacingMergeTree(updated_at)
|
||||
-- оставляет позднюю строку. Здесь колонки версии нет вовсе — повтор гасит
|
||||
-- замена партиции целиком: день собирается заново и подменяется одним
|
||||
-- атомарным движением. Поэтому каждая строка таблицы — ровно один заказ, а
|
||||
-- чтению не нужен FINAL.
|
||||
|
||||
-- Локальная таблица шарда.
|
||||
--
|
||||
-- Партиция — order_date, день заказа в поясе счётчика. Партиция дневная не
|
||||
-- ради размера куска, а потому что день — единица пересборки: пока заказ
|
||||
-- внутри окна изменяемости, его день «дышит» и собирается заново, за окном
|
||||
@@ -93,3 +89,110 @@ 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));
|
||||
|
||||
-- DDS: известная связь куки трекера с пользователем магазина.
|
||||
--
|
||||
-- Полный запуск снова пишет все известные пары. Физические повторы до
|
||||
-- фонового слияния допустимы: _load_ts выбирает последнюю версию пары.
|
||||
-- Партиции нет, потому что карта пересчитывается целиком и её зерно не
|
||||
-- связано с днём.
|
||||
CREATE TABLE IF NOT EXISTS dds.identity_map_rep ON CLUSTER clickstream_cluster
|
||||
(
|
||||
client_id UInt64,
|
||||
user_id UInt64,
|
||||
_load_id String,
|
||||
_load_ts DateTime64(3, 'UTC')
|
||||
)
|
||||
ENGINE = ReplicatedReplacingMergeTree(
|
||||
'/clickhouse/tables/{shard}/{database}/{table}',
|
||||
'{replica}',
|
||||
_load_ts
|
||||
)
|
||||
ORDER BY (client_id, user_id);
|
||||
|
||||
-- Карта лежит по ключу будущего соединения с событиями и сессиями.
|
||||
CREATE TABLE IF NOT EXISTS dds.identity_map_dist ON CLUSTER clickstream_cluster
|
||||
AS dds.identity_map_rep
|
||||
ENGINE = Distributed(
|
||||
'clickstream_cluster',
|
||||
'dds',
|
||||
'identity_map_rep',
|
||||
cityHash64(client_id)
|
||||
);
|
||||
@@ -99,3 +99,52 @@ 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;
|
||||
|
||||
-- DDS: точная известная связь куки с пользователем магазина.
|
||||
--
|
||||
-- Полный пересчёт оставляет физические версии одной пары до слияния частей.
|
||||
-- FINAL прячет это устройство от читателя; явный список колонок не даёт
|
||||
-- служебным изменениям таблицы случайно расширить договор.
|
||||
CREATE VIEW IF NOT EXISTS dds.identity_map_v ON CLUSTER clickstream_cluster
|
||||
AS
|
||||
SELECT
|
||||
client_id,
|
||||
user_id,
|
||||
_load_id,
|
||||
_load_ts
|
||||
FROM dds.identity_map_dist FINAL;
|
||||
@@ -0,0 +1,170 @@
|
||||
-- DM: физические дневные витрины и доноры их партиций.
|
||||
|
||||
-- Выручка хранится в зерне «день заказа × категория товара». День служит
|
||||
-- единицей пересборки, поэтому совпадает с ключом партиции.
|
||||
CREATE TABLE IF NOT EXISTS dm.revenue_daily_rep ON CLUSTER clickstream_cluster
|
||||
(
|
||||
report_date Date,
|
||||
product_category LowCardinality(String),
|
||||
orders UInt64,
|
||||
units UInt64,
|
||||
revenue Decimal(18, 2),
|
||||
aov Decimal(18, 2),
|
||||
_load_id String,
|
||||
_load_ts DateTime64(3, 'UTC')
|
||||
)
|
||||
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
|
||||
PARTITION BY report_date
|
||||
ORDER BY (report_date, product_category);
|
||||
|
||||
-- Категория — единственное измерение кроме дня. Шесть категорий стартового
|
||||
-- мира близки по объему, поэтому простой ключ раскладывает строки достаточно
|
||||
-- ровно и не добавляет в учебный пример составной хеш без читателя.
|
||||
CREATE TABLE IF NOT EXISTS dm.revenue_daily_dist ON CLUSTER clickstream_cluster
|
||||
AS dm.revenue_daily_rep
|
||||
ENGINE = Distributed(
|
||||
'clickstream_cluster',
|
||||
'dm',
|
||||
'revenue_daily_rep',
|
||||
cityHash64(product_category)
|
||||
);
|
||||
|
||||
-- Донор объявлен через AS и шардируется тем же ключом, что цель. REPLACE
|
||||
-- PARTITION идет по локальным таблицам, поэтому структура и раскладка донора
|
||||
-- обязаны совпадать с целью (docs/architecture/storage.md, «Раскладка по
|
||||
-- шардам»).
|
||||
CREATE TABLE IF NOT EXISTS dm.revenue_daily_stage_rep ON CLUSTER clickstream_cluster
|
||||
AS dm.revenue_daily_rep
|
||||
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
|
||||
PARTITION BY report_date
|
||||
ORDER BY (report_date, product_category);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm.revenue_daily_stage_dist ON CLUSTER clickstream_cluster
|
||||
AS dm.revenue_daily_stage_rep
|
||||
ENGINE = Distributed(
|
||||
'clickstream_cluster',
|
||||
'dm',
|
||||
'revenue_daily_stage_rep',
|
||||
cityHash64(product_category)
|
||||
);
|
||||
|
||||
-- Сверка хранит одну строку на заказ. День — единица пересборки: пока рядом
|
||||
-- живут два источника, строка может перейти из awaiting_order в обычный класс.
|
||||
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_rep ON CLUSTER clickstream_cluster
|
||||
(
|
||||
order_day Date,
|
||||
order_id String,
|
||||
declared_revenue Nullable(Decimal(18, 2)),
|
||||
items_total Nullable(Decimal(18, 2)),
|
||||
status LowCardinality(Nullable(String)),
|
||||
mismatch_class LowCardinality(String),
|
||||
_load_id String,
|
||||
_load_ts DateTime64(3, 'UTC')
|
||||
)
|
||||
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
|
||||
PARTITION BY order_day
|
||||
ORDER BY (order_day, order_id);
|
||||
|
||||
-- Ключ совпадает с DDS, поэтому строка заказа на всех слоях попадает на один
|
||||
-- шард.
|
||||
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_dist ON CLUSTER clickstream_cluster
|
||||
AS dm.purchase_vs_orders_rep
|
||||
ENGINE = Distributed(
|
||||
'clickstream_cluster',
|
||||
'dm',
|
||||
'purchase_vs_orders_rep',
|
||||
cityHash64(order_id)
|
||||
);
|
||||
|
||||
-- Донор повторяет структуру и раскладку цели по правилу, разобранному у
|
||||
-- revenue_daily_stage_rep и в docs/architecture/storage.md.
|
||||
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_stage_rep ON CLUSTER clickstream_cluster
|
||||
AS dm.purchase_vs_orders_rep
|
||||
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
|
||||
PARTITION BY order_day
|
||||
ORDER BY (order_day, order_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm.purchase_vs_orders_stage_dist ON CLUSTER clickstream_cluster
|
||||
AS dm.purchase_vs_orders_stage_rep
|
||||
ENGINE = Distributed(
|
||||
'clickstream_cluster',
|
||||
'dm',
|
||||
'purchase_vs_orders_stage_rep',
|
||||
cityHash64(order_id)
|
||||
);
|
||||
|
||||
-- Трафик пересчитывается целиком: новая связь куки с пользователем меняет
|
||||
-- прошлые дни. Версия запуска позволяет точному представлению скрыть строки
|
||||
-- прежних пересчетов без TRUNCATE и без партиций.
|
||||
CREATE TABLE IF NOT EXISTS dm.daily_traffic_rep ON CLUSTER clickstream_cluster
|
||||
(
|
||||
report_date Date,
|
||||
visitors UInt64,
|
||||
known_users UInt64,
|
||||
_load_id String,
|
||||
_load_ts DateTime64(3, 'UTC')
|
||||
)
|
||||
ENGINE = ReplicatedReplacingMergeTree(
|
||||
'/clickhouse/tables/{shard}/{database}/{table}',
|
||||
'{replica}',
|
||||
_load_ts
|
||||
)
|
||||
ORDER BY report_date;
|
||||
|
||||
-- Все версии одного дня должны попадать на один шард, иначе FINAL не сможет
|
||||
-- выбрать последнюю через границу шардов.
|
||||
CREATE TABLE IF NOT EXISTS dm.daily_traffic_dist ON CLUSTER clickstream_cluster
|
||||
AS dm.daily_traffic_rep
|
||||
ENGINE = Distributed(
|
||||
'clickstream_cluster',
|
||||
'dm',
|
||||
'daily_traffic_rep',
|
||||
cityHash64(report_date)
|
||||
);
|
||||
|
||||
-- Сводка качества хранит текущее состояние атомарной проверки одного дня.
|
||||
-- История запусков остаётся в Airflow, поэтому версия строки не нужна:
|
||||
-- следующий успешный прогон заменяет дневную партицию целиком.
|
||||
CREATE TABLE IF NOT EXISTS dm.dq_summary_rep ON CLUSTER clickstream_cluster
|
||||
(
|
||||
data_date Date,
|
||||
check_name LowCardinality(String),
|
||||
reference_rows UInt64,
|
||||
actual_rows UInt64,
|
||||
failed_rows UInt64,
|
||||
status LowCardinality(String),
|
||||
_load_id String,
|
||||
_load_ts DateTime64(3, 'UTC')
|
||||
)
|
||||
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
|
||||
PARTITION BY data_date
|
||||
ORDER BY (data_date, check_name);
|
||||
|
||||
-- Все строки одной проверки лежат на одном шарде. Таблица мала, а ключ
|
||||
-- check_name делает раскладку донора и цели одинаковой без нового измерения.
|
||||
CREATE TABLE IF NOT EXISTS dm.dq_summary_dist ON CLUSTER clickstream_cluster
|
||||
AS dm.dq_summary_rep
|
||||
ENGINE = Distributed(
|
||||
'clickstream_cluster',
|
||||
'dm',
|
||||
'dq_summary_rep',
|
||||
cityHash64(check_name)
|
||||
);
|
||||
|
||||
-- Источник и цель REPLACE PARTITION обязаны совпадать по структуре, ключам и
|
||||
-- политике хранения. Требование повторно сверено по документации ClickHouse
|
||||
-- через Context7 27 августа 2026 года; AS не даёт формам разойтись.
|
||||
CREATE TABLE IF NOT EXISTS dm.dq_summary_stage_rep ON CLUSTER clickstream_cluster
|
||||
AS dm.dq_summary_rep
|
||||
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/{database}/{table}', '{replica}')
|
||||
PARTITION BY data_date
|
||||
ORDER BY (data_date, check_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dm.dq_summary_stage_dist ON CLUSTER clickstream_cluster
|
||||
AS dm.dq_summary_stage_rep
|
||||
ENGINE = Distributed(
|
||||
'clickstream_cluster',
|
||||
'dm',
|
||||
'dq_summary_stage_rep',
|
||||
cityHash64(check_name)
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
-- DM: публичные договоры стендовых витрин.
|
||||
|
||||
-- День и категория — зерно выручки. Служебные колонки показывают запуск,
|
||||
-- который последним заменил дневную партицию.
|
||||
CREATE VIEW IF NOT EXISTS dm.revenue_daily_v ON CLUSTER clickstream_cluster
|
||||
AS
|
||||
SELECT
|
||||
report_date,
|
||||
product_category,
|
||||
orders,
|
||||
units,
|
||||
revenue,
|
||||
aov,
|
||||
_load_id,
|
||||
_load_ts
|
||||
FROM dm.revenue_daily_dist;
|
||||
|
||||
-- Непарные стороны остаются NULL. При join_use_nulls = 0 ClickHouse заполнил
|
||||
-- бы их нулями и пустыми строками, скрыв отсутствие источника.
|
||||
-- Служебные колонки называют запуск, который последним заменил день.
|
||||
CREATE VIEW IF NOT EXISTS dm.purchase_vs_orders_v ON CLUSTER clickstream_cluster
|
||||
AS
|
||||
SELECT
|
||||
order_day,
|
||||
order_id,
|
||||
declared_revenue,
|
||||
items_total,
|
||||
status,
|
||||
mismatch_class,
|
||||
_load_id,
|
||||
_load_ts
|
||||
FROM dm.purchase_vs_orders_dist;
|
||||
|
||||
-- Полный пересчет пишет новую версию каждого дня. FINAL оставляет текущие
|
||||
-- счетчики и прячет повторы запусков от потребителя.
|
||||
CREATE VIEW IF NOT EXISTS dm.daily_traffic_v ON CLUSTER clickstream_cluster
|
||||
AS
|
||||
SELECT
|
||||
report_date,
|
||||
visitors,
|
||||
known_users,
|
||||
_load_id,
|
||||
_load_ts
|
||||
FROM dm.daily_traffic_dist FINAL;
|
||||
|
||||
-- Сводка отвечает, какие атомарные договоры дня выполняются сейчас.
|
||||
-- Явный список не пропускает в публичный договор будущие физические колонки.
|
||||
CREATE VIEW IF NOT EXISTS dm.dq_summary_v ON CLUSTER clickstream_cluster
|
||||
AS
|
||||
SELECT
|
||||
data_date,
|
||||
check_name,
|
||||
reference_rows,
|
||||
actual_rows,
|
||||
failed_rows,
|
||||
status,
|
||||
_load_id,
|
||||
_load_ts
|
||||
FROM dm.dq_summary_dist;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- DDS: полный пересчёт известных пар куки с пользователем.
|
||||
--
|
||||
-- События разложены по client_id; заказы по order_id. Ключи соединения не
|
||||
-- ко-локированы, поэтому правило слоя требует явный GLOBAL. С подзапросами с
|
||||
-- обеих сторон ClickHouse 26.3 выполняет соединение один раз на инициаторе
|
||||
-- (docs/research/2026-08-22-distributed-join.md).
|
||||
|
||||
INSERT INTO dds.identity_map_dist
|
||||
(
|
||||
client_id,
|
||||
user_id,
|
||||
_load_id,
|
||||
_load_ts
|
||||
)
|
||||
SELECT
|
||||
client_id,
|
||||
user_id,
|
||||
{load_id:String},
|
||||
now64(3, 'UTC')
|
||||
FROM
|
||||
(
|
||||
SELECT DISTINCT
|
||||
purchase.client_id,
|
||||
orders.user_id
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
client_id,
|
||||
-- По договору события purchase_id содержит ровно один номер
|
||||
-- заказа: одно подтверждение оформляет один заказ (спека, раздел 4).
|
||||
purchase_id[1] AS order_id
|
||||
FROM dds.event_v
|
||||
WHERE event_type = 'purchase'
|
||||
) AS purchase
|
||||
GLOBAL INNER JOIN
|
||||
(
|
||||
SELECT
|
||||
order_id,
|
||||
user_id
|
||||
FROM dds.order_v
|
||||
) AS orders ON purchase.order_id = orders.order_id
|
||||
)
|
||||
-- В этой форме ClickHouse 26.3 уже пишет через инициатор. Явный ноль фиксирует
|
||||
-- раскладку по ключу цели, если форма запроса или план изменятся (капкан #137).
|
||||
SETTINGS distributed_foreground_insert = 1,
|
||||
parallel_distributed_insert_select = 0;
|
||||
+26
-28
@@ -10,11 +10,11 @@
|
||||
-- запросом, дописывая формат ответа, — точка с запятой сделала бы из этого
|
||||
-- мультизапрос, который ClickHouse не принимает.
|
||||
--
|
||||
-- Объём складывается из двух частей. Первая — дышащее окно: K последних дней
|
||||
-- источника, состояние которых слепок ещё может изменить. Вторая — хвост от
|
||||
-- первого дня, которого в слое нет: первая сборка застаёт слой пустым и
|
||||
-- собирает весь стартовый мир, а после разгона мира пачкой слой догоняет
|
||||
-- уехавший вперёд источник.
|
||||
-- Объём складывается из двух частей. Первая — дышащее окно: дни, которые нёс
|
||||
-- последний слепок, то есть те, чьё состояние источник ещё может изменить.
|
||||
-- Вторая — хвост от первого дня, которого в слое нет: первая сборка застаёт
|
||||
-- слой пустым и собирает весь стартовый мир, а после разгона мира пачкой слой
|
||||
-- догоняет уехавший вперёд источник.
|
||||
--
|
||||
-- **Хвост считается от первого недостающего дня, а не от последнего
|
||||
-- собранного.** Разница не косметическая: замены партиций идут отдельными
|
||||
@@ -24,22 +24,26 @@
|
||||
-- зеленеть над щербатым слоем. Цена честного ответа — проход по дню заказа
|
||||
-- в ODS.
|
||||
--
|
||||
-- Край окна берётся из делового поля, а не из ярлыков доставки: время
|
||||
-- создания заказа одинаково у всех его версий, а `snapshot_date` и `_load_id`
|
||||
-- выжившей строки достаются жребием (docs/architecture/etl.md, «Объём
|
||||
-- прогона», и docs/architecture/orders/ingestion.md, «Роль ODS»).
|
||||
-- Окно читается из ярлыка слепка, а не вычисляется вычитанием окна
|
||||
-- изменяемости: у выжившей строки snapshot_date — последний слепок, который
|
||||
-- нёс этот заказ (ADR 0014). Поэтому оба края окна знает сам источник, а
|
||||
-- копии его константы на стороне стенда больше нет.
|
||||
--
|
||||
-- Известная неточность края: если в самый свежий день источника не родилось
|
||||
-- ни одного заказа, окно съезжает на день назад — лишний замёрзший день
|
||||
-- пересоберётся впустую, а пустой день не тронется. Данные от этого не
|
||||
-- страдают, меняется только ярлык загрузки лишнего дня. Точный ответ здесь
|
||||
-- дала бы дата слепка, но она и есть тот самый жребий.
|
||||
-- FINAL не нужен ни здесь, ни ниже: время рождения заказа у всех его версий
|
||||
-- одно, а строки последнего слепка несут максимум колонки версии — мерж
|
||||
-- оставляет как раз их.
|
||||
WITH
|
||||
-- FINAL не нужен: время рождения заказа версии не различают.
|
||||
(SELECT max(toDate(toTimeZone(created_at, 'Europe/Samara')))
|
||||
FROM ods.order_dist) AS last_source_day,
|
||||
(SELECT min(toDate(toTimeZone(created_at, 'Europe/Samara')))
|
||||
FROM ods.order_dist) AS first_source_day,
|
||||
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
|
||||
(
|
||||
SELECT min(toDate(toTimeZone(created_at, 'Europe/Samara')))
|
||||
FROM ods.order_dist
|
||||
WHERE snapshot_date = last_snapshot
|
||||
) AS window_start,
|
||||
(
|
||||
SELECT max(toDate(toTimeZone(created_at, 'Europe/Samara')))
|
||||
FROM ods.order_dist
|
||||
WHERE snapshot_date = last_snapshot
|
||||
) AS window_end,
|
||||
-- GLOBAL обязателен: обе таблицы распределённые, и обычный NOT IN сверял
|
||||
-- бы дни источника с локальной частью слоя на каждом шарде — день, чьи
|
||||
-- заказы легли на соседний шард, показался бы недостающим.
|
||||
@@ -51,15 +55,9 @@ WITH
|
||||
FROM ods.order_dist
|
||||
)
|
||||
WHERE day GLOBAL NOT IN (SELECT order_date FROM dds.order_dist)
|
||||
) AS first_missing_day,
|
||||
last_source_day - ({window_days:UInt16} - 1) AS window_start
|
||||
) AS first_missing_day
|
||||
SELECT
|
||||
-- Дыр нет — начинаем с окна; есть — с первой из них, если она старше окна.
|
||||
-- greatest держит левый край не раньше первого дня, известного источнику:
|
||||
-- у молодого мира окно шире самого мира.
|
||||
greatest(
|
||||
least(window_start, coalesce(first_missing_day, window_start)),
|
||||
first_source_day
|
||||
) AS first_day,
|
||||
last_source_day AS last_day
|
||||
least(window_start, coalesce(first_missing_day, window_start)) AS first_day,
|
||||
window_end AS last_day
|
||||
WHERE (SELECT count() FROM ods.order_dist) > 0
|
||||
@@ -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).
|
||||
--
|
||||
-- За «доехал» позиция не отвечает — только за «прожит». Перед расчётом объёма
|
||||
-- первый шаг дага дожидается хвоста потока и очереди доставки: иначе
|
||||
-- недожеванный хвост заморозил бы недособранный день
|
||||
-- (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
|
||||
@@ -0,0 +1,29 @@
|
||||
-- DM: полный пересчет дневного трафика.
|
||||
--
|
||||
-- Публичные _v скрывают Distributed-таблицы от локальной подстановки:
|
||||
-- EXPLAIN на ClickHouse 26.3 показал чтение обоих шардов на инициаторе.
|
||||
-- Поэтому GLOBAL назван явно, а физические таблицы не подставлены. LEFT JOIN
|
||||
-- сохраняет анонимные сессии.
|
||||
INSERT INTO dm.daily_traffic_dist
|
||||
(
|
||||
report_date,
|
||||
visitors,
|
||||
known_users,
|
||||
_load_id,
|
||||
_load_ts
|
||||
)
|
||||
SELECT
|
||||
sessions.session_date,
|
||||
uniqExact(sessions.client_id),
|
||||
-- При стендовом join_use_nulls=0 непарный UInt64 получает 0. Условие
|
||||
-- не дает анонимной сессии превратиться в известного пользователя.
|
||||
uniqExactIf(identities.user_id, identities.user_id != 0),
|
||||
{load_id:String},
|
||||
now64(3, 'UTC')
|
||||
FROM dds.session_v AS sessions
|
||||
GLOBAL LEFT JOIN dds.identity_map_v AS identities USING (client_id)
|
||||
GROUP BY sessions.session_date
|
||||
-- Ноль оставляет итоговую группировку на инициаторе: иначе каждый шард записал
|
||||
-- бы свою строку дня, а FINAL через границу шардов их не объединяет.
|
||||
SETTINGS distributed_foreground_insert = 1,
|
||||
parallel_distributed_insert_select = 0;
|
||||
@@ -0,0 +1,71 @@
|
||||
-- DM: сверка покупок трекера с текущими заказами бэкенда.
|
||||
--
|
||||
-- Обе стороны FULL OUTER JOIN — подзапросы. С голой левой Distributed
|
||||
-- ClickHouse вернул бы непарный заказ по разу с каждого шарда; форма измерена
|
||||
-- в docs/research/2026-08-22-distributed-join.md.
|
||||
-- Выбор DROP PARTITION ALL вместо TRUNCATE разобран в соседнем
|
||||
-- sql/dds/order_rebuild.sql.
|
||||
|
||||
ALTER TABLE dm.purchase_vs_orders_stage_rep ON CLUSTER clickstream_cluster
|
||||
DROP PARTITION ALL;
|
||||
|
||||
INSERT INTO dm.purchase_vs_orders_stage_dist
|
||||
(
|
||||
order_day,
|
||||
order_id,
|
||||
declared_revenue,
|
||||
items_total,
|
||||
status,
|
||||
mismatch_class,
|
||||
_load_id,
|
||||
_load_ts
|
||||
)
|
||||
SELECT
|
||||
-- У парного заказа день берется у бэкенда; трекер дает его только
|
||||
-- непарной покупке.
|
||||
coalesce(order_date, purchase_day),
|
||||
coalesce(purchase_order_id, backend_order_id),
|
||||
declared_revenue,
|
||||
items_total,
|
||||
status,
|
||||
multiIf(
|
||||
backend_order_id IS NULL, 'awaiting_order',
|
||||
status = 'cancelled', 'cancelled',
|
||||
purchase_order_id IS NULL, 'lost_event',
|
||||
purchase_count > 1, 'duplicate_event',
|
||||
declared_revenue != items_total, 'amount_delta',
|
||||
'match'
|
||||
),
|
||||
{load_id:String},
|
||||
now64(3, 'UTC')
|
||||
FROM
|
||||
(
|
||||
-- Контракт purchase несет один номер и одну объявленную сумму в массивах.
|
||||
-- Бизнес-дубль повторяет оба значения, поэтому count хранится отдельно,
|
||||
-- а сумма берется одна — складывать дубли значило бы выдумать дельту.
|
||||
SELECT
|
||||
purchase_id[1] AS purchase_order_id,
|
||||
any(event_date) AS purchase_day,
|
||||
any(purchase_revenue[1]) AS declared_revenue,
|
||||
count() AS purchase_count
|
||||
FROM dds.event_v
|
||||
WHERE event_type = 'purchase'
|
||||
AND event_date BETWEEN {first_day:Date} AND {last_day:Date}
|
||||
GROUP BY purchase_order_id
|
||||
) AS purchases
|
||||
GLOBAL FULL OUTER JOIN
|
||||
(
|
||||
SELECT
|
||||
order_id AS backend_order_id,
|
||||
order_date,
|
||||
items_total,
|
||||
status
|
||||
FROM dds.order_v
|
||||
WHERE order_date BETWEEN {first_day:Date} AND {last_day:Date}
|
||||
) AS orders ON purchase_order_id = backend_order_id
|
||||
-- NULL обозначает отсутствующую сторону. При join_use_nulls = 0 ClickHouse
|
||||
-- заполнил бы ее нулем или пустой строкой и скрыл отсутствие источника.
|
||||
-- Вставка идет через ключ цели: источники разложены по другим ключам.
|
||||
SETTINGS join_use_nulls = 1,
|
||||
distributed_foreground_insert = 1,
|
||||
parallel_distributed_insert_select = 0;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- DM: подмена одного дня сверки собранной партицией донора.
|
||||
--
|
||||
-- День для PARTITION берет purchase_vs_orders_scope.sql. Выбор Jinja,
|
||||
-- атомарность и повтор разобраны в sql/dds/order_replace.sql.
|
||||
--
|
||||
-- Пустая партиция законна: если источник больше не показывает строк дня,
|
||||
-- замена убирает этот день из витрины.
|
||||
ALTER TABLE dm.purchase_vs_orders_rep ON CLUSTER clickstream_cluster
|
||||
REPLACE PARTITION '{{ params.day }}' FROM dm.purchase_vs_orders_stage_rep;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- DM: объем пересборки сверки покупок с заказами.
|
||||
--
|
||||
-- У источников разные правые границы. Заказы называют изменяемое окно ярлыком
|
||||
-- последнего принятого слепка. События заканчиваются последним прожитым днем,
|
||||
-- который выводится из позиции мира. Одна граница другую не подменяет.
|
||||
--
|
||||
-- Левая граница возвращает не только пропуски, но и самый ранний
|
||||
-- awaiting_order. Такой день уже есть в витрине, поэтому обычный поиск дыры
|
||||
-- его не увидит. Иначе пакетный разгон длиннее K навсегда оставил бы прежний
|
||||
-- хвост предварительным.
|
||||
--
|
||||
-- Файл заканчивается без точки с запятой: почему — sql/dds/order_scope.sql.
|
||||
WITH
|
||||
(SELECT min(event_date) FROM dds.event_v) AS first_event_day,
|
||||
first_event_day + ({position:UInt16} - 1) AS last_lived_day,
|
||||
(SELECT max(snapshot_date) FROM ods.order_dist) AS last_snapshot,
|
||||
(
|
||||
SELECT min(toDate(toTimeZone(created_at, 'Europe/Samara')))
|
||||
FROM ods.order_dist
|
||||
WHERE snapshot_date = last_snapshot
|
||||
) AS window_start,
|
||||
(
|
||||
SELECT max(toDate(toTimeZone(created_at, 'Europe/Samara')))
|
||||
FROM ods.order_dist
|
||||
WHERE snapshot_date = last_snapshot
|
||||
) AS window_end,
|
||||
(
|
||||
SELECT maxOrNull(event_date)
|
||||
FROM dds.event_v
|
||||
WHERE event_type = 'purchase' AND event_date <= last_lived_day
|
||||
) AS last_purchase_day,
|
||||
(
|
||||
SELECT minOrNull(day)
|
||||
FROM (
|
||||
SELECT order_date AS day
|
||||
FROM dds.order_v
|
||||
UNION DISTINCT
|
||||
SELECT event_date AS day
|
||||
FROM dds.event_v
|
||||
WHERE event_type = 'purchase' AND event_date <= last_lived_day
|
||||
)
|
||||
WHERE day GLOBAL NOT IN (
|
||||
SELECT order_day FROM dm.purchase_vs_orders_dist
|
||||
)
|
||||
) AS first_missing_day,
|
||||
(
|
||||
SELECT minOrNull(order_day)
|
||||
FROM dm.purchase_vs_orders_dist
|
||||
WHERE mismatch_class = 'awaiting_order'
|
||||
) AS first_provisional_day
|
||||
SELECT
|
||||
least(
|
||||
window_start,
|
||||
coalesce(first_missing_day, window_start),
|
||||
coalesce(first_provisional_day, window_start)
|
||||
) AS first_day,
|
||||
greatest(window_end, last_purchase_day) AS last_day
|
||||
-- Без принятого слепка неизвестно окно заказов. Без готового DDS или прожитой
|
||||
-- покупки донор был бы пуст, поэтому подменять партиции еще нельзя.
|
||||
WHERE (SELECT count() FROM ods.order_dist) > 0
|
||||
AND (SELECT count() FROM dds.order_v) > 0
|
||||
AND last_purchase_day IS NOT NULL
|
||||
@@ -0,0 +1,55 @@
|
||||
-- DM: сборка выручки в донор перед заменой дневных партиций.
|
||||
-- DROP PARTITION ALL очищает локальные таблицы на каждом узле; причина не
|
||||
-- применять TRUNCATE разобрана в соседнем sql/dds/order_rebuild.sql.
|
||||
|
||||
ALTER TABLE dm.revenue_daily_stage_rep ON CLUSTER clickstream_cluster
|
||||
DROP PARTITION ALL;
|
||||
|
||||
INSERT INTO dm.revenue_daily_stage_dist
|
||||
(
|
||||
report_date,
|
||||
product_category,
|
||||
orders,
|
||||
units,
|
||||
revenue,
|
||||
aov,
|
||||
_load_id,
|
||||
_load_ts
|
||||
)
|
||||
SELECT
|
||||
report_date,
|
||||
product_category,
|
||||
orders,
|
||||
units,
|
||||
CAST(revenue, 'Decimal(18, 2)'),
|
||||
-- Обычное деление Decimal усекает доли копейки вместо округления;
|
||||
-- запас масштаба сохраняет их до денежного результата.
|
||||
CAST(
|
||||
round(divideDecimal(revenue, toDecimal128(orders, 0), 6), 2),
|
||||
'Decimal(18, 2)'
|
||||
),
|
||||
{load_id:String},
|
||||
now64(3, 'UTC')
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
order_date AS report_date,
|
||||
dictGet('dic.products', 'category', item_sku) AS product_category,
|
||||
uniqExact(order_id) AS orders,
|
||||
sum(item_quantity) AS units,
|
||||
sum(item_price * item_quantity) AS revenue
|
||||
FROM dds.order_v
|
||||
ARRAY JOIN
|
||||
item_sku,
|
||||
item_quantity,
|
||||
item_price
|
||||
WHERE status = 'paid'
|
||||
AND order_date BETWEEN {first_day:Date} AND {last_day:Date}
|
||||
GROUP BY
|
||||
report_date,
|
||||
product_category
|
||||
)
|
||||
-- Источник лежит по заказу, цель — по категории. Ноль заставляет инициатор
|
||||
-- слить частичные агрегаты и разложить готовые строки по ключу цели.
|
||||
SETTINGS distributed_foreground_insert = 1,
|
||||
parallel_distributed_insert_select = 0;
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user