Compare commits
15
Commits
d0f15bea02
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f72e63b14 | ||
|
|
cf1c6c4636 | ||
|
|
3b4827d819 | ||
|
|
905ca5bbaf | ||
|
|
ede1df765c | ||
|
|
8d54a3caba | ||
|
|
c17c5ef995 | ||
|
|
86e2d6dbb2 | ||
|
|
34267e8494 | ||
|
|
c656ca198f | ||
|
|
f51048dd23 | ||
|
|
292302e161 | ||
|
|
35dc933d29 | ||
|
|
61bc156d6a | ||
|
|
9d43ff5489 |
@@ -15,6 +15,11 @@ GRAFANA_PORT=23000
|
|||||||
AIRFLOW_PORT=28080
|
AIRFLOW_PORT=28080
|
||||||
SUPERSET_PORT=28088
|
SUPERSET_PORT=28088
|
||||||
|
|
||||||
|
# GID группы `docker` на этой машине: без него планировщик Airflow не достучится
|
||||||
|
# до сокета докера, и пульт мира не поднимет контейнер генератора.
|
||||||
|
# Подсмотреть свой — `getent group docker`.
|
||||||
|
DOCKER_GID=127
|
||||||
|
|
||||||
# Учётные данные и ключи учебного стенда. Для VPS замените значения образца.
|
# Учётные данные и ключи учебного стенда. Для VPS замените значения образца.
|
||||||
GRAFANA_ADMIN_USER=admin
|
GRAFANA_ADMIN_USER=admin
|
||||||
GRAFANA_ADMIN_PASSWORD=admin
|
GRAFANA_ADMIN_PASSWORD=admin
|
||||||
|
|||||||
@@ -44,3 +44,6 @@ Thumbs.db
|
|||||||
# Локальные настройки Claude Code
|
# Локальные настройки Claude Code
|
||||||
.claude/*
|
.claude/*
|
||||||
!.claude/agents/
|
!.claude/agents/
|
||||||
|
|
||||||
|
# Локальные рабочие файлы агентов: хэндоффы, обменные артефакты ревью
|
||||||
|
.scratch/
|
||||||
|
|||||||
@@ -158,4 +158,7 @@ Airflow) и названия из кода. Если для понятия ес
|
|||||||
- Имена файлов в `docs/architecture/` — слаг строчными латинскими буквами через
|
- Имена файлов в `docs/architecture/` — слаг строчными латинскими буквами через
|
||||||
дефис (`storage.md`). Здесь живут рабочие справочники по зонам
|
дефис (`storage.md`). Здесь живут рабочие справочники по зонам
|
||||||
ответственности: не событие истории и не решение, а текущее устройство —
|
ответственности: не событие истории и не решение, а текущее устройство —
|
||||||
один файл на зону, правится по мере постройки.
|
один файл на зону, правится по мере постройки. Крупный компонент живёт
|
||||||
|
подпапкой: индекс `README.md` и файл на каждую часть устройства
|
||||||
|
(`architecture/orders/`), имена по тому же правилу
|
||||||
|
([ADR 0011](docs/adr/0011-component-docs.md)).
|
||||||
|
|||||||
+46
-9
@@ -34,6 +34,20 @@ _Избегать_: состояние мира
|
|||||||
Люди, впервые пришедшие в мир в один день, со всеми их куками и днями
|
Люди, впервые пришедшие в мир в один день, со всеми их куками и днями
|
||||||
активности. Единица плана состава: когорта — функция зерна и номера дня.
|
активности. Единица плана состава: когорта — функция зерна и номера дня.
|
||||||
|
|
||||||
|
**Человек**:
|
||||||
|
Устойчивая личность в составе мира, которой принадлежат одна или две куки.
|
||||||
|
Не поле источника: кликстрим видит куку, а заказ — пользователя магазина.
|
||||||
|
|
||||||
|
**Посетитель**:
|
||||||
|
Кука, наблюдаемая в кликстриме и обозначенная `ClientID`; единица
|
||||||
|
`uniq(ClientID)`. Один человек может быть представлен несколькими посетителями.
|
||||||
|
_Избегать_: «посетитель» про человека после склейки
|
||||||
|
|
||||||
|
**Пользователь магазина**:
|
||||||
|
Представление человека со стороны заказов; в записи заказа обозначается
|
||||||
|
`user_id`. Через заказы связывает разные куки человека, в кликстрим напрямую
|
||||||
|
не попадает.
|
||||||
|
|
||||||
**Приток**:
|
**Приток**:
|
||||||
Появление новых кук на всём протяжении оси модельного времени; единица —
|
Появление новых кук на всём протяжении оси модельного времени; единица —
|
||||||
кука (`ClientID`). Из-за притока накопленная аудитория растёт с
|
кука (`ClientID`). Из-за притока накопленная аудитория растёт с
|
||||||
@@ -102,10 +116,11 @@ ClickHouse. Форма файла решена, длина — нет: стро
|
|||||||
_Избегать_: зерновой мир
|
_Избегать_: зерновой мир
|
||||||
|
|
||||||
**Опись мира**:
|
**Опись мира**:
|
||||||
`data/world-inventory.json` — единственное, что о мире хранится в git:
|
`data/world-inventory.json` — единственное, что о мире хранится в git: паспорт
|
||||||
паспорт (зерно, версия генератора, хеш каталога) и по строке на день с
|
мира (зерно, версия генератора, хеш каталога), строка на каждый день событий
|
||||||
датой, числом событий и хешем его байтов. Сам мир в git не лежит — он
|
(число и хеш байтов) и строка на каждый отправленный слепок (хеш байтов).
|
||||||
пересчитывается. Опись отвечает на один вопрос: тот ли это мир.
|
Счётчик сверх этого заводится только с названным читателем. Сам мир не
|
||||||
|
хранится, а пересчитывается; опись отвечает на один вопрос: тот ли это мир.
|
||||||
_Избегать_: манифест, мини-манифест
|
_Избегать_: манифест, мини-манифест
|
||||||
|
|
||||||
**Пошаговый режим**:
|
**Пошаговый режим**:
|
||||||
@@ -114,12 +129,18 @@ _Избегать_: манифест, мини-манифест
|
|||||||
|
|
||||||
**Живой день**:
|
**Живой день**:
|
||||||
Проигрывание текущего модельного дня в реальном времени с ускорением;
|
Проигрывание текущего модельного дня в реальном времени с ускорением;
|
||||||
включается по требованию, не постоянный фон.
|
включается по требованию — постоянным фоном идёт, только пока включён
|
||||||
|
выключатель пульта.
|
||||||
|
|
||||||
**Пакетный режим**:
|
**Пакетный режим**:
|
||||||
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
|
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
|
||||||
стенда, пересборки и проверки.
|
стенда, пересборки и проверки.
|
||||||
|
|
||||||
|
**Пульт мира**:
|
||||||
|
Даги Airflow, которыми двигают ось модельного времени: работники играют день —
|
||||||
|
пачкой или в темпе, — а выключатель держит стенд живущим, пока включён.
|
||||||
|
_Избегать_: управляющие даги
|
||||||
|
|
||||||
**Граница суток**:
|
**Граница суток**:
|
||||||
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
|
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
|
||||||
снимается на ней, день проживается только целиком.
|
снимается на ней, день проживается только целиком.
|
||||||
@@ -141,8 +162,8 @@ Python-модуль с описателями колонок события —
|
|||||||
_Избегать_: описание схемы, документация контракта
|
_Избегать_: описание схемы, документация контракта
|
||||||
|
|
||||||
**Канонический сериализатор**:
|
**Канонический сериализатор**:
|
||||||
Единственное место, где событие превращается в байты. Фиксированный порядок
|
Единственная граница, где запись генератора превращается в байты. Фиксированный
|
||||||
ключей и строк — основа побайтовой воспроизводимости.
|
порядок ключей и строк — основа побайтовой воспроизводимости.
|
||||||
|
|
||||||
**Проигрыватель**:
|
**Проигрыватель**:
|
||||||
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
|
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
|
||||||
@@ -150,8 +171,24 @@ _Избегать_: описание схемы, документация кон
|
|||||||
|
|
||||||
**Слепок**:
|
**Слепок**:
|
||||||
Полная выгрузка заказов окна изменяемости, снятая бэкендом на границе суток:
|
Полная выгрузка заказов окна изменяемости, снятая бэкендом на границе суток:
|
||||||
состояние заказов на этот момент, а не поток их изменений. Один заказ
|
состояние заказов на этот момент, а не поток их изменений. Опоздавший заказ
|
||||||
приезжает в стольких слепках, сколько дней окна он прожил.
|
может отсутствовать в ранних слепках; после первого появления ездит до конца
|
||||||
|
своего окна.
|
||||||
|
|
||||||
|
**Дата слепка**:
|
||||||
|
Дата завершившегося модельного дня, состояние которого снято на исходящей
|
||||||
|
границе суток. Одна для всех записей выгрузки; не дата заказа и не время
|
||||||
|
отправки сообщения.
|
||||||
|
|
||||||
|
**Время аудита источника**:
|
||||||
|
Время создания или последнего изменения строки заказа по часам базы источника.
|
||||||
|
В записи слепка это `created_at` и `updated_at`; бизнес-время покупки живёт в
|
||||||
|
событии `purchase` отдельно.
|
||||||
|
|
||||||
|
**Судьба заказа**:
|
||||||
|
Исход, моменты изменений, вычеркнутая позиция и опоздание заказа — всё
|
||||||
|
решается бросками при его рождении и укладывается в окно изменяемости целиком.
|
||||||
|
Слепок любого дня — чтение готовой судьбы, а не накопление состояния.
|
||||||
|
|
||||||
**Окно изменяемости**:
|
**Окно изменяемости**:
|
||||||
Сколько модельных дней заказ ещё может измениться и потому продолжает ездить
|
Сколько модельных дней заказ ещё может измениться и потому продолжает ездить
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ make check-services
|
|||||||
Дальше, в рабочей петле, обычно хватает `make smoke`.
|
Дальше, в рабочей петле, обычно хватает `make smoke`.
|
||||||
|
|
||||||
`.env.example` — образец обязательной локальной настройки. В нём живут имя
|
`.env.example` — образец обязательной локальной настройки. В нём живут имя
|
||||||
экземпляра, внешние порты, учебные учётные данные и ключи. `compose.yaml` не
|
экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца
|
||||||
|
верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и
|
||||||
|
свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь,
|
||||||
|
кроме пульта мира — о нём ниже. `compose.yaml` не
|
||||||
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
|
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
|
||||||
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
|
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
|
||||||
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
|
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
|
||||||
@@ -219,6 +222,33 @@ uv run --project generator python -m clickstream_generator batch \
|
|||||||
код возврата, ещё до первого события. Поэтому умолчание дня и живёт в
|
код возврата, ещё до первого события. Поэтому умолчание дня и живёт в
|
||||||
`Makefile`: день называет тот, кто запускает, а не тот, кого запускают.
|
`Makefile`: день называет тот, кто запускает, а не тот, кого запускают.
|
||||||
|
|
||||||
|
### Пульт мира
|
||||||
|
|
||||||
|
Считать дни самому необязательно. В Airflow живёт **пульт мира** — три дага,
|
||||||
|
которые зовут тот же образ генератора и ведут позицию на оси за вас.
|
||||||
|
|
||||||
|
| Даг | Что делает |
|
||||||
|
| --- | --- |
|
||||||
|
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
|
||||||
|
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
|
||||||
|
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём |
|
||||||
|
|
||||||
|
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
|
||||||
|
дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон
|
||||||
|
позицию не двигает, и следующий запуск играет тот же день заново. Нет
|
||||||
|
переменной — мир в стартовом состоянии.
|
||||||
|
|
||||||
|
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
|
||||||
|
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
|
||||||
|
целиком — [ADR 0009](docs/adr/0009-world-control.md).
|
||||||
|
|
||||||
|
**Плата названа вслух: планировщику Airflow отдан сокет докера** — иначе
|
||||||
|
контейнер генератора ему не поднять. Доступ к сокету равен праву root на
|
||||||
|
машине; для локального учебного стенда размен принят, но знать о нём надо.
|
||||||
|
Открывает дверь не монтирование, а членство в группе: GID группы `docker` у
|
||||||
|
каждой машины свой, живёт в `.env` и подсматривается командой
|
||||||
|
`getent group docker`.
|
||||||
|
|
||||||
## Состав и доступ
|
## Состав и доступ
|
||||||
|
|
||||||
- `clickhouse-01` — инициатор DDL и точка подключения Airflow;
|
- `clickhouse-01` — инициатор DDL и точка подключения Airflow;
|
||||||
|
|||||||
+38
-5
@@ -29,6 +29,17 @@ x-clickhouse-common: &clickhouse-common
|
|||||||
retries: 30
|
retries: 30
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
# Факты стенда, у которых стало по два потребителя: разовая служба генератора и
|
||||||
|
# пульт мира. Названы по одному разу — иначе однажды разойдутся, и заметит это
|
||||||
|
# не проверка, а менти с пустым топиком.
|
||||||
|
x-generator-image: &generator-image clickstream-generator:local
|
||||||
|
|
||||||
|
x-kafka-target: &kafka-target
|
||||||
|
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
|
||||||
|
KAFKA_TOPIC: hits
|
||||||
|
|
||||||
|
x-world-starting-days: &world-starting-days "8"
|
||||||
|
|
||||||
x-airflow-common: &airflow-common
|
x-airflow-common: &airflow-common
|
||||||
image: clickstream-airflow:local
|
image: clickstream-airflow:local
|
||||||
build:
|
build:
|
||||||
@@ -50,6 +61,13 @@ x-airflow-common: &airflow-common
|
|||||||
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}
|
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}
|
||||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
|
# Пульт мира поднимает генератор сам, отдельным контейнером, — значит те же
|
||||||
|
# факты стенда, что compose даёт разовой службе генератора, нужны и дагам.
|
||||||
|
# Имя сети собирается из имени проекта: у второй копии стенда оно другое.
|
||||||
|
<<: *kafka-target
|
||||||
|
GENERATOR_IMAGE: *generator-image
|
||||||
|
STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default
|
||||||
|
WORLD_STARTING_DAYS: *world-starting-days
|
||||||
volumes:
|
volumes:
|
||||||
- ./dags:/opt/airflow/dags:ro
|
- ./dags:/opt/airflow/dags:ro
|
||||||
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
|
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
|
||||||
@@ -57,7 +75,7 @@ x-airflow-common: &airflow-common
|
|||||||
- airflow_auth:/opt/airflow/auth
|
- airflow_auth:/opt/airflow/auth
|
||||||
|
|
||||||
x-generator-common: &generator-common
|
x-generator-common: &generator-common
|
||||||
image: clickstream-generator:local
|
image: *generator-image
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: generator/Dockerfile
|
dockerfile: generator/Dockerfile
|
||||||
@@ -75,8 +93,7 @@ x-generator-common: &generator-common
|
|||||||
# Адрес брокера и имя топика — факты стенда, и называет их стенд.
|
# Адрес брокера и имя топика — факты стенда, и называет их стенд.
|
||||||
# Остальное (день, зерно, число дней, предел пачки) приходит аргументами
|
# Остальное (день, зерно, число дней, предел пачки) приходит аргументами
|
||||||
# от того, кто запускает: у службы нет позиции на оси мира.
|
# от того, кто запускает: у службы нет позиции на оси мира.
|
||||||
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
|
<<: *kafka-target
|
||||||
KAFKA_TOPIC: hits
|
|
||||||
|
|
||||||
x-superset-common: &superset-common
|
x-superset-common: &superset-common
|
||||||
image: clickstream-superset:local
|
image: clickstream-superset:local
|
||||||
@@ -261,14 +278,15 @@ services:
|
|||||||
# Число дней стоит здесь числом: YAML не читает Python, и одно из двух мест
|
# Число дней стоит здесь числом: YAML не читает Python, и одно из двух мест
|
||||||
# (второе — `STARTING_DAYS` в inventory.py) лишнее по построению. Правя одно,
|
# (второе — `STARTING_DAYS` в inventory.py) лишнее по построению. Правя одно,
|
||||||
# правьте второе — на страже тут никто не стоит: залей эта служба лишний
|
# правьте второе — на страже тут никто не стоит: залей эта служба лишний
|
||||||
# день, он лёг бы за рамкой дат описи и остался бы незамеченным.
|
# день, он лёг бы за рамкой дат описи и остался бы незамеченным. Внутри YAML
|
||||||
|
# число одно на всех: то же говорит дагам пульта, где кончается стартовый мир.
|
||||||
#
|
#
|
||||||
# Повторный `make up` заливает мир заново, и это не оплошность: `WatchID` у
|
# Повторный `make up` заливает мир заново, и это не оплошность: `WatchID` у
|
||||||
# событий те же, ReplacingMergeTree схлопнет повтор в ODS. Сырьё в STG при
|
# событий те же, ReplacingMergeTree схлопнет повтор в ODS. Сырьё в STG при
|
||||||
# этом честно удвоится — свойство слоя, описанное в storage.md.
|
# этом честно удвоится — свойство слоя, описанное в storage.md.
|
||||||
world-init:
|
world-init:
|
||||||
<<: *generator-common
|
<<: *generator-common
|
||||||
command: ["batch", "--day", "0", "--days", "8"]
|
command: ["batch", "--day", "0", "--days", *world-starting-days]
|
||||||
|
|
||||||
# Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`.
|
# Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`.
|
||||||
# Под профилем — чтобы обычный подъём стенда её не трогал.
|
# Под профилем — чтобы обычный подъём стенда её не трогал.
|
||||||
@@ -350,6 +368,21 @@ services:
|
|||||||
airflow-init:
|
airflow-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
command: scheduler
|
command: scheduler
|
||||||
|
# Пульт мира зовёт генератор отдельным контейнером, а при LocalExecutor
|
||||||
|
# задачи исполняет сам планировщик — значит сокет докера нужен ему одному.
|
||||||
|
# Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root
|
||||||
|
# на машине. Открывает дверь не монтирование, а группа: у сокета права 660
|
||||||
|
# и группа `docker`, чей GID на каждой машине свой и живёт в `.env`.
|
||||||
|
group_add:
|
||||||
|
- ${DOCKER_GID:?Скопируйте .env.example в .env}
|
||||||
|
# Тома перечислены заново: список службы общий не дополняет, а заменяет.
|
||||||
|
# Появится новый том у остальных служб Airflow — добавьте и сюда.
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./dags:/opt/airflow/dags:ro
|
||||||
|
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
|
||||||
|
- airflow_logs:/opt/airflow/logs
|
||||||
|
- airflow_auth:/opt/airflow/auth
|
||||||
mem_limit: 640m
|
mem_limit: 640m
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"]
|
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"]
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Пульт мира: даги, которыми двигают ось модельного времени.
|
||||||
|
|
||||||
|
Их три, и они двух родов. **Работники** играют день и запускаются руками:
|
||||||
|
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
|
||||||
|
один день в темпе модельного времени. **Выключатель** `world_live` своей работы
|
||||||
|
не делает: он тикает по расписанию и дёргает работника живого дня, дожидаясь
|
||||||
|
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
|
||||||
|
границе модельных суток.
|
||||||
|
|
||||||
|
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку
|
||||||
|
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник
|
||||||
|
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
|
||||||
|
|
||||||
|
Календарь Airflow к оси мира отношения не имеет: какой день играть, работник
|
||||||
|
спрашивает у переменной, а не у логической даты прогона.
|
||||||
|
|
||||||
|
Решения и доводы целиком — ADR 0009.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
from airflow.providers.docker.operators.docker import DockerOperator
|
||||||
|
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
|
||||||
|
from airflow.sdk import Param, Variable, dag, get_current_context, task
|
||||||
|
|
||||||
|
# Факты стенда — образ генератора, сеть, адрес брокера, топик и размер
|
||||||
|
# стартового мира — приходят окружением, и называет их compose: тот же, что
|
||||||
|
# называет их разовой службе генератора.
|
||||||
|
GENERATOR_IMAGE = os.environ["GENERATOR_IMAGE"]
|
||||||
|
STAND_NETWORK = os.environ["STAND_NETWORK"]
|
||||||
|
GENERATOR_ENVIRONMENT = {
|
||||||
|
"KAFKA_BOOTSTRAP_SERVERS": os.environ["KAFKA_BOOTSTRAP_SERVERS"],
|
||||||
|
"KAFKA_TOPIC": os.environ["KAFKA_TOPIC"],
|
||||||
|
}
|
||||||
|
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
|
||||||
|
|
||||||
|
# Позиция на оси: номер первого несыгранного дня. Переменной нет — мир в
|
||||||
|
# стартовом состоянии, и играть надо сразу за ним.
|
||||||
|
#
|
||||||
|
# Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее
|
||||||
|
# при таком правиле — сыгранный дважды день, а повтор схлопнет
|
||||||
|
# ReplacingMergeTree. Увеличение молча съело бы день, и в мире осталась бы
|
||||||
|
# дыра, которой никто не заметит.
|
||||||
|
WORLD_POSITION = "world_position"
|
||||||
|
|
||||||
|
# Тик выключателя. Каденцию задаёт не он, а сама длина живого дня — около
|
||||||
|
# двадцати четырёх минут: тик только спрашивает «не пора ли снова».
|
||||||
|
LIVE_TICK = datetime.timedelta(minutes=25)
|
||||||
|
|
||||||
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
|
TAGS = ["пульт мира"]
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def first_unplayed_day() -> int:
|
||||||
|
"""Номер дня, с которого играть."""
|
||||||
|
return int(Variable.get(WORLD_POSITION, default=STARTING_DAYS))
|
||||||
|
|
||||||
|
|
||||||
|
def _play(task_id: str, command: list[str]) -> DockerOperator:
|
||||||
|
"""Задача, играющая дни в каноническом контейнере генератора.
|
||||||
|
|
||||||
|
Внутрь образа Airflow генератор не поставить: он требует Python 3.14, а
|
||||||
|
образ несёт 3.13. Да и обещание побайтовой воспроизводимости дано для
|
||||||
|
зафиксированного образа генератора — держится оно только там.
|
||||||
|
"""
|
||||||
|
return DockerOperator(
|
||||||
|
task_id=task_id,
|
||||||
|
image=GENERATOR_IMAGE,
|
||||||
|
command=command,
|
||||||
|
network_mode=STAND_NETWORK,
|
||||||
|
environment=GENERATOR_ENVIRONMENT,
|
||||||
|
# Контейнер убирается в любом исходе: вывод генератора оператор уже
|
||||||
|
# перелил в журнал задачи, а мёртвые контейнеры копить незачем.
|
||||||
|
auto_remove="force",
|
||||||
|
# По умолчанию оператор монтирует контейнеру временный каталог. Здесь
|
||||||
|
# это ловушка: путь он заводит внутри Airflow, а монтирует демон с
|
||||||
|
# хоста, где такого пути нет. Генератору временный каталог не нужен.
|
||||||
|
mount_tmp_dir=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
dag_id="world_next_day",
|
||||||
|
schedule=None,
|
||||||
|
start_date=START_DATE,
|
||||||
|
is_paused_upon_creation=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=TAGS,
|
||||||
|
params={"days": Param(1, type="integer", minimum=1, title="Сколько дней прожить")},
|
||||||
|
)
|
||||||
|
def world_next_day():
|
||||||
|
"""Прожить следующие дни пачкой, без пауз.
|
||||||
|
|
||||||
|
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
|
||||||
|
нажимом, а не десятью: сколько дней играть — параметр запуска.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@task
|
||||||
|
def remember_played(first_day: int) -> None:
|
||||||
|
"""Позиция ставится по сыгранным дням и только по успеху."""
|
||||||
|
days = get_current_context()["params"]["days"]
|
||||||
|
Variable.set(WORLD_POSITION, str(first_day + days))
|
||||||
|
|
||||||
|
first_day = first_unplayed_day()
|
||||||
|
played = _play(
|
||||||
|
"play_days",
|
||||||
|
[
|
||||||
|
"batch",
|
||||||
|
"--day",
|
||||||
|
"{{ ti.xcom_pull(task_ids='first_unplayed_day') }}",
|
||||||
|
"--days",
|
||||||
|
"{{ params.days }}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
first_day >> played >> remember_played(first_day)
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
dag_id="world_live_day",
|
||||||
|
schedule=None,
|
||||||
|
start_date=START_DATE,
|
||||||
|
is_paused_upon_creation=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=TAGS,
|
||||||
|
)
|
||||||
|
def world_live_day():
|
||||||
|
"""Прожить следующий день в темпе модельного времени.
|
||||||
|
|
||||||
|
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
|
||||||
|
реальные минуты, и суточная волна разворачивается на глазах. Запускается
|
||||||
|
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@task
|
||||||
|
def remember_played(first_day: int) -> None:
|
||||||
|
"""Позиция ставится по сыгранному дню и только по успеху."""
|
||||||
|
Variable.set(WORLD_POSITION, str(first_day + 1))
|
||||||
|
|
||||||
|
first_day = first_unplayed_day()
|
||||||
|
played = _play(
|
||||||
|
"play_day",
|
||||||
|
["live", "--day", "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}"],
|
||||||
|
)
|
||||||
|
|
||||||
|
first_day >> played >> remember_played(first_day)
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
dag_id="world_live",
|
||||||
|
schedule=LIVE_TICK,
|
||||||
|
start_date=START_DATE,
|
||||||
|
catchup=False,
|
||||||
|
is_paused_upon_creation=True,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=TAGS,
|
||||||
|
)
|
||||||
|
def world_live():
|
||||||
|
"""Выключатель: пока включён, мир живёт день за днём.
|
||||||
|
|
||||||
|
Своей работы у выключателя нет — он дёргает `world_live_day` и ждёт конца.
|
||||||
|
Ожидание тут несущая конструкция, а не вежливость: без него тик шёл бы
|
||||||
|
независимо от хода дня, лишние прогоны скопились бы очередью, и мир потом
|
||||||
|
промчался бы по ней без всякого темпа.
|
||||||
|
|
||||||
|
Ждём триггером, а не сенсором: оператор опрашивает тот прогон, который сам
|
||||||
|
и создал, и ссылка на дочерний прогон видна прямо отсюда. Упал день —
|
||||||
|
краснеет и выключатель.
|
||||||
|
"""
|
||||||
|
TriggerDagRunOperator(
|
||||||
|
task_id="trigger_live_day",
|
||||||
|
trigger_dag_id="world_live_day",
|
||||||
|
wait_for_completion=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
world_next_day()
|
||||||
|
world_live_day()
|
||||||
|
world_live()
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
# ADR 0008. Приём заказов: пакетный забор слепка, инициируемый Airflow
|
# ADR 0008. Приём заказов: пакетный забор слепка, инициируемый Airflow
|
||||||
|
|
||||||
Дата: 12 августа 2026 года. Статус: принято. Реализация — отдельным тикетом.
|
Дата: 12 августа 2026 года. Статус: частично заменено
|
||||||
|
[ADR 0010](0010-order-versions-in-ods.md). Реализация — отдельным тикетом.
|
||||||
|
|
||||||
|
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
|
||||||
|
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
|
||||||
|
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
|
||||||
|
`dds.order` с `argMax`. Действующая форма STG → ODS описана в
|
||||||
|
[спецификации приёма заказов](../architecture/orders/ingestion.md).
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
@@ -10,6 +17,11 @@
|
|||||||
прочитанное в `stg.orders_raw_dist`, разбирает его в типизированный слепок и
|
прочитанное в `stg.orders_raw_dist`, разбирает его в типизированный слепок и
|
||||||
заменяет партицию дня в `ods.order_snapshot`. Тот же даг проигрывает модельный
|
заменяет партицию дня в `ods.order_snapshot`. Тот же даг проигрывает модельный
|
||||||
день генератором, поэтому переливается ровно то, что он положил в топик.
|
день генератором, поэтому переливается ровно то, что он положил в топик.
|
||||||
|
Уточнение со сдвигом отправки, решённым позже (#71, [слепок и его
|
||||||
|
доставка](../architecture/orders/snapshot.md)): даг, играющий день D, в
|
||||||
|
штатном прогоне кладёт и забирает слепок дня D−1 — слепок предыдущего дня, а
|
||||||
|
не сыгранного. После падения между шагами в топике может ждать и хвост
|
||||||
|
прежних слепков; забор принимает всё приехавшее.
|
||||||
|
|
||||||
Прямое чтение из Kafka-движка требует двух настроек, и вторая не очевидна:
|
Прямое чтение из Kafka-движка требует двух настроек, и вторая не очевидна:
|
||||||
`stream_like_engine_allow_direct_select = 1` разрешает читать чтеца запросом,
|
`stream_like_engine_allow_direct_select = 1` разрешает читать чтеца запросом,
|
||||||
@@ -128,7 +140,9 @@
|
|||||||
|
|
||||||
## Что проверено
|
## Что проверено
|
||||||
|
|
||||||
По документации ClickHouse через MCP Context7, 12 августа 2026 года.
|
Документация ClickHouse проверена через MCP Context7 12 августа 2026 года.
|
||||||
|
Предел порции одного опроса Kafka дополнительно снят 16 августа на локальном
|
||||||
|
ClickHouse `26.3.17.56`.
|
||||||
|
|
||||||
- Прямое чтение из движков-очередей (Kafka, RabbitMQ, FileLog) запрещено
|
- Прямое чтение из движков-очередей (Kafka, RabbitMQ, FileLog) запрещено
|
||||||
начиная с версии 21.12 и открывается настройкой
|
начиная с версии 21.12 и открывается настройкой
|
||||||
@@ -138,8 +152,9 @@
|
|||||||
- Прямое чтение офсеты по умолчанию **не** коммитит; коммит включается
|
- Прямое чтение офсеты по умолчанию **не** коммитит; коммит включается
|
||||||
настройкой `kafka_commit_on_select` на самой таблице. Это тот подводный
|
настройкой `kafka_commit_on_select` на самой таблице. Это тот подводный
|
||||||
камень, который молчит на первом прогоне и вылезает на втором.
|
камень, который молчит на первом прогоне и вылезает на втором.
|
||||||
- Сколько строк отдаёт одно чтение, задаёт `kafka_max_block_size`. При слепке
|
- Прямое чтение возвращает одну порцию, полученную одним опросом Kafka. При
|
||||||
порядка полутора тысяч строк это один блок с запасом.
|
настройках стенда её предел — 65 409 сообщений, поэтому слепок порядка
|
||||||
|
полутора тысяч строк помещается с запасом.
|
||||||
- Про `Distributed` поверх Kafka документация не говорит ничего — ни
|
- Про `Distributed` поверх Kafka документация не говорит ничего — ни
|
||||||
поддержки, ни запрета.
|
поддержки, ни запрета.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# ADR 0009. Пульт мира: работники, выключатель и канонический контейнер
|
||||||
|
|
||||||
|
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Ось модельного времени двигают даги пульта, и они двух родов.
|
||||||
|
|
||||||
|
**Работники** — без расписания и без паузы, запускаются руками:
|
||||||
|
|
||||||
|
- `world_next_day` — день пачкой; параметр «сколько дней» (умолчание 1) даёт
|
||||||
|
разгон вперёд одним нажимом;
|
||||||
|
- `world_live_day` — один день в темпе живого режима.
|
||||||
|
|
||||||
|
**Выключатель** — без своей работы: `world_live` несёт расписание около двадцати
|
||||||
|
пяти минут, создаётся на паузе и дёргает `world_live_day`
|
||||||
|
через `TriggerDagRunOperator`, дожидаясь его завершения. Снят с паузы — стенд
|
||||||
|
живёт день за днём; поставлен на паузу — мир встал на границе модельных суток.
|
||||||
|
|
||||||
|
Генератор задача зовёт через `DockerOperator`: тот поднимает образ
|
||||||
|
`clickstream-generator:local` — тот самый канонический контейнер, внутри
|
||||||
|
которого [спека генератора](../specs/2026-08-01-generator.md) обещает побайтовую
|
||||||
|
воспроизводимость, — в сети стенда, командой `batch` или `live`.
|
||||||
|
|
||||||
|
Позиция на оси живёт переменной Airflow, как обещает раздел 8 той же спеки.
|
||||||
|
**Ставит её тот, кто сыграл день** — работник, и только по успеху, значением
|
||||||
|
«последний сыгранный день + 1». Выключатель к переменной не прикасается. День
|
||||||
|
работник берёт из переменной, а не из логической даты прогона: календарь Airflow
|
||||||
|
к оси мира отношения не имеет.
|
||||||
|
|
||||||
|
Пакетная автоматика — второй выключатель, дёргающий `world_next_day`, —
|
||||||
|
**отложена, а не отвергнута**.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
**Работники без расписания, выключатели без работы.** Иначе кнопка паузы значит
|
||||||
|
две вещи разом: «мир не едет сам» и «даг выключен». Расписание на самом
|
||||||
|
работнике формально живёт с ручным запуском — пауза, по документации Airflow 3,
|
||||||
|
запрещает планирование, но ручной запуск разрешает, — только в списке дагов
|
||||||
|
работник выглядит выключенным, а нажимают его каждый день. У предшественника
|
||||||
|
стоит ровно эта форма: `world_next_day` с расписанием и на паузе, к ней
|
||||||
|
объясняющий комментарий в коде и сторож `assert_target_dag_not_paused` на случай
|
||||||
|
триггера в спящий даг. Разделение снимает и комментарий, и сторожа: работника
|
||||||
|
никогда не ставят на паузу, поэтому дёргать спящего некому.
|
||||||
|
|
||||||
|
**Канонический контейнер — условие обещания, а не вкус.** Детерминизм генератора
|
||||||
|
обещан внутри зафиксированного образа, и обещание держится только там. Поставить
|
||||||
|
пакет прямо в образ Airflow, как делал предшественник, здесь нельзя даже
|
||||||
|
механически: генератор требует Python 3.14, а `apache/airflow:3.3.0` несёт 3.13.
|
||||||
|
Собрать в образе Airflow второе окружение из того же `uv.lock` — можно, но это
|
||||||
|
второе место установки генератора, пересборка образа Airflow на каждую правку
|
||||||
|
мира и раскладка каталога товаров, повторённая монтированиями руками: образ
|
||||||
|
генератора делает эту работу сам. Заодно `DockerOperator` держит генератор
|
||||||
|
отдельной и заменяемой сущностью, как решил раздел 8 спеки, и показывает
|
||||||
|
приём, у которого есть боевой родственник — так в бою запускают шаг подом.
|
||||||
|
|
||||||
|
**Выключатель ждёт, и от этого зависит устойчивость.** Не ждущий выключатель
|
||||||
|
тикает по расписанию независимо от хода дня: живой день длится около двадцати
|
||||||
|
четырёх минут, а у работника `max_active_runs = 1`, поэтому лишние триггеры
|
||||||
|
скопились бы очередью прогонов, и мир потом промчался бы по ней без темпа. С
|
||||||
|
ожиданием каденцию задаёт сама длина дня, а расписание остаётся грубым тиком «не
|
||||||
|
пора ли снова»: пауза между днями равна остатку до следующего тика, до минуты.
|
||||||
|
|
||||||
|
**Ждём триггером, а не сенсором.** Различаются они тем, чем опознают прогон.
|
||||||
|
`TriggerDagRunOperator` с `wait_for_completion` ждёт тот прогон, который сам и
|
||||||
|
создал, — опрос идёт по его идентификатору. `ExternalTaskSensor` ждёт задачу
|
||||||
|
чужого дага **за конкретную логическую дату**: пришлось бы передать работнику
|
||||||
|
логическую дату и повторить её в сенсоре, то есть завести вторую бухгалтерию
|
||||||
|
времени сразу после того, как решение выше её запретило. Сенсор здесь и как урок
|
||||||
|
был бы поддельным: его дом там, где ждут события, которого сам не производишь, а
|
||||||
|
у пульта производитель и потребитель — свои же даги. Тем же доводом
|
||||||
|
[ADR 0008](0008-order-ingestion.md) снял сенсор дневного батча. Настоящая
|
||||||
|
механика показана и без него: родитель, ждущий чужого прогона, со ссылкой на
|
||||||
|
дочерний прогон в интерфейсе и явными состояниями отказа — упал день, покраснел
|
||||||
|
и выключатель. Это [ADR 0003](0003-dag-run-state.md) в действии: зелёный конец
|
||||||
|
графа не должен переживать отказ выше.
|
||||||
|
|
||||||
|
**Позиция ставится, а не увеличивается.** Правило дешевле сторожа. Если прогон
|
||||||
|
выключателя наложится на ручной, худшее — день сыгран дважды: номера событий
|
||||||
|
детерминированы, повтор схлопнет `ReplacingMergeTree`, а позиция останется
|
||||||
|
верной. Увеличение на единицу в том же случае молча проглотило бы день, и мир
|
||||||
|
получил бы дыру, которую никто не заметит. Ни пул как мьютекс, ни проверка «а не
|
||||||
|
идёт ли другой прогон» после этого не нужны.
|
||||||
|
|
||||||
|
Худший исход у правила всё-таки есть, и он мягкий: работник, кончивший позже,
|
||||||
|
ставит позицию по своему последнему дню и может отодвинуть её назад — так
|
||||||
|
бывает, если долгий живой день закончится после разгона на три дня вперёд.
|
||||||
|
Тогда часть дней переигрывается. Это дешевле дыры: переигранный день склеится,
|
||||||
|
пропущенный не найдётся никогда.
|
||||||
|
|
||||||
|
**Плата — сокет докера внутри Airflow.** Доступ к нему равен праву root на
|
||||||
|
машине, и пользователю `airflow` он открывается добавлением GID группы `docker`
|
||||||
|
в контейнер. GID у каждой машины свой, поэтому он — локальная настройка и живёт
|
||||||
|
в `.env` рядом с остальными такими, а не в описании стенда. Для локального учебного стенда размен принят —
|
||||||
|
тот же, что уже принят для пароля в trace-журнале пробника, — но плата
|
||||||
|
называется вслух и в README. Вторая плата мелкая: пока стенд живёт, из четырёх
|
||||||
|
слотов `parallelism` заняты два, ждущий выключатель и работающий контейнер.
|
||||||
|
Освободить слот ожидания умеет `deferrable`, но он требует компонент triggerer,
|
||||||
|
которого стенд не поднимает; заводить службу ради одного ожидания дороже
|
||||||
|
занятого слота.
|
||||||
|
|
||||||
|
**Пакетная автоматика отложена, потому что своего желания у неё пока одно.**
|
||||||
|
«Шагни и стой» закрывает кнопка, «уедь на неделю сейчас» — параметр «сколько
|
||||||
|
дней», «живи, пока я смотрю» — выключатель. Ей остаётся «едь сам быстрее, чем
|
||||||
|
живёшь»: день за пять минут, часами, — каденция, которой у живого темпа нет
|
||||||
|
(ускорение сверх ×60 спека отвергла как перемотку). Желание правдоподобное, но
|
||||||
|
пока не названное, а форма известна и стоит тридцать строк: второй экземпляр
|
||||||
|
выключателя на второго работника. Отложить дешевле, чем завести и объяснять.
|
||||||
|
|
||||||
|
**Что отвергнуто ещё.** Пульт на `make` с позицией в файле: дёшево сегодня, но
|
||||||
|
раздел 8 спеки уже отдал позицию переменной Airflow, а этап 3 без дага не
|
||||||
|
существует вовсе ([ADR 0008](0008-order-ingestion.md)) — платить пришлось бы
|
||||||
|
дважды, и второй хранитель позиции разошёлся бы с первым. Один долгий прогон
|
||||||
|
`live` на много дней: поток без швов, но позиция двигается только по успеху всего
|
||||||
|
прогона, а такой прогон всегда обрывают — переменная не сдвинулась бы никогда, и
|
||||||
|
обрыв на десятом дне откатывал бы все десять. Цепочка на ресурсах Airflow 3
|
||||||
|
(работник выпускает событие, выключатель на него подписан): каденция вышла бы
|
||||||
|
точной и без ожидания, но снять выключатель с паузы недостаточно — цепочку надо
|
||||||
|
толкнуть первым ручным прогоном, а читателю разбирать петлю «работник → ресурс →
|
||||||
|
выключатель → работник». Цена расшифровки выше урока.
|
||||||
|
|
||||||
|
## Следствия
|
||||||
|
|
||||||
|
Этапы 3 и 5 зовут генератор тем же приёмом: пакетный забор слепка заказов
|
||||||
|
([ADR 0008](0008-order-ingestion.md)) прирастает к тому же работнику, а
|
||||||
|
`etl_pipeline` этапа 5 получает готовую обвязку `DockerOperator` и готовое
|
||||||
|
правило позиции.
|
||||||
|
|
||||||
|
Расхождения, внесённые тем же коммитом: раздел 8 спеки генератора (позицию
|
||||||
|
двигал «только даг `next_day`»; теперь — всякий, кто сыграл день, значением
|
||||||
|
«сыгранный + 1») и раздел 1 там же вместе со словарём (живой день «не постоянный
|
||||||
|
фон»; теперь фон у него есть, и у фона есть выключатель).
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
По документации Airflow 3 через MCP Context7 и живым замером образа, 13 августа
|
||||||
|
2026 года.
|
||||||
|
|
||||||
|
- Пауза дага запрещает планирование, но **разрешает ручной запуск**
|
||||||
|
(`core-concepts/dags.rst`).
|
||||||
|
- `TriggerDagRunOperator` с `wait_for_completion` опрашивает прогон, который сам
|
||||||
|
создал, по его идентификатору; без этого флага `allowed_states`,
|
||||||
|
`failed_states` и `poke_interval` не действуют вовсе. Вариант `deferrable`
|
||||||
|
требует компонент triggerer.
|
||||||
|
- `ExternalTaskSensor` ждёт задачу чужого дага за конкретные логические даты;
|
||||||
|
сдвиги задаются `execution_delta` и `execution_date_fn`.
|
||||||
|
- Сенсор в режиме `poke` держит слот всё время, в режиме `reschedule` —
|
||||||
|
только на время проверки.
|
||||||
|
- У `DockerOperator` `network_mode` принимает имя пользовательской сети, есть
|
||||||
|
`auto_remove` и `skip_on_exit_code`.
|
||||||
|
- Образ `apache/airflow:3.3.0` несёт Python 3.13.14 (замер запуском образа);
|
||||||
|
генератор требует `>=3.14,<3.15`.
|
||||||
|
- Сокет докера на машине стенда — `root:docker` с правами `660`, GID группы 127;
|
||||||
|
пользователь образа Airflow — `uid=50000(airflow) gid=0(root)`, в группе
|
||||||
|
`docker` его нет. Значит доступ открывается добавлением GID в контейнер, а
|
||||||
|
правка прав на хосте не нужна.
|
||||||
|
|
||||||
|
Осталось проверить при исполнении, и это работа тикета: какая версия провайдера
|
||||||
|
`apache-airflow-providers-docker` встаёт к 3.3.0; что имя сети стенда
|
||||||
|
(`${COMPOSE_PROJECT_NAME}_default`) достаётся дагу без ручной подстановки; что
|
||||||
|
триггер доходит и дочерний прогон виден из выключателя; что два запуска подряд
|
||||||
|
двигают позицию на два дня, а обрыв — ни на один.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# ADR 0010. Заказы в ODS: версии сущности вместо подмены слепка
|
||||||
|
|
||||||
|
Дата: 16 августа 2026 года. Статус: принято. Частично заменяет
|
||||||
|
[ADR 0008](0008-order-ingestion.md).
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Пакетный забор заказов остаётся прямым чтением байтового Kafka-чтеца по
|
||||||
|
команде Airflow, но порция чтения больше не считается полным слепком и не
|
||||||
|
публикуется заменой партиции `snapshot_date`. Прочитанные строки получают
|
||||||
|
`_load_id` запуска, разбираются из одного среза STG в годные строки и ошибки,
|
||||||
|
а `ods.order_snapshot` хранит принятые версии заказа в
|
||||||
|
`ReplacingMergeTree(updated_at)`. Ключ сущности — `order_id`, партиция строится
|
||||||
|
от неизменного `created_at`, все версии ключа направляются на один шард.
|
||||||
|
|
||||||
|
`snapshot_date` остаётся датой наблюдения источника, `_load_id` — координатой
|
||||||
|
запуска приёма, `_load_ts` — временем прибытия строки. Ни одна из них не
|
||||||
|
заменяет бизнес-версию `updated_at`. Физическая таблица может показывать
|
||||||
|
несколько версий; точное текущее состояние ODS открывает `ods.order_v`, которое
|
||||||
|
скрывает `FINAL` или равносильный способ выбора последней версии.
|
||||||
|
|
||||||
|
Модель заказа в DDS этим решением не задаётся. DDS получает устойчивую
|
||||||
|
типизированную поверхность ODS и отдельно решает зерно, связи и способ
|
||||||
|
материализации своей модели.
|
||||||
|
|
||||||
|
## Почему отменена подмена партиции
|
||||||
|
|
||||||
|
Прямой `SELECT` Kafka Engine заканчивается после одной полученной порции, а
|
||||||
|
протокол не несёт признака конца слепка. Поэтому `snapshot_date` не доказывает,
|
||||||
|
что в STG собрана полная партиция, и её подмена могла бы удалить уже принятые
|
||||||
|
версии прошлого дня. Маркер конца, опись или фиксация конечных офсетов сделали
|
||||||
|
бы границу настоящей, но добавили бы новый протокол без нужного стенду урока.
|
||||||
|
|
||||||
|
Малый объём позволяет оставить одно чтение на запуск как проверяемое
|
||||||
|
эксплуатационное допущение, а не границу полноты. Полный контракт разбора,
|
||||||
|
граница брака и поведение повторов заданы в
|
||||||
|
[спецификации приёма заказов](../architecture/orders/ingestion.md).
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# ADR 0011. Устройство компонента — связный набор живых документов
|
||||||
|
|
||||||
|
Дата: 16 августа 2026 года. Статус: принято.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Детальное устройство крупного компонента живёт в `docs/architecture/`
|
||||||
|
подпапкой: индекс `README.md` — целевая картина и указатели — и отдельный
|
||||||
|
файл на каждую часть устройства. Имена — слаги без дат; набор правится по
|
||||||
|
мере постройки, как и остальные справочники этой папки.
|
||||||
|
|
||||||
|
Первый такой набор — [заказы бэкенда](../architecture/orders/README.md),
|
||||||
|
собранный из спеки этапа 3 и спеки приёма. Датированные файлы
|
||||||
|
`2026-08-16-orders.md` и `2026-08-16-order-ingestion.md` удалены, ссылки на
|
||||||
|
них перенацелены; историю держит git.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
**Спека и базовая документация — разные жанры, а файл был один.** Спека —
|
||||||
|
событие: проект изменения с датой в имени, замерзающий после приёмки. Но
|
||||||
|
собранная спека заказов сразу стала и детальным устройством сервиса — тем
|
||||||
|
документом, по которому этап 3 будут строить и с которым потом сверяться.
|
||||||
|
Живому устройству дата в имени врёт, а замерзать ему нельзя.
|
||||||
|
|
||||||
|
**Один большой документ плохо читается обоими читателями.** Агент, строящий
|
||||||
|
судьбу заказа, вынужден везти в контексте формат провода и приём; человек
|
||||||
|
листает пятьсот строк ради одного раздела. Индекс с файлами по частям даёт
|
||||||
|
обоим одно и то же: загружается только нужная часть, а карта целого — один
|
||||||
|
экран.
|
||||||
|
|
||||||
|
**Долговечно только то, что в git.** Резолюции развилок живут в трекере, а
|
||||||
|
трекер долговечным хранилищем не считается. Поэтому решения — вместе с
|
||||||
|
отклонёнными вариантами при каждом правиле — лежат в файлах набора; ссылки
|
||||||
|
на тикеты остаются вежливостью, не записью.
|
||||||
|
|
||||||
|
## Следствия
|
||||||
|
|
||||||
|
- `docs/specs/` остаётся событиям: мастер-спека и спека генератора живут как
|
||||||
|
есть; переводить ли их в живую форму — отдельное решение, когда встанет.
|
||||||
|
- Раздел «Структура» в AGENTS.md обновлён тем же коммитом.
|
||||||
|
- Ссылки из ADR 0008 и 0010, мастер-спеки, спеки генератора, исследования
|
||||||
|
формата и доки хранилища перенацелены на набор.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Заказы бэкенда
|
||||||
|
|
||||||
|
Устройство второго источника стенда: раз в модельный день бэкенд магазина
|
||||||
|
выгружает полный слепок заказов окна изменяемости, и деньги в витринах
|
||||||
|
считаются по нему, а не по трекеру. Набор собран картой #69 (этап 3) и
|
||||||
|
правится по мере постройки.
|
||||||
|
|
||||||
|
Границы уже решены мастер-спекой [«Боевой реализм стенда
|
||||||
|
(v2)»](../../specs/2026-07-30-stand-v2-realism.md): поля слепка, окно K = 7
|
||||||
|
как константа мира, три статуса, приоритет классов расхождений, правило
|
||||||
|
«поведение и атрибуцию считаем по трекеру, деньги — по бэкенду». Рамка, перед
|
||||||
|
которой отвечает каждое решение, — «Чем меряется генератор» в [спеке
|
||||||
|
генератора](../../specs/2026-08-01-generator.md): конструкция внутри
|
||||||
|
оправдана только наблюдаемым эффектом на выходе.
|
||||||
|
|
||||||
|
## Целевая картина одним взглядом
|
||||||
|
|
||||||
|
- **Заказ — проекция, не порождение.** Торговая половина дня-функции уже
|
||||||
|
посчитала корзину, цены, купон и номер заказа; заказная половина навешивает
|
||||||
|
судьбу и собирает слепок. Ни одного нового броска в торговом подпотоке —
|
||||||
|
[откуда берётся заказ](snapshot.md).
|
||||||
|
- **Слепок дня D — чистая функция (зерно, D)**: состояние заказов, рождённых
|
||||||
|
в дни D−6…D, снятое на границе суток D|D+1. Отправляет его следующий
|
||||||
|
прогон — ночная выгрузка бэкенда за вчера; на проводе — один JSON-документ
|
||||||
|
на заказ — [слепок и его доставка](snapshot.md).
|
||||||
|
- **Судьба заказа решается при рождении** и обязана уложиться в окно K либо
|
||||||
|
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
|
||||||
|
[судьба заказа](fate.md).
|
||||||
|
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
|
||||||
|
заказная и событийная стороны; броски независимы, пересечения выходят
|
||||||
|
арифметикой, приоритет классов работает по-настоящему —
|
||||||
|
[классы расхождений и опоздание](fate.md).
|
||||||
|
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
|
||||||
|
каждого слепка и счётчики наблюдаемых классов —
|
||||||
|
[что хранит опись](inventory.md).
|
||||||
|
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
|
||||||
|
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
|
||||||
|
[мост к склейке](identity.md).
|
||||||
|
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
|
||||||
|
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order_snapshot`
|
||||||
|
принимает версии заказа на `ReplacingMergeTree(updated_at)` —
|
||||||
|
[приём из Kafka в ODS](ingestion.md).
|
||||||
|
- **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого
|
||||||
|
дня клики есть, а заказов нет, и график выручки дозаполняется по ходу
|
||||||
|
мира — [заказы в стартовом мире](start-world.md).
|
||||||
|
|
||||||
|
Правила, обязательные для кода этапа 3, собраны в
|
||||||
|
[правилах кода](code-rules.md).
|
||||||
|
|
||||||
|
## Открытые решения
|
||||||
|
|
||||||
|
- **Имена подпотоков сторон судьбы** — при реализации; из мёртвых имён никто
|
||||||
|
не бросает, переименование ничего не сдвигает (#72).
|
||||||
|
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
|
||||||
|
доли классов, стоимость доставки — калибровка при реализации; финальная
|
||||||
|
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
|
||||||
|
потребуют только числа, не устройство.
|
||||||
|
- **Проверки приёма** — разовая приёмка допущения «один запуск — одно чтение»
|
||||||
|
и опыты из [«Рисков и проверки»](ingestion.md) — тикет реализации приёма.
|
||||||
|
- **`_load_id` выше ODS** — вместе с устройством `dds.order` (#85).
|
||||||
|
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
|
||||||
|
остаётся в тумане карты #69; естественное место разговора — этап 4.
|
||||||
|
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
|
||||||
|
механике заказов не привязан.
|
||||||
|
|
||||||
|
## Родословная
|
||||||
|
|
||||||
|
Собрано тикетом #87 по резолюциям развилок карты #69: приём (#70), генератор
|
||||||
|
слепков (#71), расхождения и опоздания (#72), мост к склейке (#73), место в
|
||||||
|
стартовом мире (#74), брак и версии в ODS (#80), форма записи на проводе
|
||||||
|
(#81). Решения о приёме — [ADR 0008](../../adr/0008-order-ingestion.md) и
|
||||||
|
[ADR 0010](../../adr/0010-order-versions-in-ods.md); контракт провода —
|
||||||
|
мастер-спека, раздел 2, и [исследование формата
|
||||||
|
слепка](../../research/2026-08-16-order-snapshot-wire-format.md). Форма
|
||||||
|
набора — [ADR 0011](../../adr/0011-component-docs.md).
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Правила кода этапа 3
|
||||||
|
|
||||||
|
Хвосты резолюций, обязательные для реализации:
|
||||||
|
|
||||||
|
- **Броски заказной стороны — на полную длину дня**, а не на отобранных
|
||||||
|
заказах (правило формы, [судьба заказа](fate.md)); моментов всегда два.
|
||||||
|
- **Целочисленная случайность** наследуется правилом кода этапа 2: таблицы
|
||||||
|
целых весов, никаких плавающих распределений; деньги — в целых копейках.
|
||||||
|
- **Подпотоки — по позиции в дереве**: стороны судьбы ветвятся по дню рождения
|
||||||
|
заказа; в `COMMERCE` новых бросков нет; `person_id` — последний бросок
|
||||||
|
когорты.
|
||||||
|
- **Окно K = 7 — константа мира** в конфигурации мира, рядом с D0 и поясом
|
||||||
|
([исследование
|
||||||
|
формата](../../research/2026-08-16-order-snapshot-wire-format.md)).
|
||||||
|
- **Один сериализатор**: запись слепка собирает явная функция `serialize.py`,
|
||||||
|
прямых `json.dumps` по коду нет.
|
||||||
|
- **Значения идентификаторов — ниже 2^53** (`user_id` наравне с прочими).
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Судьба заказа и расхождения
|
||||||
|
|
||||||
|
Резолюция развилки [«Расхождения A–D и опоздания: механика, доли и что
|
||||||
|
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72).
|
||||||
|
|
||||||
|
## Судьба заказа
|
||||||
|
|
||||||
|
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
|
||||||
|
решённая при его рождении и уложенная в окно K целиком.
|
||||||
|
|
||||||
|
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты,
|
||||||
|
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и
|
||||||
|
дубль. Довод за разделение — различимость по описи: правка заказной механики
|
||||||
|
не двигает хеши событий, правка событийной не двигает байты слепка, и по
|
||||||
|
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
|
||||||
|
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
||||||
|
компонент называет часть мира; новые стороны занимают те же позиции, имена —
|
||||||
|
при реализации. Отклонено: *один компонент на всю судьбу* — правка событийной
|
||||||
|
механики молча меняла бы байты слепка; *компонент на класс* — пять имён под
|
||||||
|
ручки калибровки, которые крутятся разом.
|
||||||
|
|
||||||
|
**Правило формы, без которого разделение не работает: броски заказной стороны
|
||||||
|
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
||||||
|
становится функцией доли, и правка одной доли перебрасывает весь подпоток
|
||||||
|
после себя. Изоляцию даёт форма броска, а не число подпотоков.
|
||||||
|
|
||||||
|
**Путь по статусам.** Три исхода, все внутри окна: **оплачен**; **оплачен и
|
||||||
|
отменён**; **не оплачен и отменён**. Инвариант на выходе из окна: заказ либо
|
||||||
|
`paid`, либо `cancelled`; `created` — только промежуточное состояние. За окном
|
||||||
|
будущего у заказа нет, а заказ, навсегда застрявший в `created`, — модельная
|
||||||
|
небрежность, которой в выгрузке живого магазина соответствия нет. Поэтому
|
||||||
|
нового значения `mismatch_class` не нужно: `cancelled` покрывает обе дороги
|
||||||
|
отмены (шестое значение занято `awaiting_order`).
|
||||||
|
|
||||||
|
**Форма броска.**
|
||||||
|
|
||||||
|
1. **Исход** — таблица долей из трёх строк; доля неоплаченных пишется явной
|
||||||
|
строкой, а не оставляется читателю складывать хвост в уме.
|
||||||
|
2. **Моменты** — таблица целых весов «сколько часов от рождения — с каким
|
||||||
|
весом», строки 0…143, плюс равномерная секунда внутри часа — чтобы разности
|
||||||
|
времён аудита не давали точных равенств (урок правки #50).
|
||||||
|
3. Моментов бросается **всегда два, на полную длину дня**; у одномоментных
|
||||||
|
исходов второй выбрасывается. Где их два по существу, ранний считается
|
||||||
|
оплатой — порядок выходит сортировкой, условной точки отсчёта не нужно.
|
||||||
|
|
||||||
|
143 часа — самый узкий край окна: у заказа, рождённого в конце суток, до
|
||||||
|
последнего его слепка 144 часа. Таблица кончается там, где кончается окно у
|
||||||
|
самого невезучего: вылезти нечему, сторожа не нужно. Цена — заказ, рождённый в
|
||||||
|
начале суток, не использует почти сутки своего окна; в хвосте таблицы веса
|
||||||
|
мизерные, в данных это не видно. Форма из #71 — «вес за краем окна означает
|
||||||
|
„не оплачен никогда“» — этим отменена: такой заказ теперь отменяется, а край
|
||||||
|
окна не выражается числом часов — иначе доля неоплаченных стала бы функцией
|
||||||
|
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
|
||||||
|
|
||||||
|
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
|
||||||
|
она делится между двумя дорогами — строки таблицы исходов. Точные числа —
|
||||||
|
калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим.
|
||||||
|
|
||||||
|
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
||||||
|
версия; различает их история версий: сырьё STG и физические версии
|
||||||
|
`ods.order_snapshot` до фоновых слияний, а в витринах — выручка дня, которая
|
||||||
|
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
|
||||||
|
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
|
||||||
|
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
|
||||||
|
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда
|
||||||
|
деться, кроме как остаться брошенным; *мгновенная отмена при рождении* —
|
||||||
|
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1,
|
||||||
|
он в резерве.
|
||||||
|
|
||||||
|
## Классы расхождений и опоздание
|
||||||
|
|
||||||
|
Классы и ориентиры долей — [мастер-спека,
|
||||||
|
раздел 4](../../specs/2026-07-30-stand-v2-realism.md); здесь — механика
|
||||||
|
каждого.
|
||||||
|
|
||||||
|
**Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии,
|
||||||
|
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
|
||||||
|
`items_total` меньше на её стоимость. Момента у неё нет — заказ приезжает
|
||||||
|
урезанным во всех своих слепках: первый слепок снимается на границе суток,
|
||||||
|
когда склад заказ уже собрал. Заказ из одной позиции дельты не получает —
|
||||||
|
пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со
|
||||||
|
спросом на доле 1–2% статистически ненаблюдаема — менти платил бы за неё
|
||||||
|
таблицей чисел мира, а увидеть не мог бы ничем. Доводы за вычёркивание:
|
||||||
|
остаток — сотни рублей, он торчит в витрине сверки сам; расхождение
|
||||||
|
объясняется сравнением позиций — разбором вложенного JSON и `ARRAY JOIN`,
|
||||||
|
ровно тем навыком, ради которого позиции разбираются; история рассказывается
|
||||||
|
словами без легенды про генератор. Отклонено: *переоценка позиции* и *другое
|
||||||
|
количество* — дельта в десятки рублей, её надо захотеть заметить; *чистая
|
||||||
|
дельта без истории* — тупик, объяснить нечем; *врёт клиент, а не бэкенд* —
|
||||||
|
заказ у нас проекция той же корзины.
|
||||||
|
|
||||||
|
**Потеря события (B) — точечная.** Уходит строка `purchase`, просмотр
|
||||||
|
`/confirmation` остаётся: события уезжают разными запросами, потерять один и
|
||||||
|
сохранить другой — обычное дело. Единственный вариант, при котором потеря
|
||||||
|
видна со стороны трекера: до подтверждения дошли сто, покупок девяносто семь.
|
||||||
|
|
||||||
|
**Дубль события (D) — сюжетный.** Обновление страницы шлёт и просмотр, и
|
||||||
|
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
|
||||||
|
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
|
||||||
|
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
|
||||||
|
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где
|
||||||
|
до следующего визита куки остаётся запас сверх таймаута: иначе сборка сессий у
|
||||||
|
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID`
|
||||||
|
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита,
|
||||||
|
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с
|
||||||
|
покупкой, по тому же правилу, что у подтверждения с торговым хвостом.
|
||||||
|
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
|
||||||
|
потеря перестаёт быть видна со стороны трекера.
|
||||||
|
|
||||||
|
**Гарантия моста сильнее порчи.** Назначенные планом покупки не теряются, и
|
||||||
|
назначенные планом заказы не опаздывают: `dds.identity_map` строится из моста
|
||||||
|
«`purchase` ↔ заказ», и выброшенное событие — как и заказ, не попавший ни в
|
||||||
|
один снятый слепок, — уносит куку из карты. Это был бы отказ лабы склейки, а
|
||||||
|
не расхождение в данных; менти различить не может.
|
||||||
|
|
||||||
|
**Опоздание — заказ прячется от ранних слепков.** `created_at` не
|
||||||
|
подделывается — строка создана, когда заказ родился, — но в слепках дней
|
||||||
|
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 —
|
||||||
|
сломалась бы та самая сверка, ради которой всё строится.
|
||||||
|
|
||||||
|
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
|
||||||
|
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
|
||||||
|
решается только у выживших покупок, а назначенное планом не теряется и не
|
||||||
|
опаздывает — дельта и отмена ему разрешены, моста они не рвут.
|
||||||
|
Следствие для калибровки: брошенная доля и наблюдаемая в сверке — разные числа
|
||||||
|
(часть заказов забирают победители по приоритету, часть у класса C недоступна —
|
||||||
|
однопозиционных заказов больше половины). Отклонено: *один класс на заказ* —
|
||||||
|
приоритет в SQL стал бы мёртвой веткой, которую менти читает как живую.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Мост к склейке: человек, `person_id`, `user_id`
|
||||||
|
|
||||||
|
Резолюция развилки [«Мост к склейке: user_id и двухкуковые
|
||||||
|
пары»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/73).
|
||||||
|
|
||||||
|
Причинная модель: план состава порождает **человека**; ему принадлежат одна
|
||||||
|
или две куки, каждая наблюдается в кликстриме как отдельный посетитель. Визит
|
||||||
|
может оформить заказ — тогда заказная сторона представляет того же человека
|
||||||
|
как **пользователя магазина**. Это не модель аккаунта: регистрации нет, люди
|
||||||
|
без визитов не порождаются, внутреннее знание наружу не выдаётся.
|
||||||
|
|
||||||
|
План владеет устойчивой личностью и отношением «кука принадлежит человеку».
|
||||||
|
Минимальная форма — непрозрачный внутренний `person_id`, выровненный по кукам:
|
||||||
|
у двух кук пары он одинаков. Заказ выводит то же значение под родным именем
|
||||||
|
`user_id` (`UInt64`); в кликстрим ни `person_id`, ни `user_id` не попадает —
|
||||||
|
анонимность формата держится формой, а не забывчивостью сериализатора.
|
||||||
|
|
||||||
|
`person_id` — последний обычный бросок потока случайности когорты, после уже
|
||||||
|
принятых свойств: добавление личности не сдвигает куки, пары, возвраты и
|
||||||
|
паспорта. Для второй куки повторяется ID её человека. Раздельные прогоны
|
||||||
|
ничего не хранят и не согласуют: генератор событий и команда слепка заново
|
||||||
|
спрашивают один план и получают тот же `person_id`.
|
||||||
|
|
||||||
|
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
|
||||||
|
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
|
||||||
|
отдельные прогоны одного мира дают то же соответствие; принятый мир
|
||||||
|
воспроизводит эффект склейки без случайных ложных объединений — конкретные
|
||||||
|
числа пар контрактом описи не являются. Межкогортные столкновения принимаются
|
||||||
|
по той же дисциплине, что у случайных `ClientID`: принимается конкретный
|
||||||
|
канонический мир по внешнему результату.
|
||||||
|
|
||||||
|
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
|
||||||
|
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
|
||||||
|
состояние между прогонами без нового внешнего эффекта; *первая кука как ID
|
||||||
|
человека* — магазин оказался бы замаскированным продолжением трекера;
|
||||||
|
*структурная координата, хеш или глобальная последовательность* — больше
|
||||||
|
механики при тех же данных; *полная модель аккаунтов* — менти её не наблюдает.
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Приём заказов из Kafka в ODS
|
||||||
|
|
||||||
|
Учебный результат: менти различает версию бизнес-сущности, наблюдение источника
|
||||||
|
и запуск загрузки, а затем читает физические версии через явную поверхность
|
||||||
|
текущего состояния.
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
|
||||||
|
отдельными сообщениями и не сообщает потребителю, где слепок закончился. Прямое
|
||||||
|
чтение Kafka Engine возвращает одну порцию. Поэтому прежняя публикация через
|
||||||
|
`REPLACE PARTITION snapshot_date` приравнивала дату наблюдения к отсутствующей
|
||||||
|
транспортной границе и могла заменить день неполным набором строк.
|
||||||
|
|
||||||
|
Одновременно типизированный ODS не должен принимать правдоподобные значения по
|
||||||
|
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
|
||||||
|
|
||||||
|
## Цели
|
||||||
|
|
||||||
|
- сохранить пакетный забор как контраст потоковому приёму событий;
|
||||||
|
- один раз принять байты в STG и независимо разложить строки на годные и брак;
|
||||||
|
- хранить в ODS типизированные версии заказов, не привязывая идемпотентность к
|
||||||
|
`snapshot_date`;
|
||||||
|
- дать следующим слоям один корректный способ прочитать текущее состояние;
|
||||||
|
- оставить код проверки коротким и ограничить его контрактом провода.
|
||||||
|
|
||||||
|
## Не входит
|
||||||
|
|
||||||
|
- модель заказа в DDS: её зерно, связи, материализация и способ наполнения;
|
||||||
|
- проверка полей внутри `items`, переходов статуса и равенств денежных сумм;
|
||||||
|
- удаление заказа по отсутствию в следующем слепке;
|
||||||
|
- маркер конца слепка, опись ожидаемых строк и транзакция между целями ODS.
|
||||||
|
|
||||||
|
## Поток данных
|
||||||
|
|
||||||
|
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
|
||||||
|
записывает полученную порцию в `stg.orders_raw`. Все строки получают `_load_id`,
|
||||||
|
равный `run_id` Airflow. `_load_ts` вычисляется при этой записи и дальше
|
||||||
|
переносится без пересчёта.
|
||||||
|
|
||||||
|
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
|
||||||
|
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
|
||||||
|
пишет годные строки в `ods.order_snapshot`, второй — брак в
|
||||||
|
`ods.order_snapshot_errors`. Транзакции между запросами нет. При частичном сбое
|
||||||
|
Airflow повторяет весь `task_id`; одинаковые исходные строки и служебные метки
|
||||||
|
не вычисляются заново.
|
||||||
|
|
||||||
|
Условия запросов взаимодополняющие: один общий предикат определяет брак, а
|
||||||
|
годная ветвь использует его буквальное отрицание. Все функции предиката
|
||||||
|
возвращают результат без исключения, а сам предикат всегда заканчивается в
|
||||||
|
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для
|
||||||
|
этого не нужен.
|
||||||
|
|
||||||
|
## Граница строгого приёма
|
||||||
|
|
||||||
|
Единица решения — одна строка `stg.orders_raw`. Корень должен быть
|
||||||
|
JSON-объектом с точным набором ключей: `order_id`, `user_id`, `status`,
|
||||||
|
`created_at`, `updated_at`, `items_total`, `discount`, `delivery`, `total`,
|
||||||
|
`items`, `snapshot_date`.
|
||||||
|
|
||||||
|
Скалярные поля проверяются по типу JSON. Деньги дополнительно обязаны быть
|
||||||
|
строками с ровно двумя знаками после точки, времена — строками RFC 3339 в UTC с
|
||||||
|
обязательными миллисекундами, дата слепка — строкой `YYYY-MM-DD`. `items`
|
||||||
|
проверяется только как JSON-массив. Каноническая форма и основания выбора
|
||||||
|
зафиксированы в
|
||||||
|
[исследовании формата](../../research/2026-08-16-order-snapshot-wire-format.md).
|
||||||
|
|
||||||
|
Проверять все верхнеуровневые поля здесь уместно: их одиннадцать, и десять
|
||||||
|
скалярных значений непосредственно образуют типизированную строку заказа. У
|
||||||
|
события из 47 полей проверяются только пять опорных; переносить то сокращение на
|
||||||
|
малый контракт заказа нет причины. Граница строгости заканчивается на форме
|
||||||
|
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
|
||||||
|
|
||||||
|
Класс брака выбирается первым совпадением:
|
||||||
|
|
||||||
|
1. `not_an_object`;
|
||||||
|
2. `keyset_mismatch`;
|
||||||
|
3. `field_invalid`.
|
||||||
|
|
||||||
|
Имя отдельного поля в класс не включается. В таблице ошибок остаются сырой
|
||||||
|
текст, метаданные доставки и `_load_id`, поэтому единичный случай можно разобрать
|
||||||
|
без постоянной детализации предиката.
|
||||||
|
|
||||||
|
## Роль ODS
|
||||||
|
|
||||||
|
`ods.order_snapshot_rep` хранит физически принятые версии в
|
||||||
|
`ReplacingMergeTree(updated_at)`. Ключ сортировки — `order_id`, партиция — день
|
||||||
|
неизменного `created_at`. `ods.order_snapshot_dist` шардирует по
|
||||||
|
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
|
||||||
|
`FINAL` даёт корректный результат через распределённую таблицу.
|
||||||
|
|
||||||
|
Четыре координаты отвечают на разные вопросы:
|
||||||
|
|
||||||
|
- `updated_at` — какая бизнес-версия заказа новее;
|
||||||
|
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
|
||||||
|
- `_load_id` — какой запуск Airflow принял строку;
|
||||||
|
- `_load_ts` — когда строка приехала в хранилище.
|
||||||
|
|
||||||
|
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
|
||||||
|
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
|
||||||
|
`ods.order_v` сохраняет те же источник-ориентированные поля без обогащения
|
||||||
|
данными модели, но возвращает одну актуальную версию на `order_id`. Сначала оно
|
||||||
|
может быть простым представлением над `_dist FINAL`; способ выбора можно
|
||||||
|
заменить, не меняя потребителей.
|
||||||
|
|
||||||
|
Это представление остаётся ответственностью ODS: оно скрывает механику чтения
|
||||||
|
версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает,
|
||||||
|
какие сущности, связи и производные признаки ему нужны. Нужен ли `_load_id`
|
||||||
|
выше ODS, решается вместе с DDS, а не здесь.
|
||||||
|
|
||||||
|
## Одно чтение Kafka
|
||||||
|
|
||||||
|
Стандартный слепок содержит около полутора тысяч строк, тогда как предел одной
|
||||||
|
порции на стенде — десятки тысяч сообщений. Поэтому один запуск Airflow делает
|
||||||
|
один прямой `SELECT`, без цикла до пустоты и без фиксации конечных офсетов.
|
||||||
|
|
||||||
|
Это допущение о размере стенда, а не доказательство полноты слепка. Если Kafka
|
||||||
|
вернёт короткую порцию, непрочитанный хвост останется в топике и приедет в один
|
||||||
|
из следующих запусков. После отказа от замены партиции это задержка, а не потеря
|
||||||
|
или публикация неполного дня.
|
||||||
|
|
||||||
|
## Отклонённые варианты
|
||||||
|
|
||||||
|
- Партиционная идемпотентность — `REPLACE PARTITION snapshot_date` или
|
||||||
|
`ReplacingMergeTree` по `(snapshot_date, order_id)`: у потребителя нет
|
||||||
|
признака полноты партиции, а дата наблюдения становится частью ключа
|
||||||
|
сущности.
|
||||||
|
- Обычный `MergeTree` в ODS с дедупликацией только в DDS: навсегда сохраняет
|
||||||
|
технические повторы там, где семантика версии уже известна.
|
||||||
|
- Маркер, опись, чтение до пустоты или конечные офсеты: добавляют протокол ради
|
||||||
|
объёма, который с большим запасом помещается в одну порцию.
|
||||||
|
- Два `task_id` или материализованный классификатор: дробят один короткий
|
||||||
|
переход слоя, не добавляя транзакционности.
|
||||||
|
- Представление текущего состояния в DDS и готовая схема `dds.order` в этой
|
||||||
|
задаче: перекладывают механику ODS на следующий слой и преждевременно задают
|
||||||
|
модель данных.
|
||||||
|
|
||||||
|
## Риски и проверка
|
||||||
|
|
||||||
|
- На стандартном мире сверить число отправленных заказов с числом строк,
|
||||||
|
принятых одним прямым чтением. Это разовая приёмка допущения, не постоянный
|
||||||
|
сторож.
|
||||||
|
- На малой управляемой порции дать по одной строке каждого класса брака и две
|
||||||
|
годные версии одного `order_id`. Две цели должны сохранить все непустые
|
||||||
|
сообщения, а `ods.order_v` — вернуть новую версию независимо от фонового
|
||||||
|
слияния.
|
||||||
|
- Повторить переход с тем же `_load_id`: строка в `ods.order_v` и её `_load_ts`
|
||||||
|
не должны измениться; версии одного заказа должны остаться на одном шарде и
|
||||||
|
в одной партиции. Таблица ошибок может снова записать тот же брак: совпавшие
|
||||||
|
`_load_id` и Kafka-координаты показывают повтор задачи.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
MCP Context7 в сессии проектирования был недоступен. На локальном ClickHouse
|
||||||
|
`26.3.17.56` проверено, что прямой `SELECT` Kafka Engine завершается после
|
||||||
|
одной порции, а `FINAL` через `Distributed` исполняется на таблицах шардов.
|
||||||
|
Поэтому версии одного `order_id` направляются на один шард. Фоновое схлопывание
|
||||||
|
`ReplacingMergeTree` и необходимость точного чтения сверены с
|
||||||
|
[официальной документацией](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree),
|
||||||
|
поведение чтения — с исходниками той же версии
|
||||||
|
[`StorageKafka.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/StorageKafka.cpp) и
|
||||||
|
[`KafkaSource.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/KafkaSource.cpp).
|
||||||
|
|
||||||
|
## Связанные решения
|
||||||
|
|
||||||
|
- [ADR 0008](../../adr/0008-order-ingestion.md) сохраняет выбор пакетного
|
||||||
|
забора, `RawBLOB`, одного чтеца и одной партиции топика.
|
||||||
|
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) заменяет публикацию
|
||||||
|
слепка версионным ODS.
|
||||||
|
- Вопрос `_load_id` выше ODS оставлен проектированию DDS в тикете #85.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Что хранит опись
|
||||||
|
|
||||||
|
Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что
|
||||||
|
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72)
|
||||||
|
и [«Места заказов в стартовом
|
||||||
|
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
||||||
|
Словарь описи — растяжка-хеш против опоры-счётчика — задан «Чем меряется
|
||||||
|
генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
|
||||||
|
термином «Опись мира» в [CONTEXT.md](../../../CONTEXT.md).
|
||||||
|
|
||||||
|
> Опись хранит только то, чего движение мира не меняет.
|
||||||
|
> Хеш кладём всегда, счётчик — только когда назван его читатель.
|
||||||
|
|
||||||
|
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
|
||||||
|
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
|
||||||
|
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же
|
||||||
|
байты») опись начинает сторожить.
|
||||||
|
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
|
||||||
|
итоговой судьбе заказов дня; единица счёта — заказ, и опись называет её
|
||||||
|
словом. Сойтись с запросом менти они могут только на днях с закрытым окном:
|
||||||
|
при N сыгранных днях таких N − 7 (день d закрывается слепком d + 6, а
|
||||||
|
последний отправленный слепок несёт день N − 2). Читатель у них придёт
|
||||||
|
этапом 4 — проверка сверки; не окажется читателя — та же бритва режет и их.
|
||||||
|
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
|
||||||
|
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
|
||||||
|
не выходит; генератор сторожит хеш, транспорт — счёт событий.
|
||||||
|
- **Опись описывает мир, а не доставку.** Работа генератора кончается на
|
||||||
|
Kafka: дошли ли байты до `ods.order_snapshot` — вопрос стенда и его
|
||||||
|
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка
|
||||||
|
приёма заказов — число заведётся вместе с ней.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Заказ и его слепок
|
||||||
|
|
||||||
|
Резолюция развилки [«Генератор слепков: где живёт и чем связан с
|
||||||
|
событиями»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/71).
|
||||||
|
|
||||||
|
## Откуда берётся заказ
|
||||||
|
|
||||||
|
Заказ и событие `purchase` — не два порождения, а две проекции одного факта
|
||||||
|
мира. Корзина, цены, купон и номер заказа посчитаны торговой половиной
|
||||||
|
дня-функции; заказная половина берёт заказы дня готовой структурой — вторым
|
||||||
|
выходом `commerce`, — навешивает на них жизнь заказа и собирает слепок. В
|
||||||
|
подпоток `COMMERCE` не добавляется ни одного нового броска: мир не сдвигается,
|
||||||
|
согласованность двух источников не удерживается, а получается по построению.
|
||||||
|
|
||||||
|
Следствия, которые уже решены соседями и здесь только связываются:
|
||||||
|
|
||||||
|
- у всякого заказа изначально ровно одно событие `purchase`; заказ без события
|
||||||
|
в трекере — не отдельная порода, а класс B, и делает его событийная сторона
|
||||||
|
выбрасыванием события после присвоения номера
|
||||||
|
([классы расхождений](fate.md));
|
||||||
|
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`,
|
||||||
|
читаемый номер «день и порядковый номер покупки» ([спека
|
||||||
|
генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все
|
||||||
|
покупки, дошедшие до потока дня, — до всяких потерь;
|
||||||
|
- скидка заказа выводится из промокода события по таблице «код → скидка» —
|
||||||
|
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
|
||||||
|
|
||||||
|
В модели строка заказа в базе источника создаётся синхронно с покупкой,
|
||||||
|
поэтому день рождения заказа и день создания строки совпадают.
|
||||||
|
|
||||||
|
Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой
|
||||||
|
`ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что
|
||||||
|
это разные источники; *независимая модель бэкенда* (заказ первичен, событие —
|
||||||
|
эхо) — кто купил, решает воронка, а воронка — это трафик, то есть опрокидывание
|
||||||
|
всего генератора; *слепок собирает SQL стенда из событий* — второй источник
|
||||||
|
исчезает вместе с уроком «две версии правды».
|
||||||
|
|
||||||
|
## Слепок и его доставка
|
||||||
|
|
||||||
|
**Запуск.** Третья команда того же пакета — `snapshot --day D [--days N]`,
|
||||||
|
свой приёмник, топик `orders`. Два источника — два запуска: трекер и бэкенд
|
||||||
|
видны глазами как два производителя, каждый со своим топиком. Один прогон с
|
||||||
|
двумя выходами отклонён: экономии он не даёт (со сдвигом отправки окно слепка
|
||||||
|
и сыгранный день не пересекаются вовсе), а правило «приёмник выбирается тем,
|
||||||
|
что для него назвали» ломает. Отклонены также: *отдельный пакет и образ* —
|
||||||
|
библиотека на двоих ради одной команды; *генератор пишет слепок файлом, в
|
||||||
|
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
|
||||||
|
топиком `hits`* — убивает два режима приёма.
|
||||||
|
|
||||||
|
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и
|
||||||
|
собирается переигровкой этих семи дней: заказы дня — производная всей воронки
|
||||||
|
дня, дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; у
|
||||||
|
начала оси окно усекается само. Отклонено: *кэш заказов на томе* — состояние
|
||||||
|
между прогонами; *окно держит хранилище* — топик перестаёт нести слепок;
|
||||||
|
*K = 1* — это страховочный срез 1 мастер-спеки, он в резерве.
|
||||||
|
|
||||||
|
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
|
||||||
|
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
|
||||||
|
бэкенда за вчера, как в бою. Содержимое слепка — чистая функция (зерно, D), от
|
||||||
|
момента отправки не зависит. Следствия:
|
||||||
|
|
||||||
|
- живой день перестаёт быть особым случаем: своего дага у него нет, слепок
|
||||||
|
живого дня отправит следующий прогон;
|
||||||
|
- пропущенный день лечится окном: слепок переснимается и даёт те же байты,
|
||||||
|
отдельного механизма самовосстановления нет;
|
||||||
|
- покупки текущего дня в сверке всегда `awaiting_order` — сюжет «вчера не
|
||||||
|
сходилось, сегодня сошлось», ради которого мастер-спека этот класс завела;
|
||||||
|
- цена — один лишний проигрыш дня на прогон (окно и сыгранный день не
|
||||||
|
пересекаются, проигрышей всегда восемь).
|
||||||
|
|
||||||
|
На старте оси дня −1 нет, поэтому прогон дня 0 не отправляет ничего; первый
|
||||||
|
слепок — дня 0 — уезжает прогоном дня 1 ([исследование
|
||||||
|
формата](../../research/2026-08-16-order-snapshot-wire-format.md)).
|
||||||
|
|
||||||
|
**Случайность.** Судьбу заказов бросает свой подпоток, ветвящийся по дню
|
||||||
|
рождения заказа: слепок несёт семь дней рождения сразу, и судьбу каждого
|
||||||
|
заказа обязан читать из его собственного дня. Вся судьба решается при
|
||||||
|
рождении, поэтому слепок любого дня — чтение готовой судьбы, а не накопление
|
||||||
|
состояния. Отклонено: *дописывать броски в конец `COMMERCE`* — правка заказа
|
||||||
|
и правка торгового поведения стали бы одним рычагом; *бросать состояние в
|
||||||
|
подпотоке дня слепка* — траектория заказа зависела бы от того, какие слепки
|
||||||
|
снимали.
|
||||||
|
|
||||||
|
## Запись на проводе
|
||||||
|
|
||||||
|
Контракт провода — на заказ один JSON-документ: деньги строками с двумя
|
||||||
|
знаками, времена RFC 3339 в UTC с миллисекундами, `items` обычным массивом —
|
||||||
|
целиком описан мастер-спекой (раздел 2); основания, отклонённые варианты и
|
||||||
|
проверка разбора — в [исследовании
|
||||||
|
формата](../../research/2026-08-16-order-snapshot-wire-format.md) (резолюция
|
||||||
|
развилки
|
||||||
|
[«Форма записи слепка на проводе»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/81)).
|
||||||
|
|
||||||
|
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
|
||||||
|
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
|
||||||
|
его даром, а хешу слепка в описи нужен именно названный порядок.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Заказы в стартовом мире
|
||||||
|
|
||||||
|
Резолюция развилки [«Места заказов в стартовом
|
||||||
|
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
||||||
|
Стартовый мир — не полный мир, а мир, остановленный на границе суток 7|8.
|
||||||
|
Генератор играет два источника с разными темпами — поток Метрики и ночную
|
||||||
|
выгрузку магазина; разные темпы дают всё остальное.
|
||||||
|
|
||||||
|
`world-init` играет восемь дней одним прогоном `batch --day 0 --days 8`;
|
||||||
|
слепки отправляет второй запуск — команда `snapshot` того же диапазона (два
|
||||||
|
источника — два запуска, [слепок и его доставка](snapshot.md)), и со сдвигом
|
||||||
|
отправки уезжают **слепки дней 0…6**. Слепок дня 7 снят на границе суток и
|
||||||
|
уедет первым же ходом мира. Особого режима у стартового мира нет: правило
|
||||||
|
отправки живёт в одном месте — в проигрывателе; генератору это решение не
|
||||||
|
стоит ничего.
|
||||||
|
|
||||||
|
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
|
||||||
|
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
|
||||||
|
**График выручки заваливается к правому краю** и дозаполняется, пока мир едет.
|
||||||
|
Это не издержка стенда, а главный наблюдаемый эффект второго источника: ночная
|
||||||
|
выгрузка отстаёт, свежие дни предварительны. На свежем стенде лаба сверки
|
||||||
|
видит целый день `awaiting_order` — норма, а не поломка; как это назвать
|
||||||
|
менти — за витринами этапа 4.
|
||||||
|
|
||||||
|
Цена принята с открытыми глазами: у части пар стартового мира поздний
|
||||||
|
назначенный заказ падает на день 7, и до первого хода мира этих пар в
|
||||||
|
`dds.identity_map` нет. Опись пар не считает
|
||||||
|
([что хранит опись](inventory.md)), поэтому красной проверки из этого не
|
||||||
|
выходит. Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на
|
||||||
|
свежем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем
|
||||||
|
ровно там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* —
|
||||||
|
число верно ровно до первого хода мира; *отменить сдвиг отправки* —
|
||||||
|
`awaiting_order` пропал бы навсегда; *подогнать план под горизонт* — мир
|
||||||
|
перестал бы быть чистой функцией зерна.
|
||||||
@@ -78,15 +78,16 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/`
|
|||||||
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из
|
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из
|
||||||
раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё
|
раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё
|
||||||
прочее — через `GLOBAL`. Значит `dds.session` и `dds.identity_map` шардируются по
|
прочее — через `GLOBAL`. Значит `dds.session` и `dds.identity_map` шардируются по
|
||||||
`cityHash64(ClientID)`, а `dds.order` и производные от заказа — по
|
`cityHash64(ClientID)`. Объекты DDS с зерном заказа должны сохранять ко-локацию
|
||||||
`cityHash64(order_id)`. Ключи витрин появятся вместе с самими витринами.
|
по `cityHash64(order_id)`; ключи остальных частей будущей модели и витрин
|
||||||
|
появятся вместе с ними.
|
||||||
|
|
||||||
Открытый вопрос на будущее — не сама замена партиций: операции с ними по
|
Замена партиций не используется для `ods.order_snapshot`: версии прошлых дней
|
||||||
локальным таблицам правило разрешает прямо. Вопрос в шаге до неё. Партиция-донор
|
доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR
|
||||||
должна быть уже разложена по шардам по тому же ключу, а разложить её можно
|
0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка
|
||||||
только вставкой через распределённую таблицу — значит у каждой пакетной сущности
|
понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее
|
||||||
появится вторая пара объектов, и имени для неё конвенция пока не даёт. Решать
|
разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким
|
||||||
это вместе со сборкой DDS, а не задним числом.
|
объектом, а не переносить на ODS заранее.
|
||||||
|
|
||||||
## Служебные колонки
|
## Служебные колонки
|
||||||
|
|
||||||
@@ -116,8 +117,10 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/`
|
|||||||
колонка молча отвечала бы на другой вопрос.
|
колонка молча отвечала бы на другой вопрос.
|
||||||
|
|
||||||
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
|
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
|
||||||
не проверяет, поэтому там оказываются и целые события, и мусор. Разбирается всё
|
не проверяет, поэтому там оказываются и целые сообщения, и мусор. События ниже
|
||||||
это ниже, в матвью ODS — см. [ADR 0005](../adr/0005-event-ingestion.md).
|
разбирают матвью ODS ([ADR 0005](../adr/0005-event-ingestion.md)), заказы —
|
||||||
|
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
|
||||||
|
0010](../adr/0010-order-versions-in-ods.md)).
|
||||||
|
|
||||||
Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
||||||
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и
|
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и
|
||||||
@@ -125,15 +128,23 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
|
|||||||
которого он заведён: повтор доставки в сырье обязан быть виден.
|
которого он заведён: повтор доставки в сырье обязан быть виден.
|
||||||
|
|
||||||
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
|
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
|
||||||
она один раз, в матвью приёма, и дальше переносится из STG в ODS как есть:
|
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
|
||||||
колонка отвечает на вопрос «когда строка приехала в хранилище», а не «когда её
|
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
|
||||||
разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у
|
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
|
||||||
этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же
|
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
|
||||||
самое, отличается только метка, поэтому какая из двух строк переживёт мерж,
|
повтор доставки. У `ods.order_snapshot` версию задаёт `updated_at` источника;
|
||||||
безразлично. Пакетной переобработки у ODS нет: слой наполняет матвью, а не
|
`_load_ts` только показывает, когда конкретная строка приехала.
|
||||||
задание Airflow, и работа с партициями начинается выше. Переделать разобранное
|
|
||||||
руками можно — вставкой из сырья с фильтром по `_load_ts`, в пределах
|
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
|
||||||
трёхсуточного окна; ничья по версии разрешается в пользу вставленного позже.
|
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
|
||||||
|
`DateTime64(3, 'UTC')`; ClickHouse их не создаёт и добавляет рядом собственную
|
||||||
|
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
|
||||||
|
|
||||||
|
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
|
||||||
|
Airflow. Переделать разобранное руками можно вставкой из сырья с фильтром по
|
||||||
|
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
|
||||||
|
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
|
||||||
|
`_load_id`; дневные партиции ODS он не заменяет.
|
||||||
|
|
||||||
Имя согласовано с каноном служебных полей соседнего учебного стенда на
|
Имя согласовано с каноном служебных полей соседнего учебного стенда на
|
||||||
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
|
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
|
||||||
@@ -141,10 +152,28 @@ Greenplum, чтобы словарь был общим у двух хранил
|
|||||||
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
|
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
|
||||||
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
|
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
|
||||||
|
|
||||||
Идентификатора пачки загрузки (`_load_id`) пока нет. В STG и ODS данные приезжают
|
Общего идентификатора пачки загрузки (`_load_id`) нет. У потока событий нет ни
|
||||||
потоком через матвью, у которого нет ни батча, ни `run_id`, и колонка была бы
|
пачки, ни `run_id`, и колонка была бы пустой формальностью. У заказов читатель
|
||||||
пустой формальностью. В слоях, которые наполняет Airflow, `run_id` появится
|
назван: `_load_id` равен `run_id` Airflow и переносится из STG в годную строку
|
||||||
по-настоящему — тогда и заведём, тем же стилем имени.
|
ODS и в таблицу ошибок. Нужен ли он выше ODS, решается вместе с моделью DDS.
|
||||||
|
|
||||||
|
## Версии заказов
|
||||||
|
|
||||||
|
`ods.order_snapshot_rep` хранит принятые версии в
|
||||||
|
`ReplacingMergeTree(updated_at)`: ключ сортировки — `order_id`, партиция —
|
||||||
|
`toDate(created_at)`. `ods.order_snapshot_dist` шардирует по
|
||||||
|
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
|
||||||
|
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
|
||||||
|
выбрал одного победителя.
|
||||||
|
|
||||||
|
Физическая пара нужна для загрузки и диагностики. Обычное чтение показывает
|
||||||
|
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
|
||||||
|
`ods.order_v` служит поверхностью точного текущего состояния для следующих
|
||||||
|
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
|
||||||
|
DDS. `snapshot_date` в нём остаётся датой наблюдения строки, а не ключом
|
||||||
|
публикации. Полное решение — в
|
||||||
|
[ADR 0010](../adr/0010-order-versions-in-ods.md) и
|
||||||
|
[спецификации приёма заказов](orders/ingestion.md).
|
||||||
|
|
||||||
## Часовые пояса
|
## Часовые пояса
|
||||||
|
|
||||||
@@ -358,6 +387,12 @@ D0 и к реальному календарю не привязана; паке
|
|||||||
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
|
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
|
||||||
доставки.
|
доставки.
|
||||||
|
|
||||||
|
`ods.order_snapshot_errors` держит тот же диагностический минимум и `_load_id`
|
||||||
|
запуска. У заказов три класса по приоритету: `not_an_object`,
|
||||||
|
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
|
||||||
|
разрастается до имени отдельного поля. Точная граница приёма — в
|
||||||
|
[спецификации заказов](orders/ingestion.md).
|
||||||
|
|
||||||
## Раскладка DDL
|
## Раскладка DDL
|
||||||
|
|
||||||
Файлы лежат в `sql/ddl/` и применяются по порядку имён. Сначала все статичные
|
Файлы лежат в `sql/ddl/` и применяются по порядку имён. Сначала все статичные
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# Формат дневного слепка заказов на проводе
|
||||||
|
|
||||||
|
Дата исследования: 2026-08-16.
|
||||||
|
|
||||||
|
Учебный результат: менти различает бизнес-время, время источника, доставки и
|
||||||
|
загрузки, не разбирая ради этого лишнюю инфраструктуру. Цена — несколько явных
|
||||||
|
правил контракта; новых полей и универсального сериализатора не требуется.
|
||||||
|
|
||||||
|
## Короткий вывод
|
||||||
|
|
||||||
|
- `created_at` и `updated_at` — аудит строки в источнике, а не время покупки.
|
||||||
|
Оба поля передаются в UTC с настоящей точностью до миллисекунд:
|
||||||
|
`2026-06-03T14:21:07.123Z`.
|
||||||
|
- В ClickHouse им соответствует `DateTime64(3, 'UTC')`. Неверная строка даёт
|
||||||
|
`NULL` и уходит в `*_errors`, а не превращается в правдоподобную дату.
|
||||||
|
- `snapshot_date` — дата завершившегося модельного дня, состояние которого
|
||||||
|
снято на исходящей границе суток. Внутри одной выгрузки она одинакова, на
|
||||||
|
следующем модельном дне меняется.
|
||||||
|
- `items` на проводе — обычный массив JSON. Тип `String` в ODS означает, что
|
||||||
|
из внешнего JSON извлекли сырой фрагмент массива, а не что источник дважды
|
||||||
|
сериализовал JSON.
|
||||||
|
- Генератору достаточно собрать один словарь с вложенным списком и один раз
|
||||||
|
вызвать `orjson.dumps`. Отдельная иерархия кодеков урока не добавляет.
|
||||||
|
|
||||||
|
## Оси времени
|
||||||
|
|
||||||
|
В потоковой обработке время события принадлежит самой записи и не зависит от
|
||||||
|
часов обработчика; время обработки отвечает на другой вопрос
|
||||||
|
([Apache Flink: Event Time и Processing Time](https://nightlies.apache.org/flink/flink-docs-stable/docs/concepts/time/)).
|
||||||
|
Debezium проводит ту же границу внутри одного сообщения: время изменения в
|
||||||
|
исходной БД хранится отдельно от времени обработки коннектором, а их разность
|
||||||
|
можно использовать как задержку
|
||||||
|
([документация коннектора PostgreSQL](https://debezium.io/documentation/reference/stable/connectors/postgresql.html#postgresql-create-events)).
|
||||||
|
|
||||||
|
| Поле | Чьи часы | На какой вопрос отвечает | Форма |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `UTCEventTime` события `purchase` | бизнес-событие, трекер | когда покупатель подтвердил покупку | отдельный контракт кликстрима; связь с заказом по `purchaseID = order_id` |
|
||||||
|
| `created_at` | база источника | когда строка заказа впервые создана в источнике | RFC 3339 UTC с тремя знаками долей секунды |
|
||||||
|
| `updated_at` | база источника | когда эта строка в последний раз изменена в источнике | тот же формат; версия состояния заказа |
|
||||||
|
| `snapshot_date` | модельный календарь | состояние какого завершившегося дня снято на границе суток | `YYYY-MM-DD`, без времени |
|
||||||
|
| `kafka_timestamp` | транспорт | когда брокер пометил доставленное сообщение | служебная колонка хранилища |
|
||||||
|
| `_load_ts` | хранилище | когда строка впервые приехала в хранилище | `DateTime64(3, 'UTC')` |
|
||||||
|
|
||||||
|
`updated_at` как метка последнего изменения исходной строки совпадает с
|
||||||
|
рекомендованным смыслом `updated_at` в timestamp-стратегии dbt snapshots;
|
||||||
|
время выполнения самого слепка dbt хранит отдельно
|
||||||
|
([официальная документация dbt](https://docs.getdbt.com/docs/build/snapshots#timestamp-strategy-recommended)).
|
||||||
|
Служебные метки ETL также являются отдельными метаданными процесса, а не
|
||||||
|
бизнес-фактами
|
||||||
|
([Kimball Group: Audit Dimension](https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/audit-dimension/)).
|
||||||
|
В этом проекте транспортная и складская оси уже разведены в
|
||||||
|
[конвенции хранилища](../architecture/storage.md): `_load_ts` ставится один раз,
|
||||||
|
а миллисекундный `kafka_timestamp` не округляется.
|
||||||
|
`created_at` и `updated_at` приезжают в сообщении источника; ClickHouse их не
|
||||||
|
создаёт и добавляет рядом собственную `_load_ts`.
|
||||||
|
|
||||||
|
Следствие для контракта: `created_at` нельзя называть временем покупки, а
|
||||||
|
`updated_at - created_at` — длительностью бизнес-перехода. Это время между
|
||||||
|
созданием и последним изменением строки в источнике. Бизнес-время живёт в
|
||||||
|
событии `purchase` и связывается с заказом по уже существующему ключу.
|
||||||
|
|
||||||
|
### Партиция и бизнес-время
|
||||||
|
|
||||||
|
`created_at` и `updated_at` — аудит строки источника. Поэтому
|
||||||
|
`toDate(created_at)` в
|
||||||
|
[спецификации приёма заказов](../architecture/orders/ingestion.md)
|
||||||
|
используется как стабильный технический ключ партиции `ods.order_snapshot`:
|
||||||
|
это день создания строки
|
||||||
|
источника, а не доказательство дня бизнес-события.
|
||||||
|
|
||||||
|
Минимальное решение — не добавлять `ordered_at` на всякий случай. Пока модель
|
||||||
|
создаёт исходную строку синхронно с покупкой, существующий ключ партиции можно
|
||||||
|
оставить, но в описании называть его днём создания строки. Время покупки для
|
||||||
|
сверки берётся из `purchase.UTCEventTime`. Отдельное поле в заказе понадобится
|
||||||
|
только тогда, когда появится самостоятельный учебный запрос к бизнес-времени
|
||||||
|
заказа или источник начнёт сохранять заказ асинхронно. Так различие остаётся
|
||||||
|
честным, но не порождает поле без потребителя.
|
||||||
|
|
||||||
|
## Точность и строгий разбор
|
||||||
|
|
||||||
|
RFC 3339 — профиль ISO 8601 для обмена датой и временем. Он разрешает дробную
|
||||||
|
часть секунды переменной длины и как `Z`, так и числовое смещение
|
||||||
|
([RFC 3339, §5.6](https://www.rfc-editor.org/rfc/rfc3339#section-5.6)). Значит
|
||||||
|
миллисекунды не следуют из названия стандарта сами по себе. Наш более узкий
|
||||||
|
контракт фиксирует ровно три цифры и UTC:
|
||||||
|
|
||||||
|
```text
|
||||||
|
YYYY-MM-DDTHH:mm:ss.SSSZ
|
||||||
|
```
|
||||||
|
|
||||||
|
Например: `2026-06-03T14:21:07.123Z`. Одинаковое число цифр дробной части и
|
||||||
|
одинаковая зона дают хронологическую сортировку таких строк в лексикографическом
|
||||||
|
порядке
|
||||||
|
([RFC 3339, §5.1](https://www.rfc-editor.org/rfc/rfc3339#section-5.1)). Три
|
||||||
|
цифры выбраны потому, что источник моделирует миллисекунды. Сериализатор всегда
|
||||||
|
выводит все три цифры, в том числе `.000` для значения точно на границе секунды.
|
||||||
|
Нельзя только выдавать секундную модель за миллисекундную простым дополнением
|
||||||
|
нулей.
|
||||||
|
|
||||||
|
В ClickHouse `DateTime64(3, 'UTC')` хранит три десятичных знака долей секунды,
|
||||||
|
то есть миллисекунды; пояс колонки используется при разборе и показе значения
|
||||||
|
([DateTime64](https://clickhouse.com/docs/sql-reference/data-types/datetime64)).
|
||||||
|
Для этого узкого формата подходит обнуляемый разбор по точному шаблону:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
parseDateTime64InJodaSyntaxOrNull(
|
||||||
|
value,
|
||||||
|
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'',
|
||||||
|
'UTC'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`OrNull` возвращает `NULL` при несовпадении, а три `S` задают точность
|
||||||
|
`DateTime64(3)`
|
||||||
|
([документация функции](https://clickhouse.com/docs/sql-reference/functions/type-conversion-functions#parsedatetime64injodasyntaxornull),
|
||||||
|
[исходный код ClickHouse](https://github.com/ClickHouse/ClickHouse/blob/master/src/Functions/parseDateTime.cpp)).
|
||||||
|
Это строже, чем `parseDateTime64BestEffortOrNull`: функция Best Effort по
|
||||||
|
назначению принимает несколько представлений даты, тогда как здесь форма сама
|
||||||
|
является частью учебного контракта
|
||||||
|
([документация Best Effort](https://clickhouse.com/docs/sql-reference/functions/type-conversion-functions#parsedatetime64besteffortornull)).
|
||||||
|
|
||||||
|
На проектном ClickHouse 26.3.17.56 это выражение локально проверено. Оно
|
||||||
|
возвращает `Nullable(DateTime64(3, 'UTC'))` для строки с `.123Z` и `NULL` для
|
||||||
|
строки без миллисекунд, с четырьмя цифрами, со смещением `+00:00` вместо `Z`
|
||||||
|
или с хвостовым мусором. Поэтому один и тот же результат разбора можно
|
||||||
|
использовать и для типизированной строки, и для маршрутизации ошибки; нулевая
|
||||||
|
дата не нужна.
|
||||||
|
|
||||||
|
`updated_at` допустим как колонка версии `ReplacingMergeTree`: ClickHouse
|
||||||
|
явно разрешает для `ver` тип `DateTime64` и оставляет строку с максимальной
|
||||||
|
версией
|
||||||
|
([ReplacingMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replacingmergetree)).
|
||||||
|
Если две версии одного заказа имеют одинаковый `updated_at`, среди них
|
||||||
|
побеждает вставленная позже. Для учебной модели достаточно гарантировать
|
||||||
|
монотонные миллисекундные `updated_at` на один заказ; отдельный счётчик версий
|
||||||
|
без такого сценария был бы лишним.
|
||||||
|
|
||||||
|
## Дата слепка и константы мира
|
||||||
|
|
||||||
|
Периодический слепок имеет зерно заранее заданного периода — например, дня, —
|
||||||
|
а не отдельной транзакции
|
||||||
|
([Kimball Group: Periodic Snapshot Fact Tables](https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/periodic-snapshot-fact-table/)).
|
||||||
|
Поэтому `snapshot_date` — значение пачки: дата завершившегося модельного дня D,
|
||||||
|
состояние которого снято на границе D|D+1. Это не UTC-дата отправки и не
|
||||||
|
глобальная константа.
|
||||||
|
|
||||||
|
На один проход генератор вычисляет дату один раз и кладёт её во все записи; на
|
||||||
|
следующем модельном дне значение меняется. Настоящие константы мира — начало
|
||||||
|
модельной оси `ORIGIN`, пояс счётчика `COUNTER_TIMEZONE_MINUTES` и окно K = 7.
|
||||||
|
Первые две уже заданы в
|
||||||
|
[модели времени](../../generator/src/clickstream_generator/world.py), окно
|
||||||
|
добавится в конфигурацию мира вместе с заказами. Глобальная `SNAPSHOT_DATE`
|
||||||
|
смешала бы правило календаря с результатом его вычисления.
|
||||||
|
|
||||||
|
На старте оси дня −1 нет, поэтому прогон дня 0 ничего не отправляет. Первый
|
||||||
|
слепок с `snapshot_date = ORIGIN` уезжает прогоном дня 1.
|
||||||
|
|
||||||
|
`as_of_date` тоже могло бы означать дату, по состоянию на которую показаны
|
||||||
|
данные. Но `snapshot_date` уже является языком спеки и ADR о приёме заказов.
|
||||||
|
Переименование не добавляет урока и может спутать дату
|
||||||
|
выгрузки с периодом бизнес-действия записи. Для этого стенда оставляем
|
||||||
|
`snapshot_date`.
|
||||||
|
|
||||||
|
## JSON и сериализация
|
||||||
|
|
||||||
|
В JSON массив и строка — разные типы значения: массив содержит значения
|
||||||
|
непосредственно, а строка содержит последовательность символов
|
||||||
|
([RFC 8259, §§3, 5 и 7](https://www.rfc-editor.org/rfc/rfc8259)). Поэтому форма
|
||||||
|
на проводе такая:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"order_id": "20260603-0001",
|
||||||
|
"user_id": 42,
|
||||||
|
"status": "paid",
|
||||||
|
"created_at": "2026-06-03T14:21:07.123Z",
|
||||||
|
"updated_at": "2026-06-03T14:24:18.456Z",
|
||||||
|
"items_total": "1299.90",
|
||||||
|
"discount": "0.00",
|
||||||
|
"delivery": "199.00",
|
||||||
|
"total": "1498.90",
|
||||||
|
"items": [
|
||||||
|
{"sku": "sku-17", "qty": 1, "price": "1299.90"}
|
||||||
|
],
|
||||||
|
"snapshot_date": "2026-06-07"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ClickHouse `JSONExtractRaw(raw, 'items')` возвращает выбранный фрагмент JSON
|
||||||
|
неразобранной строкой
|
||||||
|
([официальная документация](https://clickhouse.com/docs/sql-reference/functions/json-functions#jsonextractraw)).
|
||||||
|
На проектной версии локальная проверка обычного внешнего JSON показала
|
||||||
|
`JSONType(..., 'items') = 'Array'`, а `JSONExtractRaw` вернул компактный текст
|
||||||
|
массива, пригодный для колонки ODS `String`. Строка с JSON внутри потребовала
|
||||||
|
бы экранировать массив при первой сериализации и разбирать его второй раз, не
|
||||||
|
меняя результат в ODS.
|
||||||
|
|
||||||
|
`orjson.dumps` умеет сериализовать вложенные словари и списки напрямую и
|
||||||
|
возвращает JSON в UTF-8
|
||||||
|
([официальный репозиторий orjson](https://github.com/ijl/orjson)). Поэтому
|
||||||
|
KISS-вариант для [существующего модуля сериализации](../../generator/src/clickstream_generator/serialize.py)
|
||||||
|
— подготовить канонические строки времени и денег, положить `items` списком в
|
||||||
|
общий словарь и сделать один внешний `dumps` на запись. Класс кодеков, реестр
|
||||||
|
схем и повторный `dumps` для `items` здесь ничего не учат.
|
||||||
|
|
||||||
|
Граница этого решения: `toDecimal64OrNull(..., 2)` проверяет числовую
|
||||||
|
преобразуемость, но не лексическое правило «ровно два знака» — локально строки
|
||||||
|
`1299.90`, `1299.9` и `1299.900` дали одно значение. Проверка денежного формата
|
||||||
|
не нужна сериализатору этого слепка: он сам выпускает ровно два знака. Приём ODS
|
||||||
|
проверяет ту же каноническую форму и считает остальные формы браком; граница
|
||||||
|
строгого приёма зафиксирована в
|
||||||
|
[спецификации заказов](../architecture/orders/ingestion.md).
|
||||||
@@ -190,36 +190,52 @@ Ecommerce (заполнены только у торговых событий):
|
|||||||
выручка дня D «дышит» K дней, потом замерзает. Боевой аналог окна есть и у
|
выручка дня D «дышит» K дней, потом замерзает. Боевой аналог окна есть и у
|
||||||
трекеров: лог Метрики «доформировывается» ещё около трёх дней.
|
трекеров: лог Метрики «доформировывается» ещё около трёх дней.
|
||||||
- Запись слепка — состояние заказа на момент выгрузки, «родной» экспорт
|
- Запись слепка — состояние заказа на момент выгрузки, «родной» экспорт
|
||||||
бэкенда в snake_case:
|
бэкенда в snake_case. Таблица задаёт тип после разбора в ODS:
|
||||||
|
|
||||||
| Поле | Тип | Комментарий |
|
| Поле | Тип в ODS | Комментарий |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `order_id` | String | номер заказа; равен клиентскому `purchaseID` |
|
| `order_id` | String | номер заказа; равен клиентскому `purchaseID` |
|
||||||
| `user_id` | UInt64 | пользователь магазина — мост к склейке |
|
| `user_id` | UInt64 | пользователь магазина — мост к склейке |
|
||||||
| `status` | String | `created` → `paid` → `cancelled` |
|
| `status` | String | `created` → `paid` → `cancelled`; неоплаченный отменяется прямым переходом `created` → `cancelled` |
|
||||||
| `created_at`, `updated_at` | DateTime | |
|
| `created_at`, `updated_at` | DateTime64(3, 'UTC') | аудит строки в БД источника: создание и последнее изменение; не бизнес-время покупки и не время загрузки в ClickHouse |
|
||||||
| `items_total`, `discount`, `delivery`, `total` | Decimal(18,2) | деньги бэкенда — в Decimal |
|
| `items_total`, `discount`, `delivery`, `total` | Decimal(18,2) | деньги бэкенда — в Decimal |
|
||||||
| `items` | String | позиции вложенным JSON: `[{sku, qty, price}]` |
|
| `items` | String | сырой текст массива позиций `[{sku, qty, price}]`, извлечённый из внешнего JSON |
|
||||||
| `snapshot_date` | Date | дата слепка (день выгрузки) |
|
| `snapshot_date` | Date | завершившийся модельный день, состояние которого снято на исходящей границе суток |
|
||||||
|
|
||||||
- Приём идемпотентный, но дедуп расщеплён на два слоя:
|
На проводе один заказ — один документ JSON. Деньги, включая `items[].price`,
|
||||||
- `ods.order_snapshot` — партиция по `snapshot_date`, **без дедупа**,
|
передаются строками с ровно двумя знаками после точки; `created_at` и
|
||||||
хранит «как приехало»; идемпотентность повторного прогона — заменой
|
`updated_at` — строками RFC 3339 в UTC с обязательными миллисекундами
|
||||||
партиции дня слепка, а не ReplacingMergeTree.
|
(`YYYY-MM-DDTHH:mm:ss.SSSZ`); `snapshot_date` — строкой `YYYY-MM-DD`;
|
||||||
- Дедуп до последней версии — **argMax** в трансформации при сборке
|
`items` — обычным массивом JSON, не строкой с JSON внутри. Порядок внешних
|
||||||
`dds.order`. `dds.order` — единственная дедуплицированная таблица:
|
ключей совпадает с порядком полей в таблице контракта, у позиции — `sku`,
|
||||||
партиция по дню заказа (`toDate(created_at)`),
|
`qty`, `price`: так байты воспроизводимы без сортировки ключей. Разница с
|
||||||
ReplacingMergeTree(`updated_at`), `ORDER BY order_id` — заказ всегда
|
кликстримом намеренна: там значения `purchaseRevenue` приезжают JSON-числами
|
||||||
лежит в одной партиции, дедуп работает.
|
и разбираются как `Array(Float64)`, а бэкенд передаёт деньги строками для
|
||||||
|
точного `Decimal`. Это показывает расхождение представлений денег в двух
|
||||||
|
источниках.
|
||||||
|
Запись собирает явная функция существующего канонического сериализатора:
|
||||||
|
один словарь с вложенным списком и один `orjson.dumps`, без универсального
|
||||||
|
слоя кодеков.
|
||||||
|
Обоснование и проверка разбора — в
|
||||||
|
[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md).
|
||||||
|
|
||||||
|
- `ods.order_snapshot` принимает версии заказа в
|
||||||
|
`ReplacingMergeTree(updated_at)`: `ORDER BY order_id`, партиция по дню
|
||||||
|
неизменного `created_at`, шардирование по `cityHash64(order_id)`.
|
||||||
|
`snapshot_date` остаётся датой наблюдения источника, но не задаёт публикацию
|
||||||
|
или идемпотентность. Физическое чтение может видеть несколько версий;
|
||||||
|
`ods.order_v` возвращает точное текущее состояние. Устройство модели DDS и
|
||||||
|
способ её материализации решаются отдельно.
|
||||||
|
|
||||||
Пропущенный день ничего не ломает, следующий слепок самовосстанавливает.
|
Пропущенный день ничего не ломает, следующий слепок самовосстанавливает.
|
||||||
- Разбор JSON-позиций — **один раз**, в трансформации ODS → DDS; дальше
|
- `items` остаётся сырой строкой JSON в ODS. Разбирать позиции следует на
|
||||||
витрины работают с плоскими массивами `dds.order`: `item_sku`
|
границе ODS → DDS, но их представление определяется вместе с будущей моделью
|
||||||
Array(String), `item_qty` Array(UInt64), `item_price` Array(Decimal(18,2))
|
заказов. Это остаётся носителем навыка «вложенный JSON в ClickHouse», не
|
||||||
— одной длины, порядок как в JSON. Это единственный носитель навыка
|
превращая приём в преждевременную модель данных.
|
||||||
«вложенный JSON в ClickHouse» на стенде.
|
|
||||||
- Статусы держим все три: смена `created` → `paid` и есть причина «дыхания»
|
- Статусы держим все три: смена `created` → `paid` и есть причина «дыхания»
|
||||||
выручки внутри окна; сужение до двух — резервный срез 1.
|
выручки внутри окна; сужение до двух — резервный срез 1. На выходе из окна
|
||||||
|
заказ либо `paid`, либо `cancelled`: неоплаченного отменяют, «навсегда
|
||||||
|
`created`» не бывает ([судьба заказа](../architecture/orders/fate.md)).
|
||||||
|
|
||||||
## 3. Каталог товаров
|
## 3. Каталог товаров
|
||||||
|
|
||||||
@@ -242,9 +258,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
|
|
||||||
| | Расхождение | Механика в генераторе | Ориентир доли |
|
| | Расхождение | Механика в генераторе | Ориентир доли |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов |
|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе |
|
||||||
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
|
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
|
||||||
| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого, и создаёт его генератор намеренно: деньги считаются целыми копейками, поэтому Float64 сам по себе не плывёт | ~1–2% |
|
| C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% |
|
||||||
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
|
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
|
||||||
|
|
||||||
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
|
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
|
||||||
@@ -253,8 +269,8 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
||||||
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
||||||
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
|
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
|
||||||
опись хранит точные счётчики по каждому классу расхождений (отмены,
|
опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
|
||||||
потери, дубли).
|
только по дням с закрытым окном (раздел 8).
|
||||||
|
|
||||||
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
||||||
механически дублирует B.
|
механически дублирует B.
|
||||||
@@ -286,8 +302,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
|
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
|
||||||
Константа мира: каждый двухкуковый покупатель делает минимум по одному
|
Константа мира: каждый двухкуковый покупатель делает минимум по одному
|
||||||
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
|
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
|
||||||
(она строится только из покупок) и лаба не воспроизводится. Опись
|
(она строится только из покупок) и лаба не воспроизводится. Число таких
|
||||||
хранит число именно таких пар.
|
пар растёт, пока мир едет, и в опись не кладётся
|
||||||
|
([что хранит опись](../architecture/orders/inventory.md)).
|
||||||
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
||||||
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
||||||
|
|
||||||
@@ -342,18 +359,18 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
— только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка
|
— только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка
|
||||||
`purchase`↔заказ —
|
`purchase`↔заказ —
|
||||||
легитимная GLOBAL-витрина (заказы малы).
|
легитимная GLOBAL-витрина (заказы малы).
|
||||||
- **Конвейер без TRUNCATE**: поток — append-only в ReplacingMergeTree (дедуп
|
- **Конвейер без TRUNCATE**: поток версий — в ReplacingMergeTree, точное чтение
|
||||||
через argMax); батчевая переобработка — по дневным партициям
|
— через `FINAL` или равносильный выбор последней версии; батчевая
|
||||||
|
переобработка нижележащих объектов — по дневным партициям
|
||||||
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не
|
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не
|
||||||
применяется вовсе — полный сброс стенда делается `make clean && make up`, то
|
применяется вовсе — полный сброс стенда делается `make clean && make up`, то
|
||||||
есть вместе с томами. `DROP/REPLACE PARTITION` работает только по
|
есть вместе с томами. `DROP/REPLACE PARTITION` работает только по
|
||||||
**локальным** таблицам ON CLUSTER, не по Distributed; замена через
|
**локальным** таблицам ON CLUSTER, не по Distributed; замена через
|
||||||
DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это
|
DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это
|
||||||
осознанная цена, не баг).
|
осознанная цена, не баг).
|
||||||
- **Поздние заказы поглощает только ODS** (`ods.order_snapshot` — новая
|
- **Поздние заказы поглощает ODS** как новую версию `order_id`. Как их
|
||||||
партиция дня слепка, без переделки старого); материализованное ниже —
|
подхватывают материализованные объекты DDS и DM, решается вместе с их моделью,
|
||||||
нет. Каждый прогон ETL перестраивает партиции последних K+1 дней у
|
а не при проектировании приёма.
|
||||||
заказозависимых объектов (`dds.order` и производные, `dm.dq_summary`).
|
|
||||||
Сессии перестраиваются только за текущий день: правило мира — сессия
|
Сессии перестраиваются только за текущий день: правило мира — сессия
|
||||||
режется по границе модельных суток, дневная партиция самодостаточна.
|
режется по границе модельных суток, дневная партиция самодостаточна.
|
||||||
- Для ETL-вставок — `distributed_foreground_insert = 1` (раньше называлась
|
- Для ETL-вставок — `distributed_foreground_insert = 1` (раньше называлась
|
||||||
@@ -391,10 +408,10 @@ README.
|
|||||||
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах |
|
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах |
|
||||||
| STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг |
|
| STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг |
|
||||||
| ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree |
|
| ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree |
|
||||||
| ODS | `ods.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа |
|
| ODS | `ods.order_snapshot` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние |
|
||||||
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) |
|
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) |
|
||||||
| DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
|
| DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
|
||||||
| DDS | `dds.order` | единственная дедуплицированная таблица заказа: партиция по дню заказа (`toDate(created_at)`), ReplacingMergeTree(`updated_at`), `ORDER BY order_id`, дедуп до последней версии — argMax в трансформации при сборке |
|
| DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS |
|
||||||
| DDS | `dds.identity_map` | карта кука↔пользователь |
|
| DDS | `dds.identity_map` | карта кука↔пользователь |
|
||||||
| DDS | словарь `products` | каталог из CSV |
|
| DDS | словарь `products` | каталог из CSV |
|
||||||
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
|
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
|
||||||
@@ -404,19 +421,22 @@ README.
|
|||||||
см. [доку хранилища](../architecture/storage.md).
|
см. [доку хранилища](../architecture/storage.md).
|
||||||
|
|
||||||
Заказы принимаются **пакетным забором** ([ADR
|
Заказы принимаются **пакетным забором** ([ADR
|
||||||
0008](../adr/0008-order-ingestion.md)): чтец топика байтовый, как у событий, но
|
0008](../adr/0008-order-ingestion.md), [ADR
|
||||||
|
0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у событий, но
|
||||||
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
|
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
|
||||||
он же вставляет прочитанное в `stg.orders_raw`, разбирает в типизированный
|
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска.
|
||||||
слепок и заменяет партицию дня в `ods.order_snapshot`. Тот же даг проигрывает
|
Один следующий `task_id` двумя последовательными запросами пишет годные строки
|
||||||
модельный день генератором, поэтому переливается ровно то, что он положил в
|
и ошибки из того же среза. `ods.order_snapshot` хранит версии по `updated_at`,
|
||||||
топик. Слой сырья у заказов остаётся: без него `ods.order_snapshot` повторил бы
|
а не публикует партицию `snapshot_date`. Слой сырья остаётся точкой повтора и
|
||||||
его роль, а обещание идемпотентности повисло бы — матвью партиций не заменяет.
|
разбора одной принятой порции.
|
||||||
|
|
||||||
Стенд получает от этого два режима приёма рядом, поток и слепок, и сравнение
|
Стенд получает от этого два режима приёма рядом, поток и слепок, и сравнение
|
||||||
из опорных точек раздела 12 переформулировано под них.
|
из опорных точек раздела 12 переформулировано под них.
|
||||||
|
|
||||||
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
||||||
`ods.event` и `ods.order_snapshot` получают метку загрузки `_load_ts`, и у
|
`ods.event` и `ods.order_snapshot` получают метку загрузки `_load_ts`, но у
|
||||||
`ods.event` она же служит колонкой версии ReplacingMergeTree; а таблицы
|
заказа бизнес-версию задаёт `updated_at`; `_load_id` проходит через STG и обе
|
||||||
|
цели ODS. Таблицы
|
||||||
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
|
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
|
||||||
без них урок «какая нода читала топик» ненаблюдаем.
|
без них урок «какая нода читала топик» ненаблюдаем.
|
||||||
Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое
|
Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое
|
||||||
@@ -445,8 +465,9 @@ Kafka день переигрывается генератором заново:
|
|||||||
`total`: промокод и доставка клиенту не видны), `status`, `mismatch_class`
|
`total`: промокод и доставка клиенту не видны), `status`, `mismatch_class`
|
||||||
(`match` / `cancelled` / `lost_event` / `duplicate_event` / `amount_delta`,
|
(`match` / `cancelled` / `lost_event` / `duplicate_event` / `amount_delta`,
|
||||||
в порядке приоритета — классы пересекаются, побеждает более ранний).
|
в порядке приоритета — классы пересекаются, побеждает более ранний).
|
||||||
`match` — большинство строк; `amount_delta` — только необъяснённый остаток
|
`match` — большинство строк; `amount_delta` — остаток, не объяснённый
|
||||||
после приведения к сравнимой базе; его создаёт генератор намеренно
|
приведением к сравнимой базе: сравнением позиций он объясняется, и на этом
|
||||||
|
стоит урок класса C; создаёт его генератор намеренно
|
||||||
(~1–2% заказов, см. раздел 4).
|
(~1–2% заказов, см. раздел 4).
|
||||||
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
|
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
|
||||||
слепка, а не расхождение: она получает служебный класс `awaiting_order`
|
слепка, а не расхождение: она получает служебный класс `awaiting_order`
|
||||||
@@ -484,11 +505,15 @@ Kafka день переигрывается генератором заново:
|
|||||||
карты #10) закрыта этим же ходом: версионируется опись.
|
карты #10) закрыта этим же ходом: версионируется опись.
|
||||||
Контрольные числа описи:
|
Контрольные числа описи:
|
||||||
|
|
||||||
- заказная сторона: заказы и выручка по дням; опись хранит точные
|
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
|
||||||
счётчики по каждому классу расхождений (отмены, потери, дубли, дельты сумм) —
|
«слепок переснимается и даёт те же байты» опись сторожит наравне с днями;
|
||||||
самопроверка лабы сверки;
|
- заказная сторона: заказы, выручка и счётчики наблюдаемых классов
|
||||||
- идентичность: uniq кук, uniq известных пользователей, число двухкуковых
|
расхождений после приоритета, в заказах — по дням с закрытым окном
|
||||||
покупателей — лаба склейки получает самопроверку.
|
(самопроверка лабы сверки; при N сыгранных днях таких дней N − 7);
|
||||||
|
- контрольные числа идентичности не заводятся: uniq известных пользователей
|
||||||
|
и число двухкуковых покупателей растут, пока мир едет, а счётчик без
|
||||||
|
названного читателя в опись не кладётся
|
||||||
|
([что хранит опись](../architecture/orders/inventory.md)).
|
||||||
|
|
||||||
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
|
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
|
||||||
при пересборке.
|
при пересборке.
|
||||||
@@ -545,10 +570,12 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
целиком). В конце этапа фиксируется маленький стартовый мир для
|
целиком). В конце этапа фиксируется маленький стартовый мир для
|
||||||
стабильных приёмок следующих этапов (полная пересборка эталонного мира —
|
стабильных приёмок следующих этапов (полная пересборка эталонного мира —
|
||||||
отдельный этап 7).
|
отдельный этап 7).
|
||||||
3. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь и первый
|
3. Заказы и каталог: генератор слепков, STG/ODS заказа, словарь и даг приёма
|
||||||
настоящий даг — проигрыш модельного дня плюс переливка слепка ([ADR
|
слепка ([ADR 0008](../adr/0008-order-ingestion.md)). Модель заказа в DDS
|
||||||
0008](../adr/0008-order-ingestion.md)).
|
уехала этапу 4 — у служебной колонки и зерна читатель появляется там
|
||||||
4. Трансформации и витрины: сессии, identity_map, выручка, сверка A+C.
|
(нарезка этапа, #89).
|
||||||
|
4. Трансформации и витрины: модель DDS заказа, сессии, identity_map,
|
||||||
|
выручка, сверка A+C.
|
||||||
5. Airflow: `etl_pipeline` (партиционная переобработка).
|
5. Airflow: `etl_pipeline` (партиционная переобработка).
|
||||||
6. Расхождения B+D и опоздания; счётчики описи.
|
6. Расхождения B+D и опоздания; счётчики описи.
|
||||||
7. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация
|
7. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация
|
||||||
@@ -618,9 +645,11 @@ Kafka Engine на двух нодах снят с этого списка при
|
|||||||
третьей версии (Datasets → Assets) — актуальные операторы проверить через
|
третьей версии (Datasets → Assets) — актуальные операторы проверить через
|
||||||
Context7. Первый даг приходит этапом 3, а не 5 ([ADR
|
Context7. Первый даг приходит этапом 3, а не 5 ([ADR
|
||||||
0008](../adr/0008-order-ingestion.md)).
|
0008](../adr/0008-order-ingestion.md)).
|
||||||
- Пакетный забор слепка из Kafka: коммит офсетов прямым чтением, хватает ли
|
- Пакетный забор слепка из Kafka: прямое чтение коммитит офсеты и возвращает
|
||||||
одного чтения на слепок дня, доступны ли при нём виртуальные колонки
|
одну порцию. На стандартном мире около полутора тысяч заказов помещаются в
|
||||||
доставки — список и ответы в [ADR 0008](../adr/0008-order-ingestion.md).
|
неё с запасом; это проверяемое допущение стенда, а не граница полноты слепка
|
||||||
|
([ADR 0008](../adr/0008-order-ingestion.md), [ADR
|
||||||
|
0010](../adr/0010-order-versions-in-ods.md)).
|
||||||
- Генератор: рабочее решение — Python с производительной архитектурой
|
- Генератор: рабочее решение — Python с производительной архитектурой
|
||||||
(батчевая генерация вместо посточной, быстрая JSON-сериализация,
|
(батчевая генерация вместо посточной, быстрая JSON-сериализация,
|
||||||
распараллеливание по модельным дням). Читаемость генератора для менти —
|
распараллеливание по модельным дням). Читаемость генератора для менти —
|
||||||
@@ -676,10 +705,10 @@ v2, этап 0).
|
|||||||
наблюдаемости и того, чем платит каждый режим, — как задание. Третий способ,
|
наблюдаемости и того, чем платит каждый режим, — как задание. Третий способ,
|
||||||
типизированный чтец с `kafka_handle_error_mode`, на стенде не живёт: он
|
типизированный чтец с `kafka_handle_error_mode`, на стенде не живёт: он
|
||||||
отвергнут обоими ADR, и остаётся материалом для рассказа;
|
отвергнут обоими ADR, и остаётся материалом для рассказа;
|
||||||
- матвью как рабочий механизм, а не диковина: их видно на приёме и на сборке
|
- матвью как рабочий механизм, а не диковина: события проходят из STG в ODS на
|
||||||
ODS, а пакетная работа начинается выше. Отдельным заданием — как читать из ODS
|
лету, заказы — пакетным заданием. `ods.order_v` показывает границу между
|
||||||
последние версии, через `FINAL` или оконной функцией: что нагляднее, решаем на
|
физическими версиями и точным текущим состоянием; сравнение `FINAL` с
|
||||||
месте;
|
альтернативными способами чтения остаётся материалом задания;
|
||||||
- лекция про идентичность «как в бою»: `setUserID` и first-party id,
|
- лекция про идентичность «как в бою»: `setUserID` и first-party id,
|
||||||
детерминированная против вероятностной склейки, identity graph,
|
детерминированная против вероятностной склейки, identity graph,
|
||||||
кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер
|
кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер
|
||||||
|
|||||||
@@ -25,6 +25,32 @@
|
|||||||
касается этапа 7 — расширение подтверждено владельцем в резолюции
|
касается этапа 7 — расширение подтверждено владельцем в резолюции
|
||||||
«Производительности».
|
«Производительности».
|
||||||
|
|
||||||
|
## Чем меряется генератор
|
||||||
|
|
||||||
|
Рамка владельца, по которой принимались решения ниже. Она шире этапа 2 и
|
||||||
|
живёт дальше него: спеки следующих этапов ссылаются сюда, а не переписывают.
|
||||||
|
|
||||||
|
- **Генератор меряется тем, что приезжает на выход, а не устройством
|
||||||
|
внутри.** Он играет два источника с разными темпами: поток Метрики и
|
||||||
|
ночную выгрузку бэкенда магазина. Любое внутреннее решение отвечает перед
|
||||||
|
одним вопросом — правдоподобны ли данные на выходе.
|
||||||
|
- **Менти внутрь не смотрит.** Он видит внешние эффекты и по ним составляет
|
||||||
|
представление, как работает магазин и обо что там спотыкаются. Значит,
|
||||||
|
конструкция внутри оправдана только наблюдаемым эффектом: без него это
|
||||||
|
сложность, за которую никто не заплатил.
|
||||||
|
- **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств
|
||||||
|
мира сдвигает почти все числа разом — генератор случайных чисел выдаёт
|
||||||
|
другую последовательность. Опираться на конкретные числа поэтому нечем.
|
||||||
|
Хеши описи обещают не постоянство чисел, а то, что их сдвиг не пройдёт
|
||||||
|
молча: пересобрал опись — увидел diff.
|
||||||
|
- **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он
|
||||||
|
правкой генератора после взгляда на получившиеся распределения. Оборона,
|
||||||
|
которая дороже ошибки, не заводится.
|
||||||
|
- **Сложность изолирована нарочно.** Генератор вынесен отдельной службой,
|
||||||
|
чтобы его сложность не расползалась по стенду. Плата за изоляцию —
|
||||||
|
дешевизна правки: выбирается устройство, которое дёшево менять, а не то,
|
||||||
|
которое всё предусмотрело.
|
||||||
|
|
||||||
## Целевая картина одним взглядом
|
## Целевая картина одним взглядом
|
||||||
|
|
||||||
- **Мир — функция, не состояние.** Состав мира — чистая функция зерна;
|
- **Мир — функция, не состояние.** Состав мира — чистая функция зерна;
|
||||||
@@ -93,9 +119,10 @@
|
|||||||
человеку предыстории, чьё окно активности таких дней не оставляет,
|
человеку предыстории, чьё окно активности таких дней не оставляет,
|
||||||
пара не назначается. День-функция обязана назначенные заказы
|
пара не назначается. День-функция обязана назначенные заказы
|
||||||
реализовать; остальные покупки — вольные, их решает генератор торговых
|
реализовать; остальные покупки — вольные, их решает генератор торговых
|
||||||
событий (#40). Опись считает пары, реализованные в горизонте
|
событий (#40). План считает пары, реализованные в запрошенном горизонте;
|
||||||
снимка. Условность в данных не видна: дни назначены той же
|
опись это число не хранит — оно растёт вместе с миром. Условность в данных
|
||||||
случайностью, просто брошенной планом один раз.
|
не видна: дни назначены той же случайностью, просто брошенной планом один
|
||||||
|
раз.
|
||||||
- **Своя ось модельного времени.** Ось событий начинается в
|
- **Своя ось модельного времени.** Ось событий начинается в
|
||||||
фиксированный день D0 (понедельник — см. раздел 5); реальный
|
фиксированный день D0 (понедельник — см. раздел 5); реальный
|
||||||
календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси
|
календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси
|
||||||
@@ -105,7 +132,8 @@
|
|||||||
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
||||||
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
||||||
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
||||||
«дышат»; включается по требованию, не постоянный фон.
|
«дышат»; включается по требованию — постоянным фоном идёт, только пока
|
||||||
|
включён выключатель пульта ([ADR 0009](../adr/0009-world-control.md)).
|
||||||
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
||||||
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
||||||
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
||||||
@@ -170,7 +198,11 @@
|
|||||||
целое, поэтому у предыстории своя ветвь состава, отдельная от оси
|
целое, поэтому у предыстории своя ветвь состава, отдельная от оси
|
||||||
(уточнение при исполнении #38, 2026-08-02);
|
(уточнение при исполнении #38, 2026-08-02);
|
||||||
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
||||||
торговые события, расхождения, опоздания — в фиксированном порядке.
|
торговые события, заказная и событийная стороны (судьба заказов и порча
|
||||||
|
событийного потока) — в фиксированном порядке (уточнение [судьбой
|
||||||
|
заказа](../architecture/orders/fate.md): прежние «расхождения» и
|
||||||
|
«опоздания» были названы по классам витрины, а компонент называет часть
|
||||||
|
мира).
|
||||||
По построению: параллельный прогон равен последовательному; продление
|
По построению: параллельный прогон равен последовательному; продление
|
||||||
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
|
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
|
||||||
только его часть снимка — в описи меняются хеши только затронутых
|
только его часть снимка — в описи меняются хеши только затронутых
|
||||||
@@ -445,10 +477,12 @@
|
|||||||
теперь есть.
|
теперь есть.
|
||||||
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
|
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
|
||||||
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
||||||
- **Этап 5, позиция на оси времени.** Проигрыватель состояния не хранит
|
- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 —
|
||||||
(раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт она
|
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
|
||||||
переменной Airflow: отсутствие переменной означает мир в стартовом
|
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
|
||||||
состоянии, заводит и двигает её только даг `next_day` и только по успеху.
|
она переменной Airflow: отсутствие переменной означает мир в стартовом
|
||||||
|
состоянии, а ставит её тот даг пульта, который сыграл день, — значением
|
||||||
|
«последний сыгранный + 1» и только по успеху.
|
||||||
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
||||||
хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с
|
хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с
|
||||||
данными ClickHouse, так что позиция и мир чистятся одной командой.
|
данными ClickHouse, так что позиция и мир чистятся одной командой.
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ USER root
|
|||||||
|
|
||||||
# Официальный провайдер Kafka использует тот же confluent-kafka. Пробникам
|
# Официальный провайдер Kafka использует тот же confluent-kafka. Пробникам
|
||||||
# не нужны его подключения и обёртки, поэтому в образ добавлен сам клиент.
|
# не нужны его подключения и обёртки, поэтому в образ добавлен сам клиент.
|
||||||
|
#
|
||||||
|
# Провайдера docker, которым пульт мира зовёт генератор, ставить не нужно:
|
||||||
|
# база несёт его сама — 4.5.7 к Airflow 3.3.0, замер 13 августа 2026 года.
|
||||||
RUN uv pip install --python /home/airflow/.local/bin/python --no-cache \
|
RUN uv pip install --python /home/airflow/.local/bin/python --no-cache \
|
||||||
"clickhouse-connect==1.6.0" \
|
"clickhouse-connect==1.6.0" \
|
||||||
"confluent-kafka==2.15.0" \
|
"confluent-kafka==2.15.0" \
|
||||||
|
|||||||
Reference in New Issue
Block a user