Compare commits
233
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453950a639 | ||
|
|
c26b4530f3 | ||
|
|
eed5b4b4e6 | ||
|
|
4fd872f296 | ||
|
|
22bf09af9d | ||
|
|
502d705ca1 | ||
|
|
d22156a7da | ||
|
|
6174536cc1 | ||
|
|
989ff71495 | ||
|
|
cd82911906 | ||
|
|
3ffce6387e | ||
|
|
c999153acb | ||
|
|
c3a105a0f8 | ||
|
|
9b1826f5ff | ||
|
|
8b7987c5c2 | ||
|
|
c1b61d81b9 | ||
|
|
f93c924b33 | ||
|
|
c68a451698 | ||
|
|
8db1c58c12 | ||
|
|
58e944aa87 | ||
|
|
c28f724706 | ||
|
|
c49f39f01f | ||
|
|
dcf8db1305 | ||
|
|
6f18e9989c | ||
|
|
9cc3e5ae3b | ||
|
|
74061aa499 | ||
|
|
2a13f46a6d | ||
|
|
cd4f9c55f5 | ||
|
|
0c466c082e | ||
|
|
1f203c974d | ||
|
|
5946175efa | ||
|
|
cdfa31f51d | ||
|
|
cc836fc3c5 | ||
|
|
e422e6d1fa | ||
|
|
1fae4e589f | ||
|
|
17995d4c5e | ||
|
|
c2c4401a16 | ||
|
|
33aef73d0e | ||
|
|
f33d75113d | ||
|
|
35d43125b1 | ||
|
|
54686651df | ||
|
|
26b2e5fea1 | ||
|
|
4e60aafc31 | ||
|
|
cec11edaf3 | ||
|
|
2a6162237c | ||
|
|
20071c565f | ||
|
|
61e662c19c | ||
|
|
12a5c109b2 | ||
|
|
fb00bbe4c3 | ||
|
|
741820789f | ||
|
|
da0477f0f1 | ||
|
|
c9bcacd63b | ||
|
|
39ff0b5bb4 | ||
|
|
cdd44c469f | ||
|
|
a9cead2974 | ||
|
|
a82c75705d | ||
|
|
650efacc3f | ||
|
|
176cf5520d | ||
|
|
c361c36e3d | ||
|
|
9ab43e79cc | ||
|
|
535b03783a | ||
|
|
1e8b83ffbb | ||
|
|
96829718f9 | ||
|
|
ae5bd31dbd | ||
|
|
b8a5a6171c | ||
|
|
9ea361d199 | ||
|
|
4ea9591a16 | ||
|
|
f45ad53b7f | ||
|
|
1267b45f47 | ||
|
|
f1fee5f1d1 | ||
|
|
2f9a26eb0e | ||
|
|
92d834761c | ||
|
|
99496971e2 | ||
|
|
70a9aeddb7 | ||
|
|
70270c2d54 | ||
|
|
d1aad3b143 | ||
|
|
ebe3de4ae8 | ||
|
|
f0c4b4c627 | ||
|
|
256d51840b | ||
|
|
7fc12fb8c9 | ||
|
|
92ed3011e6 | ||
|
|
83f4e0952b | ||
|
|
4d43b7badd | ||
|
|
9237870ab5 | ||
|
|
6f2692737b | ||
|
|
111315990b | ||
|
|
40de2e5c5f | ||
|
|
7972847601 | ||
|
|
1adacc6d7c | ||
|
|
d359882e1d | ||
|
|
544e500dc8 | ||
|
|
45360b6bcf | ||
|
|
94a1ffeee2 | ||
|
|
8c76a7a0d8 | ||
|
|
f218795472 | ||
|
|
86ee901dd0 | ||
|
|
ea39982bdd | ||
|
|
20b0666d28 | ||
|
|
8d82679bc1 | ||
|
|
587e392220 | ||
|
|
bca1ffa379 | ||
|
|
7e6554507a | ||
|
|
fc5244cc67 | ||
|
|
0340b060f2 | ||
|
|
48bec014cf | ||
|
|
42075ff9fb | ||
|
|
7ffb4606f1 | ||
|
|
272660ff66 | ||
|
|
06c77c5ed2 | ||
|
|
28177cff92 | ||
|
|
ee0306d19e | ||
|
|
61eb2680ff | ||
|
|
91e281fd32 | ||
|
|
d43a9b348b | ||
|
|
f7123f2ad7 | ||
|
|
e5c573bb25 | ||
|
|
180ef5b94f | ||
|
|
2ccb8ff72c | ||
|
|
c5dd9c2d34 | ||
|
|
0bb07fd91d | ||
|
|
67313bf997 | ||
|
|
fd988babdf | ||
|
|
2a94ee9bde | ||
|
|
a24dfc4510 | ||
|
|
ba4743ed52 | ||
|
|
9bb044e1ce | ||
|
|
0dcc874419 | ||
|
|
664abf0160 | ||
|
|
64bcd3d596 | ||
|
|
7b89ccb529 | ||
|
|
5cc0ede75a | ||
|
|
d77acfdf6c | ||
|
|
b3e66034fc | ||
|
|
abc94ba406 | ||
|
|
862dde9fa4 | ||
|
|
13abc58d98 | ||
|
|
62b275ef94 | ||
|
|
a5508bd9ec | ||
|
|
bdcbe48b59 | ||
|
|
11a494f550 | ||
|
|
6bd9190142 | ||
|
|
e67ee27a17 | ||
|
|
8e21381e13 | ||
|
|
c11da3b252 | ||
|
|
0d90bd566b | ||
|
|
62941d2ee5 | ||
|
|
db632c2558 | ||
|
|
b2fcc7e593 | ||
|
|
716eafe865 | ||
|
|
ded0dac5e2 | ||
|
|
5f72e63b14 | ||
|
|
cf1c6c4636 | ||
|
|
3b4827d819 | ||
|
|
905ca5bbaf | ||
|
|
ede1df765c | ||
|
|
8d54a3caba | ||
|
|
c17c5ef995 | ||
|
|
86e2d6dbb2 | ||
|
|
34267e8494 | ||
|
|
c656ca198f | ||
|
|
f51048dd23 | ||
|
|
292302e161 | ||
|
|
35dc933d29 | ||
|
|
61bc156d6a | ||
|
|
9d43ff5489 | ||
|
|
d0f15bea02 | ||
|
|
5627f6c7aa | ||
|
|
780b2d0ef9 | ||
|
|
62dc618252 | ||
|
|
790f7c1bb7 | ||
|
|
41d057252e | ||
|
|
1fb219f836 | ||
|
|
a3d852cad9 | ||
|
|
d5c5e14cd5 | ||
|
|
c9b11224d2 | ||
|
|
e4e5688775 | ||
|
|
6a708cd5aa | ||
|
|
368fb1a66b | ||
|
|
704c139d5a | ||
|
|
5d012cc222 | ||
|
|
76405a06ee | ||
|
|
a02eba56c2 | ||
|
|
359570ec65 | ||
|
|
d67e697821 | ||
|
|
e6f300a66e | ||
|
|
a93a3bf3a3 | ||
|
|
c382b0390f | ||
|
|
2951359e6c | ||
|
|
6f5f76fafb | ||
|
|
600bd74b04 | ||
|
|
c696fce40b | ||
|
|
7c9eeedc40 | ||
|
|
30a1e9e567 | ||
|
|
f3ebc115ba | ||
|
|
a534f3cc94 | ||
|
|
68f789ba91 | ||
|
|
6910440400 | ||
|
|
d7485217d8 | ||
|
|
77fa2905a2 | ||
|
|
a61f7934ec | ||
|
|
8903b6a054 | ||
|
|
b8816466d9 | ||
|
|
896c846cbc | ||
|
|
5492e6ff3d | ||
|
|
4245daf3f1 | ||
|
|
7e4d5815a3 | ||
|
|
bbbe17cfb0 | ||
|
|
f84f8c416f | ||
|
|
a2f5b27d89 | ||
|
|
66490d124e | ||
|
|
6afa26b7be | ||
|
|
18200fb97f | ||
|
|
dd6d117d49 | ||
|
|
c99e853fd3 | ||
|
|
daf13384a8 | ||
|
|
a0144e0fff | ||
|
|
923ebad80e | ||
|
|
f1254ce79d | ||
|
|
31b274175a | ||
|
|
dedb6c6739 | ||
|
|
319db308bf | ||
|
|
a18e6b7a80 | ||
|
|
4243aa2c88 | ||
|
|
c0f4d21df9 | ||
|
|
4165f10cdd | ||
|
|
fbffeda5ea | ||
|
|
722dbe22b7 | ||
|
|
bfbaa96696 | ||
|
|
2cfda7180f | ||
|
|
a7442765ff | ||
|
|
898e00474a | ||
|
|
7dcd1c9ba3 | ||
|
|
eb433ad023 |
No files matched your search
@@ -0,0 +1,29 @@
|
|||||||
|
.git/
|
||||||
|
.agents/
|
||||||
|
.claude/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
secrets/
|
||||||
|
credentials/
|
||||||
|
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.py[cod]
|
||||||
|
**/.venv/
|
||||||
|
**/.pytest_cache/
|
||||||
|
**/.ruff_cache/
|
||||||
|
**/.mypy_cache/
|
||||||
|
**/.ty_cache/
|
||||||
|
**/*.egg-info/
|
||||||
|
**/build/
|
||||||
|
**/dist/
|
||||||
|
|
||||||
|
tmp/
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
+17
-9
@@ -1,33 +1,41 @@
|
|||||||
|
# Имя и внешние порты меняют для второй копии стенда или при конфликте.
|
||||||
COMPOSE_PROJECT_NAME=clickstream-data-platform
|
COMPOSE_PROJECT_NAME=clickstream-data-platform
|
||||||
CLICKHOUSE_IMAGE=clickhouse/clickhouse-server:26.3.17.56
|
|
||||||
CLICKHOUSE_01_HTTP_PORT=28123
|
CLICKHOUSE_01_HTTP_PORT=28123
|
||||||
CLICKHOUSE_01_TCP_PORT=29000
|
CLICKHOUSE_01_TCP_PORT=29000
|
||||||
CLICKHOUSE_02_HTTP_PORT=28124
|
CLICKHOUSE_02_HTTP_PORT=28124
|
||||||
CLICKHOUSE_02_TCP_PORT=29001
|
CLICKHOUSE_02_TCP_PORT=29001
|
||||||
KAFKA_IMAGE=apache/kafka:4.3.1
|
CLICKHOUSE_CLUSTER_SECRET=cluster-26-secret
|
||||||
|
CLICKHOUSE_DEFAULT_PASSWORD=default-26-local
|
||||||
|
CLICKHOUSE_ETL_PASSWORD=etl-26-writer
|
||||||
|
CLICKHOUSE_LIFECYCLE_PASSWORD=lifecycle-26-owner
|
||||||
|
CLICKHOUSE_BI_PASSWORD=bi-26-reader
|
||||||
|
CLICKHOUSE_ANALYST_PASSWORD=analyst-26-reader
|
||||||
|
CLICKHOUSE_GRAFANA_PASSWORD=grafana-26-reader
|
||||||
KAFKA_EXTERNAL_PORT=29092
|
KAFKA_EXTERNAL_PORT=29092
|
||||||
PROMETHEUS_IMAGE=prom/prometheus:v3.13.2
|
|
||||||
PROMETHEUS_PORT=29090
|
PROMETHEUS_PORT=29090
|
||||||
GRAFANA_IMAGE=grafana/grafana:13.1.1
|
|
||||||
GRAFANA_PORT=23000
|
GRAFANA_PORT=23000
|
||||||
|
AIRFLOW_PORT=28080
|
||||||
|
SUPERSET_PORT=28088
|
||||||
|
|
||||||
|
# GID группы `docker` на этой машине: без него планировщик Airflow не достучится
|
||||||
|
# до сокета докера, и пульт мира не поднимет контейнер генератора.
|
||||||
|
# Подсмотреть свой — `getent group docker`.
|
||||||
|
DOCKER_GID=127
|
||||||
|
|
||||||
|
# Учётные данные и ключи учебного стенда. Для VPS замените значения образца.
|
||||||
GRAFANA_ADMIN_USER=admin
|
GRAFANA_ADMIN_USER=admin
|
||||||
GRAFANA_ADMIN_PASSWORD=admin
|
GRAFANA_ADMIN_PASSWORD=admin
|
||||||
POSTGRES_IMAGE=postgres:16-alpine
|
|
||||||
POSTGRES_ADMIN_USER=postgres
|
POSTGRES_ADMIN_USER=postgres
|
||||||
POSTGRES_ADMIN_PASSWORD=postgres-local
|
POSTGRES_ADMIN_PASSWORD=postgres-local
|
||||||
AIRFLOW_METADATA_USER=airflow
|
AIRFLOW_METADATA_USER=airflow
|
||||||
AIRFLOW_METADATA_PASSWORD=airflow-local
|
AIRFLOW_METADATA_PASSWORD=airflow-local
|
||||||
SUPERSET_METADATA_USER=superset
|
SUPERSET_METADATA_USER=superset
|
||||||
SUPERSET_METADATA_PASSWORD=superset-local
|
SUPERSET_METADATA_PASSWORD=superset-local
|
||||||
AIRFLOW_IMAGE=apache/airflow:3.3.0
|
|
||||||
AIRFLOW_PORT=28080
|
|
||||||
AIRFLOW_ADMIN_USER=admin
|
AIRFLOW_ADMIN_USER=admin
|
||||||
AIRFLOW_ADMIN_PASSWORD=airflow
|
AIRFLOW_ADMIN_PASSWORD=airflow
|
||||||
AIRFLOW_JWT_SECRET=local-only-airflow-jwt-secret-do-not-use
|
AIRFLOW_JWT_SECRET=local-only-airflow-jwt-secret-do-not-use
|
||||||
AIRFLOW_API_SECRET_KEY=local-only-airflow-api-secret-do-not-use
|
AIRFLOW_API_SECRET_KEY=local-only-airflow-api-secret-do-not-use
|
||||||
AIRFLOW_FERNET_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
AIRFLOW_FERNET_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||||
SUPERSET_IMAGE=apache/superset:6.1.0
|
|
||||||
SUPERSET_PORT=28088
|
|
||||||
SUPERSET_ADMIN_USER=admin
|
SUPERSET_ADMIN_USER=admin
|
||||||
SUPERSET_ADMIN_PASSWORD=superset
|
SUPERSET_ADMIN_PASSWORD=superset
|
||||||
SUPERSET_SECRET_KEY=local-only-superset-secret-do-not-use-outside-this-stand
|
SUPERSET_SECRET_KEY=local-only-superset-secret-do-not-use-outside-this-stand
|
||||||
@@ -31,6 +31,7 @@ credentials/
|
|||||||
|
|
||||||
# Логи и временные файлы
|
# Логи и временные файлы
|
||||||
logs/
|
logs/
|
||||||
|
tmp/
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
@@ -43,3 +44,6 @@ Thumbs.db
|
|||||||
# Локальные настройки Claude Code
|
# Локальные настройки Claude Code
|
||||||
.claude/*
|
.claude/*
|
||||||
!.claude/agents/
|
!.claude/agents/
|
||||||
|
|
||||||
|
# Локальные рабочие файлы агентов: хэндоффы, обменные артефакты ревью
|
||||||
|
.scratch/
|
||||||
@@ -8,6 +8,31 @@
|
|||||||
Стенд строится по спеке
|
Стенд строится по спеке
|
||||||
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
|
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
|
||||||
|
|
||||||
|
Отсюда вопрос к любой доработке — коду, проверке, документу: **чему на ней
|
||||||
|
научится менти?** Ответ называется одной фразой до того, как писать. Не
|
||||||
|
складывается фраза — это вопрос владельцу, а не строчка кода: проверки в этом
|
||||||
|
репозитории однажды уже переросли продукт.
|
||||||
|
|
||||||
|
**Усложнение не бесплатно, и платит за него учебная ценность.** Чем сложнее
|
||||||
|
стенд, тем хуже он как учебный материал: внимание менти конечно, и каждая
|
||||||
|
конструкция, которую он обязан расшифровать по дороге к уроку, списывается с
|
||||||
|
этого счёта. Поэтому вопрос выше имеет вторую половину: **что менти платит,
|
||||||
|
чтобы это прочесть, и что получает взамен?** Ответ «вопрос — и ничего»
|
||||||
|
означает, что писать не нужно.
|
||||||
|
|
||||||
|
Оборонительный код дороже прочего. Читатель не отличает «стоит здесь, потому
|
||||||
|
что есть настоящая опасность» от «стоит здесь, потому что кто-то был
|
||||||
|
остроумен», — и предполагает первое. Лишний сторож не безобиден: он
|
||||||
|
утверждает, будто опасность заслуживает внимания, и этим врёт. Особенно это
|
||||||
|
касается защиты от ошибки, которую человек делает сам себе и тут же видит:
|
||||||
|
разбирательство с ней и есть урок, отнимать его не надо.
|
||||||
|
|
||||||
|
Ревью само по себе умеет только прибавлять: каждая находка просится в правку,
|
||||||
|
и за прогон их набираются десятки. Поэтому перед приёмкой заметной работы
|
||||||
|
идёт отдельный **проход на вычитание** — с правом только резать и с явным
|
||||||
|
списком неприкосновенного. Сомнение «резать или нет» решается в пользу
|
||||||
|
реза: вещь, которая себя не защитила, себя не защитила.
|
||||||
|
|
||||||
## Кому что поручать
|
## Кому что поручать
|
||||||
|
|
||||||
Читатель здесь не побочный потребитель, а тот, ради кого стенд существует:
|
Читатель здесь не побочный потребитель, а тот, ради кого стенд существует:
|
||||||
@@ -52,8 +77,18 @@ Airflow) и названия из кода. Если для понятия ес
|
|||||||
|
|
||||||
## Код и данные
|
## Код и данные
|
||||||
|
|
||||||
- Python — только через `uv`; проверка и формат — `ruff` (`make lint`).
|
- Python — только через `uv`; проверка и формат — `ruff`. Цель `make lint`
|
||||||
|
есть за двумя дверями и охватывает разное: в корне — код стенда, в
|
||||||
|
`generator/` — код генератора. Правишь даги или Superset — зови корневую,
|
||||||
|
правишь генератор — зови из `generator/`.
|
||||||
- Изменения держать минимальными и в границах задания.
|
- Изменения держать минимальными и в границах задания.
|
||||||
|
- `make clean` полностью удаляет постоянное состояние стенда. Перед этой
|
||||||
|
командой назови состояние вне прикладного мира, которое требуется удалить,
|
||||||
|
и объясни, почему `make rebuild-storage` его не удаляет. При изменении DDL
|
||||||
|
или проверке на чистых данных по умолчанию используй адресную очистку либо
|
||||||
|
`make rebuild-storage`.
|
||||||
|
- Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук
|
||||||
|
обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md).
|
||||||
- Секреты не коммитить: настройки — через `.env`, образец — `.env.example`.
|
- Секреты не коммитить: настройки — через `.env`, образец — `.env.example`.
|
||||||
- При изменении инфраструктуры или DDL обновлять документацию тем же PR.
|
- При изменении инфраструктуры или DDL обновлять документацию тем же PR.
|
||||||
- Коммиты — Conventional Commits: заголовок `type(scope): результат`, тело на
|
- Коммиты — Conventional Commits: заголовок `type(scope): результат`, тело на
|
||||||
@@ -122,6 +157,17 @@ Airflow) и названия из кода. Если для понятия ес
|
|||||||
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
|
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
|
||||||
истории, а текущее описание живого формата — и собирается из кода, а не
|
истории, а текущее описание живого формата — и собирается из кода, а не
|
||||||
пишется руками.
|
пишется руками.
|
||||||
|
- Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис
|
||||||
|
(`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных
|
||||||
|
заданий. Ни даты, ни номера: задание — не событие истории, а живой документ,
|
||||||
|
и правится он вместе с тем, о чем говорит. Слаг называет предмет задания.
|
||||||
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
|
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
|
||||||
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
|
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
|
||||||
в имени не нужна.
|
в имени не нужна.
|
||||||
|
- Имена файлов в `docs/architecture/` — слаг строчными латинскими буквами через
|
||||||
|
дефис (`storage.md`). Здесь живут рабочие справочники по зонам
|
||||||
|
ответственности: не событие истории и не решение, а текущее устройство —
|
||||||
|
один файл на зону, правится по мере постройки. Крупный компонент живёт
|
||||||
|
подпапкой: индекс `README.md` и файл на каждую часть устройства
|
||||||
|
(`architecture/orders/`), имена по тому же правилу
|
||||||
|
([ADR 0011](docs/adr/0011-component-docs.md)).
|
||||||
+131
-7
@@ -31,8 +31,22 @@ _Избегать_: состояние мира
|
|||||||
там колонки, здесь числа.
|
там колонки, здесь числа.
|
||||||
|
|
||||||
**Когорта дня**:
|
**Когорта дня**:
|
||||||
Люди, впервые пришедшие в мир в один день, со всеми их куками и визитами.
|
Люди, впервые пришедшие в мир в один день, со всеми их куками и днями
|
||||||
Единица плана состава: когорта — функция зерна и номера дня.
|
активности. Единица плана состава: когорта — функция зерна и номера дня.
|
||||||
|
|
||||||
|
**Человек**:
|
||||||
|
Устойчивая личность в составе мира, которой принадлежат одна или две куки.
|
||||||
|
Не поле источника: кликстрим видит куку, а заказ — пользователя магазина.
|
||||||
|
|
||||||
|
**Посетитель**:
|
||||||
|
Кука, наблюдаемая в кликстриме и обозначенная `ClientID`; единица
|
||||||
|
`uniq(ClientID)`. Один человек может быть представлен несколькими посетителями.
|
||||||
|
_Избегать_: «посетитель» про человека после склейки
|
||||||
|
|
||||||
|
**Пользователь магазина**:
|
||||||
|
Представление человека со стороны заказов; в записи заказа обозначается
|
||||||
|
`user_id`. Через заказы связывает разные куки человека, в кликстрим напрямую
|
||||||
|
не попадает.
|
||||||
|
|
||||||
**Приток**:
|
**Приток**:
|
||||||
Появление новых кук на всём протяжении оси модельного времени; единица —
|
Появление новых кук на всём протяжении оси модельного времени; единица —
|
||||||
@@ -40,29 +54,100 @@ _Избегать_: состояние мира
|
|||||||
горизонтом и не совпадает с дневной.
|
горизонтом и не совпадает с дневной.
|
||||||
|
|
||||||
**Хвост возвратов**:
|
**Хвост возвратов**:
|
||||||
Окно активности, отсчитанное от первого визита человека и общее на обе
|
Окно активности, отсчитанное от первого дня человека и общее на обе
|
||||||
его куки; дольше окна кука не возвращается.
|
его куки; дольше окна кука не возвращается.
|
||||||
|
|
||||||
**Предыстория**:
|
**Предыстория**:
|
||||||
Когорты плана с первым визитом до D0; событий не порождают.
|
Когорты плана с первым днём активности до D0; событий не порождают.
|
||||||
|
|
||||||
|
**День активности**:
|
||||||
|
День, в который кука хоть раз появилась в мире: день её рождения или день
|
||||||
|
возврата. Единица плана состава: за день он у куки один, а визитов внутри
|
||||||
|
него бывает несколько.
|
||||||
|
_Избегать_: визит (визит — про сессию)
|
||||||
|
|
||||||
|
**Визит**:
|
||||||
|
Подряд идущие события одной куки без пауз длиннее 30 минут, не пересекающие
|
||||||
|
границу суток. Поле `VisitID` — эталон для лабы сессий. «Сессия» — то же
|
||||||
|
понятие словами аналитики: в данных и в коде оно зовётся визитом, но имена
|
||||||
|
вроде «лаба сессий» и «сборка сессий» остаются.
|
||||||
|
|
||||||
|
**Паспорт куки**:
|
||||||
|
Устройство и город, приписанные куке на всю жизнь: кука — это браузер на
|
||||||
|
устройстве. Держит их план состава, разворачивает в поля события
|
||||||
|
день-функция; у двухкуковой пары город один на две куки, устройства разные.
|
||||||
|
|
||||||
|
**Покупатель**:
|
||||||
|
Человек, которого план состава пометил склонным покупать. Метка значима:
|
||||||
|
помеченный доходит до заказа заметно чаще прочих, но и непомеченный иногда
|
||||||
|
покупает. Из покупателей отбираются двухкуковые пары.
|
||||||
|
_Избегать_: «покупатель» про того, кто купил в конкретный день — это визит
|
||||||
|
с заказом.
|
||||||
|
|
||||||
|
**День-функция**:
|
||||||
|
Функция (зерно, номер дня), выдающая упорядоченный поток событий этих
|
||||||
|
модельных суток. Состояния между днями нет: день D не зависит от того,
|
||||||
|
прожиты ли дни до него.
|
||||||
|
|
||||||
|
**Торговое событие**:
|
||||||
|
Событие корзины или покупки — отдельная строка потока, а не просмотр
|
||||||
|
страницы. Садится на ту страницу, где случилось: корзина — на карточку
|
||||||
|
товара, покупка — на страницу подтверждения заказа.
|
||||||
|
|
||||||
|
**Справочник**:
|
||||||
|
Данные, которые хранилище не производит, а получает готовыми со стороны:
|
||||||
|
содержимым владеет источник вне хранилища, слои только читают. Живут в базе
|
||||||
|
`dic`. Первый и пока единственный — каталог товаров.
|
||||||
|
_Избегать_: словарь — этим словом в проекте зовут и глоссарий понятий, и
|
||||||
|
объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник доступен.
|
||||||
|
|
||||||
|
**Каталог товаров**:
|
||||||
|
`data/catalog/products.csv` — общий файл генератора и словаря
|
||||||
|
ClickHouse. Форма файла решена, длина — нет: строки дописываются.
|
||||||
|
|
||||||
|
**Уровень спроса**:
|
||||||
|
Свойство товара в каталоге: как часто открытую карточку кладут в корзину.
|
||||||
|
Три значения — магнит, обычный, залёживается. Постоянная часть мира, а не
|
||||||
|
поведение дня: он и делает конверсию «просмотр → корзина» по товарам и
|
||||||
|
брендам различимой.
|
||||||
|
|
||||||
**Ось модельного времени**:
|
**Ось модельного времени**:
|
||||||
Собственный календарь мира генератора. Дни считаются от фиксированного
|
Собственный календарь мира генератора. Дни считаются от фиксированного
|
||||||
первого дня D0; реальный календарь в модели не участвует. Между прогонами
|
первого дня D0; реальный календарь в модели не участвует. Между прогонами
|
||||||
живут только зерно и позиция на оси.
|
живут только зерно и позиция на оси.
|
||||||
|
|
||||||
|
**Стартовый мир**:
|
||||||
|
Первые восемь дней оси (понедельник по понедельник), которыми стенд
|
||||||
|
наполняется при каждом `make up`. Он же эталонный мир, всегда один и тот же:
|
||||||
|
на нем принимаются следующие этапы.
|
||||||
|
_Избегать_: зерновой мир
|
||||||
|
|
||||||
|
**Опись мира**:
|
||||||
|
`data/world-inventory.json` — единственное, что о мире хранится в git: паспорт
|
||||||
|
мира (зерно, версия генератора, хеш каталога), строка на каждый день событий
|
||||||
|
(число и хеш байтов) и строка на каждый отправленный слепок (хеш байтов).
|
||||||
|
Счётчик сверх этого заводится только с названным читателем. Сам мир не
|
||||||
|
хранится, а пересчитывается; опись отвечает на один вопрос: тот ли это мир.
|
||||||
|
_Избегать_: манифест, мини-манифест
|
||||||
|
|
||||||
**Пошаговый режим**:
|
**Пошаговый режим**:
|
||||||
Базовый способ движения по оси модельного времени: «прожить следующий
|
Базовый способ движения по оси модельного времени: «прожить следующий
|
||||||
день» — явное действие.
|
день» — явное действие.
|
||||||
|
|
||||||
**Живой день**:
|
**Живой день**:
|
||||||
Проигрывание текущего модельного дня в реальном времени с ускорением;
|
Проигрывание текущего модельного дня в реальном времени с ускорением;
|
||||||
включается по требованию, не постоянный фон.
|
включается по требованию — постоянным фоном идёт, только пока включён
|
||||||
|
выключатель пульта.
|
||||||
|
|
||||||
**Пакетный режим**:
|
**Пакетный режим**:
|
||||||
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
|
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
|
||||||
стенда, пересборки и проверки.
|
стенда, пересборки и проверки.
|
||||||
|
|
||||||
|
**Пульт мира**:
|
||||||
|
Даги Airflow, которыми двигают ось модельного времени: работники играют день —
|
||||||
|
пачкой или в темпе, — а выключатель держит стенд живущим, пока включён.
|
||||||
|
_Избегать_: управляющие даги
|
||||||
|
|
||||||
**Граница суток**:
|
**Граница суток**:
|
||||||
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
|
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
|
||||||
снимается на ней, день проживается только целиком.
|
снимается на ней, день проживается только целиком.
|
||||||
@@ -84,9 +169,48 @@ Python-модуль с описателями колонок события —
|
|||||||
_Избегать_: описание схемы, документация контракта
|
_Избегать_: описание схемы, документация контракта
|
||||||
|
|
||||||
**Канонический сериализатор**:
|
**Канонический сериализатор**:
|
||||||
Единственное место, где событие превращается в байты. Фиксированный порядок
|
Единственная граница, где запись генератора превращается в байты. Фиксированный
|
||||||
ключей и строк — основа побайтовой воспроизводимости.
|
порядок ключей и строк — основа побайтовой воспроизводимости.
|
||||||
|
|
||||||
**Проигрыватель**:
|
**Проигрыватель**:
|
||||||
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
|
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
|
||||||
(пачкой, без темпа) и живой день.
|
(пачкой, без темпа) и живой день.
|
||||||
|
|
||||||
|
**Слепок**:
|
||||||
|
Полная выгрузка заказов окна изменяемости, снятая бэкендом на границе суток:
|
||||||
|
состояние заказов на этот момент, а не поток их изменений. Опоздавший заказ
|
||||||
|
может отсутствовать в ранних слепках; после первого появления ездит до конца
|
||||||
|
своего окна.
|
||||||
|
|
||||||
|
**Дата слепка**:
|
||||||
|
Дата завершившегося модельного дня, состояние которого снято на исходящей
|
||||||
|
границе суток. Одна для всех записей выгрузки; не дата заказа и не время
|
||||||
|
отправки сообщения.
|
||||||
|
|
||||||
|
**Время аудита источника**:
|
||||||
|
Время создания или последнего изменения строки заказа по часам базы источника.
|
||||||
|
В записи слепка это `created_at` и `updated_at`; бизнес-время покупки живёт в
|
||||||
|
событии `purchase` отдельно.
|
||||||
|
|
||||||
|
**Судьба заказа**:
|
||||||
|
Исход, моменты изменений, вычеркнутая позиция и опоздание заказа — всё
|
||||||
|
решается бросками при его рождении и укладывается в окно изменяемости целиком.
|
||||||
|
Слепок любого дня — чтение готовой судьбы, а не накопление состояния.
|
||||||
|
|
||||||
|
**Окно изменяемости**:
|
||||||
|
Сколько модельных дней заказ ещё может измениться и потому продолжает ездить
|
||||||
|
в слепках. Константа мира: семь дней. За окном заказ замёрз, выручка дня
|
||||||
|
перестала «дышать».
|
||||||
|
|
||||||
|
**Пакетный забор**:
|
||||||
|
Способ приёма топика, при котором данные тянет запрос по команде, а не
|
||||||
|
матвью непрерывно. Так стенд принимает заказы: у слепка есть начало и конец,
|
||||||
|
и забирать его уместно тогда, когда он собран целиком. Противоположность —
|
||||||
|
потоковый приём событий.
|
||||||
|
_Избегать_: путать с приёмником и пакетным режимом — те про сторону
|
||||||
|
генератора, про отправку; забор — про сторону хранилища, про чтение.
|
||||||
|
|
||||||
|
**Техническая проверка**:
|
||||||
|
Проверка, чей оракул — внутренний эталон стенда (опись мира), которого у
|
||||||
|
настоящего магазина нет. За учебный урок DQ не выдаётся.
|
||||||
|
_Избегать_: DQ-проверка — та работает на наблюдаемых данных магазина.
|
||||||
@@ -1,45 +1,66 @@
|
|||||||
COMPOSE ?= docker compose
|
COMPOSE ?= docker compose
|
||||||
|
GENERATOR_DAY ?= 0
|
||||||
|
GENERATOR_LIMIT ?=
|
||||||
|
GENERATOR_SPEED ?=
|
||||||
|
|
||||||
.PHONY: up down clean ps logs config-test lint typecheck test docs smoke smoke-cluster smoke-guards
|
# Цели корня — про стенд; проверки генератора — в `generator/Makefile`.
|
||||||
|
.PHONY: up down clean rebuild-storage ps logs smoke check-clickhouse check-services config-test lint generate-batch generate-live
|
||||||
|
|
||||||
|
# --- Жизнь стенда ---
|
||||||
|
|
||||||
|
# Compose дожидается инфраструктуры, Airflow — готового прикладного мира.
|
||||||
up:
|
up:
|
||||||
$(COMPOSE) up --detach --build --wait --wait-timeout 600
|
$(COMPOSE) up --detach --build --wait --wait-timeout 600 --remove-orphans
|
||||||
|
COMPOSE_BIN="$(COMPOSE)" ./scripts/run-dag.sh world_initialize
|
||||||
|
|
||||||
down:
|
down:
|
||||||
$(COMPOSE) down --remove-orphans
|
$(COMPOSE) down --remove-orphans
|
||||||
|
|
||||||
|
# Полное удаление стенда, а не способ получить чистые данные перед проверкой.
|
||||||
|
# Граница операции и требование к обоснованию — в AGENTS.md.
|
||||||
clean:
|
clean:
|
||||||
$(COMPOSE) down --volumes --remove-orphans
|
$(COMPOSE) down --volumes --remove-orphans
|
||||||
|
|
||||||
|
# Возврат прикладного мира к началу без удаления инфраструктурных томов.
|
||||||
|
rebuild-storage:
|
||||||
|
COMPOSE_BIN="$(COMPOSE)" ./scripts/run-dag.sh world_recreate
|
||||||
|
|
||||||
ps:
|
ps:
|
||||||
$(COMPOSE) ps
|
$(COMPOSE) ps
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
$(COMPOSE) logs --follow
|
$(COMPOSE) logs --follow
|
||||||
|
|
||||||
config-test:
|
# --- Проверки, которым нужен поднятый стенд ---
|
||||||
COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh
|
|
||||||
./tests/stand-smoke-static.sh
|
|
||||||
|
|
||||||
lint:
|
|
||||||
cd generator && uv run ruff check && uv run ruff format --check
|
|
||||||
|
|
||||||
typecheck:
|
|
||||||
cd generator && uv run ty check
|
|
||||||
|
|
||||||
test:
|
|
||||||
cd generator && uv run pytest
|
|
||||||
|
|
||||||
docs:
|
|
||||||
cd generator && uv run python -m clickstream_generator.schema_doc \
|
|
||||||
../docs/formats/clickstream-event.md
|
|
||||||
|
|
||||||
smoke:
|
smoke:
|
||||||
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh
|
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh
|
||||||
|
|
||||||
smoke-cluster:
|
check-clickhouse:
|
||||||
./scripts/clickhouse-smoke.sh
|
./scripts/check-clickhouse.sh
|
||||||
|
|
||||||
smoke-guards:
|
check-services:
|
||||||
./tests/smoke-guards.sh
|
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-services.sh
|
||||||
COMPOSE_BIN="$(COMPOSE)" ./tests/stand-smoke-guards.sh
|
|
||||||
|
# --- Проверки, которым стенд не нужен ---
|
||||||
|
|
||||||
|
config-test:
|
||||||
|
COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh
|
||||||
|
|
||||||
|
# Пути названы вслух: без них ruff из корня прошёлся бы и по генератору, а у
|
||||||
|
# того своя дверь и свой конфиг. Версия закреплена, потому что лока в корне
|
||||||
|
# нет, а форматтер между версиями меняет вывод — иначе проверка однажды
|
||||||
|
# покраснела бы сама, без единой правки в репозитории.
|
||||||
|
lint:
|
||||||
|
uvx ruff@0.16.1 check dags infra/superset
|
||||||
|
uvx ruff@0.16.1 format --check dags infra/superset
|
||||||
|
|
||||||
|
# --- Наполнение миром ---
|
||||||
|
|
||||||
|
generate-batch:
|
||||||
|
$(COMPOSE) --profile generator run --rm generator batch --day "$(GENERATOR_DAY)" \
|
||||||
|
$(if $(GENERATOR_LIMIT),--limit "$(GENERATOR_LIMIT)")
|
||||||
|
|
||||||
|
generate-live:
|
||||||
|
$(COMPOSE) --profile generator run --rm generator live --day "$(GENERATOR_DAY)" \
|
||||||
|
$(if $(GENERATOR_SPEED),--speed "$(GENERATOR_SPEED)")
|
||||||
@@ -9,9 +9,14 @@
|
|||||||
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
|
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
|
||||||
Сейчас работают кластер ClickHouse из двух шардов, отдельный
|
Сейчас работают кластер ClickHouse из двух шардов, отдельный
|
||||||
clickhouse-keeper, односерверная Kafka в режиме KRaft, Airflow 3.3, Superset,
|
clickhouse-keeper, односерверная Kafka в режиме KRaft, Airflow 3.3, Superset,
|
||||||
Prometheus, Grafana и общая база Postgres для метаданных. Начат генератор
|
Prometheus, Grafana, экспортёр метрик Kafka и общая база Postgres для
|
||||||
кликстрима: в [`generator/`](generator/) заведён контракт схемы события, из
|
метаданных. Генератор кликстрима в [`generator/`](generator/) собран
|
||||||
которого собрано [описание выгрузки](docs/formats/clickstream-event.md).
|
целиком со стороны клиента: у него есть контракт схемы события, из которого
|
||||||
|
собрано [описание выгрузки](docs/formats/clickstream-event.md), модельный мир и
|
||||||
|
проигрыватель, отправляющий дни в Kafka или в файл. События доезжают до
|
||||||
|
типизированного `ods.event`, а `dds.event_v` отдаёт их на языке бизнес-модели
|
||||||
|
без копии данных. `make up` наполняет стенд стартовым миром — первой неделей
|
||||||
|
модельного времени.
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
@@ -23,90 +28,255 @@ Prometheus, Grafana и общая база Postgres для метаданных.
|
|||||||
пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не
|
пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не
|
||||||
хватает, стенд одинаково хорошо живёт на недорогом VPS.
|
хватает, стенд одинаково хорошо живёт на недорогом VPS.
|
||||||
|
|
||||||
Полная проверка также использует `curl`, `jq`, `awk`,
|
Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`,
|
||||||
`grep`, `sed`, `tail`, `sleep` и `timeout`. По умолчанию должны быть свободны
|
`grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им
|
||||||
|
читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны
|
||||||
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
|
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
|
||||||
и `29092`. Проверкам без стенда — `make config-test`, `make lint`,
|
и `29092`. Проверкам без стенда — `make config-test` и `make lint` в корне,
|
||||||
`make typecheck`, `make test` — и сборке документации `make docs` нужен `uv`.
|
всем целям генератора в `generator/` — нужен `uv`.
|
||||||
|
|
||||||
Стенд запускается без `.env`:
|
Сначала скопируйте настройки стенда:
|
||||||
|
|
||||||
```bash
|
|
||||||
make up
|
|
||||||
make smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
Чтобы изменить образы, порты или учебные учётные данные, скопируйте образец:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
make up
|
make up
|
||||||
make smoke
|
make smoke
|
||||||
|
make check-clickhouse
|
||||||
|
make check-services
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Первому знакомству нужны все три проверки на стенде: `make smoke` говорит, что
|
||||||
|
стенд собран, `make check-clickhouse` — что кластер работает кластером, а
|
||||||
|
`make check-services` — что Airflow запускает DAG, а Superset ходит в базу.
|
||||||
|
Дальше, в рабочей петле, обычно хватает `make smoke`.
|
||||||
|
|
||||||
|
`.env.example` — образец обязательной локальной настройки. В нём живут имя
|
||||||
|
экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца
|
||||||
|
верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и
|
||||||
|
свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь,
|
||||||
|
кроме пульта мира — о нём ниже. `compose.yaml` не
|
||||||
|
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
|
||||||
|
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
|
||||||
|
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
|
||||||
|
|
||||||
Учётные данные Postgres и Grafana применяются при создании их томов.
|
Учётные данные Postgres и Grafana применяются при создании их томов.
|
||||||
После первого запуска меняйте их только вместе с `make clean`: команда удалит
|
После первого запуска меняйте их только вместе с `make clean`: команда удалит
|
||||||
все локальные данные стенда, а следующий `make up` создаст их с новыми
|
все локальные данные стенда, а следующий `make up` создаст их с новыми
|
||||||
значениями.
|
значениями.
|
||||||
|
|
||||||
`make up` собирает локальные образы Airflow и Superset, поднимает весь стенд и
|
`make up` собирает локальные образы Airflow, Superset и Grafana, поднимает весь
|
||||||
ждёт здорового состояния долгоживущих контейнеров. В образ Airflow добавлены
|
стенд и ждёт здорового состояния долгоживущих контейнеров. В образ Airflow
|
||||||
закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и
|
добавлены закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и
|
||||||
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
|
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
|
||||||
подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет
|
подготавливает администратора и два подключения к `clickhouse-01`. Второй
|
||||||
Superset, создаёт администратора и импортирует подключение к `clickhouse-02`.
|
обновляет Superset, создаёт администратора и импортирует подключение к
|
||||||
|
`clickhouse-02`. На ноутбуке в режиме энергосбережения приёмочный подъём с
|
||||||
|
пустых томов занял около десяти минут, повторный на живом стенде — около трёх.
|
||||||
|
|
||||||
`make smoke` проверяет согласованность `.env.example` с Compose, зависимости
|
`make smoke` за секунды спрашивает, собран ли стенд: зависимости машины,
|
||||||
машины, здоровье контейнеров, Kafka через порт машины, три цели Prometheus,
|
здоровье контейнеров, устройство keeper, ответ Kafka с машины через отображённый
|
||||||
источник Grafana, компоненты Airflow, ручной запуск пробников `test_clickhouse`
|
порт, четыре цели Prometheus, два источника Grafana, компоненты Airflow и
|
||||||
и `test_kafka`, метаданные и подключение Superset. Первый пробник создаёт
|
подготовленное подключение к `clickhouse-01`. В конце проверка спрашивает у
|
||||||
таблицы на обеих нодах и читает через `Distributed` на ноде 2 строку из
|
Docker, не убивало ли ядро что-нибудь в долгоживущих контейнерах за нехватку
|
||||||
локальной таблицы ноды 1. Второй пишет в Kafka и читает свой маркер. В конце
|
памяти и не включалась ли политика перезапуска: убитый контейнер Docker
|
||||||
проверка спрашивает у Docker, не убивало ли ядро что-нибудь в долгоживущих
|
поднимает сам, и проверка состояния об этом промолчит.
|
||||||
контейнерах за нехватку памяти и не включалась ли политика перезапуска: убитый
|
|
||||||
контейнер Docker поднимает сам, и проверка состояния об этом промолчит.
|
Двенадцать проверок здоровья сразу после `make up --wait` повторяют то, чего
|
||||||
Временный топик проверки с машины и запуски DAG удаляются;
|
Compose уже дождался: у каждой долгоживущей службы есть своя `healthcheck`.
|
||||||
|
Оставлены они потому, что первый вопрос к стенду всё равно «всё ли живо», а
|
||||||
|
ответ на него стоит меньше секунды. Устройство keeper — другое дело: он должен
|
||||||
|
работать от пользователя `clickhouse`, с пределом в 262144 открытых файла и со
|
||||||
|
своим томом под данные. Всё это объявлено в `compose.yaml`, но здоровым keeper
|
||||||
|
выглядит и без этого, поэтому смоук спрашивает у живого контейнера, дошли ли
|
||||||
|
объявленные настройки до процесса.
|
||||||
|
|
||||||
|
Kafka по той же причине спрашивают снаружи. Её `healthcheck` обращается к
|
||||||
|
брокеру изнутри контейнера и по внутреннему слушателю, поэтому здоровой Kafka
|
||||||
|
остаётся и тогда, когда объявленный наружу адрес ведёт не туда. Клиент с машины
|
||||||
|
в этом случае подключается, получает метаданные и молча виснет на адресе,
|
||||||
|
которого с его стороны не существует. Смоук идёт тем же путём, что и будущий
|
||||||
|
генератор: стучится в отображённый порт и просит список топиков.
|
||||||
|
|
||||||
|
`make check-services` проверяет то, ради чего приходится ждать службу: работу с
|
||||||
|
топиком Kafka с машины — создан, найден, удалён, — ручной запуск пробников
|
||||||
|
`test_clickhouse` и `test_kafka`, вход в Superset, его метаданные и подключение
|
||||||
|
к `clickhouse-02`. Первый пробник создаёт таблицы на обеих нодах и читает через
|
||||||
|
`Distributed` на ноде 2 строку из локальной таблицы ноды 1. Второй пишет в Kafka
|
||||||
|
и читает свой маркер. Временный топик проверки с машины и запуски DAG удаляются;
|
||||||
постоянный топик пробника сохраняется, а старые записи чистит Kafka.
|
постоянный топик пробника сохраняется, а старые записи чистит Kafka.
|
||||||
|
|
||||||
`make smoke-cluster` запускает отдельную глубокую проверку ClickHouse: описание
|
`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse:
|
||||||
кластера, макросы, связь с keeper, `ReplicatedMergeTree`, `Distributed`, очередь
|
описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`,
|
||||||
распределённых DDL и очистку временных таблиц.
|
`Distributed`, очередь распределённых DDL и очистку временных таблиц. Девятая
|
||||||
|
из десяти проверок спрашивает словарь товаров на обеих нодах. Последняя —
|
||||||
|
единственная на настоящих событиях: она подневно сверяет стартовый мир с описью
|
||||||
|
и при расхождении говорит, где искать — в событиях или в браке.
|
||||||
|
|
||||||
`make config-test` проверяет Compose, синтаксис Bash и Python, малые проверки
|
`make config-test` проверяет Compose, синтаксис файлов DAG и пробельные ошибки
|
||||||
логики пробников и пробельные ошибки в diff без запуска стенда.
|
в diff без запуска стенда.
|
||||||
|
|
||||||
`make smoke-guards` сначала проверяет аварийную семантику кластерной проверки,
|
Обычный рабочий цикл — `make config-test` и `make smoke`; остальные цели гоняют
|
||||||
а затем удаляет служебную таблицу пробника только на второй ноде и
|
тогда, когда правка их касается. Что утверждает каждая проверка, нужен ли ей
|
||||||
останавливает Prometheus с Kafka. Общая проверка должна назвать Prometheus и
|
поднятый стенд, сколько стоит прогон и куда класть новую — в
|
||||||
оба пробника, после чего завершиться с ошибкой. В конце стенд восстанавливается.
|
[карте проверок](docs/architecture/testing.md).
|
||||||
|
|
||||||
### Какую проверку когда запускать
|
Остановить контейнеры без удаления данных можно командой `make down`. Если
|
||||||
|
нужно вернуть прикладной мир к началу, `make rebuild-storage` запустит в
|
||||||
|
Airflow его явную пересборку: удалит прикладные базы ClickHouse, топики и
|
||||||
|
позиции потребителей Kafka, затем создаст схему и данные заново. Службы и их
|
||||||
|
тома останутся на месте. `make clean` удаляет весь стенд вместе с именованными
|
||||||
|
томами и для обычной разработки не нужен. Перед пересборкой поставьте
|
||||||
|
`world_live` на паузу и дайте уже начатому работнику закончить день.
|
||||||
|
|
||||||
Проверки выстроены лесенкой: чем дороже прогон, тем больше связей он трогает.
|
## Стартовый мир
|
||||||
|
|
||||||
- `make config-test` — секунды, стенд поднимать не нужно. Видит только то, что
|
Стенд поднимается не пустым: `make up` через REST API запускает даг Airflow
|
||||||
есть в файлах, и о работоспособности не говорит ничего. Дёшево настолько, что
|
`world_initialize`. Тот создаёт прикладные топики и схему, собирает образ
|
||||||
можно гонять перед каждым коммитом.
|
генератора и играет в `hits` первые восемь дней модельного времени — понедельник
|
||||||
- `make lint` и `make test` — тоже секунды и тоже без стенда, но про другой
|
по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в
|
||||||
код: ruff и тесты генератора в `generator/`. Тесты сторожат контракт схемы
|
`orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только
|
||||||
события и свежесть собранного из него описания выгрузки.
|
тогда ставит позицию мира в `8`. Так у всякой лабы есть данные, и всегда одни и
|
||||||
- `make smoke` — минута-две на поднятом стенде. Дороже, но проверяет связи
|
те же.
|
||||||
между службами, а не отдельные файлы: это интеграционная проверка.
|
|
||||||
- `make smoke-cluster` — около минуты. Одна связь, зато до дна: межнодовое
|
|
||||||
устройство ClickHouse.
|
|
||||||
- `make smoke-guards` — около пяти минут, и отвечает на другой вопрос. Не
|
|
||||||
«работает ли стенд», а «умеют ли проверки падать»: она намеренно ломает стенд
|
|
||||||
и смотрит, покраснеет ли `make smoke` и назовёт ли виновника, потом чинит и
|
|
||||||
убеждается, что стенд снова зелёный. Отсюда и три прогона `make smoke`
|
|
||||||
внутри — до поломки, во время неё и после починки.
|
|
||||||
|
|
||||||
Обычный рабочий цикл — `make config-test` и `make smoke`. `make smoke-guards`
|
Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
|
||||||
нужна тому, кто правит сами проверки или пробники: без неё легко завести
|
уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика
|
||||||
проверку, которая зелена всегда.
|
выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так
|
||||||
|
виден разный темп потокового трекера и ночной выгрузки магазина.
|
||||||
|
|
||||||
Остановить контейнеры без удаления данных можно командой `make down`. Для
|
Повторный `make up` безопасен: `world_initialize` видит готовую позицию,
|
||||||
полного сброса с удалением всех именованных томов используйте `make clean`.
|
сообщает в журнале, что мир уже существует, и не применяет DDL, не собирает
|
||||||
Повторный `make up` безопасен: одноразовая подготовка приложений идемпотентна.
|
образ и не заливает данные повторно. Изменённую схему применяют адресно либо
|
||||||
|
явной командой `make rebuild-storage`.
|
||||||
|
|
||||||
|
Сам мир в git не хранится — он чистая функция зерна, и держать его в
|
||||||
|
репозитории значило бы держать там кэш. Вместо него лежит **опись мира**,
|
||||||
|
[`data/world-inventory.json`](data/world-inventory.json): зерно, версия
|
||||||
|
генератора, хеш каталога товаров и по строке на каждый день — дата, число
|
||||||
|
событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики
|
||||||
|
классов расхождений по заказам. Опись отвечает на единственный вопрос: тот ли
|
||||||
|
это мир, что был вчера.
|
||||||
|
|
||||||
|
Спрашивают её двое. `make test` в `generator/` сверяет опись с тем, что
|
||||||
|
собирается из кода сегодня: правка генератора меняет мир, и опись надо
|
||||||
|
пересобрать — `make inventory` там же. `make check-clickhouse` в корне сверяет
|
||||||
|
с описью то, что доехало до `ods.event`, подневно. Хеш дня можно пересчитать и
|
||||||
|
руками — это обычный `sha256sum` файла, который пишет файловый приёмник:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --project generator python -m clickstream_generator batch \
|
||||||
|
--day 0 --file tmp/day0.jsonl
|
||||||
|
sha256sum tmp/day0.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как позвать генератор
|
||||||
|
|
||||||
|
События производит генератор из [`generator/`](generator/). Модельный день —
|
||||||
|
функция зерна и номера дня, поэтому один и тот же день всегда даёт те же
|
||||||
|
события: обрыв лечится повтором. Позицию на оси генератор не помнит — какой
|
||||||
|
день играть, решает зовущий.
|
||||||
|
|
||||||
|
Режима два, и различаются они темпом. **Пакетный** гонит день подряд, без пауз:
|
||||||
|
так заливается мир и так переигрывается день после обрыва. **Живой** держит темп
|
||||||
|
модельного времени — по умолчанию ×60: модельные сутки за 24 реальные минуты,
|
||||||
|
суточная волна разворачивается на глазах, отставание видно в логе.
|
||||||
|
|
||||||
|
На стенде генератор ходит своим образом — разовой службой, которую поднимают
|
||||||
|
и убирают на один прогон. На каждый режим по цели:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# день D0 целиком в топик hits
|
||||||
|
make generate-batch
|
||||||
|
# первая сотня событий дня D3
|
||||||
|
make generate-batch GENERATOR_DAY=3 GENERATOR_LIMIT=100
|
||||||
|
# день D3 живьём, ускорение ×1000
|
||||||
|
make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000
|
||||||
|
```
|
||||||
|
|
||||||
|
| Переменная | Что задаёт | Умолчание |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GENERATOR_DAY` | номер дня на оси мира (D0 — первый) | `0`, задано в `Makefile` |
|
||||||
|
| `GENERATOR_LIMIT` | потолок событий на прогон; только `generate-batch` | нет: день целиком |
|
||||||
|
| `GENERATOR_SPEED` | ускорение модельного времени; только `generate-live` | ×60, задано в генераторе |
|
||||||
|
|
||||||
|
Куда отправлять, обеим целям задаёт сам стенд: внутри сети Compose это брокер
|
||||||
|
`kafka:9092` и топик `hits`.
|
||||||
|
|
||||||
|
Образ цели не пересобирают: нет образа — Compose соберёт его сам, есть —
|
||||||
|
возьмёт как есть. Пересобрать намеренно, после правки `generator/Dockerfile`
|
||||||
|
или зависимостей, — отдельной командой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile generator build generator
|
||||||
|
```
|
||||||
|
|
||||||
|
Тот же образ несёт заливка стартового мира. Перед такой заливкой
|
||||||
|
`world_initialize` сам собирает его из текущего дерева; на готовом мире
|
||||||
|
повторный `make up` до сборки не доходит.
|
||||||
|
|
||||||
|
Разведено это нарочно: собрать образ и запустить контейнер — разные действия, и
|
||||||
|
цель запуска, молча пересобирающая образ, стирает между ними границу. Что образ
|
||||||
|
устарел, видно по собственному прогону — это обратная связь, а не ловушка.
|
||||||
|
|
||||||
|
Посмотреть на события, не поднимая стенд, помогает файловый приёмник: одно
|
||||||
|
событие — одна строка. Флаг `--limit` берёт начало дня вместо целого дня —
|
||||||
|
тому, кто смотрит на конвейер, ждать полсотни тысяч событий незачем.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --project generator python -m clickstream_generator batch \
|
||||||
|
--day 0 --limit 100 --file tmp/day0.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Несколько дней подряд играет один запуск: `--days 8`. Всё, что принимается
|
||||||
|
флагами, принимается и переменными окружения — так генератор позовёт даг
|
||||||
|
этапа 5; полный список у `--help`.
|
||||||
|
|
||||||
|
**Умолчания есть не у всего, и это нарочно.** Зерно, число дней и темп живого
|
||||||
|
дня описывают модель мира — их генератор знает сам. День на оси, приёмник и имя
|
||||||
|
топика описывают стенд, на котором его запустили: не назвали — отказ и ненулевой
|
||||||
|
код возврата, ещё до первого события. Поэтому умолчание дня и живёт в
|
||||||
|
`Makefile`: день называет тот, кто запускает, а не тот, кого запускают.
|
||||||
|
|
||||||
|
### Пульт мира
|
||||||
|
|
||||||
|
Считать дни самому необязательно. В Airflow живёт **пульт мира**: два дага
|
||||||
|
создают его начальное состояние, а три двигают ось дальше.
|
||||||
|
|
||||||
|
| Даг | Что делает |
|
||||||
|
| --- | --- |
|
||||||
|
| `world_initialize` | создаёт стартовый мир, если его ещё нет; повторный запуск ничего не меняет |
|
||||||
|
| `world_recreate` | удаляет только прикладной мир и запускает его создание заново |
|
||||||
|
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
|
||||||
|
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
|
||||||
|
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём |
|
||||||
|
|
||||||
|
Работник не только играет день: следом он отправляет слепок заказов за
|
||||||
|
сыгранное и дёргает отдельный даг, `orders_ingest`, — тот забирает приехавший
|
||||||
|
слепок из топика `orders` в сырьё, — и дожидается его конца. Так две половины
|
||||||
|
стенда идут в ногу: упал приём — краснеет и работник, а позиция остаётся на
|
||||||
|
месте ([ADR 0008](docs/adr/0008-order-ingestion.md)).
|
||||||
|
|
||||||
|
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
|
||||||
|
дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон
|
||||||
|
позицию не двигает, и следующий запуск играет тот же день заново. Отсутствующая
|
||||||
|
переменная означает, что мир ещё не создавали; `-1` — очистка не закончена,
|
||||||
|
`0` — очистка закончена, а начальная генерация ещё не завершена. Работники
|
||||||
|
начинают играть только с позиции `8` и больше.
|
||||||
|
|
||||||
|
В форме запуска можно выбрать только число дней. Первый день работник берет из
|
||||||
|
`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options».
|
||||||
|
Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую
|
||||||
|
позицию работник пишет в журнал шага `remember_played`.
|
||||||
|
|
||||||
|
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
|
||||||
|
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
|
||||||
|
целиком — [ADR 0009](docs/adr/0009-world-control.md).
|
||||||
|
|
||||||
|
**Плата названа вслух: планировщику Airflow отдан сокет докера** — иначе
|
||||||
|
контейнер генератора ему не поднять. Доступ к сокету равен праву root на
|
||||||
|
машине; для локального учебного стенда размен принят, но знать о нём надо.
|
||||||
|
Открывает дверь не монтирование, а членство в группе: GID группы `docker` у
|
||||||
|
каждой машины свой, живёт в `.env` и подсматривается командой
|
||||||
|
`getent group docker`.
|
||||||
|
|
||||||
## Состав и доступ
|
## Состав и доступ
|
||||||
|
|
||||||
@@ -114,14 +284,15 @@ Superset, создаёт администратора и импортирует
|
|||||||
- `clickhouse-02` — точка подключения Superset;
|
- `clickhouse-02` — точка подключения Superset;
|
||||||
- `clickhouse-keeper` — координатор кластера;
|
- `clickhouse-keeper` — координатор кластера;
|
||||||
- `kafka` — один брокер KRaft;
|
- `kafka` — один брокер KRaft;
|
||||||
|
- `kafka-exporter` — экспортёр метрик Kafka для Prometheus;
|
||||||
- `postgres-metadata` — один Postgres с отдельными базами и пользователями
|
- `postgres-metadata` — один Postgres с отдельными базами и пользователями
|
||||||
`airflow` и `superset`;
|
`airflow` и `superset`;
|
||||||
- `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor` —
|
- `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor` —
|
||||||
Airflow 3.3 с LocalExecutor, без triggerer;
|
Airflow 3.3 с LocalExecutor, без triggerer;
|
||||||
- `superset` — интерфейс и подготовленное подключение ClickHouse;
|
- `superset` — интерфейс и подготовленное подключение ClickHouse;
|
||||||
- `prometheus` и `grafana` — сбор и просмотр встроенных метрик ClickHouse.
|
- `prometheus` и `grafana` — сбор и просмотр метрик ClickHouse и Kafka.
|
||||||
|
|
||||||
Порты доступны только с локальной машины:
|
С настройками из `.env.example` порты доступны только с локальной машины:
|
||||||
|
|
||||||
- нода 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
|
- нода 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
|
||||||
- нода 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
|
- нода 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
|
||||||
@@ -131,16 +302,48 @@ Superset, создаёт администратора и импортирует
|
|||||||
- Prometheus — `http://127.0.0.1:29090`;
|
- Prometheus — `http://127.0.0.1:29090`;
|
||||||
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
|
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
|
||||||
|
|
||||||
У локального учебного кластера нет пароля: ноды используют общего пользователя
|
В ClickHouse семь пользователей и шесть ролей:
|
||||||
`default` для запросов `Distributed`. Порты поэтому привязаны к `127.0.0.1` и
|
|
||||||
не открыты во внешнюю сеть.
|
|
||||||
|
|
||||||
Пароли интерфейсов, пароли Postgres, ключи Airflow и Superset, отсутствие
|
- `etl` подключает рабочие даги Airflow; роль `etl_writer` читает и пишет слои
|
||||||
пароля ClickHouse и отсутствие проверки доступа у Kafka и Prometheus —
|
хранилища;
|
||||||
намеренно простые и явно ненастоящие настройки локального учебного стенда. Это
|
- `lifecycle` применяет канонический DDL и пересоздаёт прикладные базы; роль
|
||||||
не пример настройки защиты: не копируйте значения из `.env.example` в рабочую
|
`lifecycle_owner` не даёт эти административные права рабочим дагам;
|
||||||
среду. Все опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу
|
- `bi` подключает Superset; роль `bi_reader` читает ODS, DDS, DM и справочники
|
||||||
не опубликован.
|
`dic`, а запросы Superset обращаются к публичным `_v`;
|
||||||
|
- `analyst` предназначен для подключения человека и читает все слои и
|
||||||
|
справочники;
|
||||||
|
- `grafana` с ролью `monitoring_reader` подключает Grafana и читает все слои,
|
||||||
|
системные таблицы и данные со всех реплик кластера;
|
||||||
|
- `dict` читает только базу `dic` и пароля не имеет: им словарь товаров ходит
|
||||||
|
за своей подложкой;
|
||||||
|
- `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри
|
||||||
|
контейнеров, но не приложения.
|
||||||
|
|
||||||
|
Почему пользователи объявлены файлом, а ноды доверяют общему секрету, — в
|
||||||
|
[ADR 0007](docs/adr/0007-clickhouse-access.md).
|
||||||
|
Пользователи и роли объявлены в `infra/clickhouse/users.d/access.xml`. Пароли и
|
||||||
|
общий секрет нод живут в `.env` и передаются в конфигурацию через окружение;
|
||||||
|
Superset получает пароль `bi`, а Grafana — пароль `grafana` тем же путём.
|
||||||
|
Значения для локального стенда есть в `.env.example`.
|
||||||
|
|
||||||
|
Изменения значений ClickHouse в `.env` и файлов настройки серверов в
|
||||||
|
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/` применяются
|
||||||
|
пересозданием нод:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --force-recreate --wait clickhouse-01 clickhouse-02
|
||||||
|
```
|
||||||
|
|
||||||
|
Команда возвращает управление, когда обе ноды снова здоровы; именованные тома
|
||||||
|
при этом сохраняются. `docker compose restart` оставит прежнее окружение и
|
||||||
|
может оставить старую версию отдельно смонтированного файла.
|
||||||
|
|
||||||
|
Пароли ClickHouse и интерфейсов, пароли Postgres, ключи Airflow и Superset,
|
||||||
|
отсутствие проверки доступа у Kafka и Prometheus — намеренно простые и явно
|
||||||
|
ненастоящие настройки локального учебного стенда. Это не пример настройки
|
||||||
|
защиты: не копируйте значения из `.env.example` в рабочую среду. Все
|
||||||
|
опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не
|
||||||
|
опубликован.
|
||||||
|
|
||||||
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
|
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
|
||||||
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
|
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
|
||||||
@@ -162,24 +365,28 @@ Superset, создаёт администратора и импортирует
|
|||||||
серверы и keeper используют один образ. Настройки Kafka 4.3.1 сверены с
|
серверы и keeper используют один образ. Настройки Kafka 4.3.1 сверены с
|
||||||
[примером односерверного KRaft](https://github.com/apache/kafka/blob/4.3.1/docker/examples/docker-compose-files/single-node/plaintext/docker-compose.yml).
|
[примером односерверного KRaft](https://github.com/apache/kafka/blob/4.3.1/docker/examples/docker-compose-files/single-node/plaintext/docker-compose.yml).
|
||||||
Секция метрик взята из конфигурации закреплённого образа ClickHouse и проверена
|
Секция метрик взята из конфигурации закреплённого образа ClickHouse и проверена
|
||||||
на серверах и keeper. Подготовка источника Grafana сверена с
|
на серверах и keeper. Подготовка источников Grafana сверена с
|
||||||
[официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/).
|
[официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/)
|
||||||
Prometheus собирает только встроенные метрики двух серверов и keeper; внешних
|
и [документацией плагина ClickHouse](https://grafana.com/docs/plugins/grafana-clickhouse-datasource/latest/configure/).
|
||||||
сборщиков, панелей и правил оповещения пока нет.
|
Prometheus собирает встроенные метрики двух серверов и keeper, а через
|
||||||
После изменения `infra/clickhouse/config.d/prometheus.xml` выполните
|
`kafka-exporter` — состояние Kafka. Поверх этого в Grafana поднимается дашборд
|
||||||
`docker compose restart clickhouse-01 clickhouse-02`: обычный `make up` не
|
«Данные»: свежесть, поток, брак, сходимость Kafka с ClickHouse, отставание
|
||||||
перезапускает уже созданные серверы и они продолжают работать со старой
|
чтения и дневной слепок заказов. Устройство зоны — в [справочнике
|
||||||
конфигурацией.
|
мониторинга](docs/architecture/monitoring/README.md), разбор панелей — в
|
||||||
|
[описании дашборда](docs/architecture/monitoring/data.md); правил оповещения
|
||||||
|
пока нет.
|
||||||
|
|
||||||
Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor,
|
Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor,
|
||||||
публичный `airflow.sdk`, API здоровья и SimpleAuthManager сверены с
|
публичный `airflow.sdk`, API здоровья и SimpleAuthManager сверены с
|
||||||
[архитектурой Airflow 3.3](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/overview.html),
|
[архитектурой Airflow 3.3](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/overview.html),
|
||||||
[публичным интерфейсом](https://airflow.apache.org/docs/apache-airflow/3.3.0/public-airflow-interface.html)
|
[публичным интерфейсом](https://airflow.apache.org/docs/apache-airflow/3.3.0/public-airflow-interface.html)
|
||||||
и [описанием здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html).
|
и [описанием здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html).
|
||||||
Для пробников проверены публичный `Connection.get` из `airflow.sdk` и клиент
|
Для ClickHouse установлен официальный провайдер Airflow: SQL-файлы исполняет
|
||||||
|
общий `SQLExecuteQueryOperator`, программные запросы идут через
|
||||||
|
`ClickHouseHook`. Провайдер работает поверх закреплённого
|
||||||
`clickhouse-connect`. Официальный провайдер Kafka сам использует
|
`clickhouse-connect`. Официальный провайдер Kafka сам использует
|
||||||
`confluent-kafka`; отдельное подключение и обёртки провайдера здесь не нужны,
|
`confluent-kafka`; отдельное подключение и его обёртки здесь не нужны, поэтому
|
||||||
поэтому прямой клиент оставляет образ и пример короче.
|
клиент Kafka добавлен в образ напрямую.
|
||||||
Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, форма
|
Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, форма
|
||||||
`clickhousedb://` и драйвер Postgres сверены с
|
`clickhousedb://` и драйвер Postgres сверены с
|
||||||
[документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/)
|
[документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/)
|
||||||
@@ -204,9 +411,16 @@ Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, ф
|
|||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
- [docs/specs/](docs/specs/) — спеки: источник истины о задуманном.
|
- [docs/specs/](docs/specs/) — спеки: источник истины о задуманном.
|
||||||
|
- [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми
|
||||||
|
вариантами: почему сделано так, а не иначе.
|
||||||
|
- [docs/architecture/](docs/architecture/) — рабочие справочники по зонам
|
||||||
|
ответственности: [хранилище](docs/architecture/storage.md) — конвенции имён,
|
||||||
|
раскладка по шардам, приём событий, карта таблиц;
|
||||||
|
[проверки](docs/architecture/testing.md) — что утверждает каждая цель `make`
|
||||||
|
и куда класть новую проверку.
|
||||||
- [docs/research/](docs/research/) — исследования; сейчас это формат
|
- [docs/research/](docs/research/) — исследования; сейчас это формат
|
||||||
кликстрима Яндекса, по которому строится модель события.
|
кликстрима Яндекса, по которому строится модель события.
|
||||||
- [docs/formats/](docs/formats/) — описания форматов источников: по ним
|
- [docs/formats/](docs/formats/) — описания форматов источников: по ним
|
||||||
пишется сторона хранилища. Собираются из кода командой `make docs`, руками
|
пишется сторона хранилища. Собираются из кода командой `make docs` в
|
||||||
не правятся.
|
`generator/`, руками не правятся.
|
||||||
- [AGENTS.md](AGENTS.md) — контракт работы в репозитории.
|
- [AGENTS.md](AGENTS.md) — контракт работы в репозитории.
|
||||||
+154
-49
@@ -1,4 +1,4 @@
|
|||||||
name: ${COMPOSE_PROJECT_NAME:-clickstream-data-platform}
|
name: ${COMPOSE_PROJECT_NAME:?Скопируйте .env.example в .env}
|
||||||
|
|
||||||
x-clickhouse-nofile: &clickhouse-nofile
|
x-clickhouse-nofile: &clickhouse-nofile
|
||||||
nofile:
|
nofile:
|
||||||
@@ -6,11 +6,21 @@ x-clickhouse-nofile: &clickhouse-nofile
|
|||||||
hard: 262144
|
hard: 262144
|
||||||
|
|
||||||
x-clickhouse-common: &clickhouse-common
|
x-clickhouse-common: &clickhouse-common
|
||||||
image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
|
image: clickhouse/clickhouse-server:26.3.17.56
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Стенд локальный: нодам нужен общий пользователь без пароля для Distributed.
|
# Пользователей объявляет access.xml; настройка учётки образом не нужна.
|
||||||
CLICKHOUSE_SKIP_USER_SETUP: "1"
|
CLICKHOUSE_SKIP_USER_SETUP: "1"
|
||||||
|
# Каталог товаров смонтирован в user_files только для чтения. Entrypoint
|
||||||
|
# иначе пытается сделать ему chown и первый запуск ноды завершается ошибкой.
|
||||||
|
CLICKHOUSE_DO_NOT_CHOWN: "1"
|
||||||
|
CLICKHOUSE_CLUSTER_SECRET: ${CLICKHOUSE_CLUSTER_SECRET:?Скопируйте .env.example в .env}
|
||||||
|
CLICKHOUSE_DEFAULT_PASSWORD: ${CLICKHOUSE_DEFAULT_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
|
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
|
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
|
CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
|
CLICKHOUSE_ANALYST_PASSWORD: ${CLICKHOUSE_ANALYST_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
|
CLICKHOUSE_GRAFANA_PASSWORD: ${CLICKHOUSE_GRAFANA_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
depends_on:
|
depends_on:
|
||||||
clickhouse-keeper:
|
clickhouse-keeper:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -18,52 +28,90 @@ x-clickhouse-common: &clickhouse-common
|
|||||||
# Гигабайта не хватало: половину съедали страницы самого бинарника (ADR 0004).
|
# Гигабайта не хватало: половину съедали страницы самого бинарника (ADR 0004).
|
||||||
mem_limit: 4g
|
mem_limit: 4g
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "clickhouse-client --host 127.0.0.1 --query 'SELECT 1' >/dev/null 2>&1"]
|
test: ["CMD-SHELL", "clickhouse-client --host 127.0.0.1 --user default --password \"$$CLICKHOUSE_DEFAULT_PASSWORD\" --query 'SELECT 1' >/dev/null 2>&1"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 30
|
retries: 30
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
# Факты генератора и пульта мира названы по одному разу — иначе они однажды
|
||||||
|
# разойдутся, и заметит это не проверка, а менти с пустым топиком.
|
||||||
|
x-generator-image: &generator-image clickstream-generator:local
|
||||||
|
x-orders-topic: &orders-topic orders
|
||||||
|
|
||||||
|
x-kafka-common: &kafka-common
|
||||||
|
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
|
||||||
|
|
||||||
|
x-kafka-target: &kafka-target
|
||||||
|
<<: *kafka-common
|
||||||
|
KAFKA_TOPIC: hits
|
||||||
|
|
||||||
|
x-world-starting-days: &world-starting-days "8"
|
||||||
|
|
||||||
|
# Общие образы Airflow и Superset собирают службы инициализации. Причина
|
||||||
|
# отдельного build: ADR 0001.
|
||||||
x-airflow-common: &airflow-common
|
x-airflow-common: &airflow-common
|
||||||
image: clickstream-airflow:local
|
image: clickstream-airflow:local
|
||||||
build:
|
|
||||||
context: ./infra/airflow
|
|
||||||
args:
|
|
||||||
AIRFLOW_BASE_IMAGE: ${AIRFLOW_IMAGE:-apache/airflow:3.3.0}
|
|
||||||
environment:
|
environment:
|
||||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||||
AIRFLOW__CORE__PARALLELISM: "4"
|
AIRFLOW__CORE__PARALLELISM: "4"
|
||||||
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||||
AIRFLOW__CORE__AUTH_MANAGER: airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager
|
AIRFLOW__CORE__AUTH_MANAGER: airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager
|
||||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:-admin}:admin"
|
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}:admin"
|
||||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE: /opt/airflow/auth/simple_auth_manager_passwords.json
|
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE: /opt/airflow/auth/simple_auth_manager_passwords.json
|
||||||
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/
|
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/
|
||||||
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=}
|
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:?Скопируйте .env.example в .env}
|
||||||
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:-local-only-airflow-jwt-secret-do-not-use}
|
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:?Скопируйте .env.example в .env}
|
||||||
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||||
AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:-local-only-airflow-api-secret-do-not-use}
|
AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:?Скопируйте .env.example в .env}
|
||||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: "postgresql+psycopg2://${AIRFLOW_METADATA_USER:-airflow}:${AIRFLOW_METADATA_PASSWORD:-airflow-local}@postgres-metadata:5432/airflow"
|
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: "postgresql+psycopg2://${AIRFLOW_METADATA_USER:?Скопируйте .env.example в .env}:${AIRFLOW_METADATA_PASSWORD:?Скопируйте .env.example в .env}@postgres-metadata:5432/airflow"
|
||||||
AIRFLOW__SCHEDULER__ENABLE_HEALTH_CHECK: "true"
|
AIRFLOW__SCHEDULER__ENABLE_HEALTH_CHECK: "true"
|
||||||
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:-admin}
|
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}
|
||||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-airflow}
|
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
|
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
|
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
|
# Пульт мира поднимает генератор отдельным контейнером. Имя сети собирается
|
||||||
|
# из имени проекта: у второй копии стенда оно другое.
|
||||||
|
<<: *kafka-target
|
||||||
|
GENERATOR_IMAGE: *generator-image
|
||||||
|
STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default
|
||||||
|
WORLD_STARTING_DAYS: *world-starting-days
|
||||||
|
KAFKA_ORDERS_TOPIC: *orders-topic
|
||||||
volumes:
|
volumes:
|
||||||
- ./dags:/opt/airflow/dags:ro
|
- ./dags:/opt/airflow/dags:ro
|
||||||
|
# Запросы дагов: даг называет файл, а обработчик DAG при разборе загружает
|
||||||
|
# его текст. Каталог тот же, что применяет DDL, — вся SQL стенда живёт в sql/.
|
||||||
|
- ./sql:/opt/airflow/sql:ro
|
||||||
|
# Инициализация мира собирает канонический образ из того же контекста, что
|
||||||
|
# ручной generator. Docker SDK архивирует эти файлы внутри задачи.
|
||||||
|
- ./generator:/opt/airflow/world/generator:ro
|
||||||
|
- ./data:/opt/airflow/world/data:ro
|
||||||
|
- ./.dockerignore:/opt/airflow/world/.dockerignore:ro
|
||||||
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
|
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
|
||||||
- airflow_logs:/opt/airflow/logs
|
- airflow_logs:/opt/airflow/logs
|
||||||
- airflow_auth:/opt/airflow/auth
|
- airflow_auth:/opt/airflow/auth
|
||||||
|
|
||||||
|
x-generator-common: &generator-common
|
||||||
|
image: *generator-image
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: generator/Dockerfile
|
||||||
|
restart: "no"
|
||||||
|
environment:
|
||||||
|
# Адрес брокера и имя топика — факты стенда, и называет их стенд.
|
||||||
|
# Остальное (день, зерно, число дней, предел пачки) приходит аргументами
|
||||||
|
# от того, кто запускает: у службы нет позиции на оси мира.
|
||||||
|
<<: *kafka-target
|
||||||
|
|
||||||
x-superset-common: &superset-common
|
x-superset-common: &superset-common
|
||||||
image: clickstream-superset:local
|
image: clickstream-superset:local
|
||||||
build:
|
|
||||||
context: ./infra/superset
|
|
||||||
args:
|
|
||||||
SUPERSET_BASE_IMAGE: ${SUPERSET_IMAGE:-apache/superset:6.1.0}
|
|
||||||
environment:
|
environment:
|
||||||
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
|
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
|
||||||
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-local-only-superset-secret-do-not-use-outside-this-stand}
|
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env}
|
||||||
SUPERSET__SQLALCHEMY_DATABASE_URI: "postgresql+psycopg2://${SUPERSET_METADATA_USER:-superset}:${SUPERSET_METADATA_PASSWORD:-superset-local}@postgres-metadata:5432/superset"
|
SUPERSET__SQLALCHEMY_DATABASE_URI: "postgresql+psycopg2://${SUPERSET_METADATA_USER:?Скопируйте .env.example в .env}:${SUPERSET_METADATA_PASSWORD:?Скопируйте .env.example в .env}@postgres-metadata:5432/superset"
|
||||||
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:-admin}
|
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:?Скопируйте .env.example в .env}
|
||||||
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD:-superset}
|
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
|
CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./infra/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
- ./infra/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
||||||
- ./infra/superset/init.sh:/app/infra/init.sh:ro
|
- ./infra/superset/init.sh:/app/infra/init.sh:ro
|
||||||
@@ -72,7 +120,7 @@ x-superset-common: &superset-common
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
clickhouse-keeper:
|
clickhouse-keeper:
|
||||||
image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
|
image: clickhouse/clickhouse-server:26.3.17.56
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: clickhouse
|
user: clickhouse
|
||||||
ulimits: *clickhouse-nofile
|
ulimits: *clickhouse-nofile
|
||||||
@@ -91,42 +139,48 @@ services:
|
|||||||
retries: 30
|
retries: 30
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
# Инициатор DDL и будущее подключение Airflow.
|
# Инициатор DDL и точка подключения Airflow.
|
||||||
clickhouse-01:
|
clickhouse-01:
|
||||||
<<: *clickhouse-common
|
<<: *clickhouse-common
|
||||||
|
hostname: clickhouse-01
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${CLICKHOUSE_01_HTTP_PORT:-28123}:8123"
|
- "127.0.0.1:${CLICKHOUSE_01_HTTP_PORT:?Скопируйте .env.example в .env}:8123"
|
||||||
- "127.0.0.1:${CLICKHOUSE_01_TCP_PORT:-29000}:9000"
|
- "127.0.0.1:${CLICKHOUSE_01_TCP_PORT:?Скопируйте .env.example в .env}:9000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
|
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
|
||||||
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
|
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
|
||||||
- ./infra/clickhouse/config.d/macros-01.xml:/etc/clickhouse-server/config.d/macros.xml:ro
|
- ./infra/clickhouse/config.d/macros-01.xml:/etc/clickhouse-server/config.d/macros.xml:ro
|
||||||
|
- ./infra/clickhouse/users.d/access.xml:/etc/clickhouse-server/users.d/access.xml:ro
|
||||||
|
- ./data/catalog:/var/lib/clickhouse/user_files/catalog:ro
|
||||||
- clickhouse_01_data:/var/lib/clickhouse
|
- clickhouse_01_data:/var/lib/clickhouse
|
||||||
|
|
||||||
# Будущее подключение Superset.
|
# Точка подключения Superset.
|
||||||
clickhouse-02:
|
clickhouse-02:
|
||||||
<<: *clickhouse-common
|
<<: *clickhouse-common
|
||||||
|
hostname: clickhouse-02
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${CLICKHOUSE_02_HTTP_PORT:-28124}:8123"
|
- "127.0.0.1:${CLICKHOUSE_02_HTTP_PORT:?Скопируйте .env.example в .env}:8123"
|
||||||
- "127.0.0.1:${CLICKHOUSE_02_TCP_PORT:-29001}:9000"
|
- "127.0.0.1:${CLICKHOUSE_02_TCP_PORT:?Скопируйте .env.example в .env}:9000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
|
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
|
||||||
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
|
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
|
||||||
- ./infra/clickhouse/config.d/macros-02.xml:/etc/clickhouse-server/config.d/macros.xml:ro
|
- ./infra/clickhouse/config.d/macros-02.xml:/etc/clickhouse-server/config.d/macros.xml:ro
|
||||||
|
- ./infra/clickhouse/users.d/access.xml:/etc/clickhouse-server/users.d/access.xml:ro
|
||||||
|
- ./data/catalog:/var/lib/clickhouse/user_files/catalog:ro
|
||||||
- clickhouse_02_data:/var/lib/clickhouse
|
- clickhouse_02_data:/var/lib/clickhouse
|
||||||
|
|
||||||
kafka:
|
kafka:
|
||||||
image: ${KAFKA_IMAGE:-apache/kafka:4.3.1}
|
image: apache/kafka:4.3.1
|
||||||
hostname: kafka
|
hostname: kafka
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${KAFKA_EXTERNAL_PORT:-29092}:29092"
|
- "127.0.0.1:${KAFKA_EXTERNAL_PORT:?Скопируйте .env.example в .env}:29092"
|
||||||
environment:
|
environment:
|
||||||
KAFKA_NODE_ID: "1"
|
KAFKA_NODE_ID: "1"
|
||||||
KAFKA_PROCESS_ROLES: "broker,controller"
|
KAFKA_PROCESS_ROLES: "broker,controller"
|
||||||
KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093"
|
KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093"
|
||||||
KAFKA_LISTENERS: "INTERNAL://:9092,CONTROLLER://:9093,EXTERNAL://:29092"
|
KAFKA_LISTENERS: "INTERNAL://:9092,CONTROLLER://:9093,EXTERNAL://:29092"
|
||||||
KAFKA_ADVERTISED_LISTENERS: "INTERNAL://kafka:9092,EXTERNAL://localhost:${KAFKA_EXTERNAL_PORT:-29092}"
|
KAFKA_ADVERTISED_LISTENERS: "INTERNAL://kafka:9092,EXTERNAL://localhost:${KAFKA_EXTERNAL_PORT:?Скопируйте .env.example в .env}"
|
||||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT"
|
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT"
|
||||||
KAFKA_INTER_BROKER_LISTENER_NAME: "INTERNAL"
|
KAFKA_INTER_BROKER_LISTENER_NAME: "INTERNAL"
|
||||||
KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
|
KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
|
||||||
@@ -150,8 +204,29 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|
||||||
|
kafka-exporter:
|
||||||
|
image: danielqsj/kafka-exporter:v1.9.0
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
kafka:
|
||||||
|
condition: service_healthy
|
||||||
|
command: ["--kafka.server=kafka:9092"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9308/metrics >/dev/null 2>&1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 15
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`.
|
||||||
|
# Под профилем — чтобы обычный подъём стенда её не трогал.
|
||||||
|
generator:
|
||||||
|
<<: *generator-common
|
||||||
|
profiles: ["generator"]
|
||||||
|
command: ["batch"]
|
||||||
|
|
||||||
postgres-metadata:
|
postgres-metadata:
|
||||||
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command:
|
command:
|
||||||
- postgres
|
- postgres
|
||||||
@@ -160,13 +235,13 @@ services:
|
|||||||
- -c
|
- -c
|
||||||
- max_connections=50
|
- max_connections=50
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_ADMIN_USER:-postgres}
|
POSTGRES_USER: ${POSTGRES_ADMIN_USER:?Скопируйте .env.example в .env}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:-postgres-local}
|
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
POSTGRES_DB: postgres
|
POSTGRES_DB: postgres
|
||||||
AIRFLOW_METADATA_USER: ${AIRFLOW_METADATA_USER:-airflow}
|
AIRFLOW_METADATA_USER: ${AIRFLOW_METADATA_USER:?Скопируйте .env.example в .env}
|
||||||
AIRFLOW_METADATA_PASSWORD: ${AIRFLOW_METADATA_PASSWORD:-airflow-local}
|
AIRFLOW_METADATA_PASSWORD: ${AIRFLOW_METADATA_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
SUPERSET_METADATA_USER: ${SUPERSET_METADATA_USER:-superset}
|
SUPERSET_METADATA_USER: ${SUPERSET_METADATA_USER:?Скопируйте .env.example в .env}
|
||||||
SUPERSET_METADATA_PASSWORD: ${SUPERSET_METADATA_PASSWORD:-superset-local}
|
SUPERSET_METADATA_PASSWORD: ${SUPERSET_METADATA_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./infra/postgres/init/10-metadata-databases.sh:/docker-entrypoint-initdb.d/10-metadata-databases.sh:ro
|
- ./infra/postgres/init/10-metadata-databases.sh:/docker-entrypoint-initdb.d/10-metadata-databases.sh:ro
|
||||||
- postgres_metadata_data:/var/lib/postgresql/data
|
- postgres_metadata_data:/var/lib/postgresql/data
|
||||||
@@ -180,6 +255,8 @@ services:
|
|||||||
|
|
||||||
airflow-init:
|
airflow-init:
|
||||||
<<: *airflow-common
|
<<: *airflow-common
|
||||||
|
build:
|
||||||
|
context: ./infra/airflow
|
||||||
user: "0:0"
|
user: "0:0"
|
||||||
restart: "no"
|
restart: "no"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -198,7 +275,7 @@ services:
|
|||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
command: api-server
|
command: api-server
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${AIRFLOW_PORT:-28080}:8080"
|
- "127.0.0.1:${AIRFLOW_PORT:?Скопируйте .env.example в .env}:8080"
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/api/v2/monitor/health | python -c 'import json,sys; sys.exit(0 if json.load(sys.stdin)[\"metadatabase\"][\"status\"] == \"healthy\" else 1)'"]
|
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/api/v2/monitor/health | python -c 'import json,sys; sys.exit(0 if json.load(sys.stdin)[\"metadatabase\"][\"status\"] == \"healthy\" else 1)'"]
|
||||||
@@ -214,7 +291,29 @@ services:
|
|||||||
airflow-init:
|
airflow-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
command: scheduler
|
command: scheduler
|
||||||
mem_limit: 640m
|
# Пульт мира зовёт генератор отдельным контейнером, а при 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
|
||||||
|
- ./sql:/opt/airflow/sql:ro
|
||||||
|
- ./generator:/opt/airflow/world/generator:ro
|
||||||
|
- ./data:/opt/airflow/world/data:ro
|
||||||
|
- ./.dockerignore:/opt/airflow/world/.dockerignore:ro
|
||||||
|
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
|
||||||
|
- airflow_logs:/opt/airflow/logs
|
||||||
|
- airflow_auth:/opt/airflow/auth
|
||||||
|
# LocalExecutor запускает здесь до четырех процессов задач одновременно.
|
||||||
|
# 640 МиБ дали OOM, а 1 ГиБ - давление на memory.max при штатном dq_check;
|
||||||
|
# 1,5 ГиБ оставляет запас при AIRFLOW__CORE__PARALLELISM=4.
|
||||||
|
mem_limit: 1536m
|
||||||
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"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -241,6 +340,8 @@ services:
|
|||||||
|
|
||||||
superset-init:
|
superset-init:
|
||||||
<<: *superset-common
|
<<: *superset-common
|
||||||
|
build:
|
||||||
|
context: ./infra/superset
|
||||||
restart: "no"
|
restart: "no"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: true
|
disable: true
|
||||||
@@ -259,7 +360,7 @@ services:
|
|||||||
superset-init:
|
superset-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${SUPERSET_PORT:-28088}:8088"
|
- "127.0.0.1:${SUPERSET_PORT:?Скопируйте .env.example в .env}:8088"
|
||||||
mem_limit: 1g
|
mem_limit: 1g
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8088/health >/dev/null"]
|
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8088/health >/dev/null"]
|
||||||
@@ -269,7 +370,7 @@ services:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|
||||||
prometheus:
|
prometheus:
|
||||||
image: ${PROMETHEUS_IMAGE:-prom/prometheus:v3.13.2}
|
image: prom/prometheus:v3.13.2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
clickhouse-keeper:
|
clickhouse-keeper:
|
||||||
@@ -287,7 +388,7 @@ services:
|
|||||||
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
- prometheus_data:/prometheus
|
- prometheus_data:/prometheus
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${PROMETHEUS_PORT:-29090}:9090"
|
- "127.0.0.1:${PROMETHEUS_PORT:?Скопируйте .env.example в .env}:9090"
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9090/-/ready >/dev/null 2>&1"]
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9090/-/ready >/dev/null 2>&1"]
|
||||||
@@ -297,23 +398,27 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
grafana:
|
grafana:
|
||||||
image: ${GRAFANA_IMAGE:-grafana/grafana:13.1.1}
|
image: clickstream-grafana:local
|
||||||
|
build:
|
||||||
|
context: ./infra/grafana
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
prometheus:
|
prometheus:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:?Скопируйте .env.example в .env}
|
||||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
GF_NEWS_NEWS_FEED_ENABLED: "false"
|
GF_NEWS_NEWS_FEED_ENABLED: "false"
|
||||||
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||||
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
|
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
|
||||||
|
CLICKHOUSE_GRAFANA_PASSWORD: ${CLICKHOUSE_GRAFANA_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./infra/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
|
- ./infra/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
|
||||||
|
- ./infra/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
|
||||||
- grafana_data:/var/lib/grafana
|
- grafana_data:/var/lib/grafana
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${GRAFANA_PORT:-23000}:3000"
|
- "127.0.0.1:${GRAFANA_PORT:?Скопируйте .env.example в .env}:3000"
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:3000/api/health >/dev/null"]
|
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:3000/api/health >/dev/null"]
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
"""Трансформации слоя DDS: сборка моделей.
|
||||||
|
|
||||||
|
Заказ и сессия собираются заменой дневных партиций. Карта идентичностей
|
||||||
|
пересчитывается целиком после заказа, потому что читает его текущую модель.
|
||||||
|
|
||||||
|
Расписания у дага нет и параметров тоже: объём каждого прогона он выводит сам
|
||||||
|
из состояния слоёв и позиции мира, поэтому один и тот же запуск одинаково
|
||||||
|
верен из триггера дневного конвейера, с кнопки менти и после падения
|
||||||
|
(docs/architecture/etl.md, «Объём прогона»).
|
||||||
|
|
||||||
|
Заказ и сессия гасят повтор заменой партиции целиком: день собирается в
|
||||||
|
таблицу-двойник и подменяет собой партицию цели. Карта пишет полный набор пар
|
||||||
|
новой версией, а точное чтение даёт FINAL. Замена партиции разобрана в
|
||||||
|
sql/dds/order_replace.sql; точное чтение карты описано в
|
||||||
|
sql/ddl/50-dds-views.sql.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from airflow.exceptions import AirflowException
|
||||||
|
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
|
||||||
|
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
|
||||||
|
from airflow.sdk import Variable, dag, task, task_group
|
||||||
|
|
||||||
|
# Пропуск приезжает из Task SDK: в airflow.exceptions он объявлен устаревшим
|
||||||
|
# и говорит об этом в журнал задачи (проверено на 3.3.0).
|
||||||
|
from airflow.sdk.exceptions import AirflowSkipException
|
||||||
|
|
||||||
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
|
|
||||||
|
SQL_ROOT = Path("/opt/airflow/sql")
|
||||||
|
CLICKHOUSE_CONNECTION = "clickhouse_default"
|
||||||
|
CLICKHOUSE_CLUSTER = "clickstream_cluster"
|
||||||
|
|
||||||
|
KAFKA_BOOTSTRAP_SERVERS = os.environ["KAFKA_BOOTSTRAP_SERVERS"]
|
||||||
|
HITS_TOPIC = os.environ["KAFKA_TOPIC"]
|
||||||
|
HITS_GROUP = "clickstream_hits"
|
||||||
|
HITS_PARTITIONS = (0, 1)
|
||||||
|
|
||||||
|
READINESS_TIMEOUT_SECONDS = 300
|
||||||
|
READINESS_POLL_SECONDS = 3
|
||||||
|
|
||||||
|
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит
|
||||||
|
# пульт мира, и только она отличает прожитый день от живого — зачем это
|
||||||
|
# сессиям, разобрано в sql/dds/session_scope.sql.
|
||||||
|
WORLD_POSITION = "world_position"
|
||||||
|
|
||||||
|
|
||||||
|
def _kafka_readiness() -> tuple[bool, str]:
|
||||||
|
"""Проверить, дочитала ли группа оба раздела событий."""
|
||||||
|
from confluent_kafka import ConsumerGroupTopicPartitions, TopicPartition
|
||||||
|
from confluent_kafka.admin import AdminClient, OffsetSpec
|
||||||
|
|
||||||
|
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
|
||||||
|
groups = admin.list_consumer_groups(request_timeout=10).result()
|
||||||
|
if groups.errors:
|
||||||
|
raise RuntimeError(f"Kafka не вернула список групп: {groups.errors}")
|
||||||
|
|
||||||
|
group_exists = any(group.group_id == HITS_GROUP for group in groups.valid)
|
||||||
|
committed: dict[int, int] = {}
|
||||||
|
if group_exists:
|
||||||
|
request = ConsumerGroupTopicPartitions(
|
||||||
|
HITS_GROUP,
|
||||||
|
[TopicPartition(HITS_TOPIC, partition) for partition in HITS_PARTITIONS],
|
||||||
|
)
|
||||||
|
result = admin.list_consumer_group_offsets([request], request_timeout=10)[
|
||||||
|
HITS_GROUP
|
||||||
|
].result()
|
||||||
|
for topic_partition in result.topic_partitions:
|
||||||
|
if topic_partition.error is None and topic_partition.offset >= 0:
|
||||||
|
committed[topic_partition.partition] = topic_partition.offset
|
||||||
|
|
||||||
|
high_watermarks: dict[int, int] = {}
|
||||||
|
watermark_errors: dict[int, str] = {}
|
||||||
|
# OffsetSpec.latest возвращает следующее смещение после хвоста; сверено
|
||||||
|
# через Context7 и на установленном confluent-kafka 2.15.0.
|
||||||
|
latest = admin.list_offsets(
|
||||||
|
{
|
||||||
|
TopicPartition(HITS_TOPIC, partition): OffsetSpec.latest()
|
||||||
|
for partition in HITS_PARTITIONS
|
||||||
|
},
|
||||||
|
request_timeout=10,
|
||||||
|
)
|
||||||
|
for topic_partition, future in latest.items():
|
||||||
|
try:
|
||||||
|
offset = future.result().offset
|
||||||
|
if offset >= 0:
|
||||||
|
high_watermarks[topic_partition.partition] = offset
|
||||||
|
except Exception as error:
|
||||||
|
watermark_errors[topic_partition.partition] = str(error)
|
||||||
|
|
||||||
|
all_known = group_exists
|
||||||
|
total_lag = 0
|
||||||
|
parts: list[str] = []
|
||||||
|
for partition in HITS_PARTITIONS:
|
||||||
|
offset = committed.get(partition)
|
||||||
|
high = high_watermarks.get(partition)
|
||||||
|
lag = high - offset if offset is not None and high is not None else None
|
||||||
|
if lag is None or lag < 0:
|
||||||
|
all_known = False
|
||||||
|
else:
|
||||||
|
total_lag += lag
|
||||||
|
logging.info(
|
||||||
|
"Kafka %s[%s]: подтверждено=%s, верхнее смещение=%s, отставание=%s",
|
||||||
|
HITS_TOPIC,
|
||||||
|
partition,
|
||||||
|
offset,
|
||||||
|
high,
|
||||||
|
lag,
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f"{HITS_TOPIC}[{partition}]: подтверждено={offset}, "
|
||||||
|
f"верхнее смещение={high}, отставание={lag}"
|
||||||
|
)
|
||||||
|
if partition in watermark_errors:
|
||||||
|
parts[-1] += (
|
||||||
|
f", ошибка чтения верхнего смещения={watermark_errors[partition]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not group_exists:
|
||||||
|
logging.info("группа потребителей %s отсутствует", HITS_GROUP)
|
||||||
|
lag_text = str(total_lag) if all_known else "неизвестно"
|
||||||
|
logging.info("суммарное отставание Kafka: %s", lag_text)
|
||||||
|
detail = "; ".join(parts)
|
||||||
|
if not group_exists:
|
||||||
|
detail = f"группа {HITS_GROUP} отсутствует; {detail}"
|
||||||
|
return all_known and total_lag == 0, detail
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery_queue_readiness() -> tuple[bool, str]:
|
||||||
|
"""Проверить, пуста ли очередь доставки событий на каждой ноде."""
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
node,
|
||||||
|
sum(queued_files) AS files,
|
||||||
|
arrayStringConcat(
|
||||||
|
groupArrayIf(
|
||||||
|
last_exception,
|
||||||
|
queued_files > 0 AND notEmpty(last_exception)
|
||||||
|
),
|
||||||
|
'; '
|
||||||
|
) AS last_exception
|
||||||
|
FROM
|
||||||
|
(
|
||||||
|
-- У пустой очереди нет строк; system.one оставляет обе ноды
|
||||||
|
-- видимыми и в зеленом опросе.
|
||||||
|
SELECT
|
||||||
|
hostName() AS node,
|
||||||
|
toUInt64(0) AS queued_files,
|
||||||
|
'' AS last_exception
|
||||||
|
FROM clusterAllReplicas('{CLICKHOUSE_CLUSTER}', system.one)
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
hostName(),
|
||||||
|
data_files + broken_data_files AS queued_files,
|
||||||
|
last_exception
|
||||||
|
FROM clusterAllReplicas(
|
||||||
|
'{CLICKHOUSE_CLUSTER}', system.distribution_queue
|
||||||
|
)
|
||||||
|
WHERE database = 'ods' AND table = 'event_dist'
|
||||||
|
)
|
||||||
|
GROUP BY node
|
||||||
|
ORDER BY node
|
||||||
|
"""
|
||||||
|
rows = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION).get_records(query)
|
||||||
|
ready = True
|
||||||
|
nodes: list[str] = []
|
||||||
|
for node, files, last_exception in rows:
|
||||||
|
files = int(files)
|
||||||
|
ready = ready and files == 0
|
||||||
|
logging.info(
|
||||||
|
"очередь ods.event_dist на %s: файлов=%s, последняя ошибка=%s",
|
||||||
|
node,
|
||||||
|
files,
|
||||||
|
last_exception or "нет",
|
||||||
|
)
|
||||||
|
detail = f"{node}: файлов={files}"
|
||||||
|
if last_exception:
|
||||||
|
detail += f", последняя ошибка={last_exception}"
|
||||||
|
nodes.append(detail)
|
||||||
|
return ready, "; ".join(nodes)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_phase(
|
||||||
|
probe,
|
||||||
|
phase: str,
|
||||||
|
deadline: float,
|
||||||
|
) -> None:
|
||||||
|
"""Дождаться прохождения одного барьера до общего крайнего срока."""
|
||||||
|
last_observation = "первый опрос не выполнен"
|
||||||
|
last_error: str | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
ready, last_observation = probe()
|
||||||
|
last_error = None
|
||||||
|
except Exception as error:
|
||||||
|
ready = False
|
||||||
|
last_error = str(error)
|
||||||
|
logging.warning("%s: ошибка опроса: %s", phase, last_error)
|
||||||
|
if ready:
|
||||||
|
return
|
||||||
|
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining > 0:
|
||||||
|
time.sleep(min(READINESS_POLL_SECONDS, remaining))
|
||||||
|
|
||||||
|
detail = last_observation
|
||||||
|
if last_error:
|
||||||
|
detail += f"; ошибка последнего опроса: {last_error}"
|
||||||
|
raise AirflowException(
|
||||||
|
f"общий срок ожидания готовности в {READINESS_TIMEOUT_SECONDS} с "
|
||||||
|
f'истек на этапе "{phase}": {detail}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _world_position() -> int:
|
||||||
|
"""Позиция мира числом, с внятным отказом на непригодном значении.
|
||||||
|
|
||||||
|
Ту же переменную читает пульт, но общего модуля у дагов нет намеренно:
|
||||||
|
связь между пультом и трансформацией держит сама переменная, а не импорт.
|
||||||
|
"""
|
||||||
|
raw_position = Variable.get(WORLD_POSITION, default=None)
|
||||||
|
if raw_position is None:
|
||||||
|
raise AirflowException(
|
||||||
|
"мир ещё не создан: выполните make up и дождитесь world_initialize"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
position = int(raw_position)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise AirflowException(
|
||||||
|
f"world_position={raw_position!r} не является номером дня; "
|
||||||
|
"выполните make rebuild-storage"
|
||||||
|
) from error
|
||||||
|
if position < 1:
|
||||||
|
raise AirflowException(
|
||||||
|
f"мир не готов: world_position={position}; "
|
||||||
|
"выполните make up или make rebuild-storage"
|
||||||
|
)
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
|
||||||
|
"""Дни отрезка строками — в таком виде их ждёт замена партиции."""
|
||||||
|
return [
|
||||||
|
(first_day + datetime.timedelta(days=shift)).isoformat()
|
||||||
|
for shift in range((last_day - first_day).days + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
|
||||||
|
"""Разложить дни в аргументы размноженной задачи замены.
|
||||||
|
|
||||||
|
Шаг существует ради ограничения Airflow: размножать задачу можно
|
||||||
|
только по целому возврату другой задачи — ни по ключу словаря, ни по
|
||||||
|
карте поверх него. Отсюда отдельный возврат под один список.
|
||||||
|
|
||||||
|
День едет в запрос подстановкой Jinja, а не параметром ClickHouse:
|
||||||
|
в ALTER … PARTITION сервер параметр не подставляет — замерено,
|
||||||
|
отвечает синтаксической ошибкой. Значение при этом не внешнее, его
|
||||||
|
вернул сам ClickHouse запросом объёма.
|
||||||
|
"""
|
||||||
|
return [{"params": {"day": day}} for day in scope["days"]]
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
dag_id="dds_transform",
|
||||||
|
schedule=None,
|
||||||
|
start_date=START_DATE,
|
||||||
|
is_paused_upon_creation=False,
|
||||||
|
# Два прогона разом собирали бы один и тот же двойник: второй затёр бы
|
||||||
|
# чужую сборку между вставкой и заменой партиций.
|
||||||
|
max_active_runs=1,
|
||||||
|
template_searchpath=str(SQL_ROOT),
|
||||||
|
tags=["dds"],
|
||||||
|
)
|
||||||
|
def dds_transform():
|
||||||
|
"""Пересобрать сущности DDS по текущему состоянию источников."""
|
||||||
|
|
||||||
|
@task
|
||||||
|
def wait_for_source() -> None:
|
||||||
|
"""Дождаться чтения событий из Kafka и их доставки в ODS."""
|
||||||
|
deadline = time.monotonic() + READINESS_TIMEOUT_SECONDS
|
||||||
|
_wait_for_phase(_kafka_readiness, "Kafka", deadline)
|
||||||
|
_wait_for_phase(
|
||||||
|
_delivery_queue_readiness,
|
||||||
|
"очередь доставки ClickHouse",
|
||||||
|
deadline,
|
||||||
|
)
|
||||||
|
|
||||||
|
@task_group(group_id="order")
|
||||||
|
def order():
|
||||||
|
"""Заказ: дышащее окно плюс дни, которых в слое ещё нет."""
|
||||||
|
|
||||||
|
@task
|
||||||
|
def scope() -> dict[str, object]:
|
||||||
|
"""Спросить у хранилища, какие дни собирает этот запуск."""
|
||||||
|
query = (SQL_ROOT / "dds" / "order_scope.sql").read_text(encoding="utf-8")
|
||||||
|
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
|
||||||
|
row = hook.get_first(query)
|
||||||
|
# Пустой ответ означает пустой источник: собирать нечего, и
|
||||||
|
# зеленеть на этом нельзя — прогон встаёт с внятной причиной.
|
||||||
|
if row is None:
|
||||||
|
raise AirflowException(
|
||||||
|
"в ods.order нет ни одного заказа: собирать нечего; "
|
||||||
|
"проверьте, дошёл ли слепок до хранилища"
|
||||||
|
)
|
||||||
|
first_day, last_day = row
|
||||||
|
days = _days(first_day, last_day)
|
||||||
|
logging.info(
|
||||||
|
"объём пересборки: %s … %s, дней %d",
|
||||||
|
first_day,
|
||||||
|
last_day,
|
||||||
|
len(days),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"first_day": first_day.isoformat(),
|
||||||
|
"last_day": last_day.isoformat(),
|
||||||
|
"days": days,
|
||||||
|
}
|
||||||
|
|
||||||
|
days = scope()
|
||||||
|
|
||||||
|
rebuild = SQLExecuteQueryOperator(
|
||||||
|
task_id="rebuild",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql="dds/order_rebuild.sql",
|
||||||
|
parameters={
|
||||||
|
"load_id": "{{ run_id }}",
|
||||||
|
"first_day": days["first_day"],
|
||||||
|
"last_day": days["last_day"],
|
||||||
|
},
|
||||||
|
split_statements=True,
|
||||||
|
do_xcom_push=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Замена идёт по партиции за раз — другой формы у операции нет. День
|
||||||
|
# становится отдельной задачей: в интерфейсе видно, сколько дней несёт
|
||||||
|
# прогон, а повтор после отказа стоит одного дня, а не всей пересборки.
|
||||||
|
replace = SQLExecuteQueryOperator.partial(
|
||||||
|
task_id="replace",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql="dds/order_replace.sql",
|
||||||
|
do_xcom_push=False,
|
||||||
|
).expand_kwargs(replacements(days))
|
||||||
|
|
||||||
|
rebuild >> replace
|
||||||
|
|
||||||
|
@task_group(group_id="session")
|
||||||
|
def session():
|
||||||
|
"""Сессия: дни, которые мир уже прожил, а слой ещё не собрал."""
|
||||||
|
|
||||||
|
@task
|
||||||
|
def scope() -> dict[str, object]:
|
||||||
|
"""Спросить у хранилища и у позиции мира, какие дни собирает запуск."""
|
||||||
|
query = (SQL_ROOT / "dds" / "session_scope.sql").read_text(encoding="utf-8")
|
||||||
|
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
|
||||||
|
row = hook.get_first(query, parameters={"position": _world_position()})
|
||||||
|
if row is None:
|
||||||
|
raise AirflowException(
|
||||||
|
"в ods.event нет ни одного события: собирать нечего; "
|
||||||
|
"проверьте, дошёл ли поток до хранилища"
|
||||||
|
)
|
||||||
|
first_day, last_day = row
|
||||||
|
# Пропуск, а не отказ: слой полон по последний прожитый день, а
|
||||||
|
# сегодняшний ещё идёт. Это обычное состояние повторного прогона,
|
||||||
|
# и в интерфейсе оно видно пропущенной группой.
|
||||||
|
if first_day is None:
|
||||||
|
raise AirflowSkipException(
|
||||||
|
f"сессии собраны по {last_day} включительно, "
|
||||||
|
"новых прожитых дней нет"
|
||||||
|
)
|
||||||
|
days = _days(first_day, last_day)
|
||||||
|
logging.info(
|
||||||
|
"объём сборки: %s … %s, дней %d",
|
||||||
|
first_day,
|
||||||
|
last_day,
|
||||||
|
len(days),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"first_day": first_day.isoformat(),
|
||||||
|
"last_day": last_day.isoformat(),
|
||||||
|
"days": days,
|
||||||
|
}
|
||||||
|
|
||||||
|
days = scope()
|
||||||
|
|
||||||
|
rebuild = SQLExecuteQueryOperator(
|
||||||
|
task_id="rebuild",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql="dds/session_rebuild.sql",
|
||||||
|
parameters={
|
||||||
|
"load_id": "{{ run_id }}",
|
||||||
|
"first_day": days["first_day"],
|
||||||
|
"last_day": days["last_day"],
|
||||||
|
},
|
||||||
|
split_statements=True,
|
||||||
|
do_xcom_push=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
replace = SQLExecuteQueryOperator.partial(
|
||||||
|
task_id="replace",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql="dds/session_replace.sql",
|
||||||
|
do_xcom_push=False,
|
||||||
|
).expand_kwargs(replacements(days))
|
||||||
|
|
||||||
|
rebuild >> replace
|
||||||
|
|
||||||
|
source_ready = wait_for_source()
|
||||||
|
orders = order()
|
||||||
|
sessions = session()
|
||||||
|
source_ready >> [orders, sessions]
|
||||||
|
|
||||||
|
identity_map = SQLExecuteQueryOperator(
|
||||||
|
task_id="identity_map",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql="dds/identity_map_rebuild.sql",
|
||||||
|
parameters={"load_id": "{{ run_id }}"},
|
||||||
|
do_xcom_push=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
orders >> identity_map
|
||||||
|
|
||||||
|
|
||||||
|
dds_transform()
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Трансформации слоя DM: сборка стендовых витрин.
|
||||||
|
|
||||||
|
Выручка наследует ритм заказов. Сверка соединяет окно заказов с прожитым
|
||||||
|
хвостом событий. Обе заменяют дневные партиции. Трафик наследует
|
||||||
|
ретроспективность карты идентичностей и пересчитывает всю историю новой
|
||||||
|
версией. Объем каждого прогона выводится из состояния слоев.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from airflow.exceptions import AirflowException
|
||||||
|
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
|
||||||
|
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
|
||||||
|
from airflow.sdk import Variable, dag, task, task_group
|
||||||
|
|
||||||
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
|
|
||||||
|
SQL_ROOT = Path("/opt/airflow/sql")
|
||||||
|
CLICKHOUSE_CONNECTION = "clickhouse_default"
|
||||||
|
# Та же ось модельного времени, что у DDS. Общего модуля у дагов нет
|
||||||
|
# намеренно: связь держит переменная Airflow, а не импорт одного дага другим.
|
||||||
|
WORLD_POSITION = "world_position"
|
||||||
|
|
||||||
|
|
||||||
|
def _world_position() -> int:
|
||||||
|
"""Позиция мира для границы прожитых событий сверки.
|
||||||
|
|
||||||
|
Причина отдельного чтения переменной разобрана у одноименной функции в
|
||||||
|
``dds_transform.py``.
|
||||||
|
"""
|
||||||
|
raw_position = Variable.get(WORLD_POSITION, default=None)
|
||||||
|
if raw_position is None:
|
||||||
|
raise AirflowException(
|
||||||
|
"мир еще не создан: выполните make up и дождитесь world_initialize"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
position = int(raw_position)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise AirflowException(
|
||||||
|
f"world_position={raw_position!r} не является номером дня; "
|
||||||
|
"выполните make rebuild-storage"
|
||||||
|
) from error
|
||||||
|
if position < 1:
|
||||||
|
raise AirflowException(
|
||||||
|
f"мир не готов: world_position={position}; "
|
||||||
|
"выполните make up или make rebuild-storage"
|
||||||
|
)
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
def _days(first_day: datetime.date, last_day: datetime.date) -> list[str]:
|
||||||
|
"""Дни отрезка строками - в таком виде их ждет замена партиции."""
|
||||||
|
return [
|
||||||
|
(first_day + datetime.timedelta(days=shift)).isoformat()
|
||||||
|
for shift in range((last_day - first_day).days + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def replacements(scope: dict[str, object]) -> list[dict[str, object]]:
|
||||||
|
"""Разложить дни в аргументы размноженной задачи замены.
|
||||||
|
|
||||||
|
Отдельный словарь нужен каждому экземпляру ``expand_kwargs``; тот же
|
||||||
|
прием разобран в ``dds_transform.py`` на замене партиций заказов.
|
||||||
|
"""
|
||||||
|
return [{"params": {"day": day}} for day in scope["days"]]
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
dag_id="dm_transform",
|
||||||
|
schedule=None,
|
||||||
|
start_date=START_DATE,
|
||||||
|
is_paused_upon_creation=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
template_searchpath=str(SQL_ROOT),
|
||||||
|
tags=["dm"],
|
||||||
|
)
|
||||||
|
def dm_transform():
|
||||||
|
"""Пересобрать стендовые витрины по текущему состоянию DDS."""
|
||||||
|
|
||||||
|
@task_group(group_id="revenue_daily")
|
||||||
|
def revenue_daily():
|
||||||
|
"""Выручка: изменяемое окно заказов и пропущенная история."""
|
||||||
|
|
||||||
|
@task
|
||||||
|
def scope() -> dict[str, object]:
|
||||||
|
"""Спросить у хранилища, какие дни пересобирает запуск."""
|
||||||
|
query = (SQL_ROOT / "dm" / "revenue_daily_scope.sql").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
|
||||||
|
row = hook.get_first(query)
|
||||||
|
if row is None:
|
||||||
|
raise AirflowException(
|
||||||
|
"нет слепка заказов в ODS или готовых заказов в DDS; "
|
||||||
|
"дождитесь загрузки слепка и сначала выполните dds_transform"
|
||||||
|
)
|
||||||
|
first_day, last_day = row
|
||||||
|
days = _days(first_day, last_day)
|
||||||
|
logging.info(
|
||||||
|
"объем пересборки: %s … %s, дней %d",
|
||||||
|
first_day,
|
||||||
|
last_day,
|
||||||
|
len(days),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"first_day": first_day.isoformat(),
|
||||||
|
"last_day": last_day.isoformat(),
|
||||||
|
"days": days,
|
||||||
|
}
|
||||||
|
|
||||||
|
days = scope()
|
||||||
|
|
||||||
|
rebuild = SQLExecuteQueryOperator(
|
||||||
|
task_id="rebuild",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql="dm/revenue_daily_rebuild.sql",
|
||||||
|
parameters={
|
||||||
|
"load_id": "{{ run_id }}",
|
||||||
|
"first_day": days["first_day"],
|
||||||
|
"last_day": days["last_day"],
|
||||||
|
},
|
||||||
|
split_statements=True,
|
||||||
|
do_xcom_push=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
replace = SQLExecuteQueryOperator.partial(
|
||||||
|
task_id="replace",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql="dm/revenue_daily_replace.sql",
|
||||||
|
do_xcom_push=False,
|
||||||
|
).expand_kwargs(replacements(days))
|
||||||
|
|
||||||
|
rebuild >> replace
|
||||||
|
|
||||||
|
revenue_daily()
|
||||||
|
|
||||||
|
@task_group(group_id="purchase_vs_orders")
|
||||||
|
def purchase_vs_orders():
|
||||||
|
"""Сверка: окно заказов, прожитый хвост и предварительные дни."""
|
||||||
|
|
||||||
|
@task
|
||||||
|
def scope() -> dict[str, object]:
|
||||||
|
"""Спросить у обоих источников, какие дни пересобрать."""
|
||||||
|
query = (SQL_ROOT / "dm" / "purchase_vs_orders_scope.sql").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
hook = ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
|
||||||
|
row = hook.get_first(query, parameters={"position": _world_position()})
|
||||||
|
if row is None:
|
||||||
|
raise AirflowException(
|
||||||
|
"нет принятого слепка заказов или прожитых покупок; "
|
||||||
|
"дождитесь источников и сначала выполните dds_transform"
|
||||||
|
)
|
||||||
|
first_day, last_day = row
|
||||||
|
days = _days(first_day, last_day)
|
||||||
|
logging.info(
|
||||||
|
"объем пересборки: %s … %s, дней %d",
|
||||||
|
first_day,
|
||||||
|
last_day,
|
||||||
|
len(days),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"first_day": first_day.isoformat(),
|
||||||
|
"last_day": last_day.isoformat(),
|
||||||
|
"days": days,
|
||||||
|
}
|
||||||
|
|
||||||
|
days = scope()
|
||||||
|
|
||||||
|
rebuild = SQLExecuteQueryOperator(
|
||||||
|
task_id="rebuild",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql="dm/purchase_vs_orders_rebuild.sql",
|
||||||
|
parameters={
|
||||||
|
"load_id": "{{ run_id }}",
|
||||||
|
"first_day": days["first_day"],
|
||||||
|
"last_day": days["last_day"],
|
||||||
|
},
|
||||||
|
split_statements=True,
|
||||||
|
do_xcom_push=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
replace = SQLExecuteQueryOperator.partial(
|
||||||
|
task_id="replace",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql="dm/purchase_vs_orders_replace.sql",
|
||||||
|
do_xcom_push=False,
|
||||||
|
).expand_kwargs(replacements(days))
|
||||||
|
|
||||||
|
rebuild >> replace
|
||||||
|
|
||||||
|
purchase_vs_orders()
|
||||||
|
|
||||||
|
SQLExecuteQueryOperator(
|
||||||
|
task_id="daily_traffic",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql="dm/daily_traffic_rebuild.sql",
|
||||||
|
parameters={"load_id": "{{ run_id }}"},
|
||||||
|
do_xcom_push=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
dm_transform()
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""Проверки качества стендовых моделей и витрин.
|
||||||
|
|
||||||
|
Пять проверок сначала собирают общий донор. После его полной сборки дневные
|
||||||
|
партиции публикуются одной серией замен, и только затем именованные задачи
|
||||||
|
утверждают результат. Расхождение поэтому остаётся в ``dm.dq_summary_v`` до
|
||||||
|
красного завершения дага; ошибка сборки прежнюю сводку не меняет.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from airflow.exceptions import AirflowException
|
||||||
|
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
|
||||||
|
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
|
||||||
|
from airflow.sdk import Variable, dag, get_current_context, task, task_group
|
||||||
|
|
||||||
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
|
|
||||||
|
SQL_ROOT = Path("/opt/airflow/sql")
|
||||||
|
INVENTORY_PATH = Path("/opt/airflow/world/data/world-inventory.json")
|
||||||
|
CLICKHOUSE_CONNECTION = "clickhouse_default"
|
||||||
|
WORLD_POSITION = "world_position"
|
||||||
|
DETAIL_LIMIT = 20
|
||||||
|
|
||||||
|
SUMMARY_COLUMNS = [
|
||||||
|
"data_date",
|
||||||
|
"check_name",
|
||||||
|
"reference_rows",
|
||||||
|
"actual_rows",
|
||||||
|
"failed_rows",
|
||||||
|
"status",
|
||||||
|
"_load_id",
|
||||||
|
"_load_ts",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _clickhouse_hook() -> ClickHouseHook:
|
||||||
|
"""Подключение, которое дожидается записи в Distributed-таблицу."""
|
||||||
|
return ClickHouseHook(
|
||||||
|
clickhouse_conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
session_settings={"distributed_foreground_insert": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _world_position() -> int:
|
||||||
|
"""Первый несыгранный день оси мира."""
|
||||||
|
raw_position = Variable.get(WORLD_POSITION, default=None)
|
||||||
|
if raw_position is None:
|
||||||
|
raise AirflowException(
|
||||||
|
"мир еще не создан: выполните make up и дождитесь world_initialize"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
position = int(raw_position)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise AirflowException(
|
||||||
|
f"world_position={raw_position!r} не является номером дня; "
|
||||||
|
"выполните make rebuild-storage"
|
||||||
|
) from error
|
||||||
|
if position < 1:
|
||||||
|
raise AirflowException(
|
||||||
|
f"мир не готов: world_position={position}; "
|
||||||
|
"выполните make up или make rebuild-storage"
|
||||||
|
)
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_summary(
|
||||||
|
check_name: str,
|
||||||
|
summaries: list[tuple[object, ...]],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Записать дневную сводку и вернуть малый срез готовой диагностики."""
|
||||||
|
if not summaries:
|
||||||
|
raise AirflowException(f"{check_name}: область проверки пуста")
|
||||||
|
|
||||||
|
diagnostics: list[str] = []
|
||||||
|
context = get_current_context()
|
||||||
|
load_id = context["run_id"]
|
||||||
|
load_ts = datetime.datetime.now(datetime.UTC)
|
||||||
|
rows = []
|
||||||
|
for (
|
||||||
|
data_date,
|
||||||
|
reference_rows,
|
||||||
|
actual_rows,
|
||||||
|
failed_rows,
|
||||||
|
details,
|
||||||
|
) in summaries:
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
data_date,
|
||||||
|
check_name,
|
||||||
|
reference_rows,
|
||||||
|
actual_rows,
|
||||||
|
failed_rows,
|
||||||
|
"pass" if failed_rows == 0 else "fail",
|
||||||
|
load_id,
|
||||||
|
load_ts,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
diagnostics.extend(details[: DETAIL_LIMIT - len(diagnostics)])
|
||||||
|
|
||||||
|
_clickhouse_hook().bulk_insert_rows(
|
||||||
|
"dm.dq_summary_stage_dist",
|
||||||
|
rows,
|
||||||
|
column_names=SUMMARY_COLUMNS,
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sql_check(
|
||||||
|
check_name: str,
|
||||||
|
sql_name: str,
|
||||||
|
parameters: dict[str, object] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Выполнить SQL-сверку и записать ее дневные итоги в донор."""
|
||||||
|
query = (SQL_ROOT / "dq" / sql_name).read_text(encoding="utf-8")
|
||||||
|
summaries = _clickhouse_hook().get_records(query, parameters=parameters)
|
||||||
|
return _publish_summary(check_name, summaries)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def build_sql_check(check_name: str, sql_name: str) -> list[str]:
|
||||||
|
"""Собрать проверку, которой не нужны координаты модельного мира."""
|
||||||
|
return _build_sql_check(check_name, sql_name)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def build_sessions_vs_reference() -> list[str]:
|
||||||
|
"""Сверить сессии только в пределах прожитых дней мира."""
|
||||||
|
return _build_sql_check(
|
||||||
|
"sessions_vs_reference",
|
||||||
|
"sessions_vs_reference.sql",
|
||||||
|
parameters={"position": _world_position()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def build_classes_vs_inventory() -> list[str]:
|
||||||
|
"""Сопоставить классы закрытых дней с техническим оракулом генератора."""
|
||||||
|
inventory = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
||||||
|
expected = {
|
||||||
|
(datetime.date.fromisoformat(day["date"]), mismatch_class): count
|
||||||
|
for day in inventory["days"]
|
||||||
|
if "orders" in day
|
||||||
|
for mismatch_class, count in day["orders"].items()
|
||||||
|
}
|
||||||
|
if not expected:
|
||||||
|
raise AirflowException("classes_vs_inventory: в описи нет закрытых дней")
|
||||||
|
|
||||||
|
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
|
||||||
|
# GROUP BY закономерно не возвращает группу из нуля строк.
|
||||||
|
actual = {key: 0 for key, count in expected.items() if count == 0}
|
||||||
|
actual.update(
|
||||||
|
{
|
||||||
|
(data_date, mismatch_class): count
|
||||||
|
for data_date, mismatch_class, count in _clickhouse_hook().get_records(
|
||||||
|
query
|
||||||
|
)
|
||||||
|
if (data_date, mismatch_class) in expected
|
||||||
|
}
|
||||||
|
)
|
||||||
|
summaries = []
|
||||||
|
for data_date in sorted({day for day, _ in expected}):
|
||||||
|
details = []
|
||||||
|
reference_rows = 0
|
||||||
|
actual_rows = 0
|
||||||
|
failed_rows = 0
|
||||||
|
for (day, mismatch_class), reference_count in sorted(expected.items()):
|
||||||
|
if day != data_date:
|
||||||
|
continue
|
||||||
|
actual_key = (day, mismatch_class)
|
||||||
|
actual_count = actual.get(actual_key)
|
||||||
|
reference_rows += 1
|
||||||
|
actual_rows += int(actual_key in actual)
|
||||||
|
if reference_count != actual_count:
|
||||||
|
failed_rows += 1
|
||||||
|
actual_text = actual_count if actual_count is not None else "нет ключа"
|
||||||
|
details.append(
|
||||||
|
f"{day} key={mismatch_class}: "
|
||||||
|
f"эталон={reference_count}, "
|
||||||
|
f"объект={actual_text}"
|
||||||
|
)
|
||||||
|
summaries.append((data_date, reference_rows, actual_rows, failed_rows, details))
|
||||||
|
return _publish_summary("classes_vs_inventory", summaries)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def publication_scope() -> list[dict[str, dict[str, str]]]:
|
||||||
|
"""Вернуть дни полностью собранного донора для размноженной замены."""
|
||||||
|
rows = _clickhouse_hook().get_records(
|
||||||
|
"SELECT DISTINCT data_date FROM dm.dq_summary_stage_dist ORDER BY data_date"
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
raise AirflowException("донор dm.dq_summary пуст: публиковать нечего")
|
||||||
|
return [{"params": {"day": data_date.isoformat()}} for (data_date,) in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def drop_stale_partitions() -> None:
|
||||||
|
"""Убрать дни, исчезнувшие после починки с обеих сторон сверки."""
|
||||||
|
hook = _clickhouse_hook()
|
||||||
|
stale_days = hook.get_records(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT data_date
|
||||||
|
FROM dm.dq_summary_dist
|
||||||
|
WHERE data_date GLOBAL NOT IN
|
||||||
|
(SELECT data_date FROM dm.dq_summary_stage_dist)
|
||||||
|
ORDER BY data_date
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for (data_date,) in stale_days:
|
||||||
|
# ALTER PARTITION не принимает параметр ClickHouse. Значение пришло
|
||||||
|
# из колонки Date самого хранилища и форматируется без внешнего ввода.
|
||||||
|
hook.run(
|
||||||
|
"ALTER TABLE dm.dq_summary_rep ON CLUSTER clickstream_cluster "
|
||||||
|
f"DROP PARTITION '{data_date.isoformat()}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def assert_check(check_name: str, diagnostics: list[str]) -> None:
|
||||||
|
"""Покрасить именованную задачу по уже опубликованной сводке."""
|
||||||
|
failed_days = _clickhouse_hook().get_records(
|
||||||
|
"""
|
||||||
|
SELECT data_date, reference_rows, actual_rows, failed_rows
|
||||||
|
FROM dm.dq_summary_v
|
||||||
|
WHERE check_name = {check_name:String} AND failed_rows > 0
|
||||||
|
ORDER BY data_date
|
||||||
|
""",
|
||||||
|
parameters={"check_name": check_name},
|
||||||
|
)
|
||||||
|
if not failed_days:
|
||||||
|
return
|
||||||
|
|
||||||
|
for detail in diagnostics:
|
||||||
|
logging.error("%s: %s", check_name, detail)
|
||||||
|
summary = "; ".join(
|
||||||
|
f"{data_date}: эталонных ключей {reference_rows}, "
|
||||||
|
f"фактических {actual_rows}, нарушено {failed_rows}"
|
||||||
|
for data_date, reference_rows, actual_rows, failed_rows in failed_days
|
||||||
|
)
|
||||||
|
raise AirflowException(f"{check_name}: {summary}")
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
dag_id="dq_check",
|
||||||
|
schedule=None,
|
||||||
|
start_date=START_DATE,
|
||||||
|
is_paused_upon_creation=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
template_searchpath=str(SQL_ROOT),
|
||||||
|
tags=["dq"],
|
||||||
|
)
|
||||||
|
def dq_check():
|
||||||
|
"""Собрать, опубликовать и утвердить текущее качество данных."""
|
||||||
|
prepare = SQLExecuteQueryOperator(
|
||||||
|
task_id="prepare",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql="dq/prepare.sql",
|
||||||
|
do_xcom_push=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
sessions = build_sessions_vs_reference()
|
||||||
|
classes = build_classes_vs_inventory.override(
|
||||||
|
task_id="build_classes_vs_inventory"
|
||||||
|
)()
|
||||||
|
revenue = build_sql_check.override(task_id="build_revenue_daily_vs_dds")(
|
||||||
|
"revenue_daily_vs_dds", "revenue_daily_vs_dds.sql"
|
||||||
|
)
|
||||||
|
purchases = build_sql_check.override(task_id="build_purchase_vs_orders_vs_dds")(
|
||||||
|
"purchase_vs_orders_vs_dds", "purchase_vs_orders_vs_dds.sql"
|
||||||
|
)
|
||||||
|
traffic = build_sql_check.override(task_id="build_daily_traffic_vs_dds")(
|
||||||
|
"daily_traffic_vs_dds", "daily_traffic_vs_dds.sql"
|
||||||
|
)
|
||||||
|
builders = [sessions, classes, revenue, purchases, traffic]
|
||||||
|
prepare >> builders
|
||||||
|
|
||||||
|
days = publication_scope()
|
||||||
|
for builder in builders:
|
||||||
|
builder >> days
|
||||||
|
|
||||||
|
replace = SQLExecuteQueryOperator.partial(
|
||||||
|
task_id="publish",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql="dq/replace.sql",
|
||||||
|
do_xcom_push=False,
|
||||||
|
).expand_kwargs(days)
|
||||||
|
|
||||||
|
sessions_assertion = assert_check.override(task_id="sessions_vs_reference")(
|
||||||
|
"sessions_vs_reference", sessions
|
||||||
|
)
|
||||||
|
classes_assertion = assert_check.override(task_id="classes_vs_inventory")(
|
||||||
|
"classes_vs_inventory", classes
|
||||||
|
)
|
||||||
|
|
||||||
|
@task_group(group_id="marts_vs_dds")
|
||||||
|
def assert_marts():
|
||||||
|
return [
|
||||||
|
assert_check.override(task_id="revenue_daily_vs_dds")(
|
||||||
|
"revenue_daily_vs_dds", revenue
|
||||||
|
),
|
||||||
|
assert_check.override(task_id="purchase_vs_orders_vs_dds")(
|
||||||
|
"purchase_vs_orders_vs_dds", purchases
|
||||||
|
),
|
||||||
|
assert_check.override(task_id="daily_traffic_vs_dds")(
|
||||||
|
"daily_traffic_vs_dds", traffic
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
assertions = [sessions_assertion, classes_assertion, *assert_marts()]
|
||||||
|
remove_stale = drop_stale_partitions()
|
||||||
|
replace >> remove_stale >> assertions
|
||||||
|
|
||||||
|
|
||||||
|
dq_check()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Дневной конвейер: DDS, DM и проверка качества.
|
||||||
|
|
||||||
|
Своих преобразований у дага нет. Ждущие триггеры связывают состояния:
|
||||||
|
если слой краснеет, конвейер не запускает следующий слой и краснеет сам.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
|
||||||
|
from airflow.sdk import dag
|
||||||
|
|
||||||
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
dag_id="etl_pipeline",
|
||||||
|
schedule=None,
|
||||||
|
start_date=START_DATE,
|
||||||
|
is_paused_upon_creation=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=["etl"],
|
||||||
|
)
|
||||||
|
def etl_pipeline():
|
||||||
|
"""Собрать слои последовательно по текущему состоянию данных."""
|
||||||
|
dds = TriggerDagRunOperator(
|
||||||
|
task_id="trigger_dds",
|
||||||
|
trigger_dag_id="dds_transform",
|
||||||
|
wait_for_completion=True,
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
dm = TriggerDagRunOperator(
|
||||||
|
task_id="trigger_dm",
|
||||||
|
trigger_dag_id="dm_transform",
|
||||||
|
wait_for_completion=True,
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
# Airflow 3.3: оператор провайдера standard с wait_for_completion=True
|
||||||
|
# поднимает AirflowException, если запущенный даг завершился красным.
|
||||||
|
# Поведение и путь импорта повторно сверены через Context7 27 августа
|
||||||
|
# 2026 года.
|
||||||
|
dq = TriggerDagRunOperator(
|
||||||
|
task_id="trigger_dq",
|
||||||
|
trigger_dag_id="dq_check",
|
||||||
|
wait_for_completion=True,
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
dds >> dm >> dq
|
||||||
|
|
||||||
|
|
||||||
|
etl_pipeline()
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Ручная самопроверка учебных витрин DM против их источников."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from airflow.exceptions import AirflowException
|
||||||
|
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
|
||||||
|
from airflow.sdk import dag, task
|
||||||
|
|
||||||
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
|
SQL_ROOT = Path("/opt/airflow/sql/dq")
|
||||||
|
CLICKHOUSE_CONNECTION = "clickhouse_default"
|
||||||
|
DETAIL_LIMIT = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _clickhouse_hook() -> ClickHouseHook:
|
||||||
|
"""Подключение к ClickHouse для чтения обеих сторон сверки."""
|
||||||
|
return ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
|
||||||
|
|
||||||
|
|
||||||
|
def _mart_exists(hook: ClickHouseHook, mart: str) -> bool:
|
||||||
|
"""Проверить наличие публичной витрины на ноде подключения."""
|
||||||
|
database, name = mart.split(".", maxsplit=1)
|
||||||
|
rows = hook.get_records(
|
||||||
|
"""
|
||||||
|
SELECT count()
|
||||||
|
FROM system.tables
|
||||||
|
WHERE database = {database:String} AND name = {name:String}
|
||||||
|
""",
|
||||||
|
parameters={"database": database, "name": name},
|
||||||
|
)
|
||||||
|
return rows[0][0] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def check_mart(
|
||||||
|
mart: str,
|
||||||
|
lab_path: str,
|
||||||
|
sql_name: str,
|
||||||
|
scope_sql_name: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Сверить одну домашнюю витрину с независимым ответом из источника."""
|
||||||
|
hook = _clickhouse_hook()
|
||||||
|
if not _mart_exists(hook, mart):
|
||||||
|
raise AirflowException(f"витрина {mart} не найдена; выполните ТЗ {lab_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
query = (SQL_ROOT / sql_name).read_text(encoding="utf-8")
|
||||||
|
scope_query = (
|
||||||
|
(SQL_ROOT / scope_sql_name).read_text(encoding="utf-8")
|
||||||
|
if scope_sql_name is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
raise AirflowException(
|
||||||
|
f"стенд не может прочитать запрос проверки {sql_name}: {error}; "
|
||||||
|
"сообщите наставнику"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
try:
|
||||||
|
parameter_sets = [None]
|
||||||
|
if scope_query is not None:
|
||||||
|
parameter_sets = [{"day": day} for (day,) in hook.get_records(scope_query)]
|
||||||
|
summaries = [
|
||||||
|
summary
|
||||||
|
for parameters in parameter_sets
|
||||||
|
for summary in hook.get_records(query, parameters=parameters)
|
||||||
|
]
|
||||||
|
except Exception as error:
|
||||||
|
raise AirflowException(
|
||||||
|
f"не удалось проверить витрину {mart}: {error}; "
|
||||||
|
f"сверьте договор колонок и проверку в ТЗ {lab_path}"
|
||||||
|
) from error
|
||||||
|
|
||||||
|
failed_rows = sum(summary[3] for summary in summaries)
|
||||||
|
if failed_rows == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
diagnostics = [detail for summary in summaries for detail in summary[4]][
|
||||||
|
:DETAIL_LIMIT
|
||||||
|
]
|
||||||
|
for detail in diagnostics:
|
||||||
|
logging.error("%s: %s", mart, detail)
|
||||||
|
days = "; ".join(
|
||||||
|
f"{data_date}: ключей источника {reference_rows}, "
|
||||||
|
f"ключей витрины {actual_rows}, расходятся {day_failed_rows}"
|
||||||
|
for data_date, reference_rows, actual_rows, day_failed_rows, _ in summaries
|
||||||
|
if day_failed_rows > 0
|
||||||
|
)
|
||||||
|
compared_sides = diagnostics[0]
|
||||||
|
raise AirflowException(
|
||||||
|
f"витрина {mart} расходится с источником: {days}. "
|
||||||
|
f"Пример сравнения: {compared_sides}. Сверьтесь с ТЗ {lab_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
dag_id="homework_check",
|
||||||
|
schedule=None,
|
||||||
|
start_date=START_DATE,
|
||||||
|
is_paused_upon_creation=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=["dm", "labs"],
|
||||||
|
)
|
||||||
|
def homework_check():
|
||||||
|
"""Дать отдельный результат самопроверки по каждой домашней витрине."""
|
||||||
|
check_mart.override(task_id="events_enriched")(
|
||||||
|
"dm.events_enriched_v",
|
||||||
|
"docs/labs/events-enriched.md",
|
||||||
|
"events_enriched_vs_dds.sql",
|
||||||
|
"events_enriched_scope.sql",
|
||||||
|
)
|
||||||
|
check_mart.override(task_id="top_pages_daily")(
|
||||||
|
"dm.top_pages_daily_v",
|
||||||
|
"docs/labs/top-pages-daily.md",
|
||||||
|
"top_pages_daily_vs_dds.sql",
|
||||||
|
)
|
||||||
|
check_mart.override(task_id="session_overview")(
|
||||||
|
"dm.session_overview_v",
|
||||||
|
"docs/labs/session-overview.md",
|
||||||
|
"session_overview_vs_dds.sql",
|
||||||
|
)
|
||||||
|
check_mart.override(task_id="dq_errors_daily")(
|
||||||
|
"dm.dq_errors_daily_v",
|
||||||
|
"docs/labs/dq-errors-daily.md",
|
||||||
|
"dq_errors_daily_vs_ods.sql",
|
||||||
|
)
|
||||||
|
check_mart.override(task_id="utm_effectiveness")(
|
||||||
|
"dm.utm_effectiveness_v",
|
||||||
|
"docs/labs/utm-effectiveness.md",
|
||||||
|
"utm_effectiveness_vs_dds.sql",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
homework_check()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Приём заказов: пакетный забор слепка из Kafka в хранилище.
|
||||||
|
|
||||||
|
Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор
|
||||||
|
порции в сырьё, затем разбор того же среза в типизированный ODS и в таблицу
|
||||||
|
брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, —
|
||||||
|
работник пульта мира после проигрыша дня, — и ждёт конца.
|
||||||
|
|
||||||
|
Контраст с приёмом событий и есть урок. События тянет матвью, навсегда
|
||||||
|
подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов
|
||||||
|
приезжает раз в модельный день целой выгрузкой, у которой есть начало и конец,
|
||||||
|
и забирает её запрос по команде. Push против pull — два режима на одном стенде,
|
||||||
|
каждый там, где ему место по природе источника.
|
||||||
|
|
||||||
|
Решения и доводы целиком — ADR 0008 (забор) и ADR 0010 (версии в ODS); форма
|
||||||
|
перехода — docs/architecture/orders/ingestion.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
|
||||||
|
from airflow.sdk import dag
|
||||||
|
|
||||||
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
|
|
||||||
|
# Запросы лежат файлами в sql/ репозитория, разложенные по слоям хранилища;
|
||||||
|
# сюда их монтирует compose — всем службам Airflow сразу.
|
||||||
|
SQL_ROOT = "/opt/airflow/sql"
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
dag_id="orders_ingest",
|
||||||
|
schedule=None,
|
||||||
|
start_date=START_DATE,
|
||||||
|
is_paused_upon_creation=False,
|
||||||
|
# Чтец у топика один, и группа потребителей у него одна. Два прогона разом
|
||||||
|
# дрались бы за неё, а слепок разъехался бы по двум _load_id; второй
|
||||||
|
# прогон подождёт своей очереди.
|
||||||
|
max_active_runs=1,
|
||||||
|
template_searchpath=SQL_ROOT,
|
||||||
|
tags=["заказы"],
|
||||||
|
)
|
||||||
|
def orders_ingest():
|
||||||
|
"""Забрать приехавший слепок заказов и разложить его по слоям."""
|
||||||
|
pull_batch = SQLExecuteQueryOperator(
|
||||||
|
task_id="pull_batch",
|
||||||
|
conn_id="clickhouse_default",
|
||||||
|
sql="stg/orders_raw_load.sql",
|
||||||
|
parameters={"load_id": "{{ run_id }}"},
|
||||||
|
do_xcom_push=False,
|
||||||
|
)
|
||||||
|
parse_batch = SQLExecuteQueryOperator(
|
||||||
|
task_id="parse_batch",
|
||||||
|
conn_id="clickhouse_default",
|
||||||
|
sql="ods/order_parse.sql",
|
||||||
|
parameters={"load_id": "{{ run_id }}"},
|
||||||
|
split_statements=True,
|
||||||
|
do_xcom_push=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ClickHouse не даёт транзакции между двумя вставками parse_batch. Один
|
||||||
|
# task_id сохраняет их порядок и одну точку повтора для неизменного load_id.
|
||||||
|
pull_batch >> parse_batch
|
||||||
|
|
||||||
|
|
||||||
|
orders_ingest()
|
||||||
+114
-136
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
import datetime
|
import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
|
||||||
from airflow.sdk import Connection, dag, get_current_context, task
|
from airflow.sdk import Connection, dag, get_current_context, task
|
||||||
|
|
||||||
CLUSTER = "clickstream_cluster"
|
CLUSTER = "clickstream_cluster"
|
||||||
@@ -29,98 +30,73 @@ EXPECTED_TABLES = [
|
|||||||
(LOCAL_TABLE, "ReplicatedMergeTree"),
|
(LOCAL_TABLE, "ReplicatedMergeTree"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Ноду 2 пробник читает не своим подключением, а запросом remote() с ноды 1: у
|
# У Airflow одно подключение — к ноде 1; второго ради пробника не заводят.
|
||||||
# Airflow подготовлено одно подключение — к clickhouse-01, и второго ради
|
# Ноду 2 он читает через remote(), который не использует секрет из описания
|
||||||
# пробника не заводят. При этом remote('clickhouse-02:9000', ...) делает
|
# кластера, поэтому учётные данные etl передаются явно. Порт 9000 — нативный,
|
||||||
# инициатором распределённого запроса саму ноду 2 — проверяется именно это, а
|
# тогда как подключение Airflow ходит по HTTP на 8123. Trace-журнал сервера
|
||||||
# не доступность ноды 2 по сети. Порт 9000 — межсерверный, тогда как
|
# видит пароль: это допустимо только для локального учебного стенда.
|
||||||
# подключение Airflow ходит по HTTP на 8123.
|
|
||||||
NODES = (
|
NODES = (
|
||||||
("ноде 1", "system.tables"),
|
("ноде 1", "system.tables"),
|
||||||
("ноде 2", "remote('clickhouse-02:9000', system.tables)"),
|
(
|
||||||
|
"ноде 2",
|
||||||
|
"""remote(
|
||||||
|
'clickhouse-02:9000', 'system', 'tables',
|
||||||
|
{remote_user:String}, {remote_password:String}
|
||||||
|
)""",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _clickhouse_client():
|
def _remote_parameters() -> dict[str, str]:
|
||||||
# clickhouse_connect стоит только в образе Airflow, а малые проверки грузят
|
|
||||||
# этот модуль обычным интерпретатором, где пакета нет. Импорт верхнего
|
|
||||||
# уровня красит make config-test, поэтому он живёт здесь.
|
|
||||||
import clickhouse_connect
|
|
||||||
|
|
||||||
connection = Connection.get("clickhouse_default")
|
connection = Connection.get("clickhouse_default")
|
||||||
return clickhouse_connect.get_client(
|
return {
|
||||||
host=connection.host,
|
"remote_user": connection.login,
|
||||||
port=connection.port,
|
"remote_password": connection.password,
|
||||||
username=connection.login or "default",
|
}
|
||||||
password=connection.password or "",
|
|
||||||
database=connection.schema or "default",
|
|
||||||
connect_timeout=5,
|
|
||||||
send_receive_timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _table_engines(client, source: str) -> list[tuple[str, str]]:
|
def _clickhouse_hook() -> ClickHouseHook:
|
||||||
result = client.query(
|
return ClickHouseHook(clickhouse_conn_id="clickhouse_default")
|
||||||
|
|
||||||
|
|
||||||
|
def _table_engines(hook: ClickHouseHook, source: str) -> list[tuple[str, str]]:
|
||||||
|
return hook.get_records(
|
||||||
f"""
|
f"""
|
||||||
SELECT name, engine
|
SELECT name, engine
|
||||||
FROM {source}
|
FROM {source}
|
||||||
WHERE database = 'default'
|
WHERE database = 'default'
|
||||||
AND name IN ('{LOCAL_TABLE}', '{DISTRIBUTED_TABLE}')
|
AND name IN ('{LOCAL_TABLE}', '{DISTRIBUTED_TABLE}')
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
"""
|
""",
|
||||||
)
|
parameters=_remote_parameters(),
|
||||||
return result.result_rows
|
|
||||||
|
|
||||||
|
|
||||||
def _drop_tables(client) -> None:
|
|
||||||
client.command(
|
|
||||||
f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} "
|
|
||||||
f"ON CLUSTER {CLUSTER} SYNC"
|
|
||||||
)
|
|
||||||
client.command(
|
|
||||||
f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _assert_tables_absent(client) -> None:
|
def _drop_tables(hook: ClickHouseHook) -> None:
|
||||||
|
hook.run(
|
||||||
|
[
|
||||||
|
f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} "
|
||||||
|
f"ON CLUSTER {CLUSTER} SYNC",
|
||||||
|
f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Отсутствие таблиц проверяют обе задачи — перед созданием и после уборки,
|
||||||
|
# — поэтому у этой проверки своё имя, а остальные живут прямо в теле задач.
|
||||||
|
def _assert_tables_absent(hook: ClickHouseHook) -> None:
|
||||||
for node_name, source in NODES:
|
for node_name, source in NODES:
|
||||||
remaining = _table_engines(client, source)
|
remaining = _table_engines(hook, source)
|
||||||
if remaining:
|
if remaining:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"служебные таблицы остались на {node_name}: {remaining}"
|
f"служебные таблицы остались на {node_name}: {remaining}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _assert_tables_created(client) -> None:
|
|
||||||
for node_name, source in NODES:
|
|
||||||
actual_tables = _table_engines(client, source)
|
|
||||||
if actual_tables != EXPECTED_TABLES:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"неверный набор таблиц на {node_name}: {actual_tables}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_marker_path(
|
|
||||||
*,
|
|
||||||
distributed_rows: list[tuple[int, str, str]],
|
|
||||||
write_hostname: str,
|
|
||||||
node_2_hostname: str,
|
|
||||||
marker: str,
|
|
||||||
) -> None:
|
|
||||||
if write_hostname == node_2_hostname:
|
|
||||||
raise RuntimeError("запись и чтение маркера должны выполняться с разных нод")
|
|
||||||
expected_rows = [(1, write_hostname, marker)]
|
|
||||||
if distributed_rows != expected_rows:
|
|
||||||
raise RuntimeError(
|
|
||||||
"нода 2 не прочитала маркер первого шарда через Distributed: "
|
|
||||||
f"{marker}, получено {distributed_rows}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dag(
|
@dag(
|
||||||
dag_id="test_clickhouse",
|
dag_id="test_clickhouse",
|
||||||
schedule=None,
|
schedule=None,
|
||||||
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
|
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
|
||||||
catchup=False,
|
catchup=False,
|
||||||
tags=["проверка"],
|
tags=["проверка"],
|
||||||
doc_md=__doc__,
|
doc_md=__doc__,
|
||||||
@@ -128,11 +104,11 @@ def _assert_marker_path(
|
|||||||
def test_clickhouse():
|
def test_clickhouse():
|
||||||
@task
|
@task
|
||||||
def prepare_tables() -> None:
|
def prepare_tables() -> None:
|
||||||
client = _clickhouse_client()
|
hook = _clickhouse_hook()
|
||||||
try:
|
_drop_tables(hook)
|
||||||
_drop_tables(client)
|
_assert_tables_absent(hook)
|
||||||
_assert_tables_absent(client)
|
hook.run(
|
||||||
client.command(
|
[
|
||||||
f"""
|
f"""
|
||||||
CREATE TABLE default.{LOCAL_TABLE} ON CLUSTER {CLUSTER}
|
CREATE TABLE default.{LOCAL_TABLE} ON CLUSTER {CLUSTER}
|
||||||
(
|
(
|
||||||
@@ -143,9 +119,7 @@ def test_clickhouse():
|
|||||||
'{{replica}}'
|
'{{replica}}'
|
||||||
)
|
)
|
||||||
ORDER BY marker
|
ORDER BY marker
|
||||||
"""
|
""",
|
||||||
)
|
|
||||||
client.command(
|
|
||||||
f"""
|
f"""
|
||||||
CREATE TABLE default.{DISTRIBUTED_TABLE} ON CLUSTER {CLUSTER}
|
CREATE TABLE default.{DISTRIBUTED_TABLE} ON CLUSTER {CLUSTER}
|
||||||
AS default.{LOCAL_TABLE}
|
AS default.{LOCAL_TABLE}
|
||||||
@@ -155,70 +129,77 @@ def test_clickhouse():
|
|||||||
'{LOCAL_TABLE}',
|
'{LOCAL_TABLE}',
|
||||||
cityHash64(marker)
|
cityHash64(marker)
|
||||||
)
|
)
|
||||||
"""
|
""",
|
||||||
)
|
],
|
||||||
_assert_tables_created(client)
|
)
|
||||||
finally:
|
for node_name, source in NODES:
|
||||||
client.close()
|
actual_tables = _table_engines(hook, source)
|
||||||
|
if actual_tables != EXPECTED_TABLES:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"неверный набор таблиц на {node_name}: {actual_tables}"
|
||||||
|
)
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def write_marker() -> dict[str, str]:
|
def write_marker() -> dict[str, str]:
|
||||||
client = _clickhouse_client()
|
hook = _clickhouse_hook()
|
||||||
try:
|
marker = f"{get_current_context()['run_id']}:{uuid.uuid4()}"
|
||||||
marker = f"{get_current_context()['run_id']}:{uuid.uuid4()}"
|
hook.bulk_insert_rows(
|
||||||
client.insert(
|
f"default.{LOCAL_TABLE}",
|
||||||
f"default.{LOCAL_TABLE}",
|
[[marker]],
|
||||||
[[marker]],
|
column_names=["marker"],
|
||||||
column_names=["marker"],
|
)
|
||||||
)
|
local_rows = hook.get_records(
|
||||||
local_rows = client.query(
|
f"""
|
||||||
f"""
|
SELECT hostName(), marker
|
||||||
SELECT hostName(), marker
|
FROM default.{LOCAL_TABLE}
|
||||||
FROM default.{LOCAL_TABLE}
|
WHERE marker = {{marker:String}}
|
||||||
WHERE marker = {{marker:String}}
|
""",
|
||||||
""",
|
parameters={"marker": marker},
|
||||||
parameters={"marker": marker},
|
)
|
||||||
).result_rows
|
if len(local_rows) != 1 or local_rows[0][1] != marker:
|
||||||
if len(local_rows) != 1 or local_rows[0][1] != marker:
|
raise RuntimeError(f"маркер не найден в локальной таблице: {marker}")
|
||||||
raise RuntimeError(f"маркер не найден в локальной таблице: {marker}")
|
return {"marker": marker, "hostname": local_rows[0][0]}
|
||||||
return {"marker": marker, "hostname": local_rows[0][0]}
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def read_from_node_2(written: dict[str, str]) -> None:
|
def read_from_node_2(written: dict[str, str]) -> None:
|
||||||
client = _clickhouse_client()
|
hook = _clickhouse_hook()
|
||||||
try:
|
node_2_rows = hook.get_records(
|
||||||
node_2_rows = client.query(
|
"""
|
||||||
"""
|
SELECT hostName()
|
||||||
SELECT hostName()
|
FROM remote(
|
||||||
FROM remote('clickhouse-02:9000', system.one)
|
'clickhouse-02:9000', 'system', 'one',
|
||||||
"""
|
{remote_user:String}, {remote_password:String}
|
||||||
).result_rows
|
)
|
||||||
if len(node_2_rows) != 1:
|
""",
|
||||||
raise RuntimeError(
|
parameters=_remote_parameters(),
|
||||||
f"не удалось определить имя ноды 2: {node_2_rows}"
|
)
|
||||||
)
|
if len(node_2_rows) != 1:
|
||||||
distributed_rows = client.query(
|
raise RuntimeError(f"не удалось определить имя ноды 2: {node_2_rows}")
|
||||||
f"""
|
distributed_rows = hook.get_records(
|
||||||
SELECT _shard_num, hostName(), marker
|
f"""
|
||||||
FROM remote(
|
SELECT _shard_num, hostName(), marker
|
||||||
'clickhouse-02:9000',
|
FROM remote(
|
||||||
'default',
|
'clickhouse-02:9000',
|
||||||
'{DISTRIBUTED_TABLE}'
|
'default',
|
||||||
)
|
'{DISTRIBUTED_TABLE}',
|
||||||
WHERE marker = {{marker:String}}
|
{{remote_user:String}},
|
||||||
""",
|
{{remote_password:String}}
|
||||||
parameters={"marker": written["marker"]},
|
)
|
||||||
).result_rows
|
WHERE marker = {{marker:String}}
|
||||||
_assert_marker_path(
|
""",
|
||||||
distributed_rows=distributed_rows,
|
parameters={
|
||||||
write_hostname=written["hostname"],
|
"marker": written["marker"],
|
||||||
node_2_hostname=node_2_rows[0][0],
|
**_remote_parameters(),
|
||||||
marker=written["marker"],
|
},
|
||||||
|
)
|
||||||
|
if written["hostname"] == node_2_rows[0][0]:
|
||||||
|
raise RuntimeError("запись и чтение маркера должны идти с разных нод")
|
||||||
|
expected_rows = [(1, written["hostname"], written["marker"])]
|
||||||
|
if distributed_rows != expected_rows:
|
||||||
|
raise RuntimeError(
|
||||||
|
"нода 2 не прочитала маркер первого шарда через Distributed: "
|
||||||
|
f"{written['marker']}, получено {distributed_rows}"
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
# Уборка идёт только после успеха: упавший пробник оставляет кластер таким,
|
# Уборка идёт только после успеха: упавший пробник оставляет кластер таким,
|
||||||
# каким сломался, а остатки сносит начало следующего запуска. Правило
|
# каким сломался, а остатки сносит начало следующего запуска. Правило
|
||||||
@@ -227,12 +208,9 @@ def test_clickhouse():
|
|||||||
# с упавшей проверкой (ADR 0003).
|
# с упавшей проверкой (ADR 0003).
|
||||||
@task
|
@task
|
||||||
def cleanup_tables() -> None:
|
def cleanup_tables() -> None:
|
||||||
client = _clickhouse_client()
|
hook = _clickhouse_hook()
|
||||||
try:
|
_drop_tables(hook)
|
||||||
_drop_tables(client)
|
_assert_tables_absent(hook)
|
||||||
_assert_tables_absent(client)
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
prepared = prepare_tables()
|
prepared = prepare_tables()
|
||||||
written = write_marker()
|
written = write_marker()
|
||||||
|
|||||||
+31
-52
@@ -74,52 +74,10 @@ class RecordAddress(NamedTuple):
|
|||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
def _assert_all_delivered(undelivered: int, marker: str) -> None:
|
|
||||||
if undelivered:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Kafka не приняла маркер за {FLUSH_TIMEOUT_SEC} с, "
|
|
||||||
f"не доставлено сообщений {undelivered}: {marker}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_no_delivery_errors(delivery_errors: list[str], marker: str) -> None:
|
|
||||||
if delivery_errors:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Kafka отказалась принять маркер {marker}: {delivery_errors}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_confirmed_once(addresses: list[RecordAddress], marker: str) -> None:
|
|
||||||
if len(addresses) != 1:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Kafka подтвердила запись маркера {marker} "
|
|
||||||
f"не одним сообщением: {addresses}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_message_arrived(message, marker: str) -> None:
|
|
||||||
if message is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Kafka молчала {READ_DEADLINE_SEC} с и не вернула маркер: {marker}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_no_read_error(message) -> None:
|
|
||||||
if message.error():
|
|
||||||
raise RuntimeError(f"Kafka вернула ошибку чтения: {message.error()}")
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_marker_matches(message, marker: str) -> None:
|
|
||||||
if message.value() != marker.encode():
|
|
||||||
raise RuntimeError(
|
|
||||||
f"по адресу записи лежит не маркер запуска {marker}: {message.value()!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dag(
|
@dag(
|
||||||
dag_id="test_kafka",
|
dag_id="test_kafka",
|
||||||
schedule=None,
|
schedule=None,
|
||||||
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
|
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
|
||||||
catchup=False,
|
catchup=False,
|
||||||
tags=["проверка"],
|
tags=["проверка"],
|
||||||
doc_md=__doc__,
|
doc_md=__doc__,
|
||||||
@@ -127,6 +85,10 @@ def _assert_marker_matches(message, marker: str) -> None:
|
|||||||
def test_kafka():
|
def test_kafka():
|
||||||
@task
|
@task
|
||||||
def write_marker() -> dict[str, str | int]:
|
def write_marker() -> dict[str, str | int]:
|
||||||
|
# confluent_kafka импортируется внутри задачи, а не наверху файла:
|
||||||
|
# обработчик DAG разбирает этот файл снова и снова, и импорт наверху
|
||||||
|
# оплачивался бы каждым разбором. Тяжёлые импорты Airflow советует
|
||||||
|
# держать внутри задач.
|
||||||
from confluent_kafka import Producer
|
from confluent_kafka import Producer
|
||||||
|
|
||||||
marker = f"{get_current_context()['run_id']}:{uuid.uuid4()}"
|
marker = f"{get_current_context()['run_id']}:{uuid.uuid4()}"
|
||||||
@@ -138,9 +100,7 @@ def test_kafka():
|
|||||||
if error is not None:
|
if error is not None:
|
||||||
delivery_errors.append(str(error))
|
delivery_errors.append(str(error))
|
||||||
else:
|
else:
|
||||||
addresses.append(
|
addresses.append(RecordAddress(message.partition(), message.offset()))
|
||||||
RecordAddress(message.partition(), message.offset())
|
|
||||||
)
|
|
||||||
|
|
||||||
producer = Producer(PRODUCER_CONFIG)
|
producer = Producer(PRODUCER_CONFIG)
|
||||||
# produce() не пишет, а ставит сообщение в очередь: о судьбе записи
|
# produce() не пишет, а ставит сообщение в очередь: о судьбе записи
|
||||||
@@ -158,9 +118,20 @@ def test_kafka():
|
|||||||
finally:
|
finally:
|
||||||
undelivered = producer.flush(FLUSH_TIMEOUT_SEC)
|
undelivered = producer.flush(FLUSH_TIMEOUT_SEC)
|
||||||
|
|
||||||
_assert_all_delivered(undelivered, marker)
|
if undelivered:
|
||||||
_assert_no_delivery_errors(delivery_errors, marker)
|
raise RuntimeError(
|
||||||
_assert_confirmed_once(addresses, marker)
|
f"Kafka не приняла маркер за {FLUSH_TIMEOUT_SEC} с, "
|
||||||
|
f"не доставлено сообщений {undelivered}: {marker}"
|
||||||
|
)
|
||||||
|
if delivery_errors:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Kafka отказалась принять маркер {marker}: {delivery_errors}"
|
||||||
|
)
|
||||||
|
if len(addresses) != 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Kafka подтвердила запись маркера {marker} "
|
||||||
|
f"не одним сообщением: {addresses}"
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"marker": marker,
|
"marker": marker,
|
||||||
"partition": addresses[0].partition,
|
"partition": addresses[0].partition,
|
||||||
@@ -190,9 +161,17 @@ def test_kafka():
|
|||||||
# другого нечем, поэтому у чтения обязан быть крайний срок.
|
# другого нечем, поэтому у чтения обязан быть крайний срок.
|
||||||
while message is None and time.monotonic() < deadline:
|
while message is None and time.monotonic() < deadline:
|
||||||
message = consumer.poll(POLL_TIMEOUT_SEC)
|
message = consumer.poll(POLL_TIMEOUT_SEC)
|
||||||
_assert_message_arrived(message, marker)
|
if message is None:
|
||||||
_assert_no_read_error(message)
|
raise RuntimeError(
|
||||||
_assert_marker_matches(message, marker)
|
f"Kafka молчала {READ_DEADLINE_SEC} с и не вернула маркер: {marker}"
|
||||||
|
)
|
||||||
|
if message.error():
|
||||||
|
raise RuntimeError(f"Kafka вернула ошибку чтения: {message.error()}")
|
||||||
|
if message.value() != marker.encode():
|
||||||
|
raise RuntimeError(
|
||||||
|
f"по адресу записи лежит не маркер запуска {marker}: "
|
||||||
|
f"{message.value()!r}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
consumer.close()
|
consumer.close()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""Пульт мира: даги, которыми двигают ось модельного времени.
|
||||||
|
|
||||||
|
Их три, и они двух родов. **Работники** играют день и запускаются руками:
|
||||||
|
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
|
||||||
|
один день в темпе модельного времени. **Выключатель** `world_live` своей работы
|
||||||
|
не делает: он тикает по расписанию и дёргает работника живого дня, дожидаясь
|
||||||
|
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
|
||||||
|
границе модельных суток.
|
||||||
|
|
||||||
|
Сыграть день — половина работы. Вторая половина: отправить слепок заказов и
|
||||||
|
дождаться, пока хранилище его заберёт. Поэтому у обоих работников за проигрышем
|
||||||
|
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
|
||||||
|
`orders_ingest`.
|
||||||
|
|
||||||
|
Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка
|
||||||
|
только разгоняет источники: слои после нее менти догоняет ручным запуском.
|
||||||
|
|
||||||
|
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку
|
||||||
|
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник
|
||||||
|
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
|
||||||
|
|
||||||
|
Календарь Airflow к оси мира отношения не имеет: какой день играть, работник
|
||||||
|
спрашивает у переменной, а не у логической даты прогона.
|
||||||
|
|
||||||
|
Решения и доводы целиком — ADR 0009.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from airflow.exceptions import AirflowException
|
||||||
|
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"],
|
||||||
|
}
|
||||||
|
# Топик слепка называется аргументом, а не окружением: KAFKA_TOPIC выше — топик
|
||||||
|
# событий, и промолчи мы, заказы уехали бы к ним.
|
||||||
|
ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"]
|
||||||
|
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
|
||||||
|
|
||||||
|
# Позиция на оси: номер первого несыгранного дня. До конца инициализации
|
||||||
|
# работники не имеют права угадывать её — состояние объяснено в ADR 0013.
|
||||||
|
#
|
||||||
|
# Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее
|
||||||
|
# при таком правиле — сыгранный дважды день, а повтор схлопнет
|
||||||
|
# ReplacingMergeTree. Увеличение молча съело бы день, и в мире осталась бы
|
||||||
|
# дыра, которой никто не заметит.
|
||||||
|
WORLD_POSITION = "world_position"
|
||||||
|
|
||||||
|
# Тик выключателя. Каденцию задаёт не он, а сама длина живого дня — около
|
||||||
|
# двадцати четырёх минут: тик только спрашивает «не пора ли снова».
|
||||||
|
LIVE_TICK = datetime.timedelta(minutes=25)
|
||||||
|
|
||||||
|
# Какой день играть, работник узнаёт у первой задачи. Спрашивают её все
|
||||||
|
# генераторные шаги: слепок снимается с той же позиции, что и проигрыш.
|
||||||
|
PLAYED_DAY = "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}"
|
||||||
|
|
||||||
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
|
TAGS = ["пульт мира"]
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def first_unplayed_day() -> int:
|
||||||
|
"""Номер дня, с которого играть."""
|
||||||
|
raw_position = Variable.get(WORLD_POSITION, default=None)
|
||||||
|
if raw_position is None:
|
||||||
|
raise AirflowException(
|
||||||
|
"мир ещё не создан: выполните make up и дождитесь world_initialize"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
position = int(raw_position)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise AirflowException(
|
||||||
|
f"world_position={raw_position!r} не является номером дня; "
|
||||||
|
"выполните make rebuild-storage"
|
||||||
|
) from error
|
||||||
|
if position < STARTING_DAYS:
|
||||||
|
action = (
|
||||||
|
"выполните make up" if position == 0 else "выполните make rebuild-storage"
|
||||||
|
)
|
||||||
|
raise AirflowException(f"мир не готов: world_position={position}; {action}")
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
def _generator(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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ingest_orders() -> TriggerDagRunOperator:
|
||||||
|
"""Задача забора: дёрнуть даг приёма и дождаться, чем он кончился.
|
||||||
|
|
||||||
|
Ожидание здесь несущее. Без него работник позеленел бы, не узнав, доехал
|
||||||
|
ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа
|
||||||
|
не должен переживать отказ выше (ADR 0003).
|
||||||
|
"""
|
||||||
|
return TriggerDagRunOperator(
|
||||||
|
task_id="trigger_orders_ingest",
|
||||||
|
trigger_dag_id="orders_ingest",
|
||||||
|
wait_for_completion=True,
|
||||||
|
# Умолчание — минута опроса, а весь прогон работника пачкой короче.
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
maximum=7,
|
||||||
|
title="Сколько дней прожить",
|
||||||
|
# description_md рендерится подсказкой прямо в форме запуска
|
||||||
|
# (params.rst, Airflow 3.3), чтобы правило было видно сразу.
|
||||||
|
description_md=(
|
||||||
|
"Форма задает только число дней. Номер первого несыгранного "
|
||||||
|
"дня работник берет из переменной `world_position` (Admin → "
|
||||||
|
"Variables). Поля Logical date и Run ID нужны самому Airflow "
|
||||||
|
"и на выбор дня не влияют."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def world_next_day():
|
||||||
|
"""Прожить следующие дни пачкой, без пауз.
|
||||||
|
|
||||||
|
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
|
||||||
|
нажимом, а не десятью: сколько дней играть — параметр запуска. Первый
|
||||||
|
день работник берет из `world_position`. Логическая дата прогона на выбор
|
||||||
|
дня не влияет.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@task
|
||||||
|
def remember_played(first_day: int) -> None:
|
||||||
|
"""Позиция ставится по сыгранным дням и только по успеху."""
|
||||||
|
days = get_current_context()["params"]["days"]
|
||||||
|
position = first_day + days
|
||||||
|
Variable.set(WORLD_POSITION, str(position))
|
||||||
|
logging.info(
|
||||||
|
"дни %s–%s сыграны; новая позиция — %s", first_day, position - 1, position
|
||||||
|
)
|
||||||
|
|
||||||
|
first_day = first_unplayed_day()
|
||||||
|
played = _generator(
|
||||||
|
"play_days",
|
||||||
|
["batch", "--day", PLAYED_DAY, "--days", "{{ params.days }}"],
|
||||||
|
)
|
||||||
|
# Разгон на N дней отправляет N слепков — по одному за сыгранный день, и
|
||||||
|
# каждый со своим сдвигом: прогон дня D везёт слепок дня D−1. Забор при
|
||||||
|
# этом остаётся один.
|
||||||
|
sent = _generator(
|
||||||
|
"send_snapshots",
|
||||||
|
[
|
||||||
|
"snapshot",
|
||||||
|
"--day",
|
||||||
|
PLAYED_DAY,
|
||||||
|
"--days",
|
||||||
|
"{{ params.days }}",
|
||||||
|
"--topic",
|
||||||
|
ORDERS_TOPIC,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
first_day >> played >> sent >> _ingest_orders() >> 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`.
|
||||||
|
Следующий день работник берет из `world_position`. Логическая дата прогона
|
||||||
|
на выбор дня не влияет.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@task
|
||||||
|
def remember_played(first_day: int) -> None:
|
||||||
|
"""Позиция ставится по сыгранному дню и только по успеху."""
|
||||||
|
Variable.set(WORLD_POSITION, str(first_day + 1))
|
||||||
|
logging.info("сыгран день %s; новая позиция — %s", first_day, first_day + 1)
|
||||||
|
|
||||||
|
first_day = first_unplayed_day()
|
||||||
|
played = _generator("play_day", ["live", "--day", PLAYED_DAY])
|
||||||
|
# Слепок уезжает пачкой и после дня, а не в темпе: у выгрузки бэкенда темпа
|
||||||
|
# нет вовсе — она снимается на границе суток целиком.
|
||||||
|
sent = _generator(
|
||||||
|
"send_snapshot",
|
||||||
|
["snapshot", "--day", PLAYED_DAY, "--topic", ORDERS_TOPIC],
|
||||||
|
)
|
||||||
|
|
||||||
|
position = remember_played(first_day)
|
||||||
|
etl = TriggerDagRunOperator(
|
||||||
|
task_id="trigger_etl_pipeline",
|
||||||
|
trigger_dag_id="etl_pipeline",
|
||||||
|
wait_for_completion=True,
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
first_day >> played >> sent >> _ingest_orders() >> position >> etl
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
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()
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"""Создание и пересоздание прикладного мира.
|
||||||
|
|
||||||
|
Compose поднимает службы, а этот граф создаёт всё прикладное поверх них:
|
||||||
|
топики, схему ClickHouse, данные стартовых дней, позицию модельного времени и
|
||||||
|
готовые слои. `world_initialize` безопасен для обычного `make up`;
|
||||||
|
`world_recreate` — явная операция обслуживания, которая сначала объявляет
|
||||||
|
прежний мир недействительным.
|
||||||
|
|
||||||
|
Контракт состояний и доводы — ADR 0013 и спека жизненного цикла мира.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from airflow.exceptions import AirflowException
|
||||||
|
from airflow.providers.clickhousedb.hooks.clickhouse import ClickHouseHook
|
||||||
|
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
|
||||||
|
from airflow.providers.docker.hooks.docker import DockerHook
|
||||||
|
from airflow.providers.docker.operators.docker import DockerOperator
|
||||||
|
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
|
||||||
|
from airflow.sdk import Variable, chain, dag, task
|
||||||
|
|
||||||
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
|
TAGS = ["жизненный цикл мира"]
|
||||||
|
|
||||||
|
WORLD_POSITION = "world_position"
|
||||||
|
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
|
||||||
|
HITS_TOPIC = os.environ["KAFKA_TOPIC"]
|
||||||
|
ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"]
|
||||||
|
KAFKA_BOOTSTRAP_SERVERS = os.environ["KAFKA_BOOTSTRAP_SERVERS"]
|
||||||
|
GENERATOR_IMAGE = os.environ["GENERATOR_IMAGE"]
|
||||||
|
STAND_NETWORK = os.environ["STAND_NETWORK"]
|
||||||
|
|
||||||
|
SQL_ROOT = Path("/opt/airflow/sql")
|
||||||
|
DDL_ROOT = SQL_ROOT / "ddl"
|
||||||
|
WORLD_ROOT = Path("/opt/airflow/world")
|
||||||
|
INVENTORY = WORLD_ROOT / "data/world-inventory.json"
|
||||||
|
CLICKHOUSE_CONNECTION = "clickhouse_lifecycle"
|
||||||
|
CLICKHOUSE_CLUSTER = "clickstream_cluster"
|
||||||
|
APPLICATION_DATABASES = ("stg", "dm", "dds", "ods", "dic")
|
||||||
|
CONSUMER_GROUPS = ("clickstream_hits", "clickstream_orders")
|
||||||
|
TOPICS = {HITS_TOPIC: 2, ORDERS_TOPIC: 1}
|
||||||
|
|
||||||
|
|
||||||
|
def _clickhouse_hook() -> ClickHouseHook:
|
||||||
|
"""Подключить владельца жизненного цикла."""
|
||||||
|
return ClickHouseHook(clickhouse_conn_id=CLICKHOUSE_CONNECTION)
|
||||||
|
|
||||||
|
|
||||||
|
def _generator(task_id: str, command: list[str]) -> DockerOperator:
|
||||||
|
"""Запустить канонический образ генератора в сети стенда."""
|
||||||
|
return DockerOperator(
|
||||||
|
task_id=task_id,
|
||||||
|
image=GENERATOR_IMAGE,
|
||||||
|
command=command,
|
||||||
|
network_mode=STAND_NETWORK,
|
||||||
|
environment={
|
||||||
|
"KAFKA_BOOTSTRAP_SERVERS": KAFKA_BOOTSTRAP_SERVERS,
|
||||||
|
"KAFKA_TOPIC": HITS_TOPIC,
|
||||||
|
},
|
||||||
|
# Вывод уже переехал в журнал задачи; завершённый контейнер не нужен.
|
||||||
|
auto_remove="force",
|
||||||
|
# Временный путь Airflow существует внутри контейнера, но монтировать
|
||||||
|
# его пытается хостовый Docker. Генератору этот каталог не нужен.
|
||||||
|
mount_tmp_dir=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _kafka_error_code(error: Exception) -> int:
|
||||||
|
"""Код ошибки из KafkaException."""
|
||||||
|
return error.args[0].code()
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
dag_id="world_initialize",
|
||||||
|
schedule=None,
|
||||||
|
start_date=START_DATE,
|
||||||
|
is_paused_upon_creation=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
template_searchpath=str(SQL_ROOT),
|
||||||
|
tags=TAGS,
|
||||||
|
)
|
||||||
|
def world_initialize():
|
||||||
|
"""Создать стартовый мир, если готового мира ещё нет."""
|
||||||
|
|
||||||
|
@task.branch
|
||||||
|
def choose_path() -> str:
|
||||||
|
raw_position = Variable.get(WORLD_POSITION, default=None)
|
||||||
|
if raw_position is None:
|
||||||
|
logging.info("world_position отсутствует: создаём первый мир")
|
||||||
|
return "create_topics"
|
||||||
|
try:
|
||||||
|
position = int(raw_position)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise AirflowException(
|
||||||
|
f"world_position={raw_position!r} не является номером дня; "
|
||||||
|
"выполните make rebuild-storage"
|
||||||
|
) from error
|
||||||
|
if position == 0:
|
||||||
|
logging.info("очистка завершена: создаём мир заново")
|
||||||
|
return "create_topics"
|
||||||
|
if position == -1:
|
||||||
|
raise AirflowException(
|
||||||
|
"world_position=-1: очистка мира не завершена; "
|
||||||
|
"повторите make rebuild-storage"
|
||||||
|
)
|
||||||
|
if position >= STARTING_DAYS:
|
||||||
|
logging.info("мир уже существует: world_position=%s", position)
|
||||||
|
return "world_already_exists"
|
||||||
|
raise AirflowException(
|
||||||
|
f"world_position={position}: недопустимое незавершённое состояние; "
|
||||||
|
"выполните make rebuild-storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def world_already_exists() -> None:
|
||||||
|
"""Читаемый зелёный конец ветки без изменений."""
|
||||||
|
logging.info("инициализация не требуется: готовый мир не изменён")
|
||||||
|
|
||||||
|
@task
|
||||||
|
def create_topics() -> None:
|
||||||
|
"""Создать топики до появления читающих их таблиц ClickHouse."""
|
||||||
|
from confluent_kafka import KafkaError, KafkaException
|
||||||
|
from confluent_kafka.admin import AdminClient, NewTopic
|
||||||
|
|
||||||
|
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
|
||||||
|
futures = admin.create_topics(
|
||||||
|
[
|
||||||
|
NewTopic(name, num_partitions=partitions, replication_factor=1)
|
||||||
|
for name, partitions in TOPICS.items()
|
||||||
|
],
|
||||||
|
request_timeout=30,
|
||||||
|
operation_timeout=15,
|
||||||
|
)
|
||||||
|
for name, future in futures.items():
|
||||||
|
try:
|
||||||
|
future.result()
|
||||||
|
logging.info("топик %s создан", name)
|
||||||
|
except KafkaException as error:
|
||||||
|
if _kafka_error_code(error) != KafkaError.TOPIC_ALREADY_EXISTS:
|
||||||
|
raise
|
||||||
|
logging.info("топик %s уже существует", name)
|
||||||
|
|
||||||
|
metadata = admin.list_topics(timeout=15)
|
||||||
|
for name, expected_partitions in TOPICS.items():
|
||||||
|
topic = metadata.topics.get(name)
|
||||||
|
actual_partitions = (
|
||||||
|
len(topic.partitions) if topic and not topic.error else 0
|
||||||
|
)
|
||||||
|
if actual_partitions != expected_partitions:
|
||||||
|
raise AirflowException(
|
||||||
|
f"у топика {name} разделов {actual_partitions}, "
|
||||||
|
f"ожидалось {expected_partitions}; "
|
||||||
|
"выполните make rebuild-storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def build_generator_image() -> None:
|
||||||
|
"""Собрать генератор из текущего дерева, а не из старого образа."""
|
||||||
|
client = DockerHook(
|
||||||
|
docker_conn_id=None,
|
||||||
|
base_url="unix://var/run/docker.sock",
|
||||||
|
timeout=900,
|
||||||
|
).get_conn()
|
||||||
|
output = client.build(
|
||||||
|
path=str(WORLD_ROOT),
|
||||||
|
dockerfile="generator/Dockerfile",
|
||||||
|
tag=GENERATOR_IMAGE,
|
||||||
|
rm=True,
|
||||||
|
decode=True,
|
||||||
|
)
|
||||||
|
for item in output:
|
||||||
|
if error := item.get("error"):
|
||||||
|
raise AirflowException(
|
||||||
|
f"сборка генератора завершилась ошибкой: {error}"
|
||||||
|
)
|
||||||
|
if stream := item.get("stream", "").strip():
|
||||||
|
logging.info("docker build: %s", stream)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def wait_for_events() -> None:
|
||||||
|
"""Дождаться приёма всех событий описи, включая строки брака."""
|
||||||
|
inventory = json.loads(INVENTORY.read_text(encoding="utf-8"))
|
||||||
|
expected = sum(day["events"] for day in inventory["days"])
|
||||||
|
first_date = inventory["days"][0]["date"]
|
||||||
|
last_date = inventory["days"][-1]["date"]
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
(SELECT count() FROM ods.event_dist
|
||||||
|
WHERE EventDate BETWEEN '{first_date}' AND '{last_date}')
|
||||||
|
+ (SELECT count() FROM ods.event_errors_dist)
|
||||||
|
"""
|
||||||
|
|
||||||
|
hook = _clickhouse_hook()
|
||||||
|
arrived = 0
|
||||||
|
for attempt in range(1, 101):
|
||||||
|
row = hook.get_first(query)
|
||||||
|
if row is None:
|
||||||
|
raise AirflowException("ClickHouse не вернул счётчик приёма")
|
||||||
|
arrived = int(row[0])
|
||||||
|
if arrived >= expected:
|
||||||
|
logging.info("стартовый мир принят: строк %s", arrived)
|
||||||
|
return
|
||||||
|
if attempt % 5 == 0:
|
||||||
|
logging.info("принято %s из %s строк", arrived, expected)
|
||||||
|
time.sleep(3)
|
||||||
|
raise AirflowException(
|
||||||
|
f"стартовый мир не принят за 300 с: строк {arrived} из {expected}; "
|
||||||
|
"проверьте журнал send_initial_events и "
|
||||||
|
"SELECT * FROM system.kafka_consumers"
|
||||||
|
)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def remember_starting_position() -> None:
|
||||||
|
"""Объявить мир готовым только после приёма обоих источников."""
|
||||||
|
Variable.set(WORLD_POSITION, str(STARTING_DAYS))
|
||||||
|
logging.info("мир готов: world_position=%s", STARTING_DAYS)
|
||||||
|
|
||||||
|
route = choose_path()
|
||||||
|
ready = world_already_exists()
|
||||||
|
topics = create_topics()
|
||||||
|
ddl_files = sorted(DDL_ROOT.glob("*.sql"))
|
||||||
|
if not ddl_files:
|
||||||
|
raise AirflowException(f"в {DDL_ROOT} нет файлов DDL")
|
||||||
|
ddl_tasks = [
|
||||||
|
SQLExecuteQueryOperator(
|
||||||
|
task_id=f"apply_{path.stem.replace('-', '_')}",
|
||||||
|
conn_id=CLICKHOUSE_CONNECTION,
|
||||||
|
sql=f"ddl/{path.name}",
|
||||||
|
split_statements=True,
|
||||||
|
do_xcom_push=False,
|
||||||
|
)
|
||||||
|
for path in ddl_files
|
||||||
|
]
|
||||||
|
image = build_generator_image()
|
||||||
|
events = _generator(
|
||||||
|
"send_initial_events",
|
||||||
|
["batch", "--day", "0", "--days", str(STARTING_DAYS)],
|
||||||
|
)
|
||||||
|
arrived = wait_for_events()
|
||||||
|
snapshots = _generator(
|
||||||
|
"send_initial_snapshots",
|
||||||
|
[
|
||||||
|
"snapshot",
|
||||||
|
"--day",
|
||||||
|
"0",
|
||||||
|
"--days",
|
||||||
|
str(STARTING_DAYS),
|
||||||
|
"--topic",
|
||||||
|
ORDERS_TOPIC,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
orders = TriggerDagRunOperator(
|
||||||
|
task_id="trigger_orders_ingest",
|
||||||
|
trigger_dag_id="orders_ingest",
|
||||||
|
wait_for_completion=True,
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
position = remember_starting_position()
|
||||||
|
etl = TriggerDagRunOperator(
|
||||||
|
task_id="trigger_etl_pipeline",
|
||||||
|
trigger_dag_id="etl_pipeline",
|
||||||
|
wait_for_completion=True,
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
route >> [ready, topics]
|
||||||
|
chain(topics, *ddl_tasks, image, events, arrived, snapshots, orders, position, etl)
|
||||||
|
|
||||||
|
|
||||||
|
@dag(
|
||||||
|
dag_id="world_recreate",
|
||||||
|
schedule=None,
|
||||||
|
start_date=START_DATE,
|
||||||
|
is_paused_upon_creation=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=TAGS,
|
||||||
|
)
|
||||||
|
def world_recreate():
|
||||||
|
"""Удалить только прикладной мир и делегировать его создание."""
|
||||||
|
|
||||||
|
@task
|
||||||
|
def invalidate_world() -> None:
|
||||||
|
Variable.set(WORLD_POSITION, "-1")
|
||||||
|
logging.info("прежний мир недействителен: world_position=-1")
|
||||||
|
|
||||||
|
@task
|
||||||
|
def drop_application_databases() -> None:
|
||||||
|
hook = _clickhouse_hook()
|
||||||
|
for database in APPLICATION_DATABASES:
|
||||||
|
logging.info("удаляем базу %s", database)
|
||||||
|
hook.run(
|
||||||
|
f"DROP DATABASE IF EXISTS {database} "
|
||||||
|
f"ON CLUSTER {CLICKHOUSE_CLUSTER} SYNC"
|
||||||
|
)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def delete_consumer_groups() -> None:
|
||||||
|
from confluent_kafka import KafkaError, KafkaException
|
||||||
|
from confluent_kafka.admin import AdminClient
|
||||||
|
|
||||||
|
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
|
||||||
|
remaining = set(CONSUMER_GROUPS)
|
||||||
|
for attempt in range(1, 31):
|
||||||
|
futures = admin.delete_consumer_groups(
|
||||||
|
sorted(remaining), request_timeout=15
|
||||||
|
)
|
||||||
|
for group, future in futures.items():
|
||||||
|
try:
|
||||||
|
future.result()
|
||||||
|
remaining.discard(group)
|
||||||
|
logging.info("группа %s удалена", group)
|
||||||
|
except KafkaException as error:
|
||||||
|
code = _kafka_error_code(error)
|
||||||
|
if code == KafkaError.GROUP_ID_NOT_FOUND:
|
||||||
|
remaining.discard(group)
|
||||||
|
elif code != KafkaError.NON_EMPTY_GROUP:
|
||||||
|
raise
|
||||||
|
if not remaining:
|
||||||
|
return
|
||||||
|
if attempt % 5 == 0:
|
||||||
|
logging.info("ждём выхода потребителей из групп: %s", sorted(remaining))
|
||||||
|
time.sleep(2)
|
||||||
|
raise AirflowException(
|
||||||
|
f"за 60 с не освободились группы потребителей: {sorted(remaining)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def delete_topics() -> None:
|
||||||
|
from confluent_kafka import KafkaError, KafkaException
|
||||||
|
from confluent_kafka.admin import AdminClient
|
||||||
|
|
||||||
|
admin = AdminClient({"bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS})
|
||||||
|
futures = admin.delete_topics(list(TOPICS), request_timeout=30)
|
||||||
|
for name, future in futures.items():
|
||||||
|
try:
|
||||||
|
future.result()
|
||||||
|
logging.info("топик %s удалён", name)
|
||||||
|
except KafkaException as error:
|
||||||
|
if _kafka_error_code(error) != KafkaError.UNKNOWN_TOPIC_OR_PART:
|
||||||
|
raise
|
||||||
|
|
||||||
|
for _ in range(30):
|
||||||
|
existing = set(admin.list_topics(timeout=10).topics) & set(TOPICS)
|
||||||
|
if not existing:
|
||||||
|
return
|
||||||
|
time.sleep(1)
|
||||||
|
raise AirflowException(f"топики не исчезли за 30 с: {sorted(existing)}")
|
||||||
|
|
||||||
|
@task
|
||||||
|
def mark_world_clean() -> None:
|
||||||
|
Variable.set(WORLD_POSITION, "0")
|
||||||
|
logging.info("очистка завершена: world_position=0")
|
||||||
|
|
||||||
|
invalid = invalidate_world()
|
||||||
|
databases = drop_application_databases()
|
||||||
|
groups = delete_consumer_groups()
|
||||||
|
topics = delete_topics()
|
||||||
|
clean = mark_world_clean()
|
||||||
|
initialize = TriggerDagRunOperator(
|
||||||
|
task_id="trigger_world_initialize",
|
||||||
|
trigger_dag_id="world_initialize",
|
||||||
|
wait_for_completion=True,
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
invalid >> databases >> groups >> topics >> clean >> initialize
|
||||||
|
|
||||||
|
|
||||||
|
world_initialize()
|
||||||
|
world_recreate()
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
sku,name,category,brand,price,demand
|
||||||
|
HOME-0001,Комплект органайзеров для шкафа,Товары для дома,Домовит,129000,обычный
|
||||||
|
HOME-0002,Сушилка для белья напольная,Товары для дома,Домовит,249900,обычный
|
||||||
|
HOME-0003,Настольная лампа «Тихий вечер»,Товары для дома,Ладный дом,188990,магнит
|
||||||
|
HOME-0004,Набор вешалок 12 штук,Товары для дома,Домовит,79000,магнит
|
||||||
|
TEXT-0001,Комплект постельного белья полутораспальный,Текстиль,Сатин,348990,обычный
|
||||||
|
TEXT-0002,Плед-покрывало 200×220,Текстиль,Сатин,279000,залёживается
|
||||||
|
TEXT-0003,Полотенце банное 70×140,Текстиль,Сатин,89000,обычный
|
||||||
|
TEXT-0004,Штора блэкаут 200×260,Текстиль,Ладный дом,218990,залёживается
|
||||||
|
POSU-0001,Сковорода 26 см с антипригарным покрытием,Посуда,Кухмастер,198990,залёживается
|
||||||
|
POSU-0002,Набор кастрюль 3 предмета,Посуда,Кухмастер,458999,магнит
|
||||||
|
POSU-0003,Чайник заварочный стеклянный 1 л,Посуда,Кухмастер,118990,магнит
|
||||||
|
POSU-0004,Набор столовых приборов 24 предмета,Посуда,Ладья,289000,магнит
|
||||||
|
TECH-0001,Электрочайник 1.7 л,Бытовая техника,Электрон,188990,обычный
|
||||||
|
TECH-0002,Блендер погружной 800 Вт,Бытовая техника,Электрон,279000,залёживается
|
||||||
|
TECH-0003,Отпариватель ручной,Бытовая техника,Электрон,349000,обычный
|
||||||
|
TECH-0004,Робот-пылесос,Бытовая техника,Электрон,1899000,залёживается
|
||||||
|
KIDS-0001,Стульчик для кормления,Детские товары,Малышок,649000,магнит
|
||||||
|
KIDS-0002,Ночник-проектор «Звёзды»,Детские товары,Малышок,128990,магнит
|
||||||
|
KIDS-0003,Набор детской посуды 5 предметов,Детские товары,Малышок,88999,обычный
|
||||||
|
WEAR-0001,Тапочки домашние войлочные,Одежда и обувь,Ходики,98990,обычный
|
||||||
|
WEAR-0002,Халат махровый,Одежда и обувь,Сатин,249000,магнит
|
||||||
|
WEAR-0003,Носки хлопковые 5 пар,Одежда и обувь,Ходики,59000,залёживается
|
||||||
|
HOME-0005,Корзина для белья 45 л,Товары для дома,Домовит,119000,магнит
|
||||||
|
HOME-0006,Контейнер для хранения 18 л,Товары для дома,Домовит,69000,обычный
|
||||||
|
HOME-0007,Чехол для одежды 60×100,Товары для дома,Ладный дом,38990,залёживается
|
||||||
|
HOME-0008,Коврик придверный резиновый 40×60,Товары для дома,Домовит,59000,обычный
|
||||||
|
HOME-0009,Гладильная доска настольная 73×30,Товары для дома,Ладный дом,149000,залёживается
|
||||||
|
HOME-0010,Ведро хозяйственное с крышкой 12 л,Товары для дома,Домовит,48990,залёживается
|
||||||
|
HOME-0011,Швабра с отжимом и ведром 8 л,Товары для дома,Домовит,178990,обычный
|
||||||
|
HOME-0012,Щётка для пола с черенком 120 см,Товары для дома,Домовит,45000,обычный
|
||||||
|
HOME-0013,Совок со щёткой на длинной ручке,Товары для дома,Домовит,79000,магнит
|
||||||
|
HOME-0014,Таз хозяйственный круглый 14 л,Товары для дома,Домовит,38990,магнит
|
||||||
|
HOME-0015,Этажерка для ванной 3 яруса,Товары для дома,Ладный дом,139000,магнит
|
||||||
|
HOME-0016,Полка настенная металлическая 45 см,Товары для дома,Ладный дом,99000,обычный
|
||||||
|
HOME-0017,Зеркало настенное круглое 50 см,Товары для дома,Ладный дом,168990,залёживается
|
||||||
|
HOME-0018,Набор коробок для обуви 6 штук,Товары для дома,Домовит,129000,магнит
|
||||||
|
HOME-0019,Подставка для зонтов металлическая,Товары для дома,Ладный дом,158990,обычный
|
||||||
|
HOME-0020,Крючки самоклеящиеся 8 штук,Товары для дома,Домовит,29000,обычный
|
||||||
|
HOME-0021,Верёвка бельевая 20 м,Товары для дома,Домовит,19000,обычный
|
||||||
|
HOME-0022,Прищепки усиленные 24 штуки,Товары для дома,Домовит,24990,залёживается
|
||||||
|
HOME-0023,Мешки вакуумные для одежды 5 штук,Товары для дома,Домовит,68999,обычный
|
||||||
|
HOME-0024,Органайзер подвесной с 12 карманами,Товары для дома,Ладный дом,79000,обычный
|
||||||
|
HOME-0025,Часы настенные круглые 30 см,Товары для дома,Ладный дом,88990,магнит
|
||||||
|
HOME-0026,Светильник настольный с прищепкой 7 Вт,Товары для дома,Ладный дом,119000,обычный
|
||||||
|
HOME-0027,Удлинитель бытовой 3 розетки 3 м,Товары для дома,Ладный дом,99000,магнит
|
||||||
|
HOME-0028,Лестница-стремянка 3 ступени,Товары для дома,Домовит,299000,обычный
|
||||||
|
HOME-0029,Ящик для инструментов 16 дюймов,Товары для дома,Домовит,138990,обычный
|
||||||
|
HOME-0030,Набор плечиков для брюк 6 штук,Товары для дома,Домовит,89000,обычный
|
||||||
|
TEXT-0005,Пододеяльник двуспальный 175×215,Текстиль,Сатин,169000,обычный
|
||||||
|
TEXT-0006,Простыня на резинке 160×200,Текстиль,Сатин,148990,обычный
|
||||||
|
TEXT-0007,Наволочки хлопковые 50×70 2 штуки,Текстиль,Сатин,79000,залёживается
|
||||||
|
TEXT-0008,Одеяло всесезонное 172×205,Текстиль,Сатин,299000,обычный
|
||||||
|
TEXT-0009,Подушка стёганая 50×70,Текстиль,Сатин,128990,обычный
|
||||||
|
TEXT-0010,Наматрасник непромокаемый 160×200,Текстиль,Сатин,189000,магнит
|
||||||
|
TEXT-0011,Покрывало стёганое 180×210,Текстиль,Сатин,238999,обычный
|
||||||
|
TEXT-0012,Плед флисовый 150×200,Текстиль,Сатин,118990,обычный
|
||||||
|
TEXT-0013,Полотенце лицевое 50×90,Текстиль,Сатин,49000,обычный
|
||||||
|
TEXT-0014,Полотенца кухонные 40×60 3 штуки,Текстиль,Сатин,59000,обычный
|
||||||
|
TEXT-0015,Коврик для ванной хлопковый 50×80,Текстиль,Ладный дом,99000,залёживается
|
||||||
|
TEXT-0016,Штора для ванной 180×200,Текстиль,Ладный дом,79000,обычный
|
||||||
|
TEXT-0017,Тюль вуаль 300×260,Текстиль,Ладный дом,199000,обычный
|
||||||
|
TEXT-0018,Шторы кухонные 150×170 2 полотна,Текстиль,Ладный дом,179000,залёживается
|
||||||
|
TEXT-0019,Скатерть водоотталкивающая 140×180,Текстиль,Ладный дом,129000,обычный
|
||||||
|
TEXT-0020,Дорожка на стол хлопковая 40×140,Текстиль,Ладный дом,59000,залёживается
|
||||||
|
TEXT-0021,Салфетки столовые льняные 4 штуки,Текстиль,Сатин,79000,обычный
|
||||||
|
TEXT-0022,Чехлы на табурет 35×35 4 штуки,Текстиль,Ладный дом,108990,обычный
|
||||||
|
TEXT-0023,Подушка декоративная 40×40,Текстиль,Сатин,68999,магнит
|
||||||
|
TEXT-0024,Чехол для диванной подушки 45×45,Текстиль,Сатин,39000,обычный
|
||||||
|
TEXT-0025,Фартук кухонный с карманом,Текстиль,Ладный дом,59000,магнит
|
||||||
|
TEXT-0026,Прихватки стёганые 20×20 2 штуки,Текстиль,Ладный дом,38990,магнит
|
||||||
|
TEXT-0027,Халат вафельный размер 48–50,Текстиль,Сатин,199000,залёживается
|
||||||
|
TEXT-0028,Комплект полотенец 50×90 и 70×140,Текстиль,Сатин,138999,обычный
|
||||||
|
TEXT-0029,Одеяло детское байковое 100×140,Текстиль,Сатин,169000,обычный
|
||||||
|
TEXT-0030,Подушка для стула 40×40,Текстиль,Ладный дом,59000,обычный
|
||||||
|
POSU-0005,Кастрюля эмалированная 3 л,Посуда,Кухмастер,168990,обычный
|
||||||
|
POSU-0006,Ковш нержавеющий с крышкой 1.5 л,Посуда,Кухмастер,138990,залёживается
|
||||||
|
POSU-0007,Сотейник литой с крышкой 28 см,Посуда,Кухмастер,269000,магнит
|
||||||
|
POSU-0008,Сковорода блинная 22 см,Посуда,Кухмастер,149000,обычный
|
||||||
|
POSU-0009,Казан алюминиевый 6 л,Посуда,Кухмастер,299000,обычный
|
||||||
|
POSU-0010,Форма для запекания стеклянная 2.4 л,Посуда,Кухмастер,118990,магнит
|
||||||
|
POSU-0011,Противень стальной 37×27,Посуда,Кухмастер,79000,залёживается
|
||||||
|
POSU-0012,Набор форм для выпечки 3 штуки,Посуда,Кухмастер,139000,залёживается
|
||||||
|
POSU-0013,Миска нержавеющая 24 см,Посуда,Кухмастер,68990,залёживается
|
||||||
|
POSU-0014,Дуршлаг стальной с ручками 22 см,Посуда,Кухмастер,89000,залёживается
|
||||||
|
POSU-0015,Тёрка четырёхгранная 23 см,Посуда,Кухмастер,49000,обычный
|
||||||
|
POSU-0016,Доска разделочная бамбуковая 35×25,Посуда,Кухмастер,78990,магнит
|
||||||
|
POSU-0017,Набор ножей с подставкой 6 предметов,Посуда,Кухмастер,248990,обычный
|
||||||
|
POSU-0018,Половник и шумовка 2 предмета,Посуда,Кухмастер,58990,обычный
|
||||||
|
POSU-0019,Контейнеры пищевые 7 предметов,Посуда,Кухмастер,99000,залёживается
|
||||||
|
POSU-0020,Банки для сыпучих продуктов 4 штуки,Посуда,Ладья,138990,залёживается
|
||||||
|
POSU-0021,Тарелки обеденные фарфоровые 6 штук,Посуда,Ладья,178999,обычный
|
||||||
|
POSU-0022,Тарелки суповые керамические 4 штуки,Посуда,Ладья,129000,обычный
|
||||||
|
POSU-0023,Салатник стеклянный 23 см,Посуда,Ладья,68999,обычный
|
||||||
|
POSU-0024,Кружки керамические 350 мл 4 штуки,Посуда,Ладья,118990,обычный
|
||||||
|
POSU-0025,Стаканы высокие 400 мл 6 штук,Посуда,Ладья,98990,обычный
|
||||||
|
POSU-0026,Бокалы для воды 300 мл 6 штук,Посуда,Ладья,149000,обычный
|
||||||
|
POSU-0027,Термос стальной 1 л,Посуда,Ладья,179000,залёживается
|
||||||
|
POSU-0028,Термокружка с крышкой 450 мл,Посуда,Ладья,99000,залёживается
|
||||||
|
POSU-0029,Сахарница фарфоровая 400 мл,Посуда,Ладья,59000,магнит
|
||||||
|
POSU-0030,Маслёнка стеклянная с крышкой,Посуда,Ладья,68990,обычный
|
||||||
|
TECH-0005,Миксер ручной 500 Вт,Бытовая техника,Электрон,248990,обычный
|
||||||
|
TECH-0006,Измельчитель с чашей 1.5 л,Бытовая техника,Электрон,298990,обычный
|
||||||
|
TECH-0007,Стационарный блендер 1.8 л,Бытовая техника,Электрон,549000,магнит
|
||||||
|
TECH-0008,Мясорубка электрическая 1800 Вт,Бытовая техника,Электрон,799000,магнит
|
||||||
|
TECH-0009,Тостер на 2 ломтика 850 Вт,Бытовая техника,Электрон,299000,залёживается
|
||||||
|
TECH-0010,Вафельница электрическая 1000 Вт,Бытовая техника,Электрон,348999,обычный
|
||||||
|
TECH-0011,Мультиварка с чашей 5 л,Бытовая техника,Электрон,699000,магнит
|
||||||
|
TECH-0012,Электроплитка на 1 конфорку 1500 Вт,Бытовая техника,Электрон,249000,обычный
|
||||||
|
TECH-0013,Мини-печь с грилем 30 л,Бытовая техника,Электрон,899000,залёживается
|
||||||
|
TECH-0014,Микроволновая печь 20 л,Бытовая техника,Электрон,999000,обычный
|
||||||
|
TECH-0015,Сушилка для овощей 5 поддонов,Бытовая техника,Электрон,499000,обычный
|
||||||
|
TECH-0016,Кофемолка электрическая 200 Вт,Бытовая техника,Электрон,199000,обычный
|
||||||
|
TECH-0017,Капельная кофеварка 1.2 л,Бытовая техника,Электрон,399000,залёживается
|
||||||
|
TECH-0018,Соковыжималка шнековая 150 Вт,Бытовая техника,Электрон,799000,залёживается
|
||||||
|
TECH-0019,Утюг паровой 2400 Вт,Бытовая техника,Электрон,349000,обычный
|
||||||
|
TECH-0020,Швейная машина 12 операций,Бытовая техника,Электрон,1299000,обычный
|
||||||
|
TECH-0021,Пылесос контейнерный 2000 Вт,Бытовая техника,Электрон,1099000,залёживается
|
||||||
|
TECH-0022,Пылесос вертикальный аккумуляторный,Бытовая техника,Электрон,1499000,магнит
|
||||||
|
TECH-0023,Электровеник с контейнером 0.5 л,Бытовая техника,Электрон,699000,магнит
|
||||||
|
TECH-0024,Фен с диффузором 2200 Вт,Бытовая техника,Электрон,299000,обычный
|
||||||
|
TECH-0025,Щипцы для волос 45 Вт,Бытовая техника,Электрон,249000,обычный
|
||||||
|
TECH-0026,Машинка для стрижки с 6 насадками,Бытовая техника,Электрон,399000,залёживается
|
||||||
|
TECH-0027,Весы напольные до 180 кг,Бытовая техника,Электрон,198990,залёживается
|
||||||
|
TECH-0028,Весы кухонные до 5 кг,Бытовая техника,Электрон,148990,обычный
|
||||||
|
TECH-0029,Увлажнитель воздуха 3 л,Бытовая техника,Электрон,499000,обычный
|
||||||
|
TECH-0030,Тепловентилятор керамический 1500 Вт,Бытовая техника,Электрон,398990,магнит
|
||||||
|
KIDS-0004,Кроватка кукольная деревянная 45 см,Детские товары,Малышок,248999,обычный
|
||||||
|
KIDS-0005,Манеж складной 100×100,Детские товары,Малышок,798990,залёживается
|
||||||
|
KIDS-0006,Горшок детский со спинкой,Детские товары,Малышок,79000,магнит
|
||||||
|
KIDS-0007,Ванночка детская 84 см,Детские товары,Малышок,168990,обычный
|
||||||
|
KIDS-0008,Подставка детская двухступенчатая,Детские товары,Малышок,89000,залёживается
|
||||||
|
KIDS-0009,Накладка на унитаз с ручками,Детские товары,Малышок,69000,обычный
|
||||||
|
KIDS-0010,Сиденье для купания на присосках,Детские товары,Малышок,138990,залёживается
|
||||||
|
KIDS-0011,Пелёнки муслиновые 80×100 3 штуки,Детские товары,Малышок,129000,обычный
|
||||||
|
KIDS-0012,Одеяло для коляски 90×110,Детские товары,Малышок,148990,магнит
|
||||||
|
KIDS-0013,Нагрудники непромокаемые 5 штук,Детские товары,Малышок,58999,обычный
|
||||||
|
KIDS-0014,Бутылочка с ручками 250 мл,Детские товары,Малышок,49000,обычный
|
||||||
|
KIDS-0015,Поильник непроливайка 300 мл,Детские товары,Малышок,54990,залёживается
|
||||||
|
KIDS-0016,Контейнер для пустышки 2 штуки,Детские товары,Малышок,25000,магнит
|
||||||
|
KIDS-0017,Погремушки мягкие 3 штуки,Детские товары,Малышок,59000,магнит
|
||||||
|
KIDS-0018,Кубики развивающие 12 штук,Детские товары,Малышок,89000,залёживается
|
||||||
|
KIDS-0019,Пирамидка деревянная 8 колец,Детские товары,Малышок,78990,обычный
|
||||||
|
KIDS-0020,Сортер с геометрическими фигурами,Детские товары,Малышок,98990,обычный
|
||||||
|
KIDS-0021,Конструктор деревянный 50 деталей,Детские товары,Малышок,139000,обычный
|
||||||
|
KIDS-0022,Доска для рисования магнитная 30×40,Детские товары,Малышок,128990,обычный
|
||||||
|
KIDS-0023,Мольберт детский двусторонний 105 см,Детские товары,Малышок,298999,залёживается
|
||||||
|
KIDS-0024,Набор для лепки 12 цветов,Детские товары,Малышок,69000,обычный
|
||||||
|
KIDS-0025,Коврик игровой складной 180×150,Детские товары,Малышок,399000,обычный
|
||||||
|
KIDS-0026,Палатка игровая с окошками,Детские товары,Малышок,199000,обычный
|
||||||
|
KIDS-0027,Каталка с ручкой и звуком,Детские товары,Малышок,169000,залёживается
|
||||||
|
KIDS-0028,Самокат трёхколёсный до 50 кг,Детские товары,Малышок,298999,обычный
|
||||||
|
KIDS-0029,Санки со спинкой и ремнём,Детские товары,Малышок,349000,залёживается
|
||||||
|
KIDS-0030,Рюкзак детский объёмом 8 л,Детские товары,Малышок,128990,обычный
|
||||||
|
WEAR-0004,Футболка хлопковая размер 48,Одежда и обувь,Сатин,78990,залёживается
|
||||||
|
WEAR-0005,Майка хлопковая размер 46,Одежда и обувь,Сатин,58990,обычный
|
||||||
|
WEAR-0006,Лонгслив трикотажный размер 50,Одежда и обувь,Сатин,108990,магнит
|
||||||
|
WEAR-0007,Водолазка хлопковая размер 44,Одежда и обувь,Сатин,118990,обычный
|
||||||
|
WEAR-0008,Толстовка с капюшоном размер 52,Одежда и обувь,Сатин,198999,обычный
|
||||||
|
WEAR-0009,Брюки спортивные размер 50,Одежда и обувь,Сатин,169000,залёживается
|
||||||
|
WEAR-0010,Легинсы хлопковые размер 46,Одежда и обувь,Сатин,98990,залёживается
|
||||||
|
WEAR-0011,Пижама трикотажная размер 48,Одежда и обувь,Сатин,179000,обычный
|
||||||
|
WEAR-0012,Ночная сорочка хлопковая размер 50,Одежда и обувь,Сатин,128999,обычный
|
||||||
|
WEAR-0013,Фартук рабочий с тремя карманами,Одежда и обувь,Сатин,79000,залёживается
|
||||||
|
WEAR-0014,Перчатки хозяйственные 3 пары,Одежда и обувь,Ходики,39000,обычный
|
||||||
|
WEAR-0015,Варежки вязаные размер 8,Одежда и обувь,Ходики,68990,обычный
|
||||||
|
WEAR-0016,Шапка вязаная с отворотом размер 56,Одежда и обувь,Ходики,88990,магнит
|
||||||
|
WEAR-0017,Шарф шерстяной 30×180,Одежда и обувь,Ходики,119000,обычный
|
||||||
|
WEAR-0018,Ремень текстильный длиной 120 см,Одежда и обувь,Ходики,78990,обычный
|
||||||
|
WEAR-0019,Стельки войлочные 2 пары,Одежда и обувь,Ходики,38990,залёживается
|
||||||
|
WEAR-0020,Шнурки круглые 120 см 3 пары,Одежда и обувь,Ходики,25000,обычный
|
||||||
|
WEAR-0021,Кеды текстильные размер 42,Одежда и обувь,Ходики,179000,обычный
|
||||||
|
WEAR-0022,Кроссовки прогулочные размер 39,Одежда и обувь,Ходики,248990,обычный
|
||||||
|
WEAR-0023,Ботинки утеплённые размер 41,Одежда и обувь,Ходики,348990,залёживается
|
||||||
|
WEAR-0024,Сапоги резиновые размер 40,Одежда и обувь,Ходики,199000,обычный
|
||||||
|
WEAR-0025,Полуботинки кожаные размер 43,Одежда и обувь,Ходики,398999,залёживается
|
||||||
|
WEAR-0026,Сандалии с ремешками размер 38,Одежда и обувь,Ходики,189000,обычный
|
||||||
|
WEAR-0027,Сланцы резиновые размер 41,Одежда и обувь,Ходики,79000,обычный
|
||||||
|
WEAR-0028,Балетки текстильные размер 37,Одежда и обувь,Ходики,149000,залёживается
|
||||||
|
WEAR-0029,Туфли домашние утеплённые размер 39,Одежда и обувь,Ходики,129000,магнит
|
||||||
|
WEAR-0030,Галоши садовые размер 42,Одежда и обувь,Ходики,98990,обычный
|
||||||
|
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"seed": 20260601,
|
||||||
|
"generator_version": "0.1.0",
|
||||||
|
"catalog_sha256": "4fffceb3ec278c0b364ec84d936d991946ddd2a0912ed9c36471ff7a55c7483f",
|
||||||
|
"days": [
|
||||||
|
{
|
||||||
|
"day": 0,
|
||||||
|
"date": "2026-06-01",
|
||||||
|
"events": 50628,
|
||||||
|
"sha256": "d09704ed99e45824e451e2f6bbbbd5c547df382f7a582699955010f3807eb0ee",
|
||||||
|
"orders": {
|
||||||
|
"match": 192,
|
||||||
|
"cancelled": 20,
|
||||||
|
"lost_event": 3,
|
||||||
|
"duplicate_event": 2,
|
||||||
|
"amount_delta": 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 1,
|
||||||
|
"date": "2026-06-02",
|
||||||
|
"events": 49727,
|
||||||
|
"sha256": "8005d2934eb353478e35331d7ae7ae6876c7934b2d9032fde92cf6ce86d6460f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 2,
|
||||||
|
"date": "2026-06-03",
|
||||||
|
"events": 52363,
|
||||||
|
"sha256": "e13f6f3f3977386a94d0e63b6849675cb5d2486a277d559fd415923cfc82e53a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 3,
|
||||||
|
"date": "2026-06-04",
|
||||||
|
"events": 53159,
|
||||||
|
"sha256": "91c713add62222adc3f89c04e6cc087d8c44e2385d14d0464e2c016b6952b9fd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 4,
|
||||||
|
"date": "2026-06-05",
|
||||||
|
"events": 50087,
|
||||||
|
"sha256": "b7fc27d73fc157a48bece0189c7983f6bc672f052e0703cd799b9774da4b904e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 5,
|
||||||
|
"date": "2026-06-06",
|
||||||
|
"events": 47169,
|
||||||
|
"sha256": "82cb8017d53d6dee8438d3683b7a901f1ace139f3b8ae836215722bb580fe207"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 6,
|
||||||
|
"date": "2026-06-07",
|
||||||
|
"events": 48686,
|
||||||
|
"sha256": "b50ad0fcc7d1ea0c637cfcba98e5c2b55d65215cffa3693bc77edeaa83e6c783"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 7,
|
||||||
|
"date": "2026-06-08",
|
||||||
|
"events": 49394,
|
||||||
|
"sha256": "358d51b36221d822960237702fa6327fb663a57cf49cc4415a859b16e5aa05a2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"snapshots": [
|
||||||
|
{
|
||||||
|
"day": 0,
|
||||||
|
"date": "2026-06-01",
|
||||||
|
"sha256": "93b39f261cbbe894402ebe0525460fed1809e5712639be4b73b79dac2b39f5bd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 1,
|
||||||
|
"date": "2026-06-02",
|
||||||
|
"sha256": "4fef382cd84868ccfb61a9d8610cdd26f9a749bbae8b20800c45e520e9ce414e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 2,
|
||||||
|
"date": "2026-06-03",
|
||||||
|
"sha256": "caf9ed2e77d2fcc99066a548dcba60f61f1ff0d4402de31457a06f23fd2d8306"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 3,
|
||||||
|
"date": "2026-06-04",
|
||||||
|
"sha256": "cdb9e4e00b57c04985866b5a851a8e2f23b3e9205ea131c7a5076672ee8b5669"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 4,
|
||||||
|
"date": "2026-06-05",
|
||||||
|
"sha256": "c58d2c20fe3b4cee2973f34c322090dbb66d3793bfaab0337c22b53fb5a3ec7f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 5,
|
||||||
|
"date": "2026-06-06",
|
||||||
|
"sha256": "bbf28b1d375e4815695c72a5915a22a3c9b36195b97ec4cc7c739466dbe70b53"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 6,
|
||||||
|
"date": "2026-06-07",
|
||||||
|
"sha256": "8ca077576bcf014c0d492c7d7190e38c0e46ec95951ad840df93eb75ee5a00d3"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,6 +10,19 @@
|
|||||||
обработчик DAG. Triggerer не запускается: в каркасе нет отложенных задач.
|
обработчик DAG. Triggerer не запускается: в каркасе нет отложенных задач.
|
||||||
Два интеграционных пробника написаны через публичный `airflow.sdk`.
|
Два интеграционных пробника написаны через публичный `airflow.sdk`.
|
||||||
|
|
||||||
|
Долгоживущие процессы Airflow зависят от `airflow-init`, а Superset — от
|
||||||
|
`superset-init`. Это ребро нужно самому `up --wait`: Docker Compose 2.40.3
|
||||||
|
считает успешно завершившийся одноразовый сервис без зависимого упавшим и
|
||||||
|
возвращает единицу — поведение проверено при исполнении #37. Зависимость
|
||||||
|
включает результат подготовки в общий граф ожидания.
|
||||||
|
|
||||||
|
У каждого общего локального образа один блок `build`. Он находится у службы
|
||||||
|
инициализации. Остальные службы зависят от неё и используют тот же `image`.
|
||||||
|
`docker compose up --build` завершает сборку до запуска контейнеров, поэтому
|
||||||
|
одного блока достаточно. В Buildx 0.30.1 одинаковые блоки у нескольких служб
|
||||||
|
запускают параллельный экспорт одного тега. Иногда сборка завершается ошибкой
|
||||||
|
`image already exists`.
|
||||||
|
|
||||||
Для входа выбран SimpleAuthManager. У него нет команды создания пользователя,
|
Для входа выбран SimpleAuthManager. У него нет команды создания пользователя,
|
||||||
поэтому `airflow-init` записывает пароль администратора из окружения в
|
поэтому `airflow-init` записывает пароль администратора из окружения в
|
||||||
JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит
|
JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит
|
||||||
@@ -24,13 +37,20 @@ JSON-файл отдельного тома `airflow_auth`. Новый имен
|
|||||||
пользователи и строки подключения. Это экономит один контейнер, но сохраняет
|
пользователи и строки подключения. Это экономит один контейнер, но сохраняет
|
||||||
границу владения данными.
|
границу владения данными.
|
||||||
|
|
||||||
Airflow получает подготовленное общее подключение к `clickhouse-01`, а
|
Airflow получает два подготовленных подключения к `clickhouse-01`: рабочее
|
||||||
Superset — подключение `clickhousedb://` к `clickhouse-02`. Провайдер
|
`clickhouse_default` и административное `clickhouse_lifecycle` для дагов
|
||||||
ClickHouse для Airflow не нужен: локальный образ содержит прямой клиент.
|
жизненного цикла мира ([ADR 0013](0013-world-lifecycle.md)). Оба имеют тип
|
||||||
Пробник создаёт служебные таблицы `ON CLUSTER`, пишет на первой ноде и через
|
`clickhouse`, который регистрирует официальный провайдер
|
||||||
`remote` читает `Distributed` на второй. Разные ноды сохраняют учебную
|
`apache-airflow-providers-clickhousedb`. SQL-файлы исполняет общий
|
||||||
ловушку: забытый `ON CLUSTER` проявится в Superset, даже если операция Airflow
|
`SQLExecuteQueryOperator`, а программные запросы идут через `ClickHouseHook`.
|
||||||
на первой ноде прошла успешно.
|
Сам провайдер работает через `clickhouse-connect`; его версия закреплена
|
||||||
|
отдельно, чтобы сборка не меняла транспорт незаметно.
|
||||||
|
|
||||||
|
Superset получает подключение `clickhousedb://` к `clickhouse-02`. Пробник
|
||||||
|
Airflow создаёт служебные таблицы `ON CLUSTER`, пишет на первой ноде и через
|
||||||
|
`remote` читает `Distributed` на второй. Разные ноды сохраняют учебную ловушку:
|
||||||
|
забытый `ON CLUSTER` проявится в Superset, даже если операция Airflow на первой
|
||||||
|
ноде прошла успешно.
|
||||||
|
|
||||||
Клиент Kafka также добавлен прямо в образ Airflow. Официальный провайдер
|
Клиент Kafka также добавлен прямо в образ Airflow. Официальный провайдер
|
||||||
использует тот же `confluent-kafka`, а пробнику не нужны его подключение,
|
использует тот же `confluent-kafka`, а пробнику не нужны его подключение,
|
||||||
@@ -76,10 +96,18 @@ Prometheus читает встроенные точки метрик двух с
|
|||||||
Оттуда взяты `airflow.sdk`, обязательный отдельный обработчик DAG, возможность
|
Оттуда взяты `airflow.sdk`, обязательный отдельный обработчик DAG, возможность
|
||||||
не запускать triggerer и проверка конкретных компонентов здоровья.
|
не запускать triggerer и проверка конкретных компонентов здоровья.
|
||||||
|
|
||||||
Для интеграционных пробников проверены `Connection.get` в публичном
|
Для ClickHouse через Context7 и официальную документацию Airflow проверены
|
||||||
`airflow.sdk`, запросы через `clickhouse-connect` и состав официального
|
`ClickHouseHook`, тип подключения `clickhouse` и работа
|
||||||
провайдера Kafka. Выбран прямой `confluent-kafka`: провайдер строит свои
|
`SQLExecuteQueryOperator` с SQL-файлом, параметрами и несколькими выражениями.
|
||||||
подключения и обёртки поверх него, которые двум коротким пробникам не нужны.
|
Закреплён `apache-airflow-providers-clickhousedb==1.0.0`: на 21 августа
|
||||||
|
2026 года это последний стабильный выпуск; `1.0.1` опубликован только как
|
||||||
|
предварительная версия. Зависимость провайдера допускает любой
|
||||||
|
`clickhouse-connect>=1.3.0`, поэтому прежний `clickhouse-connect==1.6.0`
|
||||||
|
оставлен прямой зависимостью образа.
|
||||||
|
|
||||||
|
Для Kafka проверен состав официального провайдера. Выбран прямой
|
||||||
|
`confluent-kafka`: провайдер строит свои подключения и обёртки поверх него,
|
||||||
|
которые двум коротким пробникам не нужны.
|
||||||
|
|
||||||
Драйверы и строки подключения проверены по документации Superset 6.1.0:
|
Драйверы и строки подключения проверены по документации Superset 6.1.0:
|
||||||
[подключения к базам](https://superset.apache.org/user-docs/6.1.0/databases/),
|
[подключения к базам](https://superset.apache.org/user-docs/6.1.0/databases/),
|
||||||
|
|||||||
@@ -114,3 +114,30 @@ ClickHouse, Kafka и Airflow, 17 — в дашборде генератора, 1
|
|||||||
(спека, раздел 10), но рассказать урок можно только про то, что дашборд
|
(спека, раздел 10), но рассказать урок можно только про то, что дашборд
|
||||||
показывает. Скопируй мы состав предшественника — урок неизбежно получился бы
|
показывает. Скопируй мы состав предшественника — урок неизбежно получился бы
|
||||||
про пульс планировщика.
|
про пульс планировщика.
|
||||||
|
|
||||||
|
21 августа 2026 года дашборд «Данные» собран. Один пункт решения исполнен не
|
||||||
|
буквально: у панели потока нет ряда «против вчерашнего». Сдвинуть ряд на
|
||||||
|
модельные сутки значит записать ускорение мира в JSON дашборда — ровно ту
|
||||||
|
пометку, от которой этот же ADR отговаривает выше, — а реальные сутки на стенде
|
||||||
|
не с чем сравнивать. Довод целиком и остальное устройство дашборда —
|
||||||
|
[docs/architecture/monitoring/data.md](../architecture/monitoring/data.md).
|
||||||
|
|
||||||
|
Тогда же собраны «Кластер» и «Запросы». Довод про `clusterAllReplicas`
|
||||||
|
подтвердился с одной поправкой: очередь распределённых DDL лежит не у ноды, а в
|
||||||
|
keeper, и обе ноды читают её целиком — запрос вернул с каждой одни и те же 124
|
||||||
|
записи. Обёртка там не добавила бы правды, а удвоила бы каждую строку, поэтому
|
||||||
|
единственная панель «Кластера» без неё — эта. Keeper своих системных таблиц не
|
||||||
|
имеет вовсе, и панель по нему приходит из Prometheus.
|
||||||
|
|
||||||
|
«Запросы» получили на две панели больше, чем сказано выше. Разбор запросов по
|
||||||
|
посетителям продиктован [ADR 0007](0007-clickhouse-access.md): своя учётка
|
||||||
|
Grafana заведена там ровно затем, чтобы её запросы были отличимы от
|
||||||
|
человеческих.
|
||||||
|
Отдельная панель ошибок понадобилась потому, что отказ достаётся шарду: в
|
||||||
|
замере с принудительным пределом памяти каждая нода записала свой, и строка
|
||||||
|
шарда видна только без фильтра по инициатору. Здешний сюжет про тяжёлый
|
||||||
|
`GROUP BY` при этом кончается не отказом, а сбросом на диск
|
||||||
|
([ADR 0004](0004-resource-limits.md)) — отказ на стенде приходится вызывать
|
||||||
|
настройкой. Устройство обоих дашбордов —
|
||||||
|
[cluster.md](../architecture/monitoring/cluster.md) и
|
||||||
|
[queries.md](../architecture/monitoring/queries.md).
|
||||||
@@ -19,16 +19,23 @@
|
|||||||
|
|
||||||
Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Дальше в этом тексте предел
|
Нодам ClickHouse — по 4 ГиБ вместо гигабайта. Дальше в этом тексте предел
|
||||||
памяти контейнера (`mem_limit`) называется коробкой: сервер живёт внутри неё и
|
памяти контейнера (`mem_limit`) называется коробкой: сервер живёт внутри неё и
|
||||||
о самой машине ничего не знает. Остальные `mem_limit` не меняются.
|
о самой машине ничего не знает. На 1 августа остальные `mem_limit` не менялись.
|
||||||
|
|
||||||
Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются
|
Числа, выставленные ради прежнего бюджета, остаются в силе, но лишаются
|
||||||
статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`,
|
статуса решения: `KAFKA_HEAP_OPTS`, `shared_buffers`, `max_connections`,
|
||||||
`AIRFLOW__CORE__PARALLELISM` и девять неизменённых `mem_limit`. Основания,
|
`AIRFLOW__CORE__PARALLELISM` и восемь оставшихся `mem_limit`. Основания,
|
||||||
кроме отменённого, у них нет. Кто в них упрётся — меняет по первому
|
кроме отменённого, у них нет. Кто в них упрётся — меняет по первому
|
||||||
свидетельству и этот ADR не пересматривает. Срок хранения метрик Prometheus
|
свидетельству и этот ADR не пересматривает. Срок хранения метрик Prometheus
|
||||||
сюда не входит: неделя истории — осознанный учебный выбор, и цена у него
|
сюда не входит: неделя истории — осознанный учебный выбор, и цена у него
|
||||||
дисковая.
|
дисковая.
|
||||||
|
|
||||||
|
27 августа таким пределом впервые стала коробка `airflow-scheduler`:
|
||||||
|
LocalExecutor исполняет в ней до четырех процессов задач одновременно, а
|
||||||
|
640 МиБ этого не вместили. Коробка поднята до 1,5 ГиБ. Ресурсные ограничения
|
||||||
|
из отдельных дагов удалены. Общий предел параллелизма уже задает Airflow.
|
||||||
|
`max_active_runs=1` остается там, где два запуска делили бы один донор и могли
|
||||||
|
повредить публикацию.
|
||||||
|
|
||||||
Ресурсный довод ADR 0001 отозван.
|
Ресурсный довод ADR 0001 отозван.
|
||||||
|
|
||||||
## Почему
|
## Почему
|
||||||
@@ -94,8 +101,8 @@ ClickHouse сам понижает каждый до 512 МиБ, а на сте
|
|||||||
заранее, и в покое стенд занимает около 3,2 ГБ. Восемь гигабайт нужны не сумме
|
заранее, и в покое стенд занимает около 3,2 ГБ. Восемь гигабайт нужны не сумме
|
||||||
пределов, а работе с запасом.
|
пределов, а работе с запасом.
|
||||||
|
|
||||||
Остальные девять лимитов не меняются, потому что ни один из них ни разу не
|
На 1 августа остальные девять лимитов не менялись, потому что ни один из них
|
||||||
сработал. Снять их скопом — то же изменение без свидетельств, каким они были
|
не сработал. Снять их скопом — то же изменение без свидетельств, каким они были
|
||||||
выставлены, только в обратную сторону. Лечение здесь не в том, чтобы стереть
|
выставлены, только в обратную сторону. Лечение здесь не в том, чтобы стереть
|
||||||
числа, а в том, чтобы лишить их статуса закона: число без основания меняют,
|
числа, а в том, чтобы лишить их статуса закона: число без основания меняют,
|
||||||
когда оно мешает, и не защищают как принятое решение.
|
когда оно мешает, и не защищают как принятое решение.
|
||||||
@@ -151,3 +158,13 @@ to 2.00 GiB because the system has limited RAM` и такие же строки
|
|||||||
Значение `max_server_memory_usage_to_ram_ratio` = 0,9 и то, что в cgroup предел
|
Значение `max_server_memory_usage_to_ram_ratio` = 0,9 и то, что в cgroup предел
|
||||||
считается от коробки, а не от машины, сверены по документации ClickHouse через
|
считается от коробки, а не от машины, сверены по документации ClickHouse через
|
||||||
MCP Context7.
|
MCP Context7.
|
||||||
|
|
||||||
|
`airflow-scheduler` проверен отдельно 27 августа 2026 года. На свежем
|
||||||
|
контейнере две попытки запустить четыре DQ-сверки параллельно в коробке
|
||||||
|
640 МиБ дошли ровно до `memory.max` и дали `oom_kill=1`: сначала при выгрузке
|
||||||
|
57 567 ключей сессий в Python, затем и после переноса дневной агрегации в
|
||||||
|
ClickHouse. В коробке 1 ГиБ сам `dq_check` прошел, но снова коснулся верхней
|
||||||
|
границы (`memory.events.max=57`). Полный `etl_pipeline` без локальных
|
||||||
|
ограничений задач в коробке 1,5 ГиБ завершился успешно: пик - 1 013 760 000
|
||||||
|
байт, `memory.events.max=0`, `oom_kill=0`. Docker не резервирует разницу между
|
||||||
|
пиком и пределом, поэтому этот запас не увеличивает расход памяти в покое.
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# ADR 0005. Приём событий: сырьё в STG байтами, разбор функциями в ODS
|
||||||
|
|
||||||
|
Дата: 3 августа 2026 года. Статус: принято.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение
|
||||||
|
ложится в `stg.hits_raw_dist` строкой, как пришло, рядом с метаданными доставки.
|
||||||
|
Ни типизации, ни проверки на этом шаге нет — слой сырья ничего не интерпретирует.
|
||||||
|
У формата есть следствие для DDL: он читает вход в одно значение и рассчитан на
|
||||||
|
таблицу с единственной колонкой, поэтому у чтеца она ровно одна — `raw`, а
|
||||||
|
метаданные доставки берутся только из виртуальных колонок и добавить к чтецу
|
||||||
|
что-либо своё нельзя.
|
||||||
|
|
||||||
|
Слово «непустое» приписано позже самого решения: у обещания нашлась измеренная
|
||||||
|
граница, и она датирована ниже, в разделе «Что проверено». Решения она не
|
||||||
|
меняет — от того, рождает ли пустая запись строку, выбор формата не зависит.
|
||||||
|
|
||||||
|
Типизированный слой наполняют две матвью, привязанные к `stg.hits_raw_dist`.
|
||||||
|
Поля достаются `JSONExtract`. В таблицу ошибок уходят три класса брака:
|
||||||
|
сообщение, не являющееся объектом JSON; объект, чей набор ключей разошёлся с
|
||||||
|
контрактным; объект, у которого не разобрался ключевой идентификатор или метка
|
||||||
|
времени. Остальное — в событие. Первый класс проверяется именно на объект, а не
|
||||||
|
на валидность: `isValidJSON('123')` возвращает единицу, скаляр — тоже законный
|
||||||
|
JSON.
|
||||||
|
|
||||||
|
Классы пересекаются: скаляр проваливает заодно и сверку ключей, потому что
|
||||||
|
`JSONExtractKeys` от него даёт пустой массив. Поэтому они проверяются по порядку,
|
||||||
|
а в колонку `error_class` пишется первый совпавший — `not_an_object`,
|
||||||
|
`keyset_mismatch`, `key_field_unparsed`. Приём тот же, что у `mismatch_class` в
|
||||||
|
витрине сверки: пересекающиеся классы плюс объявленный приоритет.
|
||||||
|
|
||||||
|
Присутствие полей целиком держит сверка набора ключей — одно сравнение
|
||||||
|
`arraySort(JSONExtractKeys(raw))` с контрактным списком, завёрнутым в тот же
|
||||||
|
`arraySort`. Обёртка с обеих сторон стоит ноль и снимает ошибку, которая иначе
|
||||||
|
увела бы в брак вообще всё: сорок семь CamelCase-имён, выписанных руками ровно в
|
||||||
|
байтовом порядке. Обязательны все сорок
|
||||||
|
семь полей: генератор шлёт их все в каждом событии, а «пусто» по контракту —
|
||||||
|
пустое значение, а не отсутствие ключа. Этим же закрыт критерий #43 про опечатку
|
||||||
|
в имени.
|
||||||
|
|
||||||
|
Тип проверяется не у всех колонок, а у пяти: `WatchID`, `VisitID`, `ClientID`,
|
||||||
|
`EventDate`, `UTCEventTime` разбираются в `Nullable` и дают NULL, если значение
|
||||||
|
не той природы. Остальные сорок две достаются обычными типами. Соотношение
|
||||||
|
цены и пользы: единственный производитель топика — собственный генератор,
|
||||||
|
сериализующий из контракта по объявленным типам, поэтому неверный тип может
|
||||||
|
прийти только из руки, а сорок семь проверок на NULL превратили бы матвью в
|
||||||
|
простыню. Пять выбраны по последствию: это идентификаторы события, визита и
|
||||||
|
посетителя, дата партиции и метка времени, по которой события упорядочиваются
|
||||||
|
внутри сессии, — порча любой отравляет всё ниже по течению. `CounterID`
|
||||||
|
формально тоже входит в ключ сортировки, но на стенде он константа, и NULL там
|
||||||
|
взяться неоткуда.
|
||||||
|
|
||||||
|
Присутствие иначе и не проверить. `Nullable`
|
||||||
|
в ClickHouse не оборачивает составные типы: `Nullable(Array)` запрещён, а
|
||||||
|
`Array(Nullable(T))` при пропавшем ключе даёт пустой массив, неотличимый от
|
||||||
|
пустого по смыслу. Таких колонок в контракте двенадцать из сорока семи.
|
||||||
|
|
||||||
|
Типизированная Kafka-таблица не используется. Режим `kafka_handle_error_mode =
|
||||||
|
'stream'` не используется тоже: при чтении байтами на входе нечему ломаться, и
|
||||||
|
ошибке разбора взяться неоткуда.
|
||||||
|
|
||||||
|
Отсюда ограничение на форму выражений разбора: они собираются только из функций,
|
||||||
|
которые не бросают исключений. `JSONExtract` и родственные возвращают значение по
|
||||||
|
умолчанию или NULL, но не падают, — и правило репозитория «грязные записи не
|
||||||
|
валят пайплайн» держится теперь именно на этом. Исключение в матвью не ошибка
|
||||||
|
формата, его не перехватит никакой режим Kafka-движка: вставка упадёт, офсеты не
|
||||||
|
закоммитятся, блок пойдёт читаться снова. Ломается это громко и чинится без
|
||||||
|
потерь — поправил матвью, потребление продолжилось с некоммиченного офсета, — но
|
||||||
|
пока не починено, топик стоит. Поэтому приведение `Nullable` к необнуляемому типу
|
||||||
|
и любая арифметика в этих выражениях живут за предикатом, который NULL уже отсёк.
|
||||||
|
|
||||||
|
Этим решение снимает ограничение, записанное в постановке #37: «ошибки разбора
|
||||||
|
должны рождаться на шаге Kafka-движка». Оно ставилось как условие достижимости
|
||||||
|
критерия #43 про громкую ошибку на опечатку в имени поля — критерий достижим и
|
||||||
|
без него, средствами матвью.
|
||||||
|
|
||||||
|
Требование строгого приёма из раздела 6 спеки остаётся в силе, меняется его
|
||||||
|
механизм: настройка формата `input_format_skip_unknown_fields` при чтении
|
||||||
|
байтами беспредметна, раскладки по полям на этом шаге нет вовсе.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Типизированный чтец не отдаёт сырьё. При `kafka_handle_error_mode = 'stream'`
|
||||||
|
виртуальные колонки `_raw_message` и `_error` заполняются только при ошибке
|
||||||
|
разбора и пусты у разобранных сообщений. Один типизированный чтец оставил бы в
|
||||||
|
STG сырьё исключительно от брака: слой сырых строк хранил бы всё, кроме того,
|
||||||
|
что доехало.
|
||||||
|
|
||||||
|
Слои идут цепочкой. Kafka → STG → ODS → DDS → DM, каждый читает предыдущий.
|
||||||
|
Отвергнутая схема с двумя чтецами одного топика — сырым для STG и типизированным
|
||||||
|
для ODS — в бою обычна и дала бы строгий приём даром, средствами самого движка.
|
||||||
|
Но ODS перестал бы быть надстройкой над STG и стал бы вторым входом с шины, а на
|
||||||
|
стенде, где слои и есть предмет изучения, это дороже сэкономленного. Побочная
|
||||||
|
выгода скромнее, чем кажется на первый взгляд: слой сырья при двух чтецах
|
||||||
|
существовал бы точно так же, но SQL разбора не существовал бы нигде, и для
|
||||||
|
переобработки дня X его пришлось бы писать заново. В цепочке он уже есть в
|
||||||
|
матвью, и ручная вставка получается его копией.
|
||||||
|
|
||||||
|
Слой сырья ничего не проверяет, и формат чтеца выбран под это. Соседний вариант,
|
||||||
|
`JSONAsString`, требует, чтобы каждое сообщение было корректным объектом JSON, и
|
||||||
|
на некорректном падает: потребление встаёт, хотя правило репозитория гласит, что
|
||||||
|
грязные записи не должны валить пайплайн. Лечится это двумя способами — включить
|
||||||
|
на чтеце режим обработки ошибок или не проверять на входе вовсе. Второе честнее:
|
||||||
|
частичная проверка в слое, чья работа — не проверять, спорит сама с собой, а
|
||||||
|
настоящий разбор всё равно идёт ниже. При `RawBLOB` ломаться нечему, доезжает
|
||||||
|
любое непустое сообщение, каким бы мусором оно ни было (про «непустое» — там же,
|
||||||
|
в «Что проверено»), и оба класса брака разбираются в одном месте.
|
||||||
|
|
||||||
|
Архив нужен буквальный и читаемый глазами. `RawBLOB` — это про способ чтения, а
|
||||||
|
не про тип колонки: на диске лежит обычный `String` с текстом события, и менти
|
||||||
|
открывает его в обычном клиенте и читает. В этом и смысл слоя — увидеть, что
|
||||||
|
реально пришло. Мусор при этом лежит в той же колонке, что и целые события, а не
|
||||||
|
в отдельной. Отвергнутый боевой вариант — собрать слой сырья на движке `Null`,
|
||||||
|
тогда данные не хранятся вовсе, а матвью работает чистым триггером. Отлаживать
|
||||||
|
так неудобно, поэтому здесь окно в трое суток, а не ноль.
|
||||||
|
|
||||||
|
Что при этом потеряно и чем закрыто. Настройка `input_format_skip_unknown_fields
|
||||||
|
= 0` ловила два случая: ожидаемое поле пропало и появилось лишнее, неизвестное.
|
||||||
|
Оба берёт на себя сверка набора ключей: опечатка в имени — это одновременно
|
||||||
|
пропавшее ожидаемое и появившееся лишнее, и сравнение множеств видит и то, и
|
||||||
|
другое. Она же единственная защита у колонок-массивов и она же ловит молчаливое
|
||||||
|
расширение контракта, когда в сообщении появляется поле, о котором хранилище не
|
||||||
|
знает. `Nullable`-разбор к этой работе отношения не имеет — он про тип, а не про
|
||||||
|
присутствие, и потому сужен до пяти колонок.
|
||||||
|
|
||||||
|
Цена решения. Контракт получает ещё два места: типы сорока семи колонок в
|
||||||
|
выражениях матвью и список тех же имён для сверки ключей — а список этот
|
||||||
|
повторён дважды, по разу на матвью. Раздел 1.4 спеки предупреждает, что,
|
||||||
|
повторяясь примерно в семи местах, они расходятся молча.
|
||||||
|
|
||||||
|
И сверка ключей эту цену покрывает не всю: она смотрит на ключи сообщения, а
|
||||||
|
не на выражения матвью. Опечатка в имени внутри `JSONExtract` даёт умолчание
|
||||||
|
типа — ноль, пустую строку, пустой массив, — и молчит она у сорока двух
|
||||||
|
обычных колонок ровно так же, как у двенадцати массивов. Громко ломаются
|
||||||
|
только пять опорных: у них разбор `Nullable` стоит в предикате, и опечатка
|
||||||
|
уводит в таблицу ошибок все строки до единой. Остальные сорок две сторожит
|
||||||
|
сверка разобранного события против сырого текста — разовый опыт при
|
||||||
|
исполнении #43, а не постоянная проверка; сила его в том, что выражения
|
||||||
|
сверки собираются из контракта, а выражения матвью написаны руками по
|
||||||
|
описанию выгрузки, и одна опечатка в двух местах не повторяется.
|
||||||
|
|
||||||
|
Второе — разбор функциями дороже разбора форматом. На объёмах стенда это
|
||||||
|
несущественно; если станет заметно, сорок семь вызовов сворачиваются в один
|
||||||
|
`JSONExtract` в именованный кортеж, и строка разбирается однократно.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
По документации ClickHouse через MCP Context7: основная сверка — 3 августа
|
||||||
|
2026 года, перепроверка после правок — 5 августа. Датировка важна: 5 августа
|
||||||
|
утверждение про `Nullable(Tuple)` развернулось на противоположное.
|
||||||
|
|
||||||
|
При режиме `stream` движок отдаёт `_raw_message` и `_error` только для
|
||||||
|
сообщений, которые не разобрались, и оставляет их пустыми для разобранных.
|
||||||
|
Отсюда весь довод о том, что типизированный чтец не может наполнить слой сырья.
|
||||||
|
|
||||||
|
Составные типы `Nullable` не оборачивает: `Nullable(Array)` и `Nullable(Map)` не
|
||||||
|
поддерживаются, а `Nullable` внутри них — да. Отсюда слепота `Nullable`-разбора
|
||||||
|
на двенадцати колонках и необходимость сверки ключей. Оговорка про кортеж:
|
||||||
|
`Nullable(Tuple)` в ClickHouse всё-таки есть, но за настройкой
|
||||||
|
`enable_nullable_tuple_type` и в статусе беты; на довод это не влияет — кортежей
|
||||||
|
в контракте нет. `Nullable` внутри кортежа разрешён без всяких флагов, поэтому
|
||||||
|
запасной вариант со свёрткой в именованный кортеж жив.
|
||||||
|
|
||||||
|
Оттуда же: у Kafka-движка есть третий режим обработки ошибок —
|
||||||
|
`dead_letter_queue` с записью в системную таблицу. Он отвергнут независимо от
|
||||||
|
остального: спека требует свои `*_errors`, системная таблица их не заменяет.
|
||||||
|
|
||||||
|
Форматы `RawBLOB` и `LineAsString` в ClickHouse есть, и Kafka-движок
|
||||||
|
поддерживает все форматы. `RawBLOB` читает вход в одно значение и рассчитан на
|
||||||
|
таблицу с единственным полем `String` — отсюда ограничение на форму чтеца.
|
||||||
|
|
||||||
|
Матвью с источником-`Distributed` срабатывает на вставку именно в эту
|
||||||
|
распределённую таблицу — блок она видит до разрезания по шардам. Проверено
|
||||||
|
владельцем на рабочих проектах; на стенде проверяется вместе с матвью разбора,
|
||||||
|
то есть при исполнении #43.
|
||||||
|
|
||||||
|
Пункт про `RawBLOB`, стоявший в списке ниже первым, закрыт при исполнении #37,
|
||||||
|
на стенде 5 августа 2026 года: формат даёт ровно одну строку на каждое непустое
|
||||||
|
сообщение, пачка продюсера границы сообщений не стирает, запасной `LineAsString`
|
||||||
|
не понадобился.
|
||||||
|
Тогда же нашлась и граница обещания — запись с пустым значением и
|
||||||
|
запись-надгробие не дают строки вовсе; из-за неё в «Решении» и в «Почему»
|
||||||
|
приписано слово «непустое». Замер целиком — в [доке
|
||||||
|
хранилища](../architecture/storage.md), раздел «Что проверено».
|
||||||
|
|
||||||
|
Остальные четыре закрыты при исполнении #43, на стенде 7 августа 2026 года,
|
||||||
|
ClickHouse 26.3.17.56. Все четыре ответили так, как ждала постановка:
|
||||||
|
|
||||||
|
- `isValidJSON('123')` возвращает единицу — скаляр законный JSON, и первый
|
||||||
|
класс брака поэтому проверяет именно объект, а не валидность;
|
||||||
|
- `JSONExtractKeys('123')` возвращает пустой массив, и он же приходит от
|
||||||
|
вовсе не-JSON. Значит скаляр проваливает и сверку ключей — отсюда
|
||||||
|
обязательный порядок классов. Раз `isValidJSON` объект от скаляра не
|
||||||
|
отличает, первый класс держит `JSONType`: она возвращает `Object` у объекта,
|
||||||
|
`Int64` у скаляра `123` и `Null` у вовсе не-JSON и у пустой строки —
|
||||||
|
исключения не бросает ни в одном случае, то есть годится в предикат;
|
||||||
|
- `JSONAsString` на некорректном вводе падает, а не пропускает строку: код 117
|
||||||
|
`INCORRECT_DATA`, «JSON object must begin with '{'». Падает и на скаляре
|
||||||
|
`123`. На этом стоит отказ от него в пользу `RawBLOB`;
|
||||||
|
- форма именованного кортежа с `Nullable`-членами работает и годится в
|
||||||
|
запасной вариант: `JSONExtract(raw, 'Tuple(WatchID Nullable(UInt64), …)')`
|
||||||
|
даёт NULL в тех членах, что не разобрались, обращение по имени члена
|
||||||
|
доступно, а на не-объекте кортеж выходит целиком из NULL и исключения нет.
|
||||||
|
|
||||||
|
Тем же заходом нашлось то, о чём никто не спрашивал, и оно оказалось
|
||||||
|
блокирующим. **`JSONExtract` с типом `DateTime` не разбирает ISO-8601 с
|
||||||
|
суффиксом зоны.** На проводе `UTCEventTime` уезжает как
|
||||||
|
`2026-06-01T12:34:56Z` (спека генератора, раздел 4), а
|
||||||
|
`JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')` отдаёт на такой
|
||||||
|
строке NULL — то есть все события до единого уходили бы в брак с классом
|
||||||
|
`key_field_unparsed`. Ни `DateTime64`, ни `DateTime('UTC')` суффикс тоже не
|
||||||
|
берут; без `Z` та же строка разбирается. Спека генератора обещала обратное
|
||||||
|
(«принимает ISO без плясок») — обещание было ошибочным и исправлено тем же PR.
|
||||||
|
Настройка `cast_string_to_date_time_mode = 'best_effort'` дела не меняет:
|
||||||
|
с ней суффикс берёт `CAST`, а `JSONExtract` по-прежнему отдаёт NULL — то есть
|
||||||
|
внутренний разбор `JSONExtract` её не слушает. `EventDate` уезжает как
|
||||||
|
`2026-06-01` и разбирается `JSONExtract` без оговорок.
|
||||||
|
|
||||||
|
Разбор метки времени идёт
|
||||||
|
`parseDateTimeOrNull(JSONExtractString(raw, 'UTCEventTime'), '%Y-%m-%dT%H:%i:%SZ', 'UTC')`
|
||||||
|
— по буквально названному формату, а не через `parseDateTimeBestEffort`.
|
||||||
|
Обе функции ISO-8601 понимают и обе в варианте `*OrNull` отдают NULL вместо
|
||||||
|
исключения, то есть годятся в предикат. Выбран точный формат потому, что
|
||||||
|
широта здесь работает против строгого приёма: `parseDateTimeBestEffort` на
|
||||||
|
непонятной строке не краснеет, а достраивает недостающее. Измерено 7 августа
|
||||||
|
2026 года — обрезанное `20:00:21` он превращает в `2026-01-01 20:00:21`,
|
||||||
|
подставив текущий год и первое января; голая дата `2026-05-31` становится
|
||||||
|
полуночью; строка цифр читается числом эпохи. Такое сообщение прошло бы
|
||||||
|
строгий приём с тихо неверным временем — ровно с той порчей, ради которой
|
||||||
|
класс `key_field_unparsed` и заведён. Разбор по названному формату отдаёт на
|
||||||
|
всех трёх NULL. Источник у топика один и шлёт одну запись, так что широта не
|
||||||
|
нужна вовсе, а платится за неё отключённой проверкой.
|
||||||
|
|
||||||
|
Третий аргумент — имя пояса, `'UTC'` — пришёл с конвенцией #63; правило и его
|
||||||
|
довод — [конвенция часовых поясов](../architecture/storage.md).
|
||||||
|
|
||||||
|
Цена выбора измерена на настоящих данных: по всем 101 252 строкам сырья
|
||||||
|
модельного дня (день залит дважды) точный формат разобрал метку у каждой, и
|
||||||
|
ни на одной не разошёлся с `parseDateTimeBestEffort`. Различаются они только
|
||||||
|
на порче.
|
||||||
|
|
||||||
|
Оговорка к обнуляемому разбору даты, измеренная там же: `Nullable(Date)`
|
||||||
|
даёт NULL на строке, которая датой не является вовсе («мусор»), и на числе,
|
||||||
|
но невозможную дату `2026-13-99` молча приводит к `1970-01-01`. То есть
|
||||||
|
класс `key_field_unparsed` ловит порчу типа, а не порчу значения внутри типа.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# ADR 0006. Имена объектов хранилища: суффикс вида
|
||||||
|
|
||||||
|
Дата: 4 августа 2026 года. Статус: принято.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Имя объекта в ClickHouse заканчивается тем, что это за объект: `_rep` —
|
||||||
|
локальная таблица шарда, `_dist` — `Distributed` поверх неё, `_kafka` — чтец
|
||||||
|
топика, `_file` — чтец файла, `_mv` — материализованное представление, `_v` —
|
||||||
|
обычное представление.
|
||||||
|
|
||||||
|
Суффикс `_file` пришёл с подложкой под словарём товаров
|
||||||
|
([ADR 0012](0012-dictionary-home.md)).
|
||||||
|
|
||||||
|
Суффикс носит каждый физический объект, поэтому голого имени у таблицы не
|
||||||
|
существует: запрос к `stg.hits_raw` даёт ошибку «нет такой таблицы».
|
||||||
|
Единственное исключение — словари: воплощение у них одно, шардировать нечего, и
|
||||||
|
суффикс ничего не различал бы. В документах и разговоре голое имя означает
|
||||||
|
сущность, у которой этих объектов несколько.
|
||||||
|
|
||||||
|
Конвенция применена к мастер-спеке тем же решением: `stg.kafka_hits` стал
|
||||||
|
`stg.hits_raw_kafka`, `dds.v_event` — `dds.event_v`, витрины `dm.v_*` —
|
||||||
|
`dm.*_v`. Полная таблица суффиксов и следствия для DDL — в
|
||||||
|
[доке хранилища](../architecture/storage.md).
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Главный довод — как ошибается забытый суффикс. Распространённая конвенция, где
|
||||||
|
голое имя означает локальную таблицу, а распределённая получает `_all`,
|
||||||
|
ошибается молча: запрос к голому имени вернёт данные одного шарда и никак об
|
||||||
|
этом не скажет. Правило спеки «проверки и контрольные суммы — только по
|
||||||
|
`Distributed`» такую тишину не переживает: контрольная сумма, посчитанная по
|
||||||
|
половине кластера, выглядит как честное число. При суффиксе вида голого имени
|
||||||
|
нет вовсе, и та же ошибка становится громкой.
|
||||||
|
|
||||||
|
Второй довод — что группируется в списке по алфавиту. Суффикс группирует объекты
|
||||||
|
по сущности: все четыре объекта топика `hits` стоят рядом, потому что различаются
|
||||||
|
хвостом. Префиксный стиль, которым пользовался стенд-предшественник (`kafka_*`,
|
||||||
|
`mv_*`, `v_*`), группирует по технологии, и объекты одной сущности
|
||||||
|
расползаются по алфавиту. В хранилище, где у одной сущности живёт по три-четыре
|
||||||
|
воплощения, полезнее первое. Довод про дерево в клиенте и про `ORDER BY name`:
|
||||||
|
порядок выдачи `SHOW TABLES` документация не оговаривает, так что на него здесь
|
||||||
|
опираться нельзя.
|
||||||
|
|
||||||
|
Третий — преемственность: `_rep` и `_dist` уже используются владельцем в других
|
||||||
|
хранилищах на ClickHouse, и общий словарь между стендами стоит больше, чем
|
||||||
|
локальная стройность.
|
||||||
|
|
||||||
|
Отвергнуты, кроме `_all`: префиксный стиль предшественника — он не покрывает
|
||||||
|
пару локальная/распределённая, для неё префикса просто нет; голое имя как
|
||||||
|
`Distributed` с суффиксом `_local` у локальной — привычное имя ведёт в
|
||||||
|
правильную таблицу, но конвенция расходится с другими стендами владельца;
|
||||||
|
раскладка пары по разным базам (`stg` и `stg_dist`) — удваивает число баз в
|
||||||
|
каждом слое и разъезжается с таблицей слоёв спеки.
|
||||||
|
|
||||||
|
Цена решения — правка принятой спеки задним числом. Имена представлений и
|
||||||
|
витрин переехали с префикса на суффикс, хотя сами объекты спроектированы не
|
||||||
|
полностью и появятся только на этапах 4 и дальше. Размен принят осознанно:
|
||||||
|
конвенция, введённая после того, как по ней написан первый слой, обходится
|
||||||
|
дороже.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
Проверять здесь нечем — это соглашение, а не поведение системы. Вместо проверки
|
||||||
|
конвенция прогнана по карте таблиц спеки, раздел 7: суффикс выводится для всех
|
||||||
|
объектов слоёв STG, ODS, DDS и DM, включая пары локальная/распределённая,
|
||||||
|
представления и матвью. Единственным объектом без выводимого суффикса оказался
|
||||||
|
словарь `products` — отсюда исключение в решении.
|
||||||
|
|
||||||
|
Первый прогон был неполным: четыре витрины из восьми остались с префиксом, и
|
||||||
|
заметило это холодное ревью, а не автор. Имена приведены в порядок 5 августа
|
||||||
|
2026 года. Урок не про имена: «прогнал по документу» — такое же утверждение,
|
||||||
|
как утверждение о поведении системы, и проверять его надо так же.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# ADR 0007. Доступ к ClickHouse: пользователи файлом, доверие нод секретом
|
||||||
|
|
||||||
|
Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader`
|
||||||
|
и пользователь `grafana` добавлены 21 августа 2026 года (#114), права
|
||||||
|
`bi_reader` расширены до ODS, DDS и DM 25 августа 2026 года (#150).
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
У кластера семь пользователей и шесть ролей.
|
||||||
|
|
||||||
|
- `default` — с паролем, только служебный: проверки здоровья нод и работа
|
||||||
|
изнутри контейнеров. Приложения им не ходят.
|
||||||
|
- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходят рабочие
|
||||||
|
даги Airflow.
|
||||||
|
- `lifecycle` с ролью `lifecycle_owner` — создание и удаление только
|
||||||
|
прикладных баз и объектов. Им даги жизненного цикла применяют DDL и
|
||||||
|
пересоздают мир ([ADR 0013](0013-world-lifecycle.md)).
|
||||||
|
- `bi` с ролью `bi_reader` — чтение слоёв ODS, DDS и DM, а также справочников
|
||||||
|
`dic`. Им ходит Superset; его запросы обращаются к публичным `_v`, хотя права
|
||||||
|
шире.
|
||||||
|
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
|
||||||
|
человек подключается снаружи, из своего клиента.
|
||||||
|
- `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и
|
||||||
|
системных таблиц, `CLUSTER` и `REMOTE`. Им ходит Grafana, и больше никто.
|
||||||
|
- `dict` с ролью `dict_reader` — чтение одной базы `dic`, пароля нет. Им
|
||||||
|
словарь читает свою подложку, и больше он никем не используется
|
||||||
|
([ADR 0012](0012-dictionary-home.md)).
|
||||||
|
|
||||||
|
Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения
|
||||||
|
на запросы `ON CLUSTER`, на чтение системных таблиц и на каждый движок
|
||||||
|
внешнего источника. Без права на системные таблицы клиент вовсе не открывает
|
||||||
|
соединение.
|
||||||
|
|
||||||
|
Пользователи, роли и права объявлены файлами настройки сервера, а не
|
||||||
|
запросами SQL. Файл монтируется на обе ноды и лежит в git. Пароли живут
|
||||||
|
в `.env` и попадают в настройку подстановкой по имени переменной; значения
|
||||||
|
собраны по конвенции «слово-число-слово», потому что пароль `analyst`
|
||||||
|
человек набирает руками.
|
||||||
|
|
||||||
|
Ноды доверяют друг другу по общему секрету, объявленному в описании
|
||||||
|
кластера. Учётные данные по репликам не расписываются.
|
||||||
|
|
||||||
|
Сетевых ограничений на `default` нет: порты стенда и так открыты только на
|
||||||
|
`127.0.0.1`, а внутри сети Compose дверь заперта паролем.
|
||||||
|
|
||||||
|
Раздел README «Состав и доступ» описывает, кто каким пользователем ходит и где
|
||||||
|
живут пароли; здесь остаются решение и доводы. Отдельного справочника по
|
||||||
|
доступу в `docs/architecture/` нет: точные права видны в `access.xml`, конвенция
|
||||||
|
чтения через `_v` описана в доке хранилища.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Стенд учит не администрированию доступа, а чтению отказа в правах: человек
|
||||||
|
должен узнать в бою картину, которую видел здесь, и понимать, чего просить.
|
||||||
|
Отсюда критерий отбора — модель должна читаться из текста ошибки, а не из
|
||||||
|
описания. Поэтому пользователей мало, имена у них говорящие. В бою BI обычно
|
||||||
|
читает витрины и модельный слой. ODS ему могут открыть для расследования или
|
||||||
|
сверки. Стенд оставляет этот доступ постоянным, но сохраняет две крупные
|
||||||
|
границы: `bi` отделён от STG и от записи. Один пользователь может сверить всю
|
||||||
|
цепочку DM → DDS → ODS, не получая доступа к сырью и изменению данных.
|
||||||
|
Справочники видны всем читателям и никому на запись: зона входит в хранилище
|
||||||
|
сбоку, и её содержимым владеет файл репозитория, а не слой.
|
||||||
|
|
||||||
|
Ограничить `bi` одними `_v` поверх `Distributed` можно только отдельным
|
||||||
|
маршрутом, учётной записью или копией данных. Эта механика не даёт нового урока.
|
||||||
|
Публичное представление остаётся договором чтения; границу прав оно не задаёт
|
||||||
|
([исследование](../research/2026-08-25-clickhouse-distributed-view-security.md)).
|
||||||
|
|
||||||
|
**Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то
|
||||||
|
есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно —
|
||||||
|
`SELECT` на `dic`, и хост источника локальный.
|
||||||
|
|
||||||
|
**Своя роль дашбордам.** Панели мониторинга живут не на витринах, а на
|
||||||
|
`system.query_log`, частях MergeTree и очереди распределённых DDL — и читают
|
||||||
|
их через `clusterAllReplicas`, потому что системные таблицы у каждой ноды свои
|
||||||
|
([ADR 0002](0002-monitoring-scope.md)). Для этого нужны `SELECT` на `system.*`
|
||||||
|
и `REMOTE`; контракт роли также включает `CLUSTER` для запросов `ON CLUSTER`.
|
||||||
|
Такого набора не было ни у одного читателя: у обеих читающих ролей из
|
||||||
|
системных таблиц только `system.settings`. Догрузить
|
||||||
|
недостающее в `analyst_reader` отвергнуто: аналитик получил бы системные
|
||||||
|
таблицы и `REMOTE` заодно, а в `system.query_log` запросы Grafana стали бы
|
||||||
|
неотличимы от человеческих — ровно там, где дашборд «Запросы» и должен их
|
||||||
|
различать. Пускать Grafana под `etl` отвергнуто сразу: читатель не получает
|
||||||
|
права писать. Осталась своя роль по здешней общей схеме — у каждого
|
||||||
|
потребителя она своя.
|
||||||
|
|
||||||
|
**Секрет, а не учётные данные по репликам.** У каждой реплики в описании
|
||||||
|
кластера своя учётка — вписанная явно или подразумеваемая, и тогда это
|
||||||
|
беспарольный `default`. Подключение к соседней ноде идёт от неё, а не от
|
||||||
|
того, кто задал вопрос. Значит, стоит `default` получить пароль — и без
|
||||||
|
правки описания кластера межнодовые запросы встают. Починить это можно двумя
|
||||||
|
способами, и они различаются не удобством. С учётными данными по репликам вся
|
||||||
|
межшардовая работа идёт от одной общей учётки: на своей ноде права
|
||||||
|
применяются, на соседней — нет. Ролевая модель тогда наполовину декорация, а
|
||||||
|
это хуже её отсутствия: она утверждает разграничение, которого не существует.
|
||||||
|
Общий секрет передаёт на соседа личность инициатора, стоит одной строки в
|
||||||
|
описании кластера и не размножает пароль по репликам. Шифрование тут ни при
|
||||||
|
чём; в соседнем `clickhouse-learning-cluster` выбран как раз путь с учётными
|
||||||
|
данными по репликам, и там пароль вписан в каждую реплику каждого из трёх
|
||||||
|
кластеров.
|
||||||
|
|
||||||
|
Довод подтверждён замером 13 августа 2026 года: на соседнем шарде сохранился
|
||||||
|
пользователь-инициатор, поэтому там применились его права. Результат замера —
|
||||||
|
в разделе «Что проверено».
|
||||||
|
|
||||||
|
**Файлы, а не SQL.** Объявленное файлом не оставляет состояния в томах:
|
||||||
|
источник истины — файл в git. Стенд считает `access.xml` и переданные через
|
||||||
|
`.env` значения настройкой при создании контейнера; отдельного способа
|
||||||
|
применять их на живом стенде нет, потому что уроки не меняют базовую модель
|
||||||
|
доступа. Порядок применения изменений — в разделе README «Состав и доступ».
|
||||||
|
Оба варианта с SQL — локальное хранилище доступа с `ON CLUSTER` и
|
||||||
|
реплицированное в keeper — делают пользователей состоянием тома: `.env` на
|
||||||
|
них больше не влияет, и появляется расхождение того же сорта, которое README
|
||||||
|
уже описывает для Postgres и Grafana. Цена выбора известна: объявленных файлом
|
||||||
|
пользователей нельзя менять запросами. Она принята как упрощение — сломать
|
||||||
|
модель случайно труднее.
|
||||||
|
|
||||||
|
**Что ещё отвергнуто.** Оставить всё на беспарольном `default`
|
||||||
|
— цена нулевая, но и рассказать по итогам нечего. Раздать всем пароль без
|
||||||
|
ролей — дёшево и показывает ровно тот антипаттерн, за который ругают в бою:
|
||||||
|
все под одним админом. Вынести доступ в лекцию, как репликационную
|
||||||
|
эксплуатацию, — довод «отдельный операционный домен» здесь не работает:
|
||||||
|
у репликации это живые процессы, учения и runbook, а тут несколько десятков
|
||||||
|
строк настройки и ноль эксплуатации. Запереть `default` ещё и по сети —
|
||||||
|
второй замок на ту же дверь.
|
||||||
|
|
||||||
|
**Отложено явно**: TLS и защищённые межсерверные соединения, настоящее
|
||||||
|
хранилище секретов, внешние поставщики учётных записей, квоты и профили под
|
||||||
|
пользователя, политики строк, аудит запросов.
|
||||||
|
|
||||||
|
## Следствия
|
||||||
|
|
||||||
|
Новые таблицы ODS, DDS и DM не требуют отдельного гранта для `bi_reader`.
|
||||||
|
Явный договор `_v` защищает потребителя от устройства хранения и случайных
|
||||||
|
новых колонок.
|
||||||
|
|
||||||
|
DDL для матвью приёма выполняется от имени `lifecycle`. Это видно в журнале
|
||||||
|
запросов, а при создании ClickHouse проверяет у этой учётки чтение источника и
|
||||||
|
вставку в цель. Так отдельная учётка показывает границу между редким
|
||||||
|
управлением структурой и повседневной записью `etl`, не выдавая рабочим дагам
|
||||||
|
право удалять базы.
|
||||||
|
|
||||||
|
Лаба «сломать права» вариантом с файлами не закрывается: создать через SQL
|
||||||
|
новую роль и нового пользователя ничто не мешает, и новую роль разрешено
|
||||||
|
выдавать даже пользователю, объявленному файлом. Недоступно обратное —
|
||||||
|
править объявленных файлом. Всё, что лаба создаст, живёт в томе и убирается
|
||||||
|
`make clean`.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
Сверено 9 августа 2026 года по документации ClickHouse через MCP Context7 и
|
||||||
|
на живом стенде 26.3.17.56.
|
||||||
|
|
||||||
|
- В `system.clusters` у каждой реплики своя колонка `user`; на стенде там
|
||||||
|
`default`. Отсюда весь довод про общий секрет.
|
||||||
|
- Секрет кластера аутентифицирует серверы хешем от соли, нонса, секрета и
|
||||||
|
запроса и позволяет назначить пользователя, от которого запрос выполняется
|
||||||
|
на соседней ноде.
|
||||||
|
- Роли и права объявляются в файле настройки пользователей наравне с самими
|
||||||
|
пользователями; SQL-хранилище доступа для этого не требуется.
|
||||||
|
- Хранилище пользователей из файла — только для чтения: попытка изменить
|
||||||
|
такого пользователя запросом даёт `ACCESS_STORAGE_READONLY`.
|
||||||
|
- Роль, созданную через SQL, разрешено выдавать пользователю из файла —
|
||||||
|
отдельное улучшение ClickHouse 2025 года, в 26.3 оно есть.
|
||||||
|
- Подстановка значений из переменных окружения — общий механизм файлов
|
||||||
|
настройки ClickHouse.
|
||||||
|
- Весь DDL репозитория идёт через `IF NOT EXISTS` (файлы `sql/ddl/`).
|
||||||
|
|
||||||
|
Права роли `monitoring_reader` сверены 21 августа 2026 года по документации
|
||||||
|
ClickHouse через MCP Context7 (#114).
|
||||||
|
|
||||||
|
- Системные таблицы выдаются обычным `SELECT`: в документации это
|
||||||
|
`GRANT SELECT ON system.query_log`, значит `SELECT ON system.*` и есть «все
|
||||||
|
системные таблицы». Отдельного права на них нет.
|
||||||
|
- `GRANT CLUSTER ON *.*` — документированная форма права на запросы
|
||||||
|
`ON CLUSTER`; текущий запрос `clusterAllReplicas` его не использует.
|
||||||
|
- `clusterAllReplicas` документация держит в одном ряду с `remote`,
|
||||||
|
`remoteSecure` и `cluster`: это табличная функция, которая берёт имя
|
||||||
|
кластера из `remote_servers` и идёт на каждую его реплику. Отсюда `REMOTE` —
|
||||||
|
без права на удалённое обращение запрос дальше своей ноды не пойдёт, а
|
||||||
|
панели ADR 0002 читают `system.*` именно так.
|
||||||
|
|
||||||
|
Набор проверен на живом стенде 21 августа 2026 года (#114): пользователь
|
||||||
|
`grafana` из Grafana выполнил
|
||||||
|
`SELECT count() AS replicas FROM clusterAllReplicas('clickstream_cluster', system.one)`
|
||||||
|
и получил 2 — прав хватает, чтобы прочесть системную таблицу с обеих нод.
|
||||||
|
|
||||||
|
Проверено на живом стенде 13 августа 2026 года, ClickHouse 26.3.17.56.
|
||||||
|
|
||||||
|
- Секрет кластера принимается внутри `remote_servers` в виде
|
||||||
|
`<secret from_env="CLICKHOUSE_CLUSTER_SECRET"/>`. Запрос от `etl` через обе
|
||||||
|
ноды выполнился на каждой под пользователем `etl`.
|
||||||
|
- `from_env` работает у элемента `password`: `etl`, `lifecycle`, `bi` и
|
||||||
|
`analyst` вошли с паролями из окружения и получили свои роли.
|
||||||
|
- При запросе от `bi` через Distributed на второй ноде `system.query_log`
|
||||||
|
показал `user = bi` и `initial_user = bi`. Значит, на соседнем шарде
|
||||||
|
применяются права инициатора, а не общей учётной записи.
|
||||||
|
- Права на отсутствующие базы `dds` и `dm` приняты. До создания баз они видны
|
||||||
|
в `SHOW GRANTS FOR etl_writer`; временная таблица в `dm` подтвердила право
|
||||||
|
`bi` на чтение после появления базы.
|
||||||
|
|
||||||
|
Расширение `bi_reader` проверено на живом стенде 25 августа 2026 года (#150):
|
||||||
|
|
||||||
|
- `SHOW GRANTS FOR bi_reader` содержит `SELECT` на ODS, DDS и DM;
|
||||||
|
- `bi` прочитал одинаковое число строк из `ods.event_v` и `dds.event_v`;
|
||||||
|
- чтение `stg.hits_raw_rep` завершилось ошибкой `ACCESS_DENIED`;
|
||||||
|
- представление над `Distributed` с `SQL SECURITY DEFINER` на удалённой ноде
|
||||||
|
сохранило исходного пользователя `bi`. Причина и отклонённые варианты
|
||||||
|
разобраны в [исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md).
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# ADR 0008. Приём заказов: пакетный забор слепка, инициируемый Airflow
|
||||||
|
|
||||||
|
Дата: 12 августа 2026 года. Статус: частично заменено
|
||||||
|
[ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93.
|
||||||
|
|
||||||
|
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
|
||||||
|
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
|
||||||
|
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
|
||||||
|
`dds.order` с `argMax` — это ADR 0010. Отдельно от него, решением #93 от
|
||||||
|
17 августа 2026 года, производитель и потребитель слепка разъехались по разным
|
||||||
|
дагам; раздел «Решение» описывает построенное. Действующая форма STG → ODS
|
||||||
|
описана в [спецификации приёма заказов](../architecture/orders/ingestion.md).
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Топик `orders` читает Kafka-таблица формата `RawBLOB` — та же форма чтеца, что
|
||||||
|
у событий ([ADR 0005](0005-event-ingestion.md)). Матвью к ней не привязана.
|
||||||
|
Сырьё забирает пакетный шаг, которым управляет Airflow: он вставляет
|
||||||
|
прочитанное в `stg.orders_raw_dist`, разбирает его в типизированный слепок и
|
||||||
|
заменяет партицию дня в `ods.order_snapshot`.
|
||||||
|
|
||||||
|
Производитель и потребитель слепка — разные даги. Работник пульта мира играет
|
||||||
|
день и отправляет слепок, а забирает его отдельный даг `orders_ingest`; зовёт
|
||||||
|
забор сам работник ждущим `TriggerDagRunOperator` и зеленеет только вслед за
|
||||||
|
ним. Связывает две половины не общий даг, а этот дождавшийся успех: зелёный
|
||||||
|
работник значит, что забор отработал. Уточнение со сдвигом
|
||||||
|
отправки, решённым позже (#71, [слепок и его
|
||||||
|
доставка](../architecture/orders/snapshot.md)): работник, играющий день D, в
|
||||||
|
штатном прогоне кладёт слепок дня D−1 — слепок предыдущего дня, а не
|
||||||
|
сыгранного. После падения между шагами в топике может ждать и хвост прежних
|
||||||
|
слепков; забор принимает всё приехавшее.
|
||||||
|
|
||||||
|
Прямое чтение из Kafka-движка требует двух настроек, и вторая не очевидна:
|
||||||
|
`stream_like_engine_allow_direct_select = 1` разрешает читать чтеца запросом,
|
||||||
|
а `kafka_commit_on_select = 1` на самой таблице заставляет запрос коммитить
|
||||||
|
офсеты. По умолчанию прямое чтение их не коммитит — без этой настройки каждый
|
||||||
|
прогон забирает один и тот же слепок заново.
|
||||||
|
|
||||||
|
Матвью к чтецу не привязана не по вкусу, а по устройству: при привязанной
|
||||||
|
матвью прямое чтение остаётся запрещённым независимо от первой настройки. Два
|
||||||
|
способа приёма взаимоисключающи по построению, и выбрать половину каждого
|
||||||
|
нельзя.
|
||||||
|
|
||||||
|
У топика **одна партиция**, а чтец объявлен **только на `clickhouse-01` и без
|
||||||
|
`ON CLUSTER`**. Пишет пакетный шаг по-прежнему в распределённую таблицу, так
|
||||||
|
что раскладка по шардам не меняется: ключ у сырья прежний, `cityHash64` сырой
|
||||||
|
строки.
|
||||||
|
|
||||||
|
Слои от этого получают разные роли, и каждая своя:
|
||||||
|
|
||||||
|
- `stg.orders_raw` — байты как приехали, срок жизни и метаданные доставки как
|
||||||
|
у сырья событий; повторная заливка дня честно удваивает строки, как и там;
|
||||||
|
- `ods.order_snapshot` — типизированный слепок дня, идемпотентный **заменой
|
||||||
|
партиции** `snapshot_date`, ровно как обещает раздел 2 мастер-спеки;
|
||||||
|
- `dds.order` — дедуп до последней версии через `argMax`, без изменений.
|
||||||
|
|
||||||
|
Сенсора дневного батча нет. Ждать нечего: забор дёргает сам отправитель
|
||||||
|
слепка, когда отправил.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
**Слепок — не поток, и приём обязан это признать.** События приезжают
|
||||||
|
непрерывно, и матвью, тянущая их на лету, — честная форма для непрерывного.
|
||||||
|
Заказы приезжают раз в модельный день целой выгрузкой окна изменяемости; у
|
||||||
|
такой доставки есть начало и конец, и забирать её уместно по команде, а не
|
||||||
|
подписью на бесконечность. Стенд от этого получает не два оттенка одного
|
||||||
|
приёма, а два разных режима — push и pull, — и каждый стоит там, где ему место
|
||||||
|
по природе источника. Именно это сравнение раздел 12 мастер-спеки и заказывал
|
||||||
|
опорной точкой; прежняя его формулировка противопоставляла байтового чтеца
|
||||||
|
типизированному, то есть две формы одного и того же приёма, и переписана.
|
||||||
|
|
||||||
|
**Почему не типизированный чтец прямо в `ods.order_snapshot`.** Он давал бы
|
||||||
|
строгий приём средствами движка и живое сравнение двух чтецов даром, но ODS
|
||||||
|
перестал бы быть надстройкой над STG и стал бы вторым входом с шины. Ровно эту
|
||||||
|
схему ADR 0005 отверг для событий, и повод здесь тот же: на стенде слои и есть
|
||||||
|
предмет изучения.
|
||||||
|
|
||||||
|
**Почему не матвью из сырья в ODS, как у событий.** Тогда `ods.order_snapshot`
|
||||||
|
получил бы те же свойства, что `stg.orders_raw`: «как приехало», без дедупа,
|
||||||
|
дубли при повторной заливке законны. Два слоя подряд с одной ролью — один
|
||||||
|
лишний. Хуже того, обещание идемпотентности из раздела 2 повисло бы ни на чём:
|
||||||
|
матвью партиций не заменяет, а у ODS, в отличие от сырья, срока жизни нет, и
|
||||||
|
удвоение слепка жило бы вечно. При пакетном шаге каждый слой отрабатывает
|
||||||
|
своё, а замену партиции делает тот, кто данные и принёс.
|
||||||
|
|
||||||
|
**Почему чтец остался байтовым.** Пулл снял возражение про второй вход с шины,
|
||||||
|
и типизированный чтец снова стал допустим — но тогда между двумя приёмами
|
||||||
|
стенда менялись бы сразу две переменные, и сравнение перестало бы читаться.
|
||||||
|
Меняется одна: push против pull. Вдобавок `RawBLOB` сохраняет за слепком то же,
|
||||||
|
что даёт событиям, — колонку, которую менти открывает в обычном клиенте и
|
||||||
|
читает глазами, и возможность переразобрать сырьё, не переигрывая день.
|
||||||
|
Свойства этого формата уже сняты живыми запросами при исполнении #37 и #43;
|
||||||
|
менять его здесь значило бы платить второй раз за уже купленное.
|
||||||
|
|
||||||
|
**Почему одна партиция и один чтец.** Вторая партиция ничего не покупает:
|
||||||
|
слепок дня — порядка полутора тысяч строк, параллелизм не нужен. А урок «какая
|
||||||
|
нода читала топик» при пулле мёртв в любом случае — читает та нода, которую
|
||||||
|
спросили. То есть вторая партиция продаёт единственный настоящий риск схемы:
|
||||||
|
половина слепка застревает у ноды, к которой запрос не пришёл. Одна партиция
|
||||||
|
риск смягчает, но не снимает — брокер отдаст её любому из двух потребителей;
|
||||||
|
снимает его единственный чтец. У Airflow подготовлено одно подключение, к
|
||||||
|
`clickhouse-01`, — там чтецу и место.
|
||||||
|
|
||||||
|
Объявление без `ON CLUSTER` — не оговорка к конвенции, а её первое осознанное
|
||||||
|
исключение: у пулла один тянущий по определению. Заодно это контрпример к
|
||||||
|
рефлексу «везде `ON CLUSTER`»: приставка не ритуал, а решение.
|
||||||
|
|
||||||
|
**Почему не `Distributed` поверх Kafka.** Один запрос к распределённой таблице
|
||||||
|
над двумя чтецами осушил бы обе ноды разом и снял бы вопрос о партициях.
|
||||||
|
Механически это, скорее всего, работает — `Distributed` просто просит каждый
|
||||||
|
шард выполнить локальное чтение по имени таблицы, — но документация о такой
|
||||||
|
связке молчит: ни поддержки, ни запрета. Цена молчания высока. Офсеты
|
||||||
|
коммитятся на каждом шарде в момент чтения, а вставка идёт следом на
|
||||||
|
инициаторе: упала вставка — потеряны обе половины, а не одна. Недоступный шард
|
||||||
|
даёт худший из возможных исходов — тихо приехавшую половину слепка. Чинить
|
||||||
|
такое пришлось бы чтением исходников. И учебная цена своя: менти обязан
|
||||||
|
расшифровать конструкцию, которой нет ни в документации, ни в бою, и получает
|
||||||
|
за это трюк. При одной партиции и одном чтеце она не нужна вовсе.
|
||||||
|
|
||||||
|
**Что отвергнуто ещё.** Сенсор дневного батча из раздела 9 мастер-спеки: у
|
||||||
|
топика нет сигнала «всё», и сенсор ловил бы момент, которого не существует, —
|
||||||
|
а раз забор зовёт сам отправитель слепка, ждать нечего по построению. Лаба,
|
||||||
|
поднимающая типизированного чтеца во второй группе потребителей ради того же
|
||||||
|
сравнения: она понадобилась бы, останься сравнение невыполненным, но push
|
||||||
|
против pull даёт его живым и постоянным.
|
||||||
|
|
||||||
|
## Следствия
|
||||||
|
|
||||||
|
Урок про виртуальные колонки — «какая нода читала топик, меняется между
|
||||||
|
прогонами» — остаётся целиком за событиями. У заказов `consumer_host` всегда
|
||||||
|
один и тот же, и это честная разница двух режимов, а не потеря: при пулле
|
||||||
|
читает тот, кого спросили.
|
||||||
|
|
||||||
|
Гарантий приёма у заказов не больше, чем у событий, и граница проходит по
|
||||||
|
живому. Успешное чтение короткой порции оставляет непрочитанный хвост в
|
||||||
|
топике — он дождётся следующего забора. А отказ после чтения теряет саму
|
||||||
|
порцию: офсеты закоммичены в момент чтения, часть строк могла лечь на шарды,
|
||||||
|
и повторное чтение вернёт ноль. Позицию мира это не двигает, поэтому день
|
||||||
|
сыграется и уедет заново; в сырье он тогда окажется частичным дублем, а
|
||||||
|
старый хвост, ушедший той же порцией, не вернётся.
|
||||||
|
|
||||||
|
Этап 3 забирает у этапа 5 первый настоящий даг. Раздел 9 мастер-спеки отдавал
|
||||||
|
даги этапу 5 целиком; приём заказов без дага не существует, поэтому порядок
|
||||||
|
меняется. `etl_pipeline` остаётся за этапом 5. Позднее сдвинулся и он:
|
||||||
|
резолюция #134 родила его этапом 4 — [дневным конвейером
|
||||||
|
трансформаций](../architecture/etl.md), — а этапу 5 оставила партиционную
|
||||||
|
переобработку.
|
||||||
|
|
||||||
|
Расхождения с мастер-спекой, внесённые тем же коммитом: раздел 6 (чтец заказов
|
||||||
|
живёт на одной ноде и без матвью), раздел 7 (развилка закрыта, у `orders` одна
|
||||||
|
партиция), раздел 9 (сенсор снят, даг переехал на этап 3), раздел 12
|
||||||
|
(сравнение приёмов переформулировано).
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
Документация ClickHouse проверена через MCP Context7 12 августа 2026 года.
|
||||||
|
Предел порции одного опроса Kafka дополнительно снят 16 августа на локальном
|
||||||
|
ClickHouse `26.3.17.56`.
|
||||||
|
|
||||||
|
- Прямое чтение из движков-очередей (Kafka, RabbitMQ, FileLog) запрещено
|
||||||
|
начиная с версии 21.12 и открывается настройкой
|
||||||
|
`stream_like_engine_allow_direct_select`.
|
||||||
|
- При привязанной матвью прямое чтение остаётся запрещённым и с этой
|
||||||
|
настройкой. Отсюда вывод, что два способа приёма взаимоисключающи.
|
||||||
|
- Прямое чтение офсеты по умолчанию **не** коммитит; коммит включается
|
||||||
|
настройкой `kafka_commit_on_select` на самой таблице. Это тот подводный
|
||||||
|
камень, который молчит на первом прогоне и вылезает на втором.
|
||||||
|
- Прямое чтение возвращает одну порцию, полученную одним опросом Kafka. При
|
||||||
|
настройках стенда её предел — 65 409 сообщений, поэтому слепок порядка
|
||||||
|
полутора тысяч строк помещается с запасом.
|
||||||
|
- Про `Distributed` поверх Kafka документация не говорит ничего — ни
|
||||||
|
поддержки, ни запрета.
|
||||||
|
|
||||||
|
Забор проверен на живом стенде 18 августа 2026 года при исполнении #93 —
|
||||||
|
ClickHouse `26.3.17.56`, Airflow 3.3.0. Три вопроса, оставленные этим ADR
|
||||||
|
реализации, закрыты; заодно снят отказной путь. Обе настройки прямого чтения
|
||||||
|
доезжают до запроса, объявленные в конце `INSERT ... SELECT`: `system.query_log`
|
||||||
|
показывает у каждой вставки единицу.
|
||||||
|
|
||||||
|
- **Виртуальные колонки доставки при прямом чтении доступны все четыре.**
|
||||||
|
`_topic`, `_partition`, `_offset` и `_timestamp_ms` читаются тем же
|
||||||
|
выражением, что в матвью приёма событий, и метаданные в `stg.orders_raw`
|
||||||
|
заполнены: 1694 строки слепка дня 7 приехали с `orders`, партицией 0,
|
||||||
|
сплошными офсетами 0…1693 и непустой меткой брокера. Отдельного механизма
|
||||||
|
пуллу не понадобилось.
|
||||||
|
- **Одного чтения хватает и на слепок, и на разгон.** Пять заборов подряд
|
||||||
|
взяли 1694, 1730, 3446, 1694 и 5097 строк — каждый раз ровно столько,
|
||||||
|
сколько напечатал генератор. Числа сверх слепка объясняются сами: 3446 — это
|
||||||
|
свежий слепок плюс хвост, оставшийся от упавшего прогона, а 5097 — три
|
||||||
|
слепка разгона `days = 3`, уехавшие одной порцией.
|
||||||
|
- **Офсеты действительно коммитятся.** Второй забор подряд по пустому топику
|
||||||
|
вернул ноль строк, а офсеты следующего продолжились с 1694 — то есть
|
||||||
|
`kafka_commit_on_select` работает, и слепок не забирается заново.
|
||||||
|
- **Упавший забор не двигает мир.** Чтец снесли, и прогон работника покраснел
|
||||||
|
на триггере забора: `remember_played` ушла в `upstream_failed`, позиция
|
||||||
|
осталась прежней, а после починки тот же день сыгран заново, и ждавший хвост
|
||||||
|
уехал одной порцией вместе со свежим слепком. Чтения в этом опыте не было
|
||||||
|
вовсе — падать было нечему, — поэтому он показывает только несдвиг позиции.
|
||||||
|
Судьба уже прочитанной порции другая, и она описана в «Следствиях».
|
||||||
|
|
||||||
|
Осталось проверить при исполнении #94, на стороне ODS: что повторный разбор
|
||||||
|
того же `_load_id` не двоит версии заказа и не меняет `_load_ts` — критерии
|
||||||
|
целиком в [спецификации приёма заказов](../architecture/orders/ingestion.md),
|
||||||
|
раздел «Риски и проверка».
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# ADR 0009. Пульт мира: работники, выключатель и канонический контейнер
|
||||||
|
|
||||||
|
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Ось модельного времени двигают даги пульта, и они двух родов.
|
||||||
|
|
||||||
|
**Работники** — без расписания и без паузы, запускаются руками:
|
||||||
|
|
||||||
|
- `world_next_day` — день пачкой; параметр «сколько дней» (умолчание 1, потолок
|
||||||
|
7) даёт разгон вперёд одним нажимом. Потолок — это и есть названное желание
|
||||||
|
«уедь на неделю сейчас», и ровно тот разгон, который забор заказов забирает
|
||||||
|
одним чтением ([ADR 0008](0008-order-ingestion.md));
|
||||||
|
- `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
|
||||||
|
к оси мира отношения не имеет.
|
||||||
|
|
||||||
|
Позднее [ADR 0013](0013-world-lifecycle.md) отдал Airflow начальную позицию и
|
||||||
|
состояния обслуживания: переменной нет до первого создания мира, `-1` означает
|
||||||
|
незавершённую очистку, `0` — чистый мир до генерации, а работники принимают
|
||||||
|
только позицию `8` и больше.
|
||||||
|
|
||||||
|
Пакетная автоматика — второй выключатель, дёргающий `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,
|
||||||
|
которого стенд не поднимает; заводить службу ради одного ожидания дороже
|
||||||
|
занятого слота.
|
||||||
|
|
||||||
|
Приросший к работникам забор заказов (#93, [ADR 0008](0008-order-ingestion.md))
|
||||||
|
делает из двух занятых слотов три: пока идёт забор, ждут выключатель и сам
|
||||||
|
работник, а третий слот занимает задача `orders_ingest`.
|
||||||
|
|
||||||
|
**Пакетная автоматика отложена, потому что своего желания у неё пока одно.**
|
||||||
|
«Шагни и стой» закрывает кнопка, «уедь на неделю сейчас» — параметр «сколько
|
||||||
|
дней», «живи, пока я смотрю» — выключатель. Ей остаётся «едь сам быстрее, чем
|
||||||
|
живёшь»: день за пять минут, часами, — каденция, которой у живого темпа нет
|
||||||
|
(ускорение сверх ×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` и готовое
|
||||||
|
правило позиции. Позднее резолюция #134 родила `etl_pipeline` этапом 4 —
|
||||||
|
[дневным конвейером трансформаций](../architecture/etl.md) без генераторных
|
||||||
|
шагов: обвязка и правило позиции остаются работникам, а живой день дёргает
|
||||||
|
конвейер после установки позиции.
|
||||||
|
|
||||||
|
Расхождения, внесённые тем же коммитом: раздел 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,39 @@
|
|||||||
|
# ADR 0010. Заказы в ODS: версии сущности вместо подмены слепка
|
||||||
|
|
||||||
|
Дата: 16 августа 2026 года. Статус: принято. Частично заменяет
|
||||||
|
[ADR 0008](0008-order-ingestion.md). Колонка версии пересмотрена
|
||||||
|
[ADR 0014](0014-order-version-snapshot-date.md): вместо `updated_at` —
|
||||||
|
`snapshot_date`.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Пакетный забор заказов остаётся прямым чтением байтового Kafka-чтеца по
|
||||||
|
команде Airflow, но порция чтения больше не считается полным слепком и не
|
||||||
|
публикуется заменой партиции `snapshot_date`. Прочитанные строки получают
|
||||||
|
`_load_id` запуска, разбираются из одного среза STG в годные строки и ошибки,
|
||||||
|
а `ods.order` хранит принятые версии заказа в
|
||||||
|
`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,219 @@
|
|||||||
|
# ADR 0012. Справочники: зона вне цепочки слоёв и подложка под словарём
|
||||||
|
|
||||||
|
Дата: 20 августа 2026 года. Статус: принято.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
**Дом.** Справочные данные живут в базе `dic`, вне цепочки STG → ODS → DDS → DM.
|
||||||
|
Каталог товаров переезжает из `dds.products` в `dic.products`. Правило одно и
|
||||||
|
оно не про слой: справочник — самостоятельная зона; читать её вправе любой
|
||||||
|
слой, писать — никто, кроме владельца источника. Будущий словарь регионов
|
||||||
|
попадает туда же, независимо от того, какой слой его читает.
|
||||||
|
|
||||||
|
**Устройство.** Словарь читает не файл, а таблицу хранилища. Под `dic.products`
|
||||||
|
лежит подложка `dic.products_file` на движке `File` — она ничего не хранит и
|
||||||
|
перечитывает CSV на каждом запросе. Словарь берёт её источником `CLICKHOUSE` от
|
||||||
|
собственного пользователя с одним правом на чтение и обновляется сам, окном
|
||||||
|
`LIFETIME(MIN 60 MAX 90)`.
|
||||||
|
|
||||||
|
Источник объявлен формой `query`, а не `table`: словарь приводит цену каталога
|
||||||
|
из целых копеек к `Decimal(18, 2)` — то есть нормализует на входе, а не
|
||||||
|
зеркалит подложку.
|
||||||
|
|
||||||
|
Форма чтения принадлежит потребителю, а не месту хранения: подстановка атрибута
|
||||||
|
по ключу идёт через `dictGet`, а соединение и фильтрация по атрибуту — по
|
||||||
|
подложке напрямую.
|
||||||
|
|
||||||
|
## Почему зона, а не прописка в слое
|
||||||
|
|
||||||
|
**Справочник читают несколько слоёв, а не производит ни один.** Каталог нужен
|
||||||
|
модели заказов в DDS и витрине выручки в DM; будущие регионы — представлению
|
||||||
|
событий в DDS; `analyst` читает его напрямую. Писателя нет вовсе: содержимое
|
||||||
|
приходит из файла репозитория. Прописка внутри `dds` приписывала слою модели
|
||||||
|
владение, которого у него нет: DDS этот объект не производит, не обновляет и не
|
||||||
|
проверяет.
|
||||||
|
|
||||||
|
**Промах был во владении, а не в направлении.** Чтение из DM объекта, лежащего
|
||||||
|
в DDS, — течение вниз, ровно то, которое разрешено; на инверсию слоёв это
|
||||||
|
решение не опирается. Зато зона закрывает будущий случай: как только справочник
|
||||||
|
понадобится выше по течению — скажем, проверка `sku` при разборе заказа в ODS —
|
||||||
|
чтение `dds.*` из `ods.*` стало бы настоящей инверсией. У зоны вне цепочки
|
||||||
|
направления относительно слоёв нет, и такой запрос законен по построению.
|
||||||
|
|
||||||
|
**Отраслевой образец говорит то же самое другим средством.** Владелец принёс
|
||||||
|
устройство промышленного хранилища телекома — источник закрытый, проверить по
|
||||||
|
ссылке нельзя, поэтому вес у довода такой же, как у полевого опыта, а не как у
|
||||||
|
документации. Там база одна, слои выражены префиксами имён, и справочники несут
|
||||||
|
собственный префикс наравне с префиксами стадий — то есть образуют свою зону, а
|
||||||
|
не приписаны к слою модели. Причём префикс накрывает всю цепочку справочника: и
|
||||||
|
таблицу, и представление над ней, и сам объект словаря. Средство разное,
|
||||||
|
утверждение одно. У нас слои выражены базами — значит справочникам полагается
|
||||||
|
база.
|
||||||
|
|
||||||
|
**Имя `dic`, а не `ref`.** `ref` точнее называет содержимое, `dic` —
|
||||||
|
узнаваемее: это то слово, которое менти встретит в промышленном хранилище. Три
|
||||||
|
буквы становятся в ряд к `stg`, `ods`, `dds`, `dm`. Опасение, что `dic` называет
|
||||||
|
механизм и содержимое его перерастёт, снято проверкой: словарь и так читается
|
||||||
|
как таблица, а зона держит и обычные таблицы тоже.
|
||||||
|
|
||||||
|
## Почему подложка, а не файл прямо в словаре
|
||||||
|
|
||||||
|
Это намеренное усложнение, и платит за него учебная ценность. Урок называется
|
||||||
|
одной фразой: **словарь читает таблицу хранилища, а не файл рядом с сервером, —
|
||||||
|
потому что в бою справочник приезжает процессом.** Файловый источник у словаря
|
||||||
|
работает и на стенде работал, но в бою он редкость: там источник приезжает ETL
|
||||||
|
или словарь смотрит прямо в чужую базу. Стенд учит форме, которую менти
|
||||||
|
встретит.
|
||||||
|
|
||||||
|
Подложка добавляет к уроку три вещи, которых у файлового источника нет:
|
||||||
|
предложение `SOURCE` с запросом и учётной записью; обновление окном вместо
|
||||||
|
ручной команды; и справочник, доступный соединению — а не только `dictGet`.
|
||||||
|
|
||||||
|
**Цена приводится к `Decimal(18, 2)` в самом источнике.** В файле лежат целые
|
||||||
|
копейки, и `129000` о своей единице не говорит ничего — а каталог единственное
|
||||||
|
место на стенде, где число правит рукой человек. Довод не в единообразии:
|
||||||
|
стенд намеренно держит три представления денег, и расхождение между ними
|
||||||
|
заявлено уроком. Довод в том, что урок про округление живёт ровно на стыке
|
||||||
|
каталога и события, где `productPrice` — уже целые рубли. С `Decimal` менти
|
||||||
|
сравнивает `1290.00` против `1290` и видит потерю; с `Int64` он сначала делит
|
||||||
|
на сто в уме, в тот самый момент, когда должен смотреть на округление.
|
||||||
|
Приведение вынуждает форму `query` у источника — подложка сырая и
|
||||||
|
конвертировать не умеет, а `Decimal` прямо на ней прочитал бы `129000.00`.
|
||||||
|
|
||||||
|
**Историзацию и представление «последняя загрузка» из образца не берём.** В
|
||||||
|
промышленном примере таблица копит загрузки с меткой времени, а представление
|
||||||
|
берёт последнюю: источник приезжал извне, и версий у него не было нигде. У нас
|
||||||
|
версии есть — **история каталога это и есть git**. Дублировать её в ClickHouse
|
||||||
|
значит поставить конструкцию, у которой на стенде нет причины, а расшифровывать её
|
||||||
|
менти всё равно придётся.
|
||||||
|
|
||||||
|
**Материализованной таблицы с шагом наполнения тоже не берём.** Шаг наполнения
|
||||||
|
— вторая половина промышленного устройства, и она стоит движущейся части:
|
||||||
|
кто-то обязан его запускать. Расписание для файла в git врало бы: каталог не
|
||||||
|
меняется сам. Подложка на файловом движке даёт `SOURCE` без этой цены.
|
||||||
|
|
||||||
|
## Отвергнутые варианты размещения
|
||||||
|
|
||||||
|
- **Оставить в `dds`** — разделяемый ресурс внутри одного из потребителей; учит
|
||||||
|
тому, что в бою устроено иначе. Совпадение с будущими регионами держится, но
|
||||||
|
по другой причине, чем у товаров, — значит это не правило.
|
||||||
|
- **Рядом с главным потребителем** — потребителей несколько и они в разных
|
||||||
|
слоях: товары читает витрина, регионы — представление DDS. Правило требует
|
||||||
|
выбрать главного там, где главного нет.
|
||||||
|
- **Рядом с владельцем содержимого или предметной областью** — владелец у обоих
|
||||||
|
справочников один и тот же, репозиторий стенда; правило ничего не различает.
|
||||||
|
Предметные базы вдобавок разошлись бы со слоёвыми.
|
||||||
|
- **Без единого правила, по происхождению каждого справочника** — оставляет
|
||||||
|
менти список случаев вместо урока.
|
||||||
|
- **Выразить принадлежность именем объекта, а не базой** (снять исключение
|
||||||
|
[ADR 0006](0006-object-naming.md), дать словарю суффикс вида) — чинит
|
||||||
|
читаемость имени, но не трогает ни владение, ни зону. При базе `dic`
|
||||||
|
избыточно: адрес говорит это раньше имени.
|
||||||
|
- **Не выражать принадлежность в хранилище вовсе** — отказ от предмета решения.
|
||||||
|
Вдобавок `SHOW DICTIONARIES` без указания базы возвращает пусто, так что поиск
|
||||||
|
объекта переложился бы на `system.dictionaries`.
|
||||||
|
- **Отказаться от объекта-словаря, читать CSV табличной функцией** — убирает
|
||||||
|
разговор о политике обновления, ради которого раздел 3 мастер-спеки словарь и
|
||||||
|
держит.
|
||||||
|
- **Отложить решение до второго справочника** — выглядит осторожным, но им не
|
||||||
|
является. Регионы стоят в спеке опорной точкой для будущих лекций, и CSV под
|
||||||
|
них не существует. DM же придёт этапом 4 и впишет старый адрес в витрины.
|
||||||
|
Повод пересмотреть не наступает, а цена только растёт.
|
||||||
|
|
||||||
|
## Условия пересмотра
|
||||||
|
|
||||||
|
- **Дом.** Появляется справочные данные, которые хранилище производит само —
|
||||||
|
скажем, таблица соответствий, собранная из DDS. Посылка решения «в хранилище
|
||||||
|
их никто не производит» на них не распространяется, и зону придётся либо
|
||||||
|
сузить до пришедших извне, либо переопределить. Дешевле всего это до этапа 4:
|
||||||
|
как только витрины начнут звать словарь по имени, каждый разворот станет
|
||||||
|
правкой по всем ссылкам.
|
||||||
|
- Справочник перестаёт быть файлом репозитория и начинает приезжать извне —
|
||||||
|
возвращается вопрос устройства: историзованная таблица, «последняя загрузка»,
|
||||||
|
словарь поверх. Адрес это переживает.
|
||||||
|
- Усложнение перестаёт окупаться: если менти проходит мимо подложки не заметив
|
||||||
|
её, режется обратно до файлового источника у словаря.
|
||||||
|
|
||||||
|
## Следствия
|
||||||
|
|
||||||
|
- [ADR 0007](0007-clickhouse-access.md) правится тем же коммитом: у зоны свой
|
||||||
|
блок прав и свой пользователь.
|
||||||
|
- [ADR 0006](0006-object-naming.md) получает шестой суффикс вида — `_file`,
|
||||||
|
чтец файла, ровно параллельный `_kafka`. Исключение для словарей остаётся и
|
||||||
|
читается яснее прежнего: голое имя означает словарь.
|
||||||
|
- **Обновление словаря на кластере не атомарно, и это заявленное свойство.**
|
||||||
|
Случайный момент внутри окна разводит опросы разных серверов, чтобы они не
|
||||||
|
ходили к источнику разом. Побочный эффект — ноды перезагружают словарь в
|
||||||
|
разное время. Ждать правки каталога каждой из них приходится от нуля до
|
||||||
|
верхней границы окна, а фазы у них независимы, поэтому расходиться они могут
|
||||||
|
почти на все полторы минуты: одна перезагрузилась сразу после правки, вторая
|
||||||
|
ещё нет. Прежнее решение `LIFETIME(0)` окно не закрывало, а сжимало до
|
||||||
|
разброса исполнения `SYSTEM RELOAD DICTIONARY ON CLUSTER`; теперь оно
|
||||||
|
раскрыто намеренно. Урок записан опорной точкой в мастер-спеке, тащить его
|
||||||
|
в курс — отдельное решение.
|
||||||
|
- **Номенклатура товаров — медленно изменяющийся справочник**, и в предметной
|
||||||
|
области это так. Стенд упрощает: файл в репозитории под версией git.
|
||||||
|
Историзации в хранилище нет, и обещать её этот документ не должен.
|
||||||
|
- Витрина выручки спроектирована представлением, поэтому категория будет
|
||||||
|
подставляться в момент запроса, и правка каталога изменит отчёт задним
|
||||||
|
числом. Материализуй мы категорию при приёме — не изменила бы. Самой витрины
|
||||||
|
ещё нет, она приходит этапом 4.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
Замерено на живом стенде 20 августа 2026 года, ClickHouse 26.3.17.56.
|
||||||
|
|
||||||
|
- Движок `File` с явным путём внутрь `user_files` работает и раскатывается
|
||||||
|
`ON CLUSTER`: обе ноды читают свой смонтированный файл, по 180 строк.
|
||||||
|
- Относительный путь у этого движка считается **от `user_files`**, а не от
|
||||||
|
корня данных: форма `./user_files/catalog/products.csv`, рабочая для
|
||||||
|
файлового источника словаря, даёт `Code: 107 ... FILE_DOESNT_EXIST`.
|
||||||
|
- Подложка ничего не хранит: строка, дописанная в файл снаружи, меняет
|
||||||
|
`count()` без единой команды перезагрузки.
|
||||||
|
- Словарь подхватывает правку сам. `element_count` изменился без
|
||||||
|
`SYSTEM RELOAD DICTIONARY` через 116 секунд после предыдущей загрузки —
|
||||||
|
внутри окна `MIN 60 MAX 120`, на котором шёл замер.
|
||||||
|
- Без указания пользователя источник `CLICKHOUSE` ходит как `default` с пустым
|
||||||
|
паролем и падает: `Code: 516 ... AUTHENTICATION_FAILED`. Пользователя надо
|
||||||
|
называть явно.
|
||||||
|
- Приведение цены: `toDecimal64(price, 2) / 100` даёт ровно `Decimal(18, 2)`,
|
||||||
|
`129000 → 1290.00`, `128990 → 1289.90`. Вариант через `divideDecimal`
|
||||||
|
возвращает `Decimal(76, 2)` — шире, чем нужно, и не взят.
|
||||||
|
- Беспарольный пользователь, объявленный файлом настройки, принимается молча:
|
||||||
|
словарь грузится, `dictGet` отвечает, в журнале после перечитывания конфига
|
||||||
|
нет ни одного предупреждения про `no_password`.
|
||||||
|
- `SHOW DICTIONARIES` без указания базы возвращает пусто; словарь в базе стоит
|
||||||
|
среди таблиц слоя и отличается от них только колонкой движка в
|
||||||
|
`system.tables`.
|
||||||
|
- До переноса `dds.products` стоял в семи строках трёх файлов: дока хранилища,
|
||||||
|
DDL словаря и проверка `scripts/check-clickhouse.sh`. Первый замер насчитал
|
||||||
|
четыре и проверку не увидел — искали с фильтром по расширениям, куда `.sh`
|
||||||
|
не попал. Из недосчёта вырос настоящий дефект: девятая проверка кластера
|
||||||
|
осталась звать несуществующий объект и упала. Поймало ревью, не автор.
|
||||||
|
Урок тот же, что в [ADR 0006](0006-object-naming.md): «прогнал по
|
||||||
|
репозиторию» — такое же утверждение, как утверждение о поведении системы, и
|
||||||
|
проверять его надо так же.
|
||||||
|
- Движок `File` требует у `etl` права на источник. Отказ называет
|
||||||
|
`TABLE ENGINE ON File`, но права с таким именем не хватает: замер тремя
|
||||||
|
пользователями показал, что работает `GRANT FILE ON *.*`, а `TABLE ENGINE ON
|
||||||
|
File` не нужен вовсе.
|
||||||
|
- Стенд, собранный с нуля этим решением, поднимается зелёным: оба объекта зоны
|
||||||
|
на месте, словарь `LOADED` со 180 строками, база `dds` пуста, `bi` читает
|
||||||
|
словарь со второй ноды, `analyst` — подложку соединением. Словарь спрашивает
|
||||||
|
`make check-clickhouse` — 10 проверок из 10; девятая берёт известный `sku` на
|
||||||
|
обеих нодах и сверяет цену с колонкой файла, умножив её обратно на сто, так
|
||||||
|
что приведение к `Decimal` проверено вместе с ответом. `make smoke` словарь
|
||||||
|
не трогает вовсе.
|
||||||
|
|
||||||
|
Сверено по документации ClickHouse через MCP Context7 20 августа 2026 года.
|
||||||
|
|
||||||
|
- Движок таблиц `Dictionary` существует затем, чтобы выставить словарь явной
|
||||||
|
таблицей — когда нужен доступ к сырым данным или соединение.
|
||||||
|
- Для небольших измерений документация рекомендует словарь вместо `JOIN`:
|
||||||
|
соединение выполняется до фильтрации `WHERE` и между запросами не кэшируется.
|
||||||
|
- Обратный случай назван там же: если фильтровать по подставленному значению на
|
||||||
|
многих строках, лучше обычная колонка с индексом — `dictGet` считается
|
||||||
|
построчно и индексом не поддержан. Отсюда обе формы чтения в решении.
|
||||||
|
- Диапазон `LIFETIME(MIN … MAX …)` перезагружает словарь в случайный момент
|
||||||
|
внутри окна, чтобы разнести обращения разных серверов к источнику.
|
||||||
|
- Если хост источника локальный, запрос идёт без сети.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# ADR 0013. Жизненный цикл мира ведёт Airflow
|
||||||
|
|
||||||
|
Дата: 21 августа 2026 года. Статус: принято. Реализация — тикет #119.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Compose поднимает службы стенда. Airflow владеет прикладным миром: топиками
|
||||||
|
Kafka, DDL прикладных баз ClickHouse, стартовыми данными и позицией модельного
|
||||||
|
времени. `make` поднимает инфраструктуру и через REST API запускает нужный даг,
|
||||||
|
дожидаясь его результата.
|
||||||
|
|
||||||
|
Безопасный даг `world_initialize` создаёт мир, если тот ещё не создан. На
|
||||||
|
готовом мире он ничего не меняет и успешно сообщает об этом в журнале.
|
||||||
|
Разрушительный даг `world_recreate` очищает прикладные базы, топики и позиции
|
||||||
|
их потребителей, затем запускает `world_initialize` и ждёт его завершения.
|
||||||
|
Канонический DDL применяет отдельная учётная запись Airflow с правами только на
|
||||||
|
прикладную структуру стенда.
|
||||||
|
|
||||||
|
`world_position` остаётся единственным состоянием пульта:
|
||||||
|
|
||||||
|
- переменной нет — стенд ещё не инициализировали;
|
||||||
|
- `-1` — прежний мир признан недействительным, очистка не завершена;
|
||||||
|
- `0` — мир очищен и готов к начальной генерации;
|
||||||
|
- `8` и больше — первый несыгранный день готового мира.
|
||||||
|
|
||||||
|
`world_recreate` ставит `-1` до первого удаления и `0` только после успешной
|
||||||
|
очистки. `world_initialize` принимает отсутствие позиции или `0`, а значение
|
||||||
|
`8` и больше считает готовым миром. После генерации стартовых дней и успешного
|
||||||
|
приема обоих источников позиция становится `8`; затем инициализация последним
|
||||||
|
шагом ждет дневной конвейер `etl_pipeline`. При отказе преобразований мир
|
||||||
|
остается готовым на позиции `8`, а конвейер после исправления запускают рукой.
|
||||||
|
Обычные даги движения мира работают только с готовой позицией.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Прежняя цепочка делила один процесс между Compose, shell-скриптами и Airflow:
|
||||||
|
одноразовые службы создавали стартовые данные, отдельный сценарий ждал их
|
||||||
|
приёма, а пересборка сама исправляла внутреннюю переменную пульта. Из-за этой
|
||||||
|
границы успешная отправка в Kafka выдавалась за создание мира, повторный
|
||||||
|
`make up` смешивался с инициализацией данных, а узкая пересборка сохраняла в
|
||||||
|
Kafka сообщения и офсеты прежнего мира.
|
||||||
|
|
||||||
|
Airflow уже ведёт последующие дни и приём заказов. Отдав ему начало той же оси,
|
||||||
|
получаем один наблюдаемый процесс и одну точку успеха: зеленая ветка создания в
|
||||||
|
`world_initialize` подтверждает, что схема создана, стартовый мир принят, его
|
||||||
|
следующий день известен, а слои DDS и DM собраны. Короткая ветка готового мира
|
||||||
|
по-прежнему подтверждает позицию и ничего не меняет.
|
||||||
|
|
||||||
|
## Что это решение уточняет
|
||||||
|
|
||||||
|
- В [ADR 0007](0007-clickhouse-access.md) DDL больше не принадлежит `etl`:
|
||||||
|
прикладную структуру ведёт отдельный `lifecycle`.
|
||||||
|
- В [ADR 0009](0009-world-control.md) отсутствие `world_position` больше не
|
||||||
|
означает готовый стартовый мир. Начальную позицию ставит
|
||||||
|
`world_initialize`, а работники принимают только `8` и больше.
|
||||||
|
- Одноразовые службы Compose и shell-ожидание, описанные прежним устройством
|
||||||
|
хранилища, отменены. Их последовательность стала наблюдаемым графом Airflow.
|
||||||
|
|
||||||
|
## Отклонено
|
||||||
|
|
||||||
|
- Оставить создание мира в Compose, а позицию исправлять из
|
||||||
|
`make rebuild-storage`: работает технически, но сохраняет двух
|
||||||
|
оркестраторов одного процесса.
|
||||||
|
- Один безусловно разрушительный даг для первого и повторного запуска: обычный
|
||||||
|
`make up` мог бы стереть уже прожитые дни.
|
||||||
|
- Отдельная переменная состояния мира: она дублировала бы
|
||||||
|
`world_position` и могла разойтись с ней. Значения `-1` и `0` выражают
|
||||||
|
нужные переходы в одном месте.
|
||||||
|
- Выдать обычной учётной записи `etl` право удаления баз: рабочий конвейер не
|
||||||
|
должен получать административные права из-за редкой операции восстановления.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# ADR 0014. Версия заказа в ODS: дата слепка
|
||||||
|
|
||||||
|
Дата: 26 августа 2026 года. Статус: принято. Пересматривает колонку версии
|
||||||
|
из [ADR 0010](0010-order-versions-in-ods.md); остальные решения того ADR в
|
||||||
|
силе. Развилка разобрана в #163, реализация — #165.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
`ods.order` хранит версии заказа в `ReplacingMergeTree(snapshot_date)`: при
|
||||||
|
схлопывании выживает строка позднейшего слепка, который нёс заказ. Формула
|
||||||
|
решения: у полного слепка версия — сам слепок; истина — то, что источник
|
||||||
|
сказал последним.
|
||||||
|
|
||||||
|
Ключ сущности, партиция и шардирование не меняются. `updated_at` остаётся
|
||||||
|
деловым полем, и деловой ответ слоя от смены версии не зависит: на
|
||||||
|
канонических слепках выбор по дате слепка побайтово совпадает с выбором по
|
||||||
|
деловой метке, потому что метка монотонна по слепкам, а слепок дня — чистая
|
||||||
|
функция зерна и дня (тесты генератора
|
||||||
|
`test_the_state_is_read_at_the_boundary_of_the_day` и
|
||||||
|
`test_two_runs_give_the_same_snapshot`).
|
||||||
|
|
||||||
|
Решение покупает две вещи.
|
||||||
|
|
||||||
|
- **Детерминизм слоя.** Разные слепки несут разные даты — ничьей между
|
||||||
|
содержательно разными строками не бывает. При версии `updated_at` доставки
|
||||||
|
неизменившегося заказа равны по версии, и выжившая строка — жребий: у 86
|
||||||
|
заказов стартового мира выживала строка серединного слепка, притом что у
|
||||||
|
всех 3034 пар строк с равным `updated_at` деловые поля совпадали побайтово
|
||||||
|
(замеры #137). Документация ClickHouse обещает при ничьей победу последней
|
||||||
|
вставленной строки — замер это опроверг, полагаться на неё было бы ошибкой.
|
||||||
|
- **Правдивый ярлык.** `snapshot_date` выжившей строки — последний слепок,
|
||||||
|
который нёс заказ. Объём пересборки DDS читается прямо из данных, причём
|
||||||
|
без `FINAL`: слияние сохраняет максимум даты по построению. Константа
|
||||||
|
окна K уходит со стороны стенда вместе с риском дрейфа её двух копий
|
||||||
|
(ревью #151 ставило этому риску MAJOR).
|
||||||
|
|
||||||
|
## Ответ доводу ADR 0010
|
||||||
|
|
||||||
|
ADR 0010 отверг семейство «публикацию решает дата наблюдения»: у потребителя
|
||||||
|
нет признака полноты слепка. Довод целил в подмену партиции — ей полнота
|
||||||
|
нужна, потому что неполная партиция стёрла бы уже принятые версии. Колонке
|
||||||
|
версии полнота не нужна: она сравнивает свежесть строк одного заказа, и
|
||||||
|
недоехавшая строка просто оставляет в силе предыдущую — ровно как при версии
|
||||||
|
по деловой метке.
|
||||||
|
|
||||||
|
## Границы допущения
|
||||||
|
|
||||||
|
- **Дисциплина слепков.** Слепок с поздней датой не несёт состояния старше.
|
||||||
|
Источник, собирающий слепок из отставшей реплики, это ломает; модельный
|
||||||
|
мир — нет: см. тесты выше.
|
||||||
|
- **Одна дата — один слепок.** Ограничение вшито в движок: вторая
|
||||||
|
внутридневная выгрузка дала бы ничью уже на деловых полях. Это условие
|
||||||
|
контракта источника, его место — в границе строгого приёма
|
||||||
|
([приём заказов](../architecture/orders/ingestion.md)).
|
||||||
|
- **Окно наследует потерю строк.** Частичный отказ вставки после коммита
|
||||||
|
офсетов может унести старший день последнего слепка — выведенное окно
|
||||||
|
молча сузится. Лекарство прежнее, сверка слоя с источником в контуре DQ
|
||||||
|
([конвейер](../architecture/etl.md), «Чего конвейер о себе не знает»).
|
||||||
|
- **Тихая правка лечится только внутри окна.** Строку, изменённую без
|
||||||
|
сдвига `updated_at`, перекроет следующий слепок; замёрзший заказ источник
|
||||||
|
больше не показывает, там лечит лишь сверка полей.
|
||||||
|
- **Повторная доставка слепка.** Байты совпадают, жребий остаётся только на
|
||||||
|
`_load_id` и `_load_ts`; ярлык даты детерминирован.
|
||||||
|
- **Правка живого мира не подхватится.** DDL исполняется при рождении мира,
|
||||||
|
`ALTER` колонку версии не меняет: решение вступает в силу пересозданием
|
||||||
|
хранилища (`make rebuild-storage`).
|
||||||
|
|
||||||
|
## Отклонённые варианты
|
||||||
|
|
||||||
|
- **Оставить как есть, сверку копий K отдать DQ.** Лотерея ярлыков остаётся
|
||||||
|
задокументированным грехом, окно — константой с риском дрейфа.
|
||||||
|
- **Убрать координаты доставки из ODS.** Наказывает поле за вопрос, на
|
||||||
|
который оно не бралось отвечать: расследованию (связка годных строк,
|
||||||
|
ошибок и сырья) координаты служили честно.
|
||||||
|
- **Дедупликация на вставке.** Детерминизм «обычно», не по построению:
|
||||||
|
анти-джойн с собственной целью не атомарен, лаг реплик протаскивает дубли.
|
||||||
|
Приём теряет урок «разобрал и вставил», а ярлык со смыслом «первый
|
||||||
|
носитель» хуже для обеих ролей поля: окну нужен последний носитель, и
|
||||||
|
сырьё живо скорее у него.
|
||||||
|
- **Склейка `(updated_at, snapshot_date)` в одну колонку версии.** Движок
|
||||||
|
берёт одну колонку, склейка требует битовой упаковки. Робастнее к
|
||||||
|
недисциплинированному источнику, но платит читаемостью DDL за защиту от
|
||||||
|
сценариев, которых модельный мир не производит.
|
||||||
|
- **Склейка `(snapshot_date, updated_at)`.** Путь отступления: если у
|
||||||
|
источника появятся переиздания слепка одной даты, версия дорастает до
|
||||||
|
этой формы. От правок без сдвига метки внутри одной даты не спасает и она.
|
||||||
|
|
||||||
|
## Связанные решения
|
||||||
|
|
||||||
|
- [ADR 0010](0010-order-versions-in-ods.md) — версии заказа в ODS; здесь
|
||||||
|
пересмотрена только колонка версии.
|
||||||
|
- [Приём заказов](../architecture/orders/ingestion.md) — граница строгого
|
||||||
|
приёма и четыре координаты строки.
|
||||||
|
- [Модель заказа в DDS](../architecture/dds/order.md) — читатель окна
|
||||||
|
пересборки.
|
||||||
|
- Развилка, веер и два холодных ревью — #163.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# ADR 0015. Модельный день считается в часовом поясе счетчика
|
||||||
|
|
||||||
|
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Модельные сутки считаются в часовом поясе счетчика UTC+4. `EventDate` обозначает
|
||||||
|
дату в этом поясе, а `UTCEventTime` — абсолютную метку времени. Граница
|
||||||
|
модельного дня приходится на полночь счетчика.
|
||||||
|
|
||||||
|
Суточная волна посетителя привязана к его местному времени. У гостя из другого
|
||||||
|
пояса она сдвигается на разницу с поясом счетчика, но общая граница дня остается
|
||||||
|
прежней.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Так устроена выгрузка Метрики: дата события уже посчитана в поясе счетчика.
|
||||||
|
Полночь в этом поясе не разрезает утренние сессии в регионе мира. Регион можно
|
||||||
|
выбирать независимо от технического пояса счетчика.
|
||||||
|
|
||||||
|
Для всего потока дня нужна одна граница суток. Местное время гостя описывает
|
||||||
|
поведение в пределах этих суток, а отдельная дата для каждого посетителя не
|
||||||
|
нужна.
|
||||||
|
|
||||||
|
## Отклонено
|
||||||
|
|
||||||
|
В разделе 9 отклоненные варианты не зафиксированы.
|
||||||
|
|
||||||
|
## Следствия
|
||||||
|
|
||||||
|
У ночных событий `toDate(UTCEventTime) != EventDate`. Хранилище берет день из
|
||||||
|
готового `EventDate`, а не выводит его из абсолютной метки. Поворот суточной
|
||||||
|
волны слегка размывает общий пик активности гостей из разных поясов.
|
||||||
|
|
||||||
|
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
|
||||||
|
раздел 9.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# ADR 0016. Паспорт куки хранится в плане состава на всю ее жизнь
|
||||||
|
|
||||||
|
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #39.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Браузер, операционная система, устройство, экран и город принадлежат куке на всю
|
||||||
|
ее жизнь. План состава выбирает и хранит паспорт, а день только переносит его в
|
||||||
|
поля событий.
|
||||||
|
|
||||||
|
Две куки одного человека получают один город и разные устройства. Броски
|
||||||
|
паспорта идут после прежних бросков состава, поэтому прежние измерения не
|
||||||
|
сдвинулись.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
Кука представляет браузер на устройстве, поэтому ее паспорт не должен меняться
|
||||||
|
между днями. Только план состава связывает две куки с одним человеком. Поэтому
|
||||||
|
он может задать им один город и разные устройства.
|
||||||
|
|
||||||
|
## Отклонено
|
||||||
|
|
||||||
|
- Выводить паспорт арифметикой из `ClientID`: этот идентификатор не несет
|
||||||
|
сведений о двухкуковой паре, поэтому по нему нельзя задать двум кукам одного
|
||||||
|
человека один город и разные устройства.
|
||||||
|
|
||||||
|
Решение принято при исполнении тикета #39 и зафиксировано в спеке этапа 2,
|
||||||
|
раздел 9.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# ADR 0017. Номер заказа объединяет две проекции покупки
|
||||||
|
|
||||||
|
Дата: 28 августа 2026 года. Статус: принято. Реализация — тикет #40.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Номер заказа включает день по модельному времени и порядковый номер покупки в
|
||||||
|
этот день, например `20260603-0042`. Событийная сторона пишет его в
|
||||||
|
`purchaseID`, бэкенд — в `order_id`; обе стороны берут один номер из плана
|
||||||
|
покупки.
|
||||||
|
|
||||||
|
Все покупки, попавшие в поток дня, нумеруются в порядке событий и до любых
|
||||||
|
потерь следующих этапов.
|
||||||
|
|
||||||
|
## Почему
|
||||||
|
|
||||||
|
По читаемому номеру сразу виден день заказа. Один номер соединяет две проекции
|
||||||
|
одной покупки. Генераторы кликстрима и бэкенда не согласуют его отдельно.
|
||||||
|
|
||||||
|
Присвоение до потерь сохраняет номера остальных заказов: исчезновение одного
|
||||||
|
события не должно перенумеровывать чужие покупки и разрывать связь с бэкендом.
|
||||||
|
|
||||||
|
## Отклонено
|
||||||
|
|
||||||
|
- Присваивать номера после внесения потерь: одна потеря сдвинула бы все
|
||||||
|
следующие номера за день и разорвала бы связь уже созданных проекций.
|
||||||
|
|
||||||
|
## Следствия
|
||||||
|
|
||||||
|
`purchaseID` и `order_id` изначально совпадают. Из-за расхождения событие может
|
||||||
|
исчезнуть после присвоения номера, но номера сохранившихся заказов не меняются.
|
||||||
|
|
||||||
|
Решение принято при исполнении тикета #40 и зафиксировано в спеке этапа 2,
|
||||||
|
раздел 9.
|
||||||
@@ -46,6 +46,51 @@
|
|||||||
уже с сохранённым токеном, например
|
уже с сохранённым токеном, например
|
||||||
`tea api repos/ddmitry/clickstream-data-platform/issues/18`.
|
`tea api repos/ddmitry/clickstream-data-platform/issues/18`.
|
||||||
|
|
||||||
|
## Правка тела issue — только через API
|
||||||
|
|
||||||
|
Тикнуть чекбокс в критериях приёмки нужно при закрытии каждой задачи, и `tea`
|
||||||
|
для этого не годится. `tea issues edit --description` принимает тело **целиком
|
||||||
|
строкой**, а взять эту строку неоткуда: `tea issues <номер>` печатает текст
|
||||||
|
обёрнутым и разрисованным для терминала, обратно его не скормишь.
|
||||||
|
|
||||||
|
Рабочий путь — забрать сырое тело из API, поправить и вернуть:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. сырой JSON тикета; тело — в поле body
|
||||||
|
tea api repos/{owner}/{repo}/issues/<n> > issue.json
|
||||||
|
|
||||||
|
# 2. правка тела любым удобным способом, например все чекбоксы разом
|
||||||
|
python3 -c "
|
||||||
|
import json
|
||||||
|
d = json.load(open('issue.json'))
|
||||||
|
json.dump({'body': d['body'].replace('- [ ]', '- [x]')},
|
||||||
|
open('patch.json', 'w'), ensure_ascii=False)
|
||||||
|
"
|
||||||
|
|
||||||
|
# 3. вернуть
|
||||||
|
tea api -X PATCH -d @patch.json repos/{owner}/{repo}/issues/<n>
|
||||||
|
```
|
||||||
|
|
||||||
|
Флаги `tea api`: `-X` — метод, `-d` — сырое тело JSON (`@файл` читает из файла,
|
||||||
|
`@-` из потока ввода), `-f` и `-F` — отдельные поля строкой и с типом. Флага
|
||||||
|
`--input` нет, хотя рука тянется написать именно его.
|
||||||
|
|
||||||
|
Тот же приём — для чек-листа родительской карты: пункт `- [ ] #NN` тикается
|
||||||
|
правкой её тела, автоматически Gitea этого не делает.
|
||||||
|
|
||||||
|
Комментарий правится тем же способом, но путь у него свой — без номера issue:
|
||||||
|
`tea api -X PATCH -d @patch.json repos/{owner}/{repo}/issues/comments/<id>`.
|
||||||
|
Идентификатор берётся из `GET .../issues/<n>/comments`; это не порядковый номер
|
||||||
|
комментария в ленте. Нужно это чаще, чем кажется: резолюции wayfinder-тикетов
|
||||||
|
живут комментариями, и правка после ревью идёт в них.
|
||||||
|
|
||||||
|
**Отсюда общее правило.** `tea` удобен там, где команда создаёт объект или
|
||||||
|
меняет его свойство: создать issue, добавить комментарий, повесить метку,
|
||||||
|
назначить исполнителя, закрыть. Как только нужно **изменить уже написанный
|
||||||
|
текст** — тело issue, тело PR, — CLI мешает: он умеет только заменить всё
|
||||||
|
целиком, а прочитать это «всё» в пригодном для правки виде не даёт. Такие
|
||||||
|
операции идут прямо в API.
|
||||||
|
|
||||||
## Слияние PR не закрывает issue
|
## Слияние PR не закрывает issue
|
||||||
|
|
||||||
Gitea понимает только английские ключевые слова автозакрытия: `closes`,
|
Gitea понимает только английские ключевые слова автозакрытия: `closes`,
|
||||||
@@ -59,7 +104,8 @@ PR #17, где issue #13 пришлось закрывать руками.
|
|||||||
|
|
||||||
Если ключевого слова не было, порядок после слияния: закрыть задачу
|
Если ключевого слова не было, порядок после слияния: закрыть задачу
|
||||||
(`tea issues close <номер>`) и тикнуть её пункт в чек-листе родительского
|
(`tea issues close <номер>`) и тикнуть её пункт в чек-листе родительского
|
||||||
issue этапа — Gitea чек-листы сама не обновляет.
|
issue этапа — Gitea чек-листы сама не обновляет. Чем тикать — выше, в разделе
|
||||||
|
про правку тела.
|
||||||
|
|
||||||
## Спека — источник истины
|
## Спека — источник истины
|
||||||
|
|
||||||
@@ -119,6 +165,14 @@ issue этапа — Gitea чек-листы сама не обновляет.
|
|||||||
|
|
||||||
## Что проверено и когда
|
## Что проверено и когда
|
||||||
|
|
||||||
|
2026-08-13: правка комментария через `repos/{owner}/{repo}/issues/comments/<id>`
|
||||||
|
снята живыми запросами при закрытии #72 — так дважды правилась резолюция тикета.
|
||||||
|
|
||||||
|
2026-08-06: правка тела issue через `tea api -X PATCH -d @файл` снята живыми
|
||||||
|
запросами при закрытии #37 — так проставлены чекбоксы самого тикета и пункт
|
||||||
|
в чек-листе карты #4. Тогда же проверено, что `tea issues edit --description`
|
||||||
|
для этого непригоден, а флага `--input` у `tea api` нет.
|
||||||
|
|
||||||
2026-07-31: Gitea 1.27.0 (`tea api version`), `tea` 0.15.0. Живыми запросами по
|
2026-07-31: Gitea 1.27.0 (`tea api version`), `tea` 0.15.0. Живыми запросами по
|
||||||
этому репозиторию проверены `tea labels list` и `tea issues list`. Остальной
|
этому репозиторию проверены `tea labels list` и `tea issues list`. Остальной
|
||||||
набор команд, флагов и приёмы с `tea api` перенесены из доки
|
набор команд, флагов и приёмы с `tea api` перенесены из доки
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Слой DDS: бизнес-модель хранилища
|
||||||
|
|
||||||
|
Устройство детального слоя: сущности, которыми хранилище говорит с
|
||||||
|
витринами, их связи и правила слоя. Набор собирается развилками карты #127
|
||||||
|
(этап 4) и правится по мере постройки.
|
||||||
|
|
||||||
|
Рамки заданы мастер-спекой [«Боевой реализм стенда
|
||||||
|
(v2)»](../../specs/2026-07-30-stand-v2-realism.md): карта таблиц и витрины —
|
||||||
|
раздел 7, сверка — раздел 4, склейка идентичностей — раздел 5. Дом выбран
|
||||||
|
развилкой #130 — первой, которой понадобилось записать резолюцию в доки:
|
||||||
|
записи этапа 4 живут здесь, по хранилищную сторону границы
|
||||||
|
«источник | хранилище». Устройство источников остаётся их
|
||||||
|
собственным наборам ([заказы бэкенда](../orders/README.md)) — генератор
|
||||||
|
переносим и о нашем DDS не знает.
|
||||||
|
|
||||||
|
## Карта модели
|
||||||
|
|
||||||
|
Целостность модели — связи и ко-локация — живёт в этом разделе; файлы
|
||||||
|
сущностей держат глубину и на карту ссылаются.
|
||||||
|
|
||||||
|
- **Заказ** (`dds.order_v`, хранение `dds.order_rep` / `_dist`) — физическая
|
||||||
|
сущность: одна строка — заказ в текущем состоянии, позиции массивами в
|
||||||
|
строке, наполняется заменой партиций — [модель заказа](order.md).
|
||||||
|
- **Сессия** (`dds.session_v`, хранение `dds.session_rep` / `_dist`) —
|
||||||
|
расчётная сущность: одна строка — визит,
|
||||||
|
нарезку по правилу визита стенд делает сам, `VisitID` генератора — эталон
|
||||||
|
сверки; наполнение заменой дневной партиции — [модель сессии](session.md).
|
||||||
|
- **Событие** (`dds.event_v`) — контракт над ODS, а не копия: представление,
|
||||||
|
переводящее событие на язык бизнес-модели, — [контракт события](event.md).
|
||||||
|
- **Карта идентичностей** (`dds.identity_map_v`, хранение
|
||||||
|
`dds.identity_map_rep` / `_dist`) — кука ↔ пользователь магазина —
|
||||||
|
[модель карты](identity.md).
|
||||||
|
|
||||||
|
Связи держатся двумя ключами моста (спека, разделы 4 и 5): `purchaseID[1]`
|
||||||
|
события равен `order_id` заказа — по нему идут сверка и сборка склейки;
|
||||||
|
`user_id` заказа связывает куки одного человека. Ко-локацию задаёт
|
||||||
|
шардирование: события и карта идентичностей раскладываются одним выражением
|
||||||
|
от куки, заказы — от `order_id`. Поэтому обогащение витрин склейкой
|
||||||
|
локально, а сверка и сборка карты ходят через GLOBAL JOIN с обеими
|
||||||
|
сторонами-подзапросами ([исследование
|
||||||
|
соединений](../../research/2026-08-22-distributed-join.md)).
|
||||||
|
|
||||||
|
## Правила слоя
|
||||||
|
|
||||||
|
- DDS обслуживает человека с дашбордом: время лежит в поясе счётчика,
|
||||||
|
пересчёт — один раз при наполнении слоя ([дока хранилища](../storage.md),
|
||||||
|
«Часовые пояса»).
|
||||||
|
- Публичный договор каждой сущности — `_v`; роли объектов и конвенция чтения
|
||||||
|
заданы [докой хранилища](../storage.md), «Имена объектов».
|
||||||
|
- Служебные колонки — координаты собственной загрузки слоя; чужие ярлыки не
|
||||||
|
переносятся, а спрашиваются у их слоя по ключу сущности. Вывод правила и
|
||||||
|
цепочка расследования — [модель заказа](order.md).
|
||||||
|
- Наполняет слой [дневной конвейер](../etl.md): запуск, объём прогона и
|
||||||
|
замена партиций на кластере — его устройство.
|
||||||
|
|
||||||
|
## Родословная
|
||||||
|
|
||||||
|
Дом набора и первая сущность собраны развилкой #130 карты #127; форма
|
||||||
|
набора — [ADR 0011](../../adr/0011-component-docs.md).
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Контракт события в DDS
|
||||||
|
|
||||||
|
Учебный результат: менти видит первый на стенде пример правила «слой — это
|
||||||
|
контракт, а не обязательно копия данных». Заказ и сессия — физические
|
||||||
|
таблицы, а событие в DDS не хранится вовсе: `dds.event_v` — представление,
|
||||||
|
которое переводит событие на язык бизнес-модели, не копируя ни строки.
|
||||||
|
Витрины DM читают его, а не ODS напрямую.
|
||||||
|
|
||||||
|
## Форма
|
||||||
|
|
||||||
|
Обычное представление над `ods.event_v` — вторым уровнем, так что `FINAL`
|
||||||
|
остаётся спрятанным в один слой, внутри контракта ODS. Единственное
|
||||||
|
сомнение против такой формы — «представление материализуется до фильтра» —
|
||||||
|
снято исследованием [проталкивания
|
||||||
|
условий](../../research/2026-08-22-view-predicate-pushdown.md): условия
|
||||||
|
внешних запросов доезжают до партиций и первичного ключа сквозь два уровня
|
||||||
|
представлений и переименование колонок.
|
||||||
|
|
||||||
|
Две оговорки исследования соблюдены контрактом. Ключевая для срезов колонка
|
||||||
|
`event_date` отдаётся переименованием `EventDate`, не пересчётом — иначе
|
||||||
|
индекс перестал бы её анализировать. Цена `FINAL` — отключённый
|
||||||
|
автоматический `PREWHERE` — свойство ODS, представление её не усугубляет.
|
||||||
|
|
||||||
|
Как публичный договор DDS, представление перечисляет колонки явно и следует
|
||||||
|
общей [конвенции чтения](../storage.md), «Имена объектов».
|
||||||
|
|
||||||
|
## Язык модели
|
||||||
|
|
||||||
|
Имена атрибутов — имена модели, а не нормализованные имена источника
|
||||||
|
([CONTEXT.md](../../../CONTEXT.md), «Нормализованное имя»). Для большинства
|
||||||
|
колонок они совпадают: атрибут переезжает под нормализованным именем из
|
||||||
|
[описания выгрузки](../../formats/clickstream-event.md). Расхождения два,
|
||||||
|
и каждое — решение модели:
|
||||||
|
|
||||||
|
- **`WatchID` → `event_id`**: модель зовёт сущность событием, «watch» —
|
||||||
|
жаргон выгрузки Метрики.
|
||||||
|
- **`UTCEventTime` → `event_time` в поясе счётчика** (`Europe/Samara`):
|
||||||
|
DDS обслуживает человека с дашбордом, время лежит местным ([дока
|
||||||
|
хранилища](../storage.md), «Часовые пояса»). Пересчёт — смена линзы, байты
|
||||||
|
те же, проталкиванию не мешает. Второй метки времени контракт не отдаёт:
|
||||||
|
выбор линзы слой сделал за читателя, UTC остаётся языком ODS.
|
||||||
|
|
||||||
|
`ClientID` остаётся `client_id`: это устоявшееся имя куки во всей
|
||||||
|
экосистеме Метрики, по нему шардирование и мост к `dds.identity_map_v`;
|
||||||
|
своё имя здесь создало бы третий синоним. `client_timezone` тоже остаётся —
|
||||||
|
это атрибут посетителя, а не линза времени.
|
||||||
|
|
||||||
|
`purchase_revenue` сохраняет смысл поля источника. Тип денег нормализуется в
|
||||||
|
ODS; точный тип и проверка описаны в разделе
|
||||||
|
[«Актуальные события»](../storage.md#актуальные-события). DDS только
|
||||||
|
переименовывает уже нормализованную колонку.
|
||||||
|
|
||||||
|
## Расшифровки
|
||||||
|
|
||||||
|
Одна: `device_category` — коды 1–4 в строки `desktop` / `mobile` /
|
||||||
|
`tablet` / `tv`. У настоящей Метрики этот атрибут и есть строка (выгрузка
|
||||||
|
стенда несёт число — принятое отступление: спека, раздел 1.1), контракт возвращает
|
||||||
|
ему боевой вид.
|
||||||
|
|
||||||
|
Значения `device_category` 1–4 получают имена, остальные сохраняются
|
||||||
|
десятичной строкой. Новый код остаётся видимым, пока у него не появится имя в
|
||||||
|
договоре.
|
||||||
|
|
||||||
|
Остальные кодированные атрибуты не расшифровываются. Для `goals_reached`
|
||||||
|
справочника целей на стенде нет, id и есть язык. Регионы в расшифровке не
|
||||||
|
нуждаются: выгрузка несёт названия рядом с id (`region_city`,
|
||||||
|
`region_country`), словарь ничего бы не раскрыл. Идея дать менти собрать
|
||||||
|
справочник регионов самому ушла в лабы — issue #143.
|
||||||
|
|
||||||
|
## Что скрыто
|
||||||
|
|
||||||
|
- **`VisitID`** — чужой суррогат сессии: свою сессию модель чеканит сама
|
||||||
|
(`session_id`, [модель сессии](session.md)), а эталон — инструмент
|
||||||
|
проверки, не атрибут модели. DQ-сверка нарезки идёт за эталоном в
|
||||||
|
`ods.event_v`: она по построению сопоставляет наш слой с чужим чеканом —
|
||||||
|
честно, что за чужим она и идёт в его слой.
|
||||||
|
- **`Sign`** — колонка формата без механики: генератор исправлений не шлёт,
|
||||||
|
и в модели события колонке ответить нечего. Честный рассказ о ней живёт в
|
||||||
|
DDL ODS и лекции.
|
||||||
|
- **`ecommerce`** — сырой JSON: сырьё, а не атрибут. Разобранные массивы
|
||||||
|
`purchase_*` и `product_*` в контракте есть; лаба про разбор JSON внутри
|
||||||
|
колонки читает ODS.
|
||||||
|
- **`_load_ts`** — у представления нет собственной загрузки, а чужие
|
||||||
|
координаты слой не переносит (правило слоя — [модель
|
||||||
|
заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по
|
||||||
|
ключу события.
|
||||||
|
|
||||||
|
Остальные колонки отдаются целиком, включая `ip_address` и оба
|
||||||
|
`region_*_id`: контракт — не цензор, он прячет только то, чему в модели
|
||||||
|
нечего сказать.
|
||||||
|
|
||||||
|
## Читатели
|
||||||
|
|
||||||
|
- Сборка сессий: ключи нарезки (`client_id`, `event_time`,
|
||||||
|
`event_date`), тип события, страница, UTM, паспорт куки — категория
|
||||||
|
устройства приходит уже расшифрованной.
|
||||||
|
- Витрины `dm.events_enriched_v`, `dm.top_pages_daily_v` и
|
||||||
|
`dm.utm_effectiveness_v` (`declared_revenue` берется из `purchase_revenue`).
|
||||||
|
- Событийная сторона сверки `dm.purchase_vs_orders_v`: `purchase_id`,
|
||||||
|
`purchase_revenue`, `event_date`.
|
||||||
|
- DQ-сверка сессий с эталоном читает не его, а `ods.event_v` —
|
||||||
|
см. «Что скрыто».
|
||||||
|
|
||||||
|
## Не входит
|
||||||
|
|
||||||
|
- Форма витрин DM — #135.
|
||||||
|
- Лаба со справочником регионов — issue #143, проектирование лаб.
|
||||||
|
|
||||||
|
## Отклонённые варианты
|
||||||
|
|
||||||
|
- **Материализация вместо представления.** Дублировала бы широкий ODS ради
|
||||||
|
переименований, и урок «слой — контракт, а не копия» пропал бы;
|
||||||
|
единственный довод за — сомнение в проталкивании условий — снят
|
||||||
|
исследованием #128.
|
||||||
|
- **`utc_event_time` второй меткой рядом с `event_time`.** Контракт вернул
|
||||||
|
бы читателю выбор линзы, который слой обязан делать сам; расхождение двух
|
||||||
|
часов — урок ODS, не DDS.
|
||||||
|
- **`visit_id` в контракте.** Сверка и лаба читали бы одну поверхность, но
|
||||||
|
чужой чекан встал бы атрибутом модели — тем же доводом модель сессии не
|
||||||
|
пустила его в свою строку. Решение владельца: DQ-опыт показательнее,
|
||||||
|
когда эталон приходит из другого слоя.
|
||||||
|
- **`cookie_id` вместо `client_id`.** Честное имя модели, но третий синоним
|
||||||
|
куки на ровном месте: проза говорит «кука», экосистема — `ClientID`.
|
||||||
|
- **Словарь регионов на стенде.** Повторил бы урок `dictGet` каталога
|
||||||
|
вторым разом, а раскрывать нечего — названия уже в строке. Идея «сделать
|
||||||
|
руками» ушла в лабы (issue #143).
|
||||||
|
- **Прочие расшифровки** (`goals_reached` в имена целей, `has_gclid` в
|
||||||
|
Bool). Справочника целей нет; косметика без читателя.
|
||||||
|
|
||||||
|
## Связанные решения
|
||||||
|
|
||||||
|
- [Проталкивание условий сквозь
|
||||||
|
представления](../../research/2026-08-22-view-predicate-pushdown.md) —
|
||||||
|
резолюция #128: форма контракта и обе её оговорки.
|
||||||
|
- [Модель сессии](session.md) — `session_id` и довод против чужого чекана;
|
||||||
|
сюда же передан вопрос о судьбе `VisitID`.
|
||||||
|
- [Модель заказа](order.md) — правило служебных колонок слоя.
|
||||||
|
- Мастер-спека, раздел 7 — два контракта: `ods.event_v` хранит язык
|
||||||
|
источника, `dds.event_v` говорит языком модели.
|
||||||
|
- [CONTEXT.md](../../../CONTEXT.md) — «Нормализованное имя», «Хранилище».
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Карта идентичностей в DDS
|
||||||
|
|
||||||
|
Учебный результат: менти строит физический мост между кукой трекера и
|
||||||
|
пользователем магазина, раскладывает его по ключу будущего соединения и видит,
|
||||||
|
как пакетный полный пересчёт сочетается с точным публичным представлением.
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Карта — адресная книга: «эта кука уже покупала как этот пользователь
|
||||||
|
магазина». Связь становится известна только после покупки, потому что в
|
||||||
|
кликстриме есть `client_id`, а `user_id` живёт в заказе. Две куки одного
|
||||||
|
покупателя дают две строки с одним `user_id`; поэтому витрина может показать
|
||||||
|
рядом число посетителей по кукам и число известных людей после склейки.
|
||||||
|
|
||||||
|
Это не полная клиентская база. Куки без покупки остаются анонимными, а карта не
|
||||||
|
пытается угадать их владельца.
|
||||||
|
|
||||||
|
## Зерно и состав строки
|
||||||
|
|
||||||
|
Одна строка — известная пара `client_id` × `user_id`. Деловых колонок две:
|
||||||
|
|
||||||
|
- `client_id UInt64` — кука трекера;
|
||||||
|
- `user_id UInt64` — пользователь магазина.
|
||||||
|
|
||||||
|
Собственные служебные колонки `_load_id String` и
|
||||||
|
`_load_ts DateTime64(3, 'UTC')` показывают, каким запуском и когда пара записана
|
||||||
|
в DDS. Они имеют читателя при расследовании повтора и потому входят в публичный
|
||||||
|
договор вместе с деловой парой.
|
||||||
|
|
||||||
|
`first_matched_at` нет. Запланированные витрины его не читают, а два возможных
|
||||||
|
смысла — «первая покупка вообще» и «момент, с которого связь была известна
|
||||||
|
хранилищу» — требуют разных исторических моделей. Этот вопрос оставлен
|
||||||
|
[самостоятельной работе](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/145),
|
||||||
|
а текущий стенд не выдаёт один смысл за другой.
|
||||||
|
|
||||||
|
Статус заказа связь не меняет: любой заказ, сопоставленный с событием покупки,
|
||||||
|
показывает владельца куки. Отмена меняет состояние заказа, но не личность
|
||||||
|
покупателя.
|
||||||
|
|
||||||
|
## Физическая модель и публичный интерфейс
|
||||||
|
|
||||||
|
- `dds.identity_map_rep` — локальная
|
||||||
|
`ReplicatedReplacingMergeTree(_load_ts)`, `ORDER BY (client_id, user_id)`,
|
||||||
|
без партиционирования;
|
||||||
|
- `dds.identity_map_dist` — распределённая поверхность записи, шардирование
|
||||||
|
по `cityHash64(client_id)`;
|
||||||
|
- `dds.identity_map_v` — публичное обычное представление над `_dist`, которое
|
||||||
|
перечисляет четыре колонки явно и применяет `FINAL`.
|
||||||
|
|
||||||
|
Повторы полного запуска физически могут лежать рядом до фонового слияния.
|
||||||
|
`FINAL` оставляет одну последнюю версию пары, поэтому пользователь всегда читает
|
||||||
|
точную карту через `_v`. Роли объектов и доступ следуют общей
|
||||||
|
[конвенции](../storage.md), «Имена объектов».
|
||||||
|
|
||||||
|
Шардирование совпадает с событиями и сессиями. Благодаря этому витрина может
|
||||||
|
обогащать их картой локально по `client_id`; точную форму распределённого
|
||||||
|
запроса решает проектирование витрин #135.
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
Карта собирается один раз в суточной пакетной цепочке после обновления модели
|
||||||
|
заказов и до зависимых витрин. При догоняющем запуске за несколько дней хватает
|
||||||
|
одной сборки после всей пачки заказов.
|
||||||
|
|
||||||
|
Источники — вся доступная история `dds.event_v` и текущее состояние заказов
|
||||||
|
`dds.order_v`. Трансформация:
|
||||||
|
|
||||||
|
1. оставляет события `purchase` и разворачивает единственный `purchase_id`;
|
||||||
|
2. соединяет его с `order_id` заказа через `GLOBAL INNER JOIN`;
|
||||||
|
3. выбирает различные пары `client_id`, `user_id`;
|
||||||
|
4. добавляет координаты текущего запуска и пишет полный набор в
|
||||||
|
`dds.identity_map_dist`.
|
||||||
|
|
||||||
|
`GLOBAL` нужен потому, что события разложены по `client_id`, а заказы — по
|
||||||
|
`order_id`: строки соединения не ко-локированы. Правая сторона мала, поэтому
|
||||||
|
рассылка заказов приемлема. Материализованное представление здесь не подходит:
|
||||||
|
заказ может появиться в пакетном слепке позже уже принятого события покупки.
|
||||||
|
|
||||||
|
## Повтор запуска
|
||||||
|
|
||||||
|
Каждый запуск заново получает все известные пары из источников. Он не читает
|
||||||
|
предыдущую карту и не делает anti-join «вставить только отсутствующее».
|
||||||
|
`_load_ts` служит версией `ReplacingMergeTree`, а `dds.identity_map_v` прячет
|
||||||
|
физические повторы через `FINAL`.
|
||||||
|
|
||||||
|
Полный пересчёт выбран потому, что карта стенда мала, источники уже содержат
|
||||||
|
нужную историю, а повтор задачи не зависит от того, на каком месте оборвался
|
||||||
|
предыдущий. Мир стенда не отзывает связь куки с пользователем, поэтому удалять
|
||||||
|
исчезнувшие пары не требуется.
|
||||||
|
|
||||||
|
## Читатели
|
||||||
|
|
||||||
|
- `dm.daily_traffic_v` — посетители по `client_id` и известные пользователи по
|
||||||
|
`user_id` рядом;
|
||||||
|
- учебные запросы — показать, как несколько кук одного человека меняют
|
||||||
|
`uniq`;
|
||||||
|
- расследование пакетного запуска — `_load_id` и `_load_ts` текущей версии
|
||||||
|
пары.
|
||||||
|
|
||||||
|
## Отклонённые варианты
|
||||||
|
|
||||||
|
- **`first_matched_at` в текущей модели.** Нет запланированного читателя и нет
|
||||||
|
однозначной временной семантики; идея передана в issue #145.
|
||||||
|
- **Словарь ClickHouse.** Для малой карты мог бы ускорить точечное обогащение,
|
||||||
|
но спрятал бы физическую модель и не добавил нового урока рядом с уже
|
||||||
|
существующим словарём товаров.
|
||||||
|
- **Одно динамическое представление без физической карты.** Повторяло бы
|
||||||
|
соединение всей истории при каждом чтении, не давало ко-локации результата и
|
||||||
|
не показывало устройство реального слоя хранения.
|
||||||
|
- **Инкремент через anti-join с самой картой.** Делал бы результат предыдущего
|
||||||
|
запуска обязательным входом и усложнял повтор без пользы на объёме стенда.
|
||||||
|
- **Материализованное представление от событий.** Не увидело бы заказ, который
|
||||||
|
приехал позже события покупки.
|
||||||
|
|
||||||
|
## Связанные решения
|
||||||
|
|
||||||
|
- [Карта DDS](README.md) — связи сущностей и публичная поверхность `_v`.
|
||||||
|
- [Модель заказа](order.md) — текущее состояние заказа и пакетное наполнение.
|
||||||
|
- [Контракт события](event.md) — поля событий на языке DDS.
|
||||||
|
- [Исследование распределённых соединений](../../research/2026-08-22-distributed-join.md)
|
||||||
|
— форма `GLOBAL JOIN` и капкан `INSERT SELECT` между разными ключами
|
||||||
|
шардирования.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Модель заказа в DDS
|
||||||
|
|
||||||
|
Учебный результат: менти проектирует бизнес-сущность поверх готового ODS —
|
||||||
|
выбирает зерно, состав строки и способ наполнения — и видит идемпотентность
|
||||||
|
трансформации, устроенную иначе, чем в приёме: заменой партиции, а не
|
||||||
|
версиями.
|
||||||
|
|
||||||
|
## Зерно
|
||||||
|
|
||||||
|
Одна строка — заказ в текущем состоянии, ключ — `order_id`. Модель отвечает
|
||||||
|
на вопрос «что сейчас известно о заказе»; историю версий не хранит:
|
||||||
|
читателя у истории нет ни в одной витрине, а ODS её и не обещает —
|
||||||
|
промежуточные версии съедают фоновые слияния. «Дыхание» выручки внутри окна
|
||||||
|
K живёт на текущем состоянии само: пересборка дня меняет статусы заказов, и
|
||||||
|
число дня меняется вместе с ними.
|
||||||
|
|
||||||
|
## Состав строки
|
||||||
|
|
||||||
|
Деловые поля источника переезжают из `ods.order_v` без переименований —
|
||||||
|
бэкенд уже говорит в snake_case: `order_id`, `user_id`, `status`, деньги в
|
||||||
|
`Decimal` (`items_total`, `discount`, `delivery`, `total`).
|
||||||
|
|
||||||
|
Времена `created_at` и `updated_at` пересчитываются при загрузке в пояс
|
||||||
|
счётчика: DDS обслуживает человека с дашбордом ([дока
|
||||||
|
хранилища](../storage.md), «Часовые пояса»). Там же один раз считается
|
||||||
|
**день заказа** — колонка `order_date`, по образцу `EventDate` у события:
|
||||||
|
суточные срезы группируются по готовой дате и пояса не упоминают.
|
||||||
|
|
||||||
|
Позиции разобраны из сырой строки `items` в три типизированных массива
|
||||||
|
одной длины — артикул, количество, цена (`Decimal`). Это родная для
|
||||||
|
ClickHouse денормализация: позиция не живёт без заказа и собственного ключа
|
||||||
|
не имеет, а разворачивает массивы читатель — `ARRAY JOIN` в витрине
|
||||||
|
выручки, с обогащением категорией через `dictGet`
|
||||||
|
([мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 7). Разбор
|
||||||
|
JSON происходит один раз, на границе ODS → DDS, куда его и положила спека
|
||||||
|
(раздел 2); записывать его параллельными колонками-массивами или `Nested` —
|
||||||
|
выбор DDL, не модели.
|
||||||
|
|
||||||
|
## Движок и наполнение
|
||||||
|
|
||||||
|
`dds.order_rep` — `ReplicatedMergeTree` без колонки версии: партиция —
|
||||||
|
`order_date` (день заказа неизменен), ключ сортировки — `order_id`.
|
||||||
|
`dds.order_dist` шардирует по `cityHash64(order_id)`, тем же выражением, что
|
||||||
|
ODS: это единственный устойчивый ключ сущности.
|
||||||
|
|
||||||
|
Идемпотентность держит замена партиции, а не версии. Сбор дня — батч после
|
||||||
|
прихода слепка: трансформация собирает партицию заново из `ods.order_v` и
|
||||||
|
атомарно подменяет. Поэтому каждая строка физической таблицы — ровно один
|
||||||
|
заказ, а чтению не нужен `FINAL`. Пока окно K живо, день «дышит» — статусы
|
||||||
|
заказов меняются, и партиция дня пересобирается очередным запуском; на выходе
|
||||||
|
из окна день замерзает.
|
||||||
|
|
||||||
|
Публичная поверхность — `dds.order_v`: обычное представление с явным списком
|
||||||
|
деловых и собственных служебных колонок над `dds.order_dist`, без `FINAL`.
|
||||||
|
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
|
||||||
|
объектов».
|
||||||
|
|
||||||
|
Механика запуска — где собирается партиция и как подменяется на кластере —
|
||||||
|
сюда не входит: это устройство [дневного конвейера](../etl.md).
|
||||||
|
|
||||||
|
## Служебные колонки
|
||||||
|
|
||||||
|
Строка получает `_load_id` и `_load_ts` запуска трансформации, собравшего
|
||||||
|
её партицию, — по общей конвенции хранилища. При замене партиции они
|
||||||
|
одинаковы у всех строк дня и отвечают на вопрос «каким запуском и когда
|
||||||
|
собран этот день».
|
||||||
|
|
||||||
|
Ярлыки ODS (`snapshot_date`, `_load_id` приёма, `_load_ts` прибытия) в модель
|
||||||
|
не переезжают. Они описывают наблюдение и доставку строки в ODS. Модель DDS
|
||||||
|
хранит текущее деловое состояние заказа. В ODS `snapshot_date` задаёт версию и
|
||||||
|
называет последний слепок, в котором источник показал заказ
|
||||||
|
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Дату
|
||||||
|
наблюдения и координаты доставки читатель спрашивает у ODS по
|
||||||
|
`order_id`. Правило слоя:
|
||||||
|
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
|
||||||
|
спрашиваются у их слоя по ключу сущности**.
|
||||||
|
|
||||||
|
Происшествие качества данных расследуется цепочкой, где каждый шаг
|
||||||
|
спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой
|
||||||
|
запуск собрал день; по `order_id` ODS показывает физические версии и брак —
|
||||||
|
там свой `_load_id` приёма связывает годные строки, ошибки и сырьё; дальше
|
||||||
|
сырьё STG, пока живо. Окно расследования задано сроками хранения: сырьё —
|
||||||
|
трое суток, брак — месяц ([дока хранилища](../storage.md)).
|
||||||
|
|
||||||
|
## Читатели
|
||||||
|
|
||||||
|
- Витрина выручки `dm.revenue_daily_v`: заказы в `paid`, `ARRAY JOIN`
|
||||||
|
позиций, категория через `dictGet` (спека, раздел 7).
|
||||||
|
- Сверка `dm.purchase_vs_orders_v`: `order_id`, `order_date`, `items_total`,
|
||||||
|
`status`; соединение — GLOBAL JOIN с обеими сторонами-подзапросами
|
||||||
|
([исследование соединений](../../research/2026-08-22-distributed-join.md)).
|
||||||
|
- Сборка карты идентичностей: пара `order_id` ↔ `user_id`, источник —
|
||||||
|
`dds.order_v`.
|
||||||
|
|
||||||
|
## Не входит
|
||||||
|
|
||||||
|
- Механика запуска трансформации и замены партиций на кластере —
|
||||||
|
[дневной конвейер](../etl.md).
|
||||||
|
- Контур проверок качества (даг DQ, `dm.dq_summary`) — отдельный разговор
|
||||||
|
этапа 4.
|
||||||
|
- Инкрементальное наполнение вместо пересборки — техдолг #2.
|
||||||
|
|
||||||
|
## Отклонённые варианты
|
||||||
|
|
||||||
|
- **Одно динамическое представление вместо физической таблицы.** Рамка стенда —
|
||||||
|
показать модель данных, которую хранилище готовит потребителям; урок
|
||||||
|
«слой — контракт, а не копия» уже закреплён за `dds.event_v`, а разбор
|
||||||
|
позиций и пересчёт времён при каждом чтении собирали бы модель заново на
|
||||||
|
лету. Публичное `dds.order_v` поверх готовой физической модели этому доводу
|
||||||
|
не противоречит: оно ничего заново не вычисляет.
|
||||||
|
- **`ReplacingMergeTree` ещё раз.** Повторил бы механику ODS вторым разом:
|
||||||
|
физически хранил бы устаревшие состояния, вернул бы `FINAL`
|
||||||
|
и обёртку-представление — а урок версий менти уже отыграл.
|
||||||
|
- **Отдельная сущность `dds.order_item`.** Вторая таблица, второй вопрос
|
||||||
|
шардирования и `JOIN` там, где хватает `ARRAY JOIN`; нормализация из
|
||||||
|
реляционного мира, а не модель под колоночное хранилище. `JOIN` менти
|
||||||
|
увидит в сверке, работу с массивами — только здесь.
|
||||||
|
- **Зерно «версия заказа» (история).** Читателя нет; ODS историю не
|
||||||
|
обещает, а мир при нужде переигрывается генератором.
|
||||||
|
- **Перенос ярлыков ODS** — см. «Служебные колонки».
|
||||||
|
- **Без модели вовсе: витрины читают `ods.order_v` напрямую.** Разбор JSON
|
||||||
|
и пересчёт времён расползлись бы по витринам, а модель данных — то, ради
|
||||||
|
чего сущность и строится, — не была бы показана.
|
||||||
|
|
||||||
|
## Связанные решения
|
||||||
|
|
||||||
|
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) — версии заказов в
|
||||||
|
ODS, устойчивая поверхность `ods.order_v`.
|
||||||
|
- [Приём заказов](../orders/ingestion.md) — граница строгого приёма и
|
||||||
|
четыре координаты строки ODS.
|
||||||
|
- [Проталкивание условий сквозь
|
||||||
|
представления](../../research/2026-08-22-view-predicate-pushdown.md) —
|
||||||
|
резолюция #128 той же карты: цена `FINAL` и цепочек представлений, фон, на
|
||||||
|
котором здесь выбрана физическая таблица.
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# Модель сессии в DDS
|
||||||
|
|
||||||
|
Учебный результат: менти строит расчётную сущность, которой нет в потоке, —
|
||||||
|
режет события на сессии по правилу визита, чеканит идентификатор и сверяет
|
||||||
|
свою нарезку с эталоном. Стенд здесь играет роль сессионизатора: в бою её
|
||||||
|
играет Метрика, которая режет визиты у себя и с октября 2025 отдаёт готовый
|
||||||
|
`VisitID` прямо в хитах. Поэтому стенд считает сессии сам, а `VisitID`
|
||||||
|
генератора использует только как эталон самопроверки — иначе сборка сессий
|
||||||
|
выродилась бы в `GROUP BY` по чужому полю и центральный урок
|
||||||
|
слоя пропал бы. Без этого абзаца действия стенда выглядят странно: «зачем
|
||||||
|
считать то, что уже дано колонкой».
|
||||||
|
|
||||||
|
## Зерно
|
||||||
|
|
||||||
|
Одна строка — один визит ([CONTEXT.md](../../../CONTEXT.md)): подряд идущие
|
||||||
|
события одной куки без пауз длиннее 30 минут, не пересекающие границу суток.
|
||||||
|
В нарезку входят все три типа событий — торговые садятся на свои страницы
|
||||||
|
и держат сессию наравне с просмотрами. События внутри сессии
|
||||||
|
упорядочиваются по метке времени события (`UTCEventTime` — опорная колонка
|
||||||
|
приёма).
|
||||||
|
|
||||||
|
Граница суток проходит по модельному дню счётчика, и он у события уже есть:
|
||||||
|
`EventDate` — свойство содержимого. Раз сессия сутки не пересекает, у всех
|
||||||
|
её событий `EventDate` один; он и становится **днём сессии** — колонкой
|
||||||
|
партиции, без пересчёта.
|
||||||
|
|
||||||
|
## Идентификатор
|
||||||
|
|
||||||
|
Идентификатор сессии в природе не существует: сессия — расчётная сущность,
|
||||||
|
и id ей чеканит тот, кто сессионизирует. `VisitID` — такой же суррогат,
|
||||||
|
отчеканенный генератором в роли Метрики; наш — `session_id`,
|
||||||
|
детерминированный хеш от пары «кука + начало сессии» (`cityHash64`).
|
||||||
|
Детерминированность обязательна: партиция дня пересобирается заменой, и
|
||||||
|
повторный прогон обязан отчеканить те же id — счётчик или UUID это правило
|
||||||
|
ломают.
|
||||||
|
|
||||||
|
`session_id` — не ключ сортировки и не ключ шардирования, а колонка-ручка:
|
||||||
|
в соединениях он не участвует — события его не несут, мост к заказам живёт
|
||||||
|
на событиях. Читатели ручки — лаба сессий (сопоставить свою нарезку со
|
||||||
|
стендовой и назвать расхождение одним числом) и разбор DQ-расхождений.
|
||||||
|
|
||||||
|
## Состав строки
|
||||||
|
|
||||||
|
Боевой образец — таблица визитов Метрики, парная к нашему формату хита; по
|
||||||
|
ней состав и собран:
|
||||||
|
|
||||||
|
- **ключи и времена**: кука, `session_id`, начало и конец сессии,
|
||||||
|
длительность, день сессии. Времена — в поясе счётчика по правилу слоя;
|
||||||
|
- **счётчики**: событий всего, просмотров, корзин, покупок;
|
||||||
|
- **страницы**: входная и выходная — как `StartURL`/`EndURL` визита; полного
|
||||||
|
списка страниц нет и у Метрики, топы считаются по событиям;
|
||||||
|
- **атрибуция**: UTM-метки первого события сессии — так визит атрибутирует
|
||||||
|
Метрика. В v1 витрина брала последнее касание — заменено осознанно;
|
||||||
|
- **паспорт куки**: категория устройства (расшифрованная, язык бизнес-модели)
|
||||||
|
и город — у куки они постоянны, берутся с первого события;
|
||||||
|
- **служебные**: `_load_id` и `_load_ts` собравшего запуска — правило слоя
|
||||||
|
([модель заказа](order.md), «Служебные колонки»).
|
||||||
|
|
||||||
|
Осознанные расхождения с образцом: **денег в сессии нет** — визит Метрики
|
||||||
|
несёт клиентскую выручку, у нас деньги считает только бэкенд (спека, раздел
|
||||||
|
7), а клиентская сумма живёт в сверке; **целей нет** — их место заняли
|
||||||
|
счётчики торговых событий.
|
||||||
|
|
||||||
|
## Движок и наполнение
|
||||||
|
|
||||||
|
`dds.session_rep` — `ReplicatedMergeTree` без колонки версии, по образцу
|
||||||
|
заказа: партиция — день сессии, ключ сортировки — кука и начало сессии.
|
||||||
|
`dds.session_dist` шардирует по `cityHash64(client_id)`, тем же выражением,
|
||||||
|
что события, — это ко-локация с `dds.identity_map_rep` ([дока
|
||||||
|
хранилища](../storage.md), «Раскладка по шардам»).
|
||||||
|
Совпадение ключа с событиями не случайно: все события куки лежат на одном
|
||||||
|
шарде, поэтому сессионизация локальна по природе — капкан смены ключа при
|
||||||
|
вставке, который дока хранилища обещает сборкам DDS, у сессий не стреляет.
|
||||||
|
|
||||||
|
Сборка — дневной батч: хранилище выше ODS по смыслу батчевое, раз второй
|
||||||
|
источник приходит раз в сутки. Собирается день, который мир уже прожил, и
|
||||||
|
собирается один раз и целиком; какой день прожит, слой спрашивает у позиции
|
||||||
|
мира ([дневной конвейер](../etl.md), «Объём прогона»). Идемпотентность держит
|
||||||
|
замена партиции. В отличие от заказов, где батч пересобирает до K «дышащих»
|
||||||
|
дней, у сессии дышащего окна нет: прожитый день самодостаточен (правило
|
||||||
|
спеки, раздел 6). Второй раз он соберётся только
|
||||||
|
вынужденно — когда упавшая замена оставила позади дыру и хвост от неё накрыл
|
||||||
|
соседние дни. Дней в одном прогоне при этом бывает несколько — столько,
|
||||||
|
сколько мир успел прожить без слоя. Внутри живого дня
|
||||||
|
модель сессии про сегодня честно молчит — «сегодня» смотрят через
|
||||||
|
`dds.event_v`; сессия и по смыслу не финальна, пока день не прожит.
|
||||||
|
|
||||||
|
Публичная поверхность — `dds.session_v`: обычное представление с явным
|
||||||
|
списком деловых и собственных служебных колонок над `dds.session_dist`.
|
||||||
|
Роли объектов и доступ следуют общей [конвенции](../storage.md), «Имена
|
||||||
|
объектов».
|
||||||
|
|
||||||
|
Источник сборки — `dds.event_v` ([контракт события](event.md)): сессии
|
||||||
|
нужны от него ключи нарезки, тип события, страница, UTM и паспорт куки.
|
||||||
|
Механика запуска — где собирается партиция и как подменяется на кластере —
|
||||||
|
не входит: это устройство [дневного конвейера](../etl.md).
|
||||||
|
|
||||||
|
## Сверка с эталоном
|
||||||
|
|
||||||
|
Счётная DQ-проверка с зерном «кука × день»: число сессий нашей нарезки
|
||||||
|
равно `uniqExact(VisitID)` по тем же событиям. Счёт по Distributed ловит и
|
||||||
|
склеенную, и разрезанную сессию — мимо него проходит лишь взаимная
|
||||||
|
компенсация ошибок внутри одной куки за день; эталон в модель не тащим —
|
||||||
|
строка сессии остаётся нашей, а не генераторной. Поштучное сопоставление
|
||||||
|
границ — материал лабы, где менти разбирает найденное расхождение руками. Дом проверки —
|
||||||
|
контур DQ этапа 4. Сверке нужен `VisitID`, а сборке сессий — нет; контракт
|
||||||
|
`dds.event_v` его скрывает, и за эталоном проверка идёт в `ods.event_v`
|
||||||
|
([контракт события](event.md)).
|
||||||
|
|
||||||
|
## Читатели
|
||||||
|
|
||||||
|
- Витрина обзора сессий `session_overview_v`: дневные итоги визитов в
|
||||||
|
срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод
|
||||||
|
в ее [ТЗ](../../labs/session-overview.md) (#135).
|
||||||
|
- DQ-сверка с эталоном (см. выше).
|
||||||
|
- Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда,
|
||||||
|
потом сравнивает — с эталоном и со стендовой.
|
||||||
|
|
||||||
|
## Не входит
|
||||||
|
|
||||||
|
- Механика запуска трансформации и замены партиций на кластере —
|
||||||
|
[дневной конвейер](../etl.md).
|
||||||
|
- Контур проверок качества (даг DQ, `dm.dq_summary`) — отдельный разговор
|
||||||
|
этапа 4.
|
||||||
|
- Форма витрин над сессиями — #135.
|
||||||
|
|
||||||
|
## Отклонённые варианты
|
||||||
|
|
||||||
|
- **Готовый `VisitID` вместо своей нарезки.** Сборка выродилась бы в
|
||||||
|
`GROUP BY` по чужому полю: центральный урок слоя съеден, сверка с эталоном
|
||||||
|
осталась бы без предмета. Боевой довод «Метрика теперь отдаёт `VisitID` в
|
||||||
|
хитах» честно рассказывается в лабе — после того как менти собрал сам.
|
||||||
|
- **Гибрид: сборка по `VisitID`, DQ пересчитывает нарезку.** Сторож на
|
||||||
|
нарезку, которой сборка не делает; та же работа, что в варианте «считать
|
||||||
|
сам», но урок спрятан в проверку.
|
||||||
|
- **Одно динамическое представление вместо физической таблицы.** Оконная
|
||||||
|
сессионизация на каждое чтение;
|
||||||
|
урок «слой — контракт, а не копия» уже закреплён за `dds.event_v` — доводы
|
||||||
|
модели заказа, здесь они только сильнее. Публичное `dds.session_v` поверх
|
||||||
|
готовой физической модели ничего заново не считает.
|
||||||
|
- **Счётчик или UUID вместо детерминированного хеша.** Пересборка партиции
|
||||||
|
выдала бы другие id — замена перестала бы быть идемпотентной.
|
||||||
|
- **`VisitID` колонкой в строке сессии.** Чужой чекан в своей модели: наша
|
||||||
|
сессия обязана уметь расходиться с эталоном, на этом стоит сверка;
|
||||||
|
поштучное сопоставление — лаба, не таблица.
|
||||||
|
- **Массив посещённых страниц.** Вес без читателя: вход и выход — как у
|
||||||
|
визита Метрики, топы страниц считает витрина по событиям.
|
||||||
|
- **UTM последнего касания (как в v1).** Визит атрибутируется своим первым
|
||||||
|
хитом; последнее касание — атрибуция другого уровня, не сессионного.
|
||||||
|
- **Деньги в сессии.** Выручка — только по бэкенду; клиентская сумма живёт
|
||||||
|
в сверке (спека, разделы 4 и 7).
|
||||||
|
|
||||||
|
## Связанные решения
|
||||||
|
|
||||||
|
- [Модель заказа](order.md) — прецедент замены партиций в DDS и правило
|
||||||
|
служебных колонок слоя.
|
||||||
|
- [Дока хранилища](../storage.md) — раскладка по шардам, конвенции имён,
|
||||||
|
часовые пояса.
|
||||||
|
- Мастер-спека, разделы 1.2 и 7 — `VisitID` как эталон самопроверки
|
||||||
|
(принятое отступление приёмки), карта таблиц.
|
||||||
|
- [CONTEXT.md](../../../CONTEXT.md) — «Визит», «Граница суток», «День
|
||||||
|
активности».
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
# Слой DM: витрины
|
||||||
|
|
||||||
|
Как модель DDS превращается в витрины для потребителя BI: какую форму
|
||||||
|
принимает витрина, кто какие витрины строит и как слой проверяется. Состав
|
||||||
|
колонок каждой витрины задаёт раздел 7 мастер-спеки; здесь — форма,
|
||||||
|
наполнение и контроль.
|
||||||
|
|
||||||
|
## Форма: по зерну, не по цене запроса
|
||||||
|
|
||||||
|
Витрина, меняющая зерно источника — дневной агрегат, сверка по заказам, —
|
||||||
|
материализуется: `dm.<имя>_rep` / `_dist` и публичное `dm.<имя>_v` с явным
|
||||||
|
списком колонок, по общим [конвенциям хранилища](storage.md). Наполняет её
|
||||||
|
`dm_transform`; ритм и механика пересборки — ниже. Витрина, сохраняющая зерно
|
||||||
|
источника — переименование, обогащение строки, — остаётся обычным
|
||||||
|
представлением: копию событий ради других имён колонок не держит ни один
|
||||||
|
боевой склад.
|
||||||
|
|
||||||
|
Правило выбрано учебной целью, а не ценой запроса. На объёмах стенда
|
||||||
|
представлениями обошлось бы всё: условия сквозь цепочку представлений
|
||||||
|
проходят ([исследование](../research/2026-08-22-view-predicate-pushdown.md)).
|
||||||
|
Но пересборка агрегатов конвейером — производственный паттерн, ради
|
||||||
|
которого стенд и строится, а пара «таблица рядом с представлением»
|
||||||
|
показывает: форма — решение по роли витрины, у одного слоя законны обе.
|
||||||
|
|
||||||
|
## Кто строит: стенд и менти
|
||||||
|
|
||||||
|
Стенд привозит готовыми витрины трёх сюжетов дашборда этапа 8 и
|
||||||
|
инфраструктуру контроля:
|
||||||
|
|
||||||
|
- `revenue_daily` — выручка по дням и категориям; образец материализованной
|
||||||
|
витрины, на него ссылаются учебные задания;
|
||||||
|
- `purchase_vs_orders` — сверка A+C с классами расхождений; зерно — заказ,
|
||||||
|
шардирование сохраняет ко-локацию заказов по `cityHash64(order_id)`;
|
||||||
|
- `daily_traffic` — пара «посетители» / «известные пользователи», обогащение
|
||||||
|
[картой идентичностей](dds/identity.md);
|
||||||
|
- `dq_summary` — сводка проверок качества (ниже).
|
||||||
|
|
||||||
|
Остальные витрины спеки — учебные задания: менти строит их сам по ТЗ с
|
||||||
|
образцом. Разминка — представление `events_enriched_v` (зерно события),
|
||||||
|
затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`,
|
||||||
|
последней — самая содержательная `utm_effectiveness` с уроком `declared_*`.
|
||||||
|
Построенная витрина встаёт в конвейер руками менти: финальный пункт ТЗ
|
||||||
|
каждого агрегата — добавить задачу своей витрины в `dm_transform`. До этого
|
||||||
|
конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается
|
||||||
|
наравне со стендовыми. У разминочного представления такого пункта нет:
|
||||||
|
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
|
||||||
|
|
||||||
|
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит
|
||||||
|
от выполненных домашек. Свой дашборд по витринам менти —
|
||||||
|
[необязательная лаба](../labs/superset-dashboard.md) после этапа 8. Устройство
|
||||||
|
стендового дашборда — в [доке зоны Superset](superset.md).
|
||||||
|
|
||||||
|
## Ритм пересборки
|
||||||
|
|
||||||
|
День витрины пересобирается, пока может измениться его источник, —
|
||||||
|
поэтому ритмов три, по числу источников:
|
||||||
|
|
||||||
|
- **от событий и сессий** — день замерзает сразу; пересборка догоняет
|
||||||
|
хвост несобранных дней заменой дневных партиций;
|
||||||
|
- **от заказов** — день дышит, пока входит в последний слепок; пересборка
|
||||||
|
заменяет партиции этого отрезка, дни за ним закрыты по построению;
|
||||||
|
- **от карты идентичностей** — не замерзает никогда: склейка
|
||||||
|
ретроспективна, покупка сегодня делает куку известной во всех её прошлых
|
||||||
|
днях, а карта пересчитывается целиком.
|
||||||
|
|
||||||
|
Поэтому `daily_traffic` наследует у своего источника и ритм, и механику:
|
||||||
|
полный пересчёт всех дней каждый прогон в `ReplacingMergeTree(_load_ts)`,
|
||||||
|
как у самой карты. Дневных партиций у неё нет: партиция — единица замены,
|
||||||
|
а эта витрина заменяется только целиком. На стенде такая работа остается
|
||||||
|
быстрой; витрина читает сессии и карту через их публичные договоры.
|
||||||
|
Пересчитывать точечно только затронутые дни не из чего: для этого надо
|
||||||
|
знать, какие связки новые, а полный пересчёт карты этот след затирает —
|
||||||
|
восстановление следа оставлено лабе повышенной сложности (issue #149).
|
||||||
|
|
||||||
|
У `purchase_vs_orders` два источника и одна непрерывная пересборка. Заказная
|
||||||
|
сторона задает изменяемое окно последним принятым слепком ODS, событийная —
|
||||||
|
последний прожитый день с `purchase`. Левая граница возвращает первую дыру и
|
||||||
|
самый ранний день с `awaiting_order`: предварительная строка уже есть в
|
||||||
|
витрине, поэтому поиск отсутствующего дня ее не заметит. Правая граница
|
||||||
|
доходит до более позднего из двух хвостов. Так один прогон догоняет пакетный
|
||||||
|
разгон длиннее K и не оставляет старое ожидание замороженным.
|
||||||
|
|
||||||
|
## Витрины стенда
|
||||||
|
|
||||||
|
Публичные представления перечисляют деловые колонки и координаты запуска
|
||||||
|
`_load_id`, `_load_ts` явно. Новая колонка физической таблицы сама в договор не
|
||||||
|
попадет.
|
||||||
|
|
||||||
|
### `dm.revenue_daily_v`
|
||||||
|
|
||||||
|
Зерно: день заказа × категория товара. Витрина читает только оплаченные
|
||||||
|
заказы из `dds.order_v`, синхронно разворачивает три массива позиций и узнает
|
||||||
|
категорию артикула через `dic.products`. Один заказ с несколькими позициями
|
||||||
|
категории считается в `orders` один раз. `units` складывает количество единиц,
|
||||||
|
`revenue` складывает цену позиции × количество. Скидка, доставка и итог заказа
|
||||||
|
между категориями не делятся. `aov` равен `revenue / orders` в том же срезе и
|
||||||
|
хранит два знака после запятой.
|
||||||
|
|
||||||
|
Первый запуск строит всю доступную историю. Следующие берут границы дней из
|
||||||
|
последнего слепка ODS: только источник знает, какие даты еще может изменить.
|
||||||
|
Пропущенный оплаченный день расширяет левую границу, чтобы частичный отказ
|
||||||
|
не оставил дыру после следующего прогона. Агрегат при этом читает DDS; ODS
|
||||||
|
отвечает только на вопрос об объеме.
|
||||||
|
|
||||||
|
`dm.revenue_daily_rep` нарезана по `report_date`; день собирается в
|
||||||
|
таблице-доноре и заменяется через `REPLACE PARTITION`. Цель и донор шардируются
|
||||||
|
по `cityHash64(product_category)`. Кроме дня, у витрины только категория, а
|
||||||
|
шесть категорий стартового мира близки по объему. Более сложный составной ключ
|
||||||
|
распределил бы эту маленькую таблицу без нового урока.
|
||||||
|
|
||||||
|
### `dm.purchase_vs_orders_v`
|
||||||
|
|
||||||
|
Зерно — заказ. Покупки из `dds.event_v` сначала сворачиваются по
|
||||||
|
`purchase_id[1]`: контракт источника обещает один номер и одну объявленную
|
||||||
|
сумму, а отдельный счет событий оставляет класс `duplicate_event`
|
||||||
|
наблюдаемым. Заказы приходят из `dds.order_v`. Обе стороны записаны
|
||||||
|
подзапросами, затем соединены `FULL OUTER GLOBAL JOIN`: голая левая
|
||||||
|
Distributed-таблица вернула бы каждый непарный заказ по разу с каждого шарда
|
||||||
|
([исследование](../research/2026-08-22-distributed-join.md)).
|
||||||
|
|
||||||
|
Непарная сторона остается `NULL` благодаря `join_use_nulls = 1`. При
|
||||||
|
`join_use_nulls = 0` ClickHouse заполнил бы ее нулем или пустой строкой и скрыл
|
||||||
|
отсутствие источника. Клиентская `declared_revenue` и серверная `items_total`
|
||||||
|
сравниваются как `Decimal(18, 2)`. Классы проверяются по порядку:
|
||||||
|
`awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`,
|
||||||
|
`duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без
|
||||||
|
события остается отмененным, а не потерянным.
|
||||||
|
|
||||||
|
Витрина нарезана по `order_day`, цель и донор шардируются по
|
||||||
|
`cityHash64(order_id)`. Диапазон собирается в доноре одной вставкой и
|
||||||
|
заменяется по дням. Первичная сборка охватывает всю историю обоих источников;
|
||||||
|
следующие прогоны идут по двустороннему ритму, описанному выше.
|
||||||
|
|
||||||
|
### `dm.daily_traffic_v`
|
||||||
|
|
||||||
|
В строке один день. `visitors` считает разные `client_id` из
|
||||||
|
`dds.session_v`; `known_users` считает разные `user_id` после левого соединения
|
||||||
|
с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине.
|
||||||
|
Несколько кук одного человека увеличивают `visitors`, но дают одного
|
||||||
|
`known_user`, поэтому `known_users <= visitors`.
|
||||||
|
|
||||||
|
Сессии и карта лежат по `client_id`, но публичные `_v` скрывают
|
||||||
|
`Distributed`-таблицы от локальной подстановки. На ClickHouse 26.3
|
||||||
|
`EXPLAIN PLAN` показал чтение обоих шардов на инициаторе: настройка
|
||||||
|
`distributed_product_mode = 'local'` локального соединения не дала. Поэтому
|
||||||
|
витрина использует явный `GLOBAL LEFT JOIN` через публичные договоры, а не
|
||||||
|
подменяет их физическими таблицами. Это дороже по сети, зато граница слоев
|
||||||
|
остается явной. Новая связь куки меняет прошлые дни, поэтому каждый запуск
|
||||||
|
пишет полную историю новой версией в `ReplacingMergeTree(_load_ts)`, а
|
||||||
|
публичное представление выбирает текущие строки через `FINAL`.
|
||||||
|
|
||||||
|
## `dm.dq_summary`
|
||||||
|
|
||||||
|
Зерно публичного `dm.dq_summary_v` — «день данных × атомарная проверка».
|
||||||
|
Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow.
|
||||||
|
Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`.
|
||||||
|
|
||||||
|
Деловые колонки сводки:
|
||||||
|
|
||||||
|
- `data_date` — проверяемый день;
|
||||||
|
- `check_name` — устойчивое имя инварианта;
|
||||||
|
- `reference_rows` — число деловых ключей на эталонной стороне;
|
||||||
|
- `actual_rows` — число деловых ключей в проверяемом объекте;
|
||||||
|
- `failed_rows` — число отсутствующих или различающихся ключей;
|
||||||
|
- `status` — `pass` при нуле нарушений, иначе `fail`.
|
||||||
|
|
||||||
|
Служебные `_load_id` и `_load_ts` называют собравший запуск. Представление
|
||||||
|
перечисляет колонки явно. Порогов и промежуточного состояния нет.
|
||||||
|
|
||||||
|
## Звено DQ конвейера
|
||||||
|
|
||||||
|
Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять
|
||||||
|
атомарных договоров:
|
||||||
|
|
||||||
|
- `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон —
|
||||||
|
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк
|
||||||
|
`dds.session_v`;
|
||||||
|
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
|
||||||
|
`orders` в описи мира, объект — число заказов класса в
|
||||||
|
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
|
||||||
|
`duplicate_event` и `amount_delta` только для покрытых описью закрытых дней;
|
||||||
|
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
|
||||||
|
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
|
||||||
|
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
|
||||||
|
поля сверки сравниваются с независимой сборкой из `dds.event_v` и
|
||||||
|
`dds.order_v`;
|
||||||
|
- `daily_traffic_vs_dds`: ключ — день. `visitors` и `known_users`
|
||||||
|
сравниваются с прямыми агрегатами `dds.session_v` и
|
||||||
|
`dds.identity_map_v`.
|
||||||
|
|
||||||
|
Область каждой проверки выводится из объединения сторон. Полностью потерянный
|
||||||
|
день или ключ поэтому не исчезает из результата. Разные ошибки тоже не могут
|
||||||
|
скомпенсировать друг друга общим итогом: `failed_rows` считается после
|
||||||
|
сопоставления деловых ключей.
|
||||||
|
|
||||||
|
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
|
||||||
|
подтверждает, что до витрины доехал записанный в описи мир, но не проверяет
|
||||||
|
сам генератор: у настоящего магазина такого оракула нет.
|
||||||
|
|
||||||
|
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow
|
||||||
|
только сводку и малый срез диагностики. Техническая сверка с описью читает
|
||||||
|
небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их
|
||||||
|
успеха даг заменяет проверенные дневные партиции. Затем именованные задачи
|
||||||
|
читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
|
||||||
|
сохраниться до красного завершения, а ошибка сборки не затирает прежний
|
||||||
|
результат. Красная задача печатает не больше двадцати нарушенных ключей за
|
||||||
|
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
|
||||||
|
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
|
||||||
|
business_key`.
|
||||||
|
|
||||||
|
Если после починки день исчез с обеих сторон, его больше нет и в новом доноре.
|
||||||
|
Такой день удаляется из сводки отдельно: старая красная строка не должна
|
||||||
|
превращаться в историю попыток.
|
||||||
|
|
||||||
|
## Даг самопроверки
|
||||||
|
|
||||||
|
Домашки контролирует `homework_check` — отдельный даг без расписания: менти
|
||||||
|
запускает его рукой. По задаче на каждое из
|
||||||
|
[пяти заданий по витринам](../labs/README.md).
|
||||||
|
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
|
||||||
|
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
|
||||||
|
золотые константы не прибиваются. В `dq_summary` даг не пишет: он инструмент
|
||||||
|
лабы, а не история качества конвейера.
|
||||||
|
|
||||||
|
Приём проверен на соседнем учебном стенде Airflow + Greenplum:
|
||||||
|
валидационный даг с группами проверок по слоям давал менти обратную связь
|
||||||
|
по каждому шагу домашки, не трогая рабочий конвейер.
|
||||||
|
|
||||||
|
## Отклонённые варианты
|
||||||
|
|
||||||
|
- **Весь слой представлениями.** Дёшево и всегда свежо, но главный
|
||||||
|
производственный паттерн слоя — пересборка агрегатов — остался бы
|
||||||
|
невидим, а `dm_transform` — без работы.
|
||||||
|
- **Материализовать всё.** Копия событий ради переименования колонок —
|
||||||
|
антипаттерн, который менти увёз бы в бой.
|
||||||
|
- **Гибрид по цене запроса.** Выбор формы по стоимости, которой на объёмах
|
||||||
|
стенда нет: сторож без настоящей опасности.
|
||||||
|
- **Проверки домашек в конвейерном звене DQ.** Конвейер стоял бы красным до
|
||||||
|
выполнения лаб, а проверка, которая всегда красная, приучает себя
|
||||||
|
игнорировать.
|
||||||
|
- **«Жёлтый» статус недостроенной витрины.** У задачи Airflow нет такого
|
||||||
|
состояния; изображать его логами — семантика, которую пришлось бы
|
||||||
|
расшифровывать. Красное «ещё не построено» честнее.
|
||||||
|
- **Самопроверка пишет в `dq_summary`.** Попытки менти смешались бы с
|
||||||
|
историей качества конвейера.
|
||||||
|
- **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили
|
||||||
|
бы число известных на момент закрытия — витрина тихо расходится с картой
|
||||||
|
и протаскивает обратно «знание на дату», отвергнутое развилкой карты
|
||||||
|
(#133).
|
||||||
|
- **Инкрементальный пересчёт затронутых дней по `first_matched_at`.**
|
||||||
|
Производственный ход, но на объёмах стенда его экономия неизмерима, а
|
||||||
|
след новых связок требует вернуть колонку, выкинутую в #133. Стал лабой
|
||||||
|
повышенной сложности — issue #149.
|
||||||
|
- **Superset поверх DDS без слоя DM.** Терялся бы урок «витрина — контракт
|
||||||
|
под потребителя», против раздела 7 спеки.
|
||||||
|
|
||||||
|
## Связанные решения
|
||||||
|
|
||||||
|
- [Мастер-спека](../specs/2026-07-30-stand-v2-realism.md), разделы 4 и 7, —
|
||||||
|
состав колонок витрин и классы сверки.
|
||||||
|
- [Дневной конвейер](etl.md) — запуск `dm_transform`, объём прогона,
|
||||||
|
замена партиций на кластере.
|
||||||
|
- [Дока хранилища](storage.md) — имена объектов, конвенция чтения через `_v`,
|
||||||
|
партиция-донор.
|
||||||
|
- [Карта модели DDS](dds/README.md) — источники витрин.
|
||||||
|
- Резолюция — тикет #135 карты #127.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Дневной конвейер трансформаций
|
||||||
|
|
||||||
|
Как принятые источники превращаются в модель DDS и витрины DM: кто запускает
|
||||||
|
трансформации, когда и в каком объёме. Правила самих сущностей — зерно,
|
||||||
|
движки, что именно пересобирается — живут в доках слоя
|
||||||
|
([карта модели DDS](dds/README.md)); здесь — только конвейер.
|
||||||
|
|
||||||
|
Дневная сборка вперёд появляется на этапе 4 и остаётся основным режимом
|
||||||
|
конвейера. На этапе 5 к ней добавляется партиционная переобработка: запуск
|
||||||
|
произвольного прошлого дня по команде.
|
||||||
|
|
||||||
|
## Форма
|
||||||
|
|
||||||
|
Дневной конвейер на стенде один — даг `etl_pipeline`. Своей работы
|
||||||
|
у него нет: он дёргает слойные даги последовательно и ждёт каждый —
|
||||||
|
`dds_transform`, затем `dm_transform` и `dq_check`. Состав проверок описан в
|
||||||
|
[доке слоя DM](dm.md).
|
||||||
|
|
||||||
|
Ждущие вызовы сделаны `TriggerDagRunOperator`. Проверка через Context7 по
|
||||||
|
ближайшей доступной документации Airflow 3.x (версия 3.1.6) подтвердила, что
|
||||||
|
оператор опрашивает точный созданный им прогон и краснеет вслед за потомком.
|
||||||
|
|
||||||
|
Слойные даги устроены по образцу `orders_ingest`: без расписания, один
|
||||||
|
активный прогон, запускаются триггером конвейера или рукой при разборе
|
||||||
|
происшествий. Витрины внутри `dm_transform` — отдельные задачи.
|
||||||
|
|
||||||
|
## Три пути запуска
|
||||||
|
|
||||||
|
```text
|
||||||
|
world_initialize ─┐
|
||||||
|
world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transform ─> dq_check
|
||||||
|
запуск рукой ─────┘
|
||||||
|
|
||||||
|
world_next_day ─────> источники без ETL
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
|
||||||
|
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
|
||||||
|
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
|
||||||
|
зеленеть, не узнав, разложился ли день, работник не должен
|
||||||
|
([ADR 0003](../adr/0003-dag-run-state.md)).
|
||||||
|
Такой отказ чинится перезапуском `etl_pipeline` рукой — перезапуску ничего
|
||||||
|
не нужно помнить, см. «Объём прогона».
|
||||||
|
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: пульт управляет
|
||||||
|
генератором и только им. После разгона менти запускает конвейер сам —
|
||||||
|
и видит, как слои догоняют уехавший вперёд мир.
|
||||||
|
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
|
||||||
|
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
|
||||||
|
ручного запуска начинаются со следующих дней.
|
||||||
|
|
||||||
|
## Объём прогона
|
||||||
|
|
||||||
|
Параметров у конвейера нет: объём он выводит сам из `world_position` и
|
||||||
|
состояния слоёв. Сессии догоняют хвост — от первого несобранного дня по
|
||||||
|
последний прожитый; заказы пересобирают дышащее окно и тот же хвост; карта
|
||||||
|
идентичностей пересчитывается целиком; правила витрин — у
|
||||||
|
[слоя DM](dm.md).
|
||||||
|
Поэтому один и тот же запуск одинаково верен из триггера, с кнопки менти и
|
||||||
|
после падения, а разгону на семь дней хватает одного прогона.
|
||||||
|
|
||||||
|
Окно заказов задаёт последний слепок, наблюдаемый в ODS. У каждого заказа
|
||||||
|
`snapshot_date` равен последнему слепку, в котором источник его показал.
|
||||||
|
Трансформация выбирает строки с максимальной `snapshot_date` и берёт среди них
|
||||||
|
минимальный и максимальный `order_date`. Это границы дней, состояние которых
|
||||||
|
источник ещё может изменить
|
||||||
|
([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Кроме живого окна,
|
||||||
|
трансформация закрывает пропуски: находит самый ранний день, представленный в
|
||||||
|
ODS и ещё не собранный в DDS, затем при необходимости расширяет до него левую
|
||||||
|
границу.
|
||||||
|
|
||||||
|
Позиция мира заказам не отвечает: она говорит, какой день прожит, а не какой
|
||||||
|
доехал. Прогон дня D везёт слепок дня D − 1, да и ставится позиция раньше
|
||||||
|
трансформации, так что после неудачного приёма обгоняет хранилище. Слой обязан
|
||||||
|
считать объём по тому, что у него есть, а не по тому, что мир о себе заявляет.
|
||||||
|
|
||||||
|
Для сессий границу задаёт `world_position`: она отличает полностью прожитый
|
||||||
|
день от ещё идущего. Сами события этой границы не показывают: и посреди дня D,
|
||||||
|
и сразу после его конца их позднейший день равен D. Если собрать сессии посреди
|
||||||
|
дня, визит, начатый в 23:50, останется обрезанным: завершённый день слой
|
||||||
|
повторно не собирает. Поэтому последний прожитый день равен первому дню
|
||||||
|
событий плюс позиция минус один
|
||||||
|
([`sql/dds/session_scope.sql`](../../sql/dds/session_scope.sql)).
|
||||||
|
Оговорка про «доехал» остаётся в силе и здесь: за хвост потока отвечает не
|
||||||
|
позиция, а «Готовность источника».
|
||||||
|
|
||||||
|
## Готовность источника
|
||||||
|
|
||||||
|
Первый шаг `dds_transform` проходит два барьера за общий срок в 300 секунд.
|
||||||
|
Сначала клиент `confluent-kafka` сверяет подтвержденные смещения группы
|
||||||
|
`clickstream_hits` с верхними смещениями обоих разделов `hits`. Группа, оба
|
||||||
|
раздела и все смещения должны существовать. Суммарное отставание должно стать
|
||||||
|
нулевым. Каждый опрос пишет в журнал оба смещения и отставание по разделу.
|
||||||
|
|
||||||
|
Только после этого шаг проверяет обе ноды ClickHouse через
|
||||||
|
`clusterAllReplicas`: у `ods.event_dist` в `system.distribution_queue` не
|
||||||
|
должно остаться файлов. Каждый опрос пишет в журнал ноды, число файлов и
|
||||||
|
`last_exception` очереди. Оба барьера опрашиваются раз в три секунды и делят
|
||||||
|
один крайний срок. После Kafka срок не отсчитывается заново.
|
||||||
|
|
||||||
|
Зелёный приём заказов о событиях не говорит ничего — топики разные и
|
||||||
|
потребители разные. Одного нулевого отставания Kafka тоже мало: чтец мог
|
||||||
|
подтвердить сообщение раньше, чем Distributed-таблица доставила файл на шард.
|
||||||
|
Два барьера закрывают обе части пути до того, как сессии выберут свой объем.
|
||||||
|
|
||||||
|
## Идемпотентность
|
||||||
|
|
||||||
|
Партиция не заменяется простой вставкой. День собирается в таблицу-двойник —
|
||||||
|
вставка через Distributed раскладывает строки по шардам, — затем
|
||||||
|
`REPLACE PARTITION` на кластере атомарно подменяет кусок каждого шарда. Эта
|
||||||
|
механика — часть самих трансформаций и строится этапом 4: без неё дневной
|
||||||
|
прогон не повторяем. Сущности, пересчитываемые целиком, идемпотентны и без
|
||||||
|
замены: полная перезапись в `ReplacingMergeTree` — так живут карта
|
||||||
|
идентичностей и витрина `daily_traffic` ([слой DM](dm.md)).
|
||||||
|
|
||||||
|
## Чего конвейер о себе не знает
|
||||||
|
|
||||||
|
Трансформация собирает слой по правилу, а не сверяет его с источником. Отсюда
|
||||||
|
три вещи, которых она не заметит, и у всех трёх один адрес — контур DQ.
|
||||||
|
|
||||||
|
- **Часть последнего слепка не доехала.** Отказ вставки после коммита офсетов
|
||||||
|
уносит прочитанное с собой. Пропади так строки самого старого дня окна —
|
||||||
|
окно, выведенное из ярлыков, молча сузится, и день не пересоберётся
|
||||||
|
([`sql/dds/order_scope.sql`](../../sql/dds/order_scope.sql)).
|
||||||
|
- **Замена партиции прошла на одном шарде из двух.** День в слое есть — значит,
|
||||||
|
объём следующего прогона его не тронет, а слой останется собранным наполовину
|
||||||
|
из старого, наполовину из нового.
|
||||||
|
- **Брак внутри `items` становится нулями.** Строгий приём внутрь позиций не
|
||||||
|
смотрит, разбор по типу молчит, и цена без числа доедет до витрины выручки
|
||||||
|
нулём ([`sql/dds/order_rebuild.sql`](../../sql/dds/order_rebuild.sql)).
|
||||||
|
|
||||||
|
Общее у них одно, и оно же подсказывает лекарство: ответить может только
|
||||||
|
сверка слоя с источником по дню — счёт заказов и позднейший `updated_at` с
|
||||||
|
обеих сторон. Трансформации такую сверку не поручают намеренно: она собирает,
|
||||||
|
а проверяет другой. Так две роли остаются различимы, и ни одна не
|
||||||
|
притворяется другой.
|
||||||
|
|
||||||
|
## Не входит
|
||||||
|
|
||||||
|
- Партиционная переобработка — переиграть произвольный прошлый день или
|
||||||
|
диапазон, включая форму необязательных параметров того же дага.
|
||||||
|
- Инкрементальный ETL вместо пересборки отклонен (#2, wontfix). Идея отдельной
|
||||||
|
лабы на эту тему — #149.
|
||||||
|
- Состав витрин `dm_transform` и проверок `dq_check` —
|
||||||
|
[дока слоя DM](dm.md).
|
||||||
|
|
||||||
|
## Отклонённые варианты
|
||||||
|
|
||||||
|
- **Задачи трансформаций прямо в работниках пульта.** Один зелёный прогон —
|
||||||
|
весь день, но пульт — про ось времени: работников два, шаги дублировались
|
||||||
|
бы, а этапу 5 пришлось бы выдирать их обратно.
|
||||||
|
- **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с
|
||||||
|
Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом
|
||||||
|
режиме слои копили бы отставание молча.
|
||||||
|
- **Временный даг с нейтральным именем до этапа 5.** Дневная сборка вперёд
|
||||||
|
никуда не денется, переезд был бы переименованием ради выдуманной границы.
|
||||||
|
- **Диапазон дней параметрами триггера.** Ручному запуску менти параметры
|
||||||
|
передавать некому, а перезапуск после падения требовал бы помнить conf
|
||||||
|
упавшего прогона.
|
||||||
|
- **Принять гонку с хвостом `hits`.** Дыра всплыла бы в DQ-сверке, а
|
||||||
|
чиниться — не раньше этапа 5.
|
||||||
|
|
||||||
|
## Связанные решения
|
||||||
|
|
||||||
|
- [ADR 0003](../adr/0003-dag-run-state.md) — зелёный конец графа не
|
||||||
|
переживает отказ выше: отсюда ожидания в триггерах.
|
||||||
|
- [ADR 0009](../adr/0009-world-control.md) — пульт мира: его работник
|
||||||
|
живого дня и дёргает конвейер.
|
||||||
|
- [ADR 0013](../adr/0013-world-lifecycle.md) — жизненный цикл мира: почему
|
||||||
|
прикладным процессом владеет Airflow и почему `world_initialize` кончается
|
||||||
|
собранными слоями.
|
||||||
|
- Резолюция — тикет #134 карты #127; что пересобирает каждая сущность —
|
||||||
|
[заказ](dds/order.md), [сессия](dds/session.md),
|
||||||
|
[карта идентичностей](dds/identity.md).
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Генератор кликстрима
|
||||||
|
|
||||||
|
Путеводитель показывает, как пройти генератор от корневого зерна до двух
|
||||||
|
выходов дня, не пересказывая код и спеки.
|
||||||
|
|
||||||
|
## Целевая картина одним взглядом
|
||||||
|
|
||||||
|
- **Подпоток определяется местом в дереве, а мир внутри подпотока — порядком
|
||||||
|
бросков** — [дерево зерен и порядок бросков](seeds.md).
|
||||||
|
- **Мир поднимает только когорты окна, нужные запрошенному дню** —
|
||||||
|
[ленивость мира](lazy-world.md).
|
||||||
|
- **Устройство и город принадлежат куке на всю жизнь и потому решаются
|
||||||
|
планом состава** — [паспорт куки](cookie-passport.md).
|
||||||
|
- **Выбор по весам сведен к целым броскам и арифметике интервалов** —
|
||||||
|
[целочисленная случайность](randomness.md).
|
||||||
|
- **Торговля получает готовые строки страниц и возвращает поток событий
|
||||||
|
вместе с покупками для бэкенда** — [шов торговых событий](commerce-seam.md).
|
||||||
|
- **Сутки принадлежат поясу счетчика, а `UTCEventTime` остается абсолютной
|
||||||
|
меткой** — [время модельного дня](counter-time.md).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
seed["Корневое зерно"] --> streams["Подпотоки"]
|
||||||
|
streams --> cohorts["Когорты"]
|
||||||
|
cohorts --> audience["Аудитория дня"]
|
||||||
|
audience --> visits["Визиты"]
|
||||||
|
visits --> output["Выход day.stream"]
|
||||||
|
output --> events["Поток событий"]
|
||||||
|
output --> backend["Заказы дня<br/>orders.of_day"]
|
||||||
|
```
|
||||||
|
|
||||||
|
За правой ветвью начинается отдельный набор документов —
|
||||||
|
[устройство заказов](../orders/README.md).
|
||||||
|
|
||||||
|
## Живой проход
|
||||||
|
|
||||||
|
Из каталога `generator/` одна команда собирает канонический D0 и показывает
|
||||||
|
все стадии на одном экране:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make demo
|
||||||
|
```
|
||||||
|
|
||||||
|
Когорту и аудиторию пример получает через открытый интерфейс плана, а визиты
|
||||||
|
считает группировкой выходных строк по `VisitID`. Код прохода —
|
||||||
|
[`demo.main`](../../../generator/src/clickstream_generator/demo.py).
|
||||||
|
|
||||||
|
## Родословная
|
||||||
|
|
||||||
|
Устройство генератора и основания решений записаны в [спеке этапа
|
||||||
|
2](../../specs/2026-08-01-generator.md), а границы двух источников и склейки —
|
||||||
|
в [мастер-спеке стенда](../../specs/2026-07-30-stand-v2-realism.md).
|
||||||
|
Основания трех решений записаны в
|
||||||
|
[ADR 0015](../../adr/0015-counter-timezone.md),
|
||||||
|
[ADR 0016](../../adr/0016-cookie-passport.md) и
|
||||||
|
[ADR 0017](../../adr/0017-order-numbering.md), а спека этапа 2 закрыта.
|
||||||
|
Путеводитель появился по тикету
|
||||||
|
[#48](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/48):
|
||||||
|
он отвечает на другой вопрос — как пройти принятое устройство по коду.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Шов торговых событий
|
||||||
|
|
||||||
|
Этот документ отвечает на вопрос, что день передает торговой части и что
|
||||||
|
получает от нее назад.
|
||||||
|
|
||||||
|
[`day.stream`](../../../generator/src/clickstream_generator/day.py) сначала
|
||||||
|
собирает выровненные по строкам ряды трафика:
|
||||||
|
|
||||||
|
- `columns` — готовые колонки просмотров страниц;
|
||||||
|
- `page` — вид страницы магазина;
|
||||||
|
- `product` — номер товара у карточки;
|
||||||
|
- `person` — человек за кукой строки.
|
||||||
|
|
||||||
|
`page` указывает, куда может сесть торговое событие, а `product` — какой товар
|
||||||
|
посетитель действительно открыл. `person` в кликстрим не выходит: он нужен
|
||||||
|
только для второй проекции покупки — заказа бэкенда.
|
||||||
|
|
||||||
|
[`commerce.weave`](../../../generator/src/clickstream_generator/commerce.py)
|
||||||
|
принимает эти ряды, вплетает `add_to_cart` и `purchase`, снова упорядочивает
|
||||||
|
поток и возвращает обновленные `columns`, `page`, `product` вместе с
|
||||||
|
[`commerce.Purchases`](../../../generator/src/clickstream_generator/commerce.py).
|
||||||
|
|
||||||
|
`Purchases` — узкая передача следующей стороне. В ней лежат номер заказа,
|
||||||
|
купленные товары и количества, клиентская выручка, промокод, момент события и
|
||||||
|
человек. Все они получены из тех же покупок, что уехали в поток, а не разобраны
|
||||||
|
назад из JSON.
|
||||||
|
|
||||||
|
[`orders.of_day`](../../../generator/src/clickstream_generator/orders.py)
|
||||||
|
принимает `Purchases`, а [`day.Day`](../../../generator/src/clickstream_generator/day.py)
|
||||||
|
отдает заказы рядом с колонками событий. Здесь поток дня расходится на две
|
||||||
|
ветви, показанные в индексе.
|
||||||
|
|
||||||
|
За этой границей начинается [устройство заказов](../orders/README.md). Судьба,
|
||||||
|
слепки и деньги бэкенда описаны там.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Паспорт куки
|
||||||
|
|
||||||
|
Этот документ отвечает на вопрос, почему паспорт куки решает план состава, а
|
||||||
|
день только разворачивает его в события.
|
||||||
|
|
||||||
|
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) получает
|
||||||
|
случайность через
|
||||||
|
[`seeds.cohort_stream`](../../../generator/src/clickstream_generator/seeds.py)
|
||||||
|
и вызывает [`plan._passports`](../../../generator/src/clickstream_generator/plan.py)
|
||||||
|
в ветви «состав мира». Это не подпоток отдельного дня.
|
||||||
|
|
||||||
|
`plan._passports` выбирает строки устройства и города из справочников. У
|
||||||
|
двухкуковой пары город первой куки переносится во вторую, а устройство
|
||||||
|
выбирается из другой половины профилей. Сведения о парах есть только у плана.
|
||||||
|
Если независимо вывести паспорт каждой куки из `ClientID`, один человек может
|
||||||
|
оказаться в двух городах — это будет ложью в данных.
|
||||||
|
|
||||||
|
[`plan.Cohort`](../../../generator/src/clickstream_generator/plan.py) хранит
|
||||||
|
номера устройства и города. `Cohort.visitors_on` переносит их в
|
||||||
|
[`plan.DayAudience`](../../../generator/src/clickstream_generator/plan.py), а
|
||||||
|
[`day.stream`](../../../generator/src/clickstream_generator/day.py) разворачивает
|
||||||
|
профили в колонки событий. День не бросает паспорт заново, поэтому кука
|
||||||
|
сохраняет его на всю жизнь.
|
||||||
|
|
||||||
|
Броски паспорта стоят после прежнего состава когорты. Правило такого
|
||||||
|
продолжения подпотока описано в [дереве зерен](seeds.md), а выбор
|
||||||
|
паспорта объясняет [ADR 0016](../../adr/0016-cookie-passport.md). Зачем стенду
|
||||||
|
нужна двухкуковая пара одного человека, фиксирует
|
||||||
|
[мастер-спека](../../specs/2026-07-30-stand-v2-realism.md), раздел 5.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Время модельного дня
|
||||||
|
|
||||||
|
Этот документ отвечает на вопрос, почему дата модельных суток не обязана
|
||||||
|
совпадать с UTC-датой события.
|
||||||
|
|
||||||
|
В коде встречаются три координаты времени:
|
||||||
|
|
||||||
|
- D — номер дня на оси модельного времени;
|
||||||
|
- `EventDate` — дата D в поясе счетчика;
|
||||||
|
- `UTCEventTime` — абсолютная метка события.
|
||||||
|
|
||||||
|
Местное время гостя задает форму активности внутри суток, а границу дня задает
|
||||||
|
пояс счетчика.
|
||||||
|
|
||||||
|
[`world.ORIGIN`](../../../generator/src/clickstream_generator/world.py) — D0
|
||||||
|
календарной оси: он переводит номер D в конкретную дату. Это не механизм
|
||||||
|
часового пояса. Механизм —
|
||||||
|
[`world.COUNTER_TIMEZONE_MINUTES`](../../../generator/src/clickstream_generator/world.py),
|
||||||
|
смещение пояса счетчика от UTC.
|
||||||
|
|
||||||
|
В [`day._columns`](../../../generator/src/clickstream_generator/day.py)
|
||||||
|
`EventDate` получается как `ORIGIN + D`. Чтобы получить абсолютную полночь
|
||||||
|
тех же суток, код вычитает смещение счетчика, а затем прибавляет секунды
|
||||||
|
события внутри дня. Поэтому сутки `2026-06-01` в UTC+4 начинаются в
|
||||||
|
`2026-05-31 20:00:00Z`: у первых четырех часов
|
||||||
|
`toDate(UTCEventTime) != EventDate`.
|
||||||
|
|
||||||
|
[`day._starts`](../../../generator/src/clickstream_generator/day.py) сначала
|
||||||
|
выбирает час из городской строки весов, собранной
|
||||||
|
[`day._hour_cumulative`](../../../generator/src/clickstream_generator/day.py),
|
||||||
|
а затем равномерно выбирает секунду внутри часа. Строка поворачивает суточную
|
||||||
|
волну на разницу между поясом гостя и поясом счетчика. Так вечерний пик гостя
|
||||||
|
попадает в нужные часы внутри дня, но граница суток и `EventDate` остаются
|
||||||
|
счетчиковыми.
|
||||||
|
|
||||||
|
Как часовой пояс меняет представление времени в ClickHouse, описано в разделе
|
||||||
|
«Часовые пояса» [документа о хранилище](../storage.md). Типы и публичный смысл
|
||||||
|
полей записаны в [описании события](../../formats/clickstream-event.md).
|
||||||
|
Выбор модели времени объясняет
|
||||||
|
[ADR 0015](../../adr/0015-counter-timezone.md).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Ленивость мира
|
||||||
|
|
||||||
|
Этот документ отвечает на вопрос, как любой день собирается без прожитой
|
||||||
|
истории и без глобального списка посетителей.
|
||||||
|
|
||||||
|
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) строит
|
||||||
|
одну когорту по зерну и дню ее появления. Отрицательные дни обозначают
|
||||||
|
предысторию; ее глубину ограничивает
|
||||||
|
[`world.RETURN_TAIL_DAYS`](../../../generator/src/clickstream_generator/world.py).
|
||||||
|
Запоминание уже рассчитанных когорт ускоряет соседние вызовы, но не становится
|
||||||
|
состоянием мира: очистка кеша не меняет результат.
|
||||||
|
|
||||||
|
[`plan.audience`](../../../generator/src/clickstream_generator/plan.py) для
|
||||||
|
дня D спрашивает `Cohort.visitors_on(D)` у когорт от
|
||||||
|
`D - RETURN_TAIL_DAYS` до D и соединяет полученные части. Поэтому вызов для
|
||||||
|
D0 поднимает всю предысторию в окне — 91 когорту от D−90 до D0.
|
||||||
|
|
||||||
|
Когорты предыстории дают D0 вернувшихся посетителей, но собственных событий
|
||||||
|
не порождают. Ось событий начинается в D0. Окно активности отсчитывается от
|
||||||
|
первого дня человека и остается общим для обеих его кук; за краем окна
|
||||||
|
когорта больше не может попасть в аудиторию.
|
||||||
|
|
||||||
|
Отсюда самостоятельность дня: D500 и D5 заглядывают назад на одинаковое число
|
||||||
|
когорт. Ни плану состава, ни день-функции горизонт мира не нужен.
|
||||||
|
Зачем приняты предыстория и ограниченное окно, объясняет [спека
|
||||||
|
генератора](../../specs/2026-08-01-generator.md), раздел 1.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Целочисленная случайность
|
||||||
|
|
||||||
|
Этот документ отвечает на вопрос, как читать целочисленный выбор по весам и
|
||||||
|
зачем генератору свой маленький `weights.py`.
|
||||||
|
|
||||||
|
За `PCG64` и целые броски `Generator.integers` отвечает NumPy.
|
||||||
|
Генератор стенда не пользуется готовым выбором по вещественным вероятностям и
|
||||||
|
плавающими распределениями: их алгоритмы и системная математика могут
|
||||||
|
различаться между версиями и архитектурами. Зависимости закреплены, а броски
|
||||||
|
остаются целыми — так выполняется побайтовое обещание, записанное в [спеке
|
||||||
|
генератора](../../specs/2026-08-01-generator.md), раздел 2.
|
||||||
|
|
||||||
|
## Одна таблица весов
|
||||||
|
|
||||||
|
[`weights.pick`](../../../generator/src/clickstream_generator/weights.py)
|
||||||
|
сначала получает накопленные веса. Для весов `2, 3, 1` это границы
|
||||||
|
`2, 5, 6`. Затем он бросает целое число из полуинтервала `[0, 6)` и находит
|
||||||
|
первую границу справа:
|
||||||
|
|
||||||
|
- броски `0–1` выбирают вариант 0;
|
||||||
|
- броски `2–4` выбирают вариант 1;
|
||||||
|
- бросок `5` выбирает вариант 2.
|
||||||
|
|
||||||
|
Размер участка и есть вес варианта. Нормировать веса до долей единицы не
|
||||||
|
нужно.
|
||||||
|
|
||||||
|
## Своя таблица для каждой строки
|
||||||
|
|
||||||
|
[`weights.pick_row`](../../../generator/src/clickstream_generator/weights.py)
|
||||||
|
работает так же, когда у каждого броска свой ряд весов. Ряды с одинаковой
|
||||||
|
суммой укладываются друг за другом. Номер ряда смещает бросок на его участок,
|
||||||
|
единый поиск находит ячейку, а обратное смещение возвращает номер варианта.
|
||||||
|
|
||||||
|
Конкретный проход виден в
|
||||||
|
[`day._hour_cumulative`](../../../generator/src/clickstream_generator/day.py)
|
||||||
|
и [`day._starts`](../../../generator/src/clickstream_generator/day.py): для
|
||||||
|
каждого города суточная волна повернута в свой ряд, но сумма весов от поворота
|
||||||
|
не меняется.
|
||||||
|
|
||||||
|
При чтении вызова `pick` полезно представлять ряд целочисленных участков:
|
||||||
|
бросок выбирает вариант, в чей участок он попал.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Дерево зерен и порядок бросков
|
||||||
|
|
||||||
|
Этот документ отвечает на вопрос, почему место подпотока стабильно, а порядок
|
||||||
|
бросков внутри него менять нельзя.
|
||||||
|
|
||||||
|
## Два порядка
|
||||||
|
|
||||||
|
[`seeds.cohort_stream`](../../../generator/src/clickstream_generator/seeds.py)
|
||||||
|
и [`seeds.day_stream`](../../../generator/src/clickstream_generator/seeds.py)
|
||||||
|
находят ветвь по позиции `SeedSequence(entropy, spawn_key)`. Поэтому результат
|
||||||
|
ветви не зависит от того, какую соседнюю ветвь вычислили раньше.
|
||||||
|
|
||||||
|
Внутри найденной ветви живет один `Generator`. Каждый его вызов забирает
|
||||||
|
следующие значения. Поэтому порядок вызовов меняет результат: прежний
|
||||||
|
пятый бросок после вставки становится шестым и достается другому свойству
|
||||||
|
мира.
|
||||||
|
|
||||||
|
Дерево целиком описано в модуле
|
||||||
|
[`seeds`](../../../generator/src/clickstream_generator/seeds.py), а
|
||||||
|
[`seeds.Component`](../../../generator/src/clickstream_generator/seeds.py)
|
||||||
|
задает его нижний уровень — четыре подпотока дня:
|
||||||
|
|
||||||
|
```text
|
||||||
|
корневое зерно
|
||||||
|
├── состав мира
|
||||||
|
│ ├── ось событий → номер дня → когорта
|
||||||
|
│ └── предыстория → глубина → когорта до D0
|
||||||
|
└── дни
|
||||||
|
└── номер дня
|
||||||
|
├── TRAFFIC
|
||||||
|
├── COMMERCE
|
||||||
|
├── ORDERS
|
||||||
|
└── BEACONS — потеря и повторная отправка событий
|
||||||
|
```
|
||||||
|
|
||||||
|
Отрицательному дню нельзя отдать отрицательную позицию, поэтому предыстория
|
||||||
|
получила отдельную ветвь. Компоненты дня тоже занимают позиции, а не получают
|
||||||
|
зерна в порядке запуска.
|
||||||
|
|
||||||
|
## Что ломает вставка
|
||||||
|
|
||||||
|
Новый бросок в середине функции сдвигает все последующие значения этого
|
||||||
|
подпотока. Код продолжает работать, но тем же свойствам достается другая
|
||||||
|
случайность. Соседние подпотоки остаются прежними, а измененная часть
|
||||||
|
канонического мира проявляется в описи и побайтовом сравнении.
|
||||||
|
|
||||||
|
Какое обещание воспроизводимости это защищает, записано в
|
||||||
|
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
|
||||||
|
различать два свойства: `SeedSequence` стабилизирует адрес ветви, но порядок
|
||||||
|
бросков внутри `Generator` стабилизирует только сам код.
|
||||||
|
|
||||||
|
## Броски последними
|
||||||
|
|
||||||
|
Новое свойство продолжает существующий подпоток после прежних бросков. Такой
|
||||||
|
бросок не отнимает значения у уже рассчитанных свойств. В
|
||||||
|
[`plan.cohort`](../../../generator/src/clickstream_generator/plan.py) паспорт
|
||||||
|
куки был дописан после состава и назначенных заказов, а личность человека —
|
||||||
|
после паспорта. Поэтому прежние ряды остались прежними.
|
||||||
|
|
||||||
|
Почему паспорт вообще принадлежит этой ветви, разобрано отдельно в
|
||||||
|
[документе о паспорте куки](cookie-passport.md).
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Мониторинг: из чего собран и за чем следит
|
||||||
|
|
||||||
|
Набор описывает зону мониторинга: какие сервисы в неё входят, что собирает
|
||||||
|
Prometheus, какие источники данных подготовлены в Grafana, каким пользователем
|
||||||
|
она ходит в ClickHouse и куда кладутся дашборды. Устройство каждого дашборда —
|
||||||
|
отдельным файлом, указатели ниже. Зачем зоне именно такой состав и какие
|
||||||
|
дашборды на нём задуманы — [ADR 0002](../../adr/0002-monitoring-scope.md);
|
||||||
|
устройство доступа — [ADR 0007](../../adr/0007-clickhouse-access.md).
|
||||||
|
|
||||||
|
**Что здесь есть и чего ещё нет.** Собран пол: свой образ Grafana с плагином
|
||||||
|
ClickHouse, два подготовленных источника данных, экспортёр метрик Kafka и цель
|
||||||
|
для него у Prometheus. Поверх пола стоят все три дашборда ADR 0002, они
|
||||||
|
перечислены ниже. Правил оповещения и серверного журнала `system.text_log`
|
||||||
|
пока нет.
|
||||||
|
|
||||||
|
## Состав
|
||||||
|
|
||||||
|
| Сервис | Образ | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `prometheus` | `prom/prometheus:v3.13.2` | опрашивает цели раз в 15 секунд, порт стенда `29090` |
|
||||||
|
| `grafana` | `clickstream-grafana:local` | интерфейс мониторинга; собирается из `infra/grafana/`, порт стенда `23000` |
|
||||||
|
| `kafka-exporter` | `danielqsj/kafka-exporter:v1.9.0` | переводит состояние брокера в метрики Prometheus, порт `9308` |
|
||||||
|
|
||||||
|
Плагин ClickHouse 4.20.0 входит в образ Grafana. При сборке Grafana CLI берёт
|
||||||
|
закреплённый выпуск из официального репозитория на GitHub, а не из каталога
|
||||||
|
Grafana: прямой запрос к каталогу при проверке вернул HTTP 451. Фоновая
|
||||||
|
установка плагинов при запуске отключена.
|
||||||
|
|
||||||
|
## Кого опрашивает Prometheus
|
||||||
|
|
||||||
|
| Цель | Задание | Метка `role` | Откуда метрики |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `clickhouse-01:9363` | `clickhouse` | `server` | встроенный экспортёр ноды |
|
||||||
|
| `clickhouse-02:9363` | `clickhouse` | `server` | встроенный экспортёр ноды |
|
||||||
|
| `clickhouse-keeper:9363` | `clickhouse-keeper` | `keeper` | встроенный экспортёр keeper |
|
||||||
|
| `kafka-exporter:9308` | `kafka` | `exporter` | внешний экспортёр |
|
||||||
|
|
||||||
|
Ровно эти четыре цели пересчитывает `check_prometheus_targets` в
|
||||||
|
`scripts/stand-smoke.sh` — и по значению `up`, и по списку адресов. Пятая цель
|
||||||
|
красит смоук; это и есть способ заметить, что состав пола поменяли молча.
|
||||||
|
|
||||||
|
## Источники данных Grafana
|
||||||
|
|
||||||
|
Оба источника подготовлены файлами в `infra/grafana/provisioning/datasources/`
|
||||||
|
и объявлены `editable: false`: править их через интерфейс нельзя, источник
|
||||||
|
истины — репозиторий.
|
||||||
|
|
||||||
|
- `prometheus` — основной по умолчанию, ходит на `http://prometheus:9090`.
|
||||||
|
- `clickhouse` — плагин `grafana-clickhouse-datasource`, нативный протокол,
|
||||||
|
`clickhouse-01:9000`, пользователь `grafana`, пароль из `.env`.
|
||||||
|
|
||||||
|
Источник ClickHouse смотрит на `clickhouse-01`. Системные таблицы обеих нод
|
||||||
|
панели читают через `clusterAllReplicas`, как принято в ADR 0002.
|
||||||
|
|
||||||
|
## Доступ к ClickHouse
|
||||||
|
|
||||||
|
Grafana ходит пользователем `grafana` с ролью `monitoring_reader`: читает слои,
|
||||||
|
таблицы базы `dic` и системные таблицы. `REMOTE` разрешает
|
||||||
|
`clusterAllReplicas`, `CLUSTER` — запросы `ON CLUSTER`. Решение о своей роли
|
||||||
|
записано в [ADR 0007](../../adr/0007-clickhouse-access.md). Пароль задаётся
|
||||||
|
переменной `CLICKHOUSE_GRAFANA_PASSWORD`.
|
||||||
|
|
||||||
|
## Дашборды
|
||||||
|
|
||||||
|
Каталог `infra/grafana/provisioning/dashboards/` смонтирован в контейнер и
|
||||||
|
объявлен провайдером в `provider.yml`: Grafana подхватывает оттуда файлы
|
||||||
|
дашбордов, удалять и править их через интерфейс запрещено. Новый дашборд — это
|
||||||
|
новый файл в каталоге и больше ничего: в `compose.yaml` заходить не нужно, ради
|
||||||
|
этого каталог и заведён заранее.
|
||||||
|
|
||||||
|
Файлы каталога разбирает `make config-test`, рядом с файлами дагов и по тому же
|
||||||
|
доводу ([testing.md](../testing.md)).
|
||||||
|
|
||||||
|
Порогов и цветовых вердиктов на панелях нет ни на одном дашборде: на них
|
||||||
|
смотрят глазами, красное и зелёное живёт в проверках и дагах DQ.
|
||||||
|
|
||||||
|
Собранные дашборды:
|
||||||
|
|
||||||
|
- **«Данные»** (`data.json`) — с потоком всё в порядке: свежесть, поток, брак,
|
||||||
|
сходимость Kafka с ClickHouse, отставание чтения и дневной слепок заказов.
|
||||||
|
Устройство и разбор панелей — [data.md](data.md).
|
||||||
|
- **«Кластер»** (`cluster.json`) — что с машиной: процессор, память и диски
|
||||||
|
обеих нод, части и слияния, keeper, очередь распределённых DDL. Устройство и
|
||||||
|
разбор панелей — [cluster.md](cluster.md).
|
||||||
|
- **«Запросы»** (`queries.json`) — что выполнялось и во что обошлось: тяжёлые
|
||||||
|
запросы, аппетит по посетителям, ошибки. Устройство и разбор панелей —
|
||||||
|
[queries.md](queries.md).
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
Права роли `monitoring_reader` сверены 21 августа 2026 года по документации
|
||||||
|
ClickHouse через MCP Context7; разбор — в
|
||||||
|
[ADR 0007](../../adr/0007-clickhouse-access.md), раздел «Что проверено».
|
||||||
|
|
||||||
|
Сам состав проверен на живом стенде 21 августа 2026 года, собранном заново
|
||||||
|
после `make clean`.
|
||||||
|
|
||||||
|
- Grafana видит плагин `grafana-clickhouse-datasource` 4.20.0
|
||||||
|
(`/api/plugins?type=datasource`), оба подготовленных источника здоровы.
|
||||||
|
- Запрос источника ClickHouse к `system.one` через `clusterAllReplicas` вернул
|
||||||
|
2 — с одной ноды видны обе.
|
||||||
|
- Prometheus видит ровно четыре цели, все со значением `up = 1`; запрос
|
||||||
|
`kafka_consumergroup_lag` вернул ряды по группам `clickstream_*`.
|
||||||
|
- `make smoke` 21/21, `make check-clickhouse` 10/10, `make check-services` 7/7.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Дашборд «Кластер»
|
||||||
|
|
||||||
|
`infra/grafana/provisioning/dashboards/cluster.json` — семь панелей. Вопрос у
|
||||||
|
экрана один: что с машиной? Состав панелей и довод за каждую —
|
||||||
|
[ADR 0002](../../adr/0002-monitoring-scope.md).
|
||||||
|
|
||||||
|
Свой вопрос каждая панель называет заголовком прямо на экране. Здесь — только
|
||||||
|
источники, о них и речь ниже.
|
||||||
|
|
||||||
|
| Панель | Откуда берёт |
|
||||||
|
|---|---|
|
||||||
|
| Процессор | `system.metric_log` |
|
||||||
|
| Память | `system.asynchronous_metric_log` |
|
||||||
|
| Диски | `system.metric_log` |
|
||||||
|
| Части | `system.parts` |
|
||||||
|
| Слияния | `system.part_log` |
|
||||||
|
| Keeper | встроенные метрики keeper через Prometheus |
|
||||||
|
| Распределённый DDL | `system.distributed_ddl_queue` |
|
||||||
|
|
||||||
|
Окно у дашборда — полчаса, обновление — полминуты. Пачка из
|
||||||
|
`make generate-batch` проходит за двадцать секунд, и на получасовой оси это
|
||||||
|
узкий всплеск в пару столбиков. Шире окно не берут, чтобы всплеск не выродился
|
||||||
|
в точку; уже — чтобы не промахнуться мимо него, заглянув через минуту, и чтобы
|
||||||
|
рядом осталось спокойное время: подъём читается только над полом. Кто хочет
|
||||||
|
разглядеть форму вплотную, растягивает окно мышью по самому всплеску.
|
||||||
|
|
||||||
|
Панель частей окна не знает вовсе. `system.parts` — опись живых частей, и
|
||||||
|
вопрос к ней про сейчас: колонки со временем в ней есть, но ни одна не годится
|
||||||
|
в ось. Очередь DDL окна тоже не знает, но по другой причине: она показывает
|
||||||
|
последние двадцать записей, потому что в спокойные полчаса пуста, а пустая
|
||||||
|
панель ничего бы не рассказала.
|
||||||
|
|
||||||
|
## Обе ноды на одной панели
|
||||||
|
|
||||||
|
Системная таблица у каждой ноды своя: `system.metric_log` на `clickhouse-01`
|
||||||
|
знает только про `clickhouse-01`. Запрос без обёртки показал бы половину
|
||||||
|
правды и ничем бы себя не выдал — вторая нода просто не появилась бы в легенде.
|
||||||
|
Поэтому запросы идут через `clusterAllReplicas`, а `hostName()` подписывает,
|
||||||
|
чья это строка.
|
||||||
|
|
||||||
|
Исключений два, и они разной природы. Очередь распределённых DDL лежит не у
|
||||||
|
ноды, а в keeper, и обе ноды читают из него одну и ту же очередь целиком,
|
||||||
|
вместе со строками про соседа. Обёртка здесь не добавила бы правды, а удвоила
|
||||||
|
бы каждую строку — это единственная панель дашборда без неё. Правило поэтому
|
||||||
|
звучит так: `clusterAllReplicas` нужен там, где у каждой ноды своя правда.
|
||||||
|
|
||||||
|
Второе исключение мягче. Панель слияний читает обе ноды той же обёрткой, но
|
||||||
|
складывает их в один ряд: шарды сливают симметрично, и разделение удвоило бы
|
||||||
|
легенду ради двух одинаковых кривых.
|
||||||
|
|
||||||
|
## Пол на графиках — журналы самого сервера
|
||||||
|
|
||||||
|
ClickHouse ведёт около десятка собственных журналов: `metric_log`,
|
||||||
|
`trace_log`, `text_log`, `query_log` и соседние. Все они — таблицы MergeTree:
|
||||||
|
их пишут и сливают постоянно, даже когда на стенде не происходит ничего.
|
||||||
|
Отсюда процессор и диски, которые никогда не падают в ноль.
|
||||||
|
|
||||||
|
Вычесть этот пол на процессоре и дисках нечем: их счётчики знают, сколько
|
||||||
|
работы сделал процесс, и не знают, ради какой таблицы. Своя нагрузка на этих
|
||||||
|
двух панелях читается как подъём над полом, а не как число само по себе.
|
||||||
|
|
||||||
|
У частей и слияний пол отделим: в `system.parts` и `system.part_log` у каждой
|
||||||
|
строки написано, какой таблице она принадлежит. Обе панели поэтому показывают
|
||||||
|
всё, кроме журналов сервера, — и у каждой на это своя причина.
|
||||||
|
|
||||||
|
Столбик у слияний шириной в минуту, а не в шаг панели. Слияния — события
|
||||||
|
редкие: на своём шаге столбик выродился бы в волосяную линию, и четыре события
|
||||||
|
за полчаса пришлось бы искать глазами.
|
||||||
|
|
||||||
|
Слияниям фильтр иначе не пережить по размеру: за спокойные полчаса они съели
|
||||||
|
6 492 части в системных журналах против 219 в `ods` и `stg` вместе. Оставь мы
|
||||||
|
журналы в кадре, ряды слоёв прижались бы к нулю, и панель рассказывала бы про
|
||||||
|
уборку за сервером вместо приехавших данных. Сколько эта уборка стоит, видно
|
||||||
|
этажом выше, на процессоре и дисках.
|
||||||
|
|
||||||
|
Частям фильтр нужен для другого: без него в таблице десятки строк, а значит
|
||||||
|
нужен предел — и предел рвёт пару нод пополам, оставляя таблицу с одной нодой в
|
||||||
|
кадре. С фильтром помещаются все, и предел не нужен.
|
||||||
|
|
||||||
|
## Что именно меряют процессор, память и диски
|
||||||
|
|
||||||
|
Процессор считается по времени самого сервера
|
||||||
|
(`ProfileEvent_OSCPUVirtualTimeMicroseconds`), а не по загрузке машины.
|
||||||
|
Метрики с именами `OS*` сняты с хоста целиком: обе ноды показывали бы в них
|
||||||
|
одно и то же число, и панель на два ряда потеряла бы смысл.
|
||||||
|
|
||||||
|
Память показана резидентной (`MemoryResident`), а не той, что сервер считает
|
||||||
|
своей (`MemoryTracking`). Разница не косметическая: на праздной ноде
|
||||||
|
`MemoryTracking` — 113 МиБ при 846 МиБ резидентной памяти, а ядро, решая, кого
|
||||||
|
убить за нехватку памяти, смотрит на вторую величину
|
||||||
|
([ADR 0004](../../adr/0004-resource-limits.md)). По первой нода, которую
|
||||||
|
вот-вот убьют, выглядит совершенно здоровой.
|
||||||
|
|
||||||
|
Пунктирный ряд «отведено» — предел памяти контейнера, та самая коробка из
|
||||||
|
ADR 0004. Он тоже читается живьём, метрикой `CGroupMemoryTotal`: впиши мы
|
||||||
|
4 ГиБ числом в JSON, панель молча соврала бы в тот день, когда предел поменяют.
|
||||||
|
|
||||||
|
У дисков считаются обращения к файлам — `OSReadChars` и `OSWriteChars`.
|
||||||
|
Счётчики блочного устройства (`OSReadBytes`) для той же панели не годятся:
|
||||||
|
блочное чтение на стенде почти всё время нулевое, части лежат в страничном
|
||||||
|
кэше, и слияние перечитывает их оттуда. Работа при этом идёт настоящая, и
|
||||||
|
панель по блокам показывала бы её рваным пунктиром.
|
||||||
|
|
||||||
|
## Keeper и очередь DDL — то, что живёт вне нод
|
||||||
|
|
||||||
|
Keeper — отдельный сервис, системных таблиц у него нет, и панель по нему
|
||||||
|
единственная на дашборде приходит из Prometheus.
|
||||||
|
|
||||||
|
Показывает она размер дерева. В keeper лежит журнал репликации, а в нём —
|
||||||
|
запись на каждую часть: пока поток идёт, дерево растёт, а когда части
|
||||||
|
домержились и старые записи убраны, оседает. Замер за полчаса живого потока:
|
||||||
|
9 078 узлов на подъёме, 11 512 на пике, 10 308 через десять минут после того,
|
||||||
|
как поток встал.
|
||||||
|
|
||||||
|
Очередь распределённых DDL — вторая половина того же сюжета: через keeper
|
||||||
|
проходит каждый запрос `ON CLUSTER`. Заглядывают сюда с одним вопросом: доехал
|
||||||
|
ли он до обеих нод и что ответила каждая.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
Дашборд проверен на живом стенде 21 августа 2026 года.
|
||||||
|
|
||||||
|
- Дашборд поднимается из каталога сам, все семь панелей отвечают через
|
||||||
|
`/api/ds/query` в окне на полчаса. Пустых нет.
|
||||||
|
- Обе ноды видны на четырёх панелях из пяти, что читают системные таблицы нод.
|
||||||
|
Пятая — слияния, там ноды сложены намеренно. Без `clusterAllReplicas`
|
||||||
|
осталась только очередь DDL, и обёртка ей действительно не нужна: запрос с
|
||||||
|
ней вернул с `clickhouse-01` и с `clickhouse-02` одно и то же число записей —
|
||||||
|
на день замера 124 и 124.
|
||||||
|
- Под нагрузкой. `make generate-batch` (50 626 событий одной пачкой), замер в
|
||||||
|
десятисекундных вёдрах: процессор поднялся с 0,04–0,18 ядра до 0,54 на одной
|
||||||
|
ноде и 0,64 на другой, запись — с 400 КиБ/с до 2,1 и 4,5 МиБ/с. Ведро тут
|
||||||
|
часть ответа: в тридцатисекундном тот же прогон даёт 0,29 и 0,21 ядра.
|
||||||
|
Следующие десять секунд обе величины уже вернулись к прежним: на таком объёме
|
||||||
|
подъём и оседание укладываются в полминуты.
|
||||||
|
- Слияния под той же нагрузкой отозвались столбиком в ряду `stg` высотой в
|
||||||
|
15 съеденных частей.
|
||||||
|
- Блочное чтение действительно молчит: за минуту вокруг вставки `OSReadBytes`
|
||||||
|
был нулевым в десяти десятисекундных окнах из четырнадцати, а обращения к
|
||||||
|
файлам шли непрерывно во всех.
|
||||||
|
- Память: около 1 ГиБ занятого против 4 ГиБ отведённого на каждой ноде.
|
||||||
|
- Панель частей показала `ods.order_rep` по 142 строки в части против 13 046 у
|
||||||
|
`ods.event_rep` — заказы приезжают дневным слепком, события потоком. Строк
|
||||||
|
вышло восемь: четыре таблицы, каждая обеими нодами. Таблиц ошибок среди них
|
||||||
|
нет, и это правда состояния — частей у них пока не появилось.
|
||||||
|
- Очередь DDL показала `CREATE MATERIALIZED VIEW ... ON CLUSTER` в состоянии
|
||||||
|
`Finished` и со временем по каждой ноде. Новые безопасные записи для показа
|
||||||
|
оставляет `make check-clickhouse`: цель создаёт и удаляет временные таблицы
|
||||||
|
`ON CLUSTER`. Канонический DDL появляется в очереди при создании мира дагом
|
||||||
|
`world_initialize`.
|
||||||
|
|
||||||
|
Подписи рядов (`${__field.labels.node}`) и правило, по которому пунктирным
|
||||||
|
становится только ряд «отведено», сверены по документации Grafana через MCP
|
||||||
|
Context7. И то и другое применяется на стороне браузера, через
|
||||||
|
`/api/ds/query` их не видно: там проверено только, что нода приезжает меткой
|
||||||
|
ряда, а метрика — его именем.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Дашборд «Данные»
|
||||||
|
|
||||||
|
`infra/grafana/provisioning/dashboards/data.json` — шесть панелей на один
|
||||||
|
экран. Вопрос у экрана один: с потоком всё в порядке? Состав панелей и довод за
|
||||||
|
каждую — [ADR 0002](../../adr/0002-monitoring-scope.md).
|
||||||
|
|
||||||
|
Свой вопрос каждая панель называет заголовком прямо на экране. Здесь — только
|
||||||
|
источники, о них и речь ниже.
|
||||||
|
|
||||||
|
| Панель | Откуда берёт |
|
||||||
|
|---|---|
|
||||||
|
| Свежесть | `ods.event_dist`, `ods.order_dist` |
|
||||||
|
| Поток | `stg.hits_raw_dist`, `stg.orders_raw_dist` |
|
||||||
|
| Брак | те же и `ods.*_errors_dist` |
|
||||||
|
| Сходимость | экспортёр Kafka и `stg.hits_raw_dist` |
|
||||||
|
| Отставание | `kafka_consumergroup_lag` |
|
||||||
|
| Дневной слепок | `stg.orders_raw_dist` |
|
||||||
|
|
||||||
|
## Почему слои у панелей разные
|
||||||
|
|
||||||
|
Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него
|
||||||
|
длиннее: сырьё привозит забор, а типизированную строку кладёт разбор. Встань
|
||||||
|
разбор — свежесть ODS это покажет, свежесть сырья нет.
|
||||||
|
|
||||||
|
Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление
|
||||||
|
прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос
|
||||||
|
здесь про доставку. Местом диагностики доставленных версий физическая таблица
|
||||||
|
названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`).
|
||||||
|
|
||||||
|
Поток и брак считаются по слою сырья. Сырьё записывает каждую доставку и ничего
|
||||||
|
не схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события,
|
||||||
|
и все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
|
||||||
|
сильнее, чем дольше живёт стенд.
|
||||||
|
|
||||||
|
Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не
|
||||||
|
отдельная куча, а часть сырья, которая не прошла разбор.
|
||||||
|
|
||||||
|
Окно у панели брака — окно дашборда, и это не косметика. Сырьё живёт трое
|
||||||
|
суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток
|
||||||
|
приехавшего и однажды перевалила бы за сотню процентов.
|
||||||
|
|
||||||
|
Панель дневного слепка, наоборот, окна не знает. Её вопрос про состояние, а не
|
||||||
|
про отрезок, и последние десять слепков она показывает всегда.
|
||||||
|
|
||||||
|
Отдельного ряда «против вчерашнего» на панели потока нет, и это решение, а не
|
||||||
|
пропуск. Реальное «вчера» на стенде чаще всего приходится на время, когда
|
||||||
|
стенда ещё не было. А сдвинуть ряд на модельные сутки значит записать в JSON
|
||||||
|
дашборда ускорение мира — ровно ту пометку про тик, которую [ADR
|
||||||
|
0002](../../adr/0002-monitoring-scope.md) в дашборде держать не велит: поменяют
|
||||||
|
ускорение, и панель молча соврёт. Сравнение остаётся глазу: модельные сутки
|
||||||
|
укладываются в минуты реального времени, и соседние дни мира помещаются в одно
|
||||||
|
окно панели сами.
|
||||||
|
|
||||||
|
## Приземлившееся считают по слою сырья
|
||||||
|
|
||||||
|
Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам
|
||||||
|
экспортёра. Таблицы движка Kafka `stg.hits_raw_kafka` и `stg.orders_raw_kafka`
|
||||||
|
хранилищем не работают: у второго стоит `kafka_commit_on_select`
|
||||||
|
(`sql/ddl/10-stg-tables.sql`), и запрос из панели забрал бы слепок себе,
|
||||||
|
сдвинул офсеты, а даг приёма приехал бы к пустому топику.
|
||||||
|
|
||||||
|
## Как считается сходимость
|
||||||
|
|
||||||
|
Произведённое живёт в Kafka, приземлившееся — в ClickHouse, и со своей стороны
|
||||||
|
ни одна из них не видит другую. Поэтому панель смешанная: ряд «произведено в
|
||||||
|
Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse.
|
||||||
|
Единицы общие: сообщения за интервал панели. Со стороны Kafka это разность
|
||||||
|
офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со
|
||||||
|
стороны ClickHouse — `count()` строк сырья по тому же интервалу.
|
||||||
|
|
||||||
|
Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase`
|
||||||
|
растягивает приращение на всё окно и на минутном интервале промахивается на
|
||||||
|
проценты: здоровый стенд выглядел бы расходящимся. Накопленный итог от начала
|
||||||
|
окна точнее обоих приростов, но отсчитывать его надо от края окна — а на
|
||||||
|
свежем стенде окно начинается раньше, чем появился экспортёр, и ряд пропадает
|
||||||
|
целиком.
|
||||||
|
|
||||||
|
Столбцы ClickHouse подписаны правым краем, а не левым. Точка Prometheus — это
|
||||||
|
прирост к моменту времени, а `count()` по интервалу — сумма, которая набирается
|
||||||
|
к его концу; подпиши мы её началом, здоровые ряды разъехались бы ровно на один
|
||||||
|
столбец. Шаг панели при этом не мельче минуты: экспортёр опрашивают раз в
|
||||||
|
пятнадцать секунд, и на более мелком шаге ряд Kafka пошёл бы ступеньками там,
|
||||||
|
где ряд ClickHouse идёт гладко.
|
||||||
|
|
||||||
|
Точка в точку ряды всё равно не совпадут, и ждать этого не надо: опрос
|
||||||
|
экспортёра не попадает на границы столбцов, поэтому на разгоне потока линия
|
||||||
|
Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в
|
||||||
|
один столбец не значит ничего.
|
||||||
|
|
||||||
|
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден
|
||||||
|
сразу. Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему
|
||||||
|
другой — не «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него
|
||||||
|
отвечает своя панель, и модельный день она берёт из самого сообщения: в сырьё
|
||||||
|
он приезжает полем `snapshot_date`.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
Дашборд проверен на живом стенде 21 августа 2026 года, `world_live` снят с
|
||||||
|
паузы.
|
||||||
|
|
||||||
|
- Дашборд поднимается из каталога сам: файл положен, панели появились без
|
||||||
|
ручного импорта. Сохранение и удаление через API отвергнуты («Cannot save
|
||||||
|
provisioned dashboard», «provisioned dashboard cannot be deleted»).
|
||||||
|
- Все шесть панелей отвечают через `/api/ds/query` — и в окне часа по
|
||||||
|
умолчанию, и в окне на шесть часов, которое начинается раньше, чем поднялся
|
||||||
|
экспортёр. Пустых нет ни при идущем потоке, ни при остановленном.
|
||||||
|
- Брак: подсунутое в топик `hits` негодное сообщение легло в
|
||||||
|
`ods.event_errors` с классом `keyset_mismatch`; панель показала единицу и
|
||||||
|
долю 0,039 % в десятиминутном окне. Строка убрана мутациями из
|
||||||
|
`stg.hits_raw_rep` и `ods.event_errors_rep`, обе таблицы вернулись к нулю.
|
||||||
|
- Сходимость: на живом потоке за двадцать пять минут медиана расхождения между
|
||||||
|
рядами 3 %, худший столбец 11 % — на переломе потока, как и описано выше.
|
||||||
|
- Свежесть реагирует на остановленный поток и возвращается. Через пять минут
|
||||||
|
после конца модельного дня панель показала 305 с у событий и 288 с у
|
||||||
|
заказов; стоило пустить следующий день, события вернулись к секунде.
|
||||||
|
Свежесть заказов при этом растёт до конца дня и садится, когда доедет его
|
||||||
|
слепок, — так и устроен второй источник.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Дашборд «Запросы»
|
||||||
|
|
||||||
|
`infra/grafana/provisioning/dashboards/queries.json` — три панели поверх одной
|
||||||
|
таблицы, `system.query_log`. Вопрос у экрана один: что выполнялось и во что это
|
||||||
|
обошлось? Состав панелей и довод за каждую —
|
||||||
|
[ADR 0002](../../adr/0002-monitoring-scope.md).
|
||||||
|
|
||||||
|
Свой вопрос каждая панель называет заголовком прямо на экране. Здесь — только
|
||||||
|
то, чего в заголовке не скажешь.
|
||||||
|
|
||||||
|
Журнал у каждой ноды свой, поэтому все три панели читают его через
|
||||||
|
`clusterAllReplicas` — то же правило, что и на [«Кластере»](cluster.md).
|
||||||
|
|
||||||
|
Окно — час, обновление — полминуты. Час взят потому, что сюда приходят с
|
||||||
|
вопросом о прошлом: «почему у меня всё стояло десять минут назад». Полминуты —
|
||||||
|
чтобы только что запущенный тяжёлый запрос не пришлось ждать: в журнал он
|
||||||
|
попадает через несколько секунд после конца.
|
||||||
|
|
||||||
|
## Первым в списке видно сам мониторинг
|
||||||
|
|
||||||
|
Grafana ходит в ClickHouse обычным пользователем, и её запросы ложатся в
|
||||||
|
журнал наравне с человеческими. На спокойном стенде верх списка тяжёлых
|
||||||
|
запросов занимает `grafana`: панель свежести с дашборда «Данные» читает по
|
||||||
|
полмиллиона строк за каждое обновление.
|
||||||
|
|
||||||
|
Так и надо: мониторинг тоже стоит денег, и цену видно там же, где и всё
|
||||||
|
остальное. Спрячь мы свои запросы фильтром — цена ушла бы с глаз, а панель «Кто
|
||||||
|
спрашивает» недосчиталась бы самого частого посетителя.
|
||||||
|
|
||||||
|
Различать посетителей дашборд умеет потому, что у Grafana своя учётка со своей
|
||||||
|
ролью ([ADR 0007](../../adr/0007-clickhouse-access.md)). Ходи она под
|
||||||
|
аналитиком, панель «Кто спрашивает» показывала бы одну строку на всех.
|
||||||
|
|
||||||
|
## Что отброшено и почему
|
||||||
|
|
||||||
|
Каждый запрос попадает в журнал дважды: строкой начала и строкой конца. В
|
||||||
|
строке начала нет ни длительности, ни памяти, ни прочитанных строк — там их
|
||||||
|
ещё неоткуда взять. Панели считают по строкам конца.
|
||||||
|
|
||||||
|
Распределённый запрос кладёт в журнал не только себя. Инициатор пишет свою
|
||||||
|
строку, а каждый шард — свою, про доставшийся ему кусок. Две панели из трёх
|
||||||
|
считают только запросы инициатора: иначе один запрос человека выглядел бы
|
||||||
|
тремя, а прочитанные строки сложились бы дважды.
|
||||||
|
|
||||||
|
Панель ошибок, наоборот, куски шардов показывает: отказ случается там, где
|
||||||
|
считали, и строка шарда — единственное место, где эта нода названа. В замере с
|
||||||
|
принудительным пределом памяти `clickhouse-01` записал свой отказ, а
|
||||||
|
`clickhouse-02` — свой, и вторая строка видна только без фильтра по
|
||||||
|
инициатору.
|
||||||
|
|
||||||
|
## Пустой пользователь
|
||||||
|
|
||||||
|
Часть строк журнала приходит без имени пользователя: так записана собственная
|
||||||
|
работа сервера — чтение топиков движком Kafka, вставки материализованных
|
||||||
|
представлений, обслуживание. Панели подписывают такие строки «фоновая работа
|
||||||
|
сервера»: пустая клетка в таблице читается как поломка, а это не она.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
Дашборд проверен на живом стенде 21 августа 2026 года.
|
||||||
|
|
||||||
|
- Дашборд поднимается из каталога сам, все три панели отвечают через
|
||||||
|
`/api/ds/query` в окне на час. Пустых нет.
|
||||||
|
- Тяжёлый запрос виден. `GROUP BY` по `ods.event_dist` от учётки `analyst`
|
||||||
|
приехал в панель со своими числами: 741 мс, 53,32 МиБ памяти, 701 994
|
||||||
|
прочитанные строки.
|
||||||
|
- Ошибки видны и различаются по природе: отказ по правам (`bi` без `SELECT` на
|
||||||
|
`ods`) и ошибка в тексте запроса (несуществующая колонка) легли в панель
|
||||||
|
разными строками, каждая с полным текстом.
|
||||||
|
- Строка шарда в панели ошибок настоящая. Принудительный предел памяти на
|
||||||
|
распределённом `GROUP BY` дал две записи: `clickhouse-01` с
|
||||||
|
`is_initial_query = 1` и `clickhouse-02` с нулём. Вторая пропала бы, стой на
|
||||||
|
панели тот же фильтр, что на соседних.
|
||||||
|
- Панель «Кто спрашивает» разделила шесть учёток — `grafana`, `analyst`, `etl`,
|
||||||
|
`dict`, `bi`, `default` — и фоновую работу сервера. Больше всех прочитала
|
||||||
|
`grafana`: 11,5 млн строк за час, своими же панелями.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Заказы бэкенда
|
||||||
|
|
||||||
|
Устройство второго источника стенда: раз в модельный день бэкенд магазина
|
||||||
|
выгружает полный слепок заказов окна изменяемости, и деньги в витринах
|
||||||
|
считаются по нему, а не по трекеру. Набор собран картой #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).
|
||||||
|
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
|
||||||
|
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
|
||||||
|
пересечения выходят арифметикой, приоритет классов работает по-настоящему —
|
||||||
|
[классы расхождений и опоздание](fate.md).
|
||||||
|
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
|
||||||
|
каждого слепка и счётчики наблюдаемых классов —
|
||||||
|
[что хранит опись](inventory.md).
|
||||||
|
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
|
||||||
|
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
|
||||||
|
[мост к склейке](identity.md).
|
||||||
|
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
|
||||||
|
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order`
|
||||||
|
принимает версии заказа на `ReplacingMergeTree(snapshot_date)` —
|
||||||
|
[приём из Kafka в ODS](ingestion.md).
|
||||||
|
- **Дальше заказ живёт моделью DDS** — уже по хранилищную сторону границы,
|
||||||
|
в наборе слоя: [модель заказа](../dds/order.md).
|
||||||
|
- **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого
|
||||||
|
дня клики есть, а заказов нет, и график выручки дозаполняется по ходу
|
||||||
|
мира — [заказы в стартовом мире](start-world.md).
|
||||||
|
|
||||||
|
Правила, обязательные для кода этапа 3, собраны в
|
||||||
|
[правилах кода](code-rules.md).
|
||||||
|
|
||||||
|
## Открытые решения
|
||||||
|
|
||||||
|
- **Контур проверок качества для расхождений** (даг 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,155 @@
|
|||||||
|
# Судьба заказа и расхождения
|
||||||
|
|
||||||
|
Резолюция развилки [«Расхождения A–D и опоздания: механика, доли и что
|
||||||
|
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72).
|
||||||
|
|
||||||
|
## Судьба заказа
|
||||||
|
|
||||||
|
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
|
||||||
|
решённая при его рождении и уложенная в окно K целиком.
|
||||||
|
|
||||||
|
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты,
|
||||||
|
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и
|
||||||
|
дубль. Довод за разделение — различимость по описи: правка заказной механики
|
||||||
|
не двигает хеши событий, правка событийной не двигает байты слепка, и по
|
||||||
|
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
|
||||||
|
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
||||||
|
компонент называет часть мира; новые стороны занимают те же позиции.
|
||||||
|
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
|
||||||
|
событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо
|
||||||
|
отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка
|
||||||
|
событийной механики молча меняла бы байты слепка; *компонент на класс* —
|
||||||
|
пять имён под ручки калибровки, которые крутятся разом.
|
||||||
|
|
||||||
|
**Правило формы, без которого разделение не работает: броски заказной стороны
|
||||||
|
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
||||||
|
становится функцией доли, и правка одной доли перебрасывает весь подпоток
|
||||||
|
после себя. Изоляцию даёт форма броска, а не число подпотоков.
|
||||||
|
|
||||||
|
**Путь по статусам.** Три исхода, все внутри окна: **оплачен**; **оплачен и
|
||||||
|
отменён**; **не оплачен и отменён**. Инвариант на выходе из окна: заказ либо
|
||||||
|
`paid`, либо `cancelled`; `created` — только промежуточное состояние. За окном
|
||||||
|
будущего у заказа нет, а заказ, навсегда застрявший в `created`, — модельная
|
||||||
|
небрежность, которой в выгрузке живого магазина соответствия нет. Поэтому
|
||||||
|
нового значения `mismatch_class` не нужно: `cancelled` покрывает обе дороги
|
||||||
|
отмены (шестое значение занято `awaiting_order`).
|
||||||
|
|
||||||
|
**Форма броска.**
|
||||||
|
|
||||||
|
1. **Исход** — таблица долей из трёх строк; доля неоплаченных пишется явной
|
||||||
|
строкой, а не оставляется читателю складывать хвост в уме.
|
||||||
|
2. **Моменты** — таблица целых весов «сколько часов от рождения — с каким
|
||||||
|
весом», строки 0…143, плюс равномерная секунда внутри часа — чтобы разности
|
||||||
|
времён аудита не давали точных равенств (урок правки #50).
|
||||||
|
3. Моментов бросается **всегда два, на полную длину дня**; у одномоментных
|
||||||
|
исходов второй выбрасывается. Где их два по существу, ранний считается
|
||||||
|
оплатой — порядок выходит сортировкой, условной точки отсчёта не нужно.
|
||||||
|
|
||||||
|
143 часа — самый узкий край окна: у заказа, рождённого в конце суток, до
|
||||||
|
последнего его слепка 144 часа. Таблица кончается там, где кончается окно у
|
||||||
|
самого невезучего: вылезти нечему, сторожа не нужно. Цена — заказ, рождённый в
|
||||||
|
начале суток, не использует почти сутки своего окна; в хвосте таблицы веса
|
||||||
|
мизерные, в данных это не видно. Форма из #71 — «вес за краем окна означает
|
||||||
|
„не оплачен никогда“» — этим отменена: такой заказ теперь отменяется, а край
|
||||||
|
окна не выражается числом часов — иначе доля неоплаченных стала бы функцией
|
||||||
|
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
|
||||||
|
|
||||||
|
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
|
||||||
|
она делится между двумя дорогами — строки таблицы исходов. Замер на
|
||||||
|
каноническом мире (28 дней, 2026-08-30, docs/architecture/testing.md, «Что
|
||||||
|
проверено»): отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12%
|
||||||
|
отменен неоплаченным; в пределах ориентира. Проверок вида «отмен от 4 до 6
|
||||||
|
процентов» не заводим.
|
||||||
|
|
||||||
|
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
||||||
|
версия; различает их история версий: сырьё STG и физические версии
|
||||||
|
`ods.order` до фоновых слияний, а в витринах — выручка дня, которая
|
||||||
|
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
|
||||||
|
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
|
||||||
|
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
|
||||||
|
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда
|
||||||
|
деться, кроме как остаться брошенным; *мгновенная отмена при рождении* —
|
||||||
|
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1,
|
||||||
|
он в резерве.
|
||||||
|
|
||||||
|
## Классы расхождений и опоздание
|
||||||
|
|
||||||
|
Классы и ориентиры долей — [мастер-спека,
|
||||||
|
раздел 4](../../specs/2026-07-30-stand-v2-realism.md); здесь — механика
|
||||||
|
каждого.
|
||||||
|
|
||||||
|
**Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии,
|
||||||
|
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
|
||||||
|
`items_total` меньше на её полную стоимость. После вычёркивания деньги заказа
|
||||||
|
считаются от оставшейся корзины по [общему
|
||||||
|
правилу](snapshot.md#откуда-берётся-заказ), поэтому `total` остаётся
|
||||||
|
неотрицательным по построению. Момента у дельты нет — заказ приезжает урезанным
|
||||||
|
во всех своих слепках: первый слепок снимается на границе суток, когда склад
|
||||||
|
заказ уже собрал. Заказ из одной позиции дельты не получает — пустых заказов
|
||||||
|
не бывает. Позиция выбирается равновероятно: корреляция со спросом на доле
|
||||||
|
1–2% статистически ненаблюдаема — менти платил бы за неё таблицей чисел мира,
|
||||||
|
а увидеть не мог бы ничем. Доводы за вычёркивание: остаток — сотни рублей, он
|
||||||
|
торчит в витрине сверки сам; расхождение объясняется сравнением позиций —
|
||||||
|
разбором вложенного JSON и `ARRAY JOIN`, ровно тем навыком, ради которого
|
||||||
|
позиции разбираются; история рассказывается словами без легенды про генератор.
|
||||||
|
Отклонено: *сохранять скидку исходной корзины и отбирать только безопасные
|
||||||
|
позиции* — отсутствие товара стало бы зависеть от купона и доставки, а выбор
|
||||||
|
позиции перестал бы быть равновероятным; *разрешить отрицательный `total` или
|
||||||
|
ослабить строгий приём* — ошибка источника превратилась бы в брак, который
|
||||||
|
молча терпит хранилище; *переоценка позиции* и *другое количество* — дельта в
|
||||||
|
десятки рублей, её надо захотеть заметить; *чистая дельта без истории* —
|
||||||
|
тупик, объяснить нечем; *врёт клиент, а не бэкенд* — заказ у нас проекция той
|
||||||
|
же корзины.
|
||||||
|
|
||||||
|
**Потеря события (B) — точечная.** Уходит строка `purchase`, просмотр
|
||||||
|
`/confirmation` остаётся: события уезжают разными запросами, потерять один и
|
||||||
|
сохранить другой — обычное дело. Единственный вариант, при котором потеря
|
||||||
|
видна со стороны трекера: до подтверждения дошли сто, покупок девяносто семь.
|
||||||
|
|
||||||
|
**Дубль события (D) — сюжетный.** Обновление страницы шлёт и просмотр, и
|
||||||
|
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
|
||||||
|
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
|
||||||
|
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
|
||||||
|
один в один — посчитал наивно, удвоил выручку. Дубль случается только там,
|
||||||
|
где пауза до следующего визита куки длиннее таймаута и задержки дубля вместе:
|
||||||
|
иначе сборка сессий у менти разошлась бы с `VisitID` — сломался бы эталон,
|
||||||
|
ради которого `VisitID` в потоке лежит. Задержка дубля —
|
||||||
|
секунды-минуты, короче таймаута визита, поэтому `VisitID` тот же. Полночь
|
||||||
|
режет дубль парой — просмотр вместе с покупкой, по тому же правилу, что у
|
||||||
|
подтверждения с торговым хвостом.
|
||||||
|
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
|
||||||
|
потеря перестаёт быть видна со стороны трекера.
|
||||||
|
|
||||||
|
**Гарантия моста сильнее порчи.** Назначенные планом покупки не теряются, и
|
||||||
|
назначенные планом заказы не опаздывают: `dds.identity_map` строится из моста
|
||||||
|
«`purchase` ↔ заказ», и выброшенное событие — как и заказ, не попавший ни в
|
||||||
|
один снятый слепок, — уносит куку из карты. Это был бы отказ лабы склейки, а
|
||||||
|
не расхождение в данных; менти различить не может.
|
||||||
|
|
||||||
|
**Опоздание — заказ прячется от ранних слепков.** `created_at` не
|
||||||
|
подделывается — строка создана, когда заказ родился, — но в слепках дней
|
||||||
|
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
|
||||||
|
дожил: отменённый на второй день и опоздавший на третий приедет в первом же
|
||||||
|
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Таблица задержки
|
||||||
|
состоит из трёх строк: δ = 0/1/2 с весами 90/5/5; 1 и 2 — это D+1 и D+2
|
||||||
|
мастер-спеки. Бросок идёт последним в подпотоке `ORDERS`; затем δ назначенного
|
||||||
|
планом заказа обнуляется. Меряется задержка в днях снятия слепка, а не
|
||||||
|
отправки: иначе сдвиг отправки удвоился бы, и обещанные D+1/D+2 стали бы
|
||||||
|
D+2/D+3. Дальше таблица не идёт: заказ с δ = 6 приехал бы ровно в одном
|
||||||
|
слепке, и обещание «пропущенный день ничего не ломает» на нём перестало бы
|
||||||
|
быть верным; при δ ≤ 2 у всякого заказа слепков не меньше пяти.
|
||||||
|
Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Отклонено:
|
||||||
|
*сдвиг `created_at`* — подделка аудита источника: день создания строки
|
||||||
|
разошёлся бы с днём покупки, чьё равенство держит синхронная модель
|
||||||
|
([откуда берётся заказ](snapshot.md)), заказ уехал бы в чужую партицию,
|
||||||
|
и «выручка дня D» перестала бы отвечать покупкам дня D — сломалась бы та
|
||||||
|
самая сверка, ради которой всё строится.
|
||||||
|
|
||||||
|
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
|
||||||
|
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
|
||||||
|
решается только у выживших покупок, а назначенное планом не теряется и не
|
||||||
|
опаздывает — дельта и отмена ему разрешены, моста они не рвут.
|
||||||
|
Следствие для калибровки: брошенная доля и наблюдаемая в сверке — разные числа
|
||||||
|
(часть заказов забирают победители по приоритету, часть у класса 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,244 @@
|
|||||||
|
# Приём заказов из Kafka в ODS
|
||||||
|
|
||||||
|
Учебный результат: менти различает версию бизнес-сущности, наблюдение источника
|
||||||
|
и запуск загрузки, а затем читает физические версии через явную поверхность
|
||||||
|
текущего состояния.
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
|
||||||
|
отдельными сообщениями и не сообщает потребителю, где слепок закончился.
|
||||||
|
Прямое чтение Kafka Engine возвращает одну порцию без признака полноты.
|
||||||
|
Поэтому `snapshot_date` не может служить границей публикации: `REPLACE
|
||||||
|
PARTITION` могла бы заменить партицию дня неполным набором строк.
|
||||||
|
|
||||||
|
Одновременно типизированный ODS не должен принимать правдоподобные значения по
|
||||||
|
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
|
||||||
|
|
||||||
|
## Цели
|
||||||
|
|
||||||
|
- сохранить пакетный забор как контраст потоковому приёму событий;
|
||||||
|
- один раз принять байты в STG и независимо разложить строки на годные и брак;
|
||||||
|
- хранить в ODS типизированные версии заказов, не публикуя слепок заменой
|
||||||
|
партиции;
|
||||||
|
- дать следующим слоям один корректный способ прочитать текущее состояние;
|
||||||
|
- оставить код проверки коротким и ограничить его контрактом провода.
|
||||||
|
|
||||||
|
## Не входит
|
||||||
|
|
||||||
|
- модель заказа в DDS: её зерно, связи, материализация и способ наполнения;
|
||||||
|
- проверка полей внутри `items`, переходов статуса и равенств денежных сумм;
|
||||||
|
- удаление заказа по отсутствию в следующем слепке;
|
||||||
|
- маркер конца слепка, опись ожидаемых строк и транзакция между целями ODS.
|
||||||
|
|
||||||
|
## Поток данных
|
||||||
|
|
||||||
|
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
|
||||||
|
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
|
||||||
|
дёргает его тот, кто положил слепок в топик, — работник пульта мира, — и ждёт
|
||||||
|
конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
|
||||||
|
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта.
|
||||||
|
Запрос забора лежит в [`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
|
||||||
|
`SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр
|
||||||
|
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
|
||||||
|
|
||||||
|
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
|
||||||
|
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
|
||||||
|
пишет годные строки в `ods.order`, второй — брак в `ods.order_errors`.
|
||||||
|
Транзакции между запросами нет. При частичном сбое Airflow повторяет весь
|
||||||
|
`task_id`; одинаковые исходные строки и служебные метки не вычисляются заново.
|
||||||
|
Обе вставки лежат в одном самодостаточном файле
|
||||||
|
[`order_parse.sql`](../../../sql/ods/order_parse.sql). Airflow сначала
|
||||||
|
шаблонизирует файл, затем делит получившийся SQL на выражения и исполняет их по
|
||||||
|
порядку в одной задаче `parse_batch`.
|
||||||
|
|
||||||
|
Условия запросов взаимодополняющие: один общий предикат определяет годную
|
||||||
|
строку, а ветвь брака использует его буквальное отрицание. Все функции предиката
|
||||||
|
возвращают результат без исключения, а сам предикат всегда заканчивается в
|
||||||
|
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для
|
||||||
|
этого не нужен. Предикат объявлен локальным макросом Jinja внутри
|
||||||
|
`order_parse.sql` и разворачивается в оба выражения. Макрос убирает
|
||||||
|
дублирование, но не вводит отдельный фрагмент, который нельзя исполнить
|
||||||
|
самостоятельно.
|
||||||
|
|
||||||
|
## Граница строгого приёма
|
||||||
|
|
||||||
|
Единица решения — одна строка `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 полей проверяются только пять опорных; переносить то сокращение на
|
||||||
|
малый контракт заказа нет причины. Граница строгости заканчивается на форме
|
||||||
|
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
|
||||||
|
|
||||||
|
Контракт источника задаёт частоту: **одной дате соответствует один слепок**. Это
|
||||||
|
свойство всего слепка, поэтому предикат одной строки его не проверяет. Вторая
|
||||||
|
выгрузка того же дня даст ничью по `snapshot_date`, и выбор строки потеряет
|
||||||
|
однозначность. Модельный источник выполняет условие; в настоящей интеграции
|
||||||
|
его фиксируют в договоре с источником
|
||||||
|
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)).
|
||||||
|
|
||||||
|
Класс брака выбирается первым совпадением:
|
||||||
|
|
||||||
|
1. `not_an_object`;
|
||||||
|
2. `keyset_mismatch`;
|
||||||
|
3. `field_invalid`.
|
||||||
|
|
||||||
|
Имя отдельного поля в класс не включается. В таблице ошибок остаются сырой
|
||||||
|
текст, метаданные доставки и `_load_id`, поэтому единичный случай можно разобрать
|
||||||
|
без постоянной детализации предиката.
|
||||||
|
|
||||||
|
## Роль ODS
|
||||||
|
|
||||||
|
`ods.order_rep` хранит физически принятые версии в
|
||||||
|
`ReplacingMergeTree(snapshot_date)`. Ключ сортировки — `order_id`, партиция —
|
||||||
|
день неизменного `created_at`. `ods.order_dist` шардирует по
|
||||||
|
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
|
||||||
|
`FINAL` даёт корректный результат через распределённую таблицу.
|
||||||
|
|
||||||
|
Четыре координаты отвечают на разные вопросы:
|
||||||
|
|
||||||
|
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
|
||||||
|
- `updated_at` — когда строку последний раз меняли в источнике;
|
||||||
|
- `_load_id` — какой запуск Airflow принял строку;
|
||||||
|
- `_load_ts` — когда строка приехала в хранилище.
|
||||||
|
|
||||||
|
Версию выбирает `snapshot_date`: выживает строка с максимальной датой слепка
|
||||||
|
([ADR 0014](../../adr/0014-order-version-snapshot-date.md)). Поэтому
|
||||||
|
`snapshot_date` показывает последнее наблюдение заказа. По строкам последней
|
||||||
|
даты слепка DDS определяет окно пересборки. `_load_id` и `_load_ts` принадлежат
|
||||||
|
той физической строке, которую сохранило слияние, и описывают её доставку.
|
||||||
|
|
||||||
|
Схема версий стоит на дисциплине источника: слепок с поздней датой не
|
||||||
|
показывает состояния старше. Модельный мир её держит — состояние читается на
|
||||||
|
границе суток, и слепок дня выходит чистой функцией зерна и дня; источник,
|
||||||
|
собирающий слепок из отставшей реплики, её ломает. Деловая метка `updated_at`
|
||||||
|
при этом монотонна по слепкам, поэтому выбор по дате слепка даёт тот же ответ,
|
||||||
|
что выбор по метке (замер — «Что проверено»). Тихая правка строки — ручным
|
||||||
|
`UPDATE` мимо приложения, миграцией — метку не двигает, но следующий слепок
|
||||||
|
перекроет её сам, пока заказ в окне изменяемости. За окном источник заказ
|
||||||
|
больше не показывает, и там помогает только сверка самих полей, в терминах
|
||||||
|
dbt snapshots — стратегия `check` вместо `timestamp`.
|
||||||
|
|
||||||
|
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
|
||||||
|
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
|
||||||
|
`ods.order_v` сохраняет те же источник-ориентированные поля без обогащения
|
||||||
|
данными модели, но возвращает одну актуальную версию на `order_id`. Сначала оно
|
||||||
|
может быть простым представлением над `_dist FINAL`; способ выбора можно
|
||||||
|
заменить, не меняя потребителей.
|
||||||
|
|
||||||
|
Это представление остаётся ответственностью ODS: оно скрывает механику чтения
|
||||||
|
версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает,
|
||||||
|
какие сущности, связи и производные признаки ему нужны. Судьба служебных
|
||||||
|
колонок выше ODS решена вместе с моделью DDS: чужие ярлыки туда не переезжают
|
||||||
|
([модель заказа](../dds/order.md)).
|
||||||
|
|
||||||
|
## Одно чтение Kafka
|
||||||
|
|
||||||
|
Стандартный слепок содержит около полутора тысяч строк, тогда как предел одной
|
||||||
|
порции на стенде — десятки тысяч сообщений. Поэтому один запуск Airflow делает
|
||||||
|
один прямой `SELECT`, без цикла до пустоты и без фиксации конечных офсетов.
|
||||||
|
|
||||||
|
Это допущение о размере стенда, а не доказательство полноты слепка. Если Kafka
|
||||||
|
вернёт короткую порцию, непрочитанный хвост останется в топике и приедет в один
|
||||||
|
из следующих запусков. После отказа от замены партиции это задержка, а не потеря
|
||||||
|
или публикация неполного дня.
|
||||||
|
|
||||||
|
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
|
||||||
|
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
|
||||||
|
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
|
||||||
|
Позиция мира при этом не двигается, и тот же день уедет заново — в сырье он
|
||||||
|
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
|
||||||
|
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»).
|
||||||
|
|
||||||
|
## Отклонённые варианты
|
||||||
|
|
||||||
|
- Партиционная идемпотентность — `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-координаты показывают повтор задачи.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94.
|
||||||
|
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение сырья на годные
|
||||||
|
строки и брак полное и непересекающееся; две версии одного заказа легли в одну
|
||||||
|
партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от фонового
|
||||||
|
слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
|
||||||
|
`_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки
|
||||||
|
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
|
||||||
|
опирается, — [документ хранилища](../storage.md), «Что проверено».
|
||||||
|
|
||||||
|
После исправления
|
||||||
|
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
|
||||||
|
честный прогон дня снят на живом стенде 20 августа 2026 года: все 1694 строки
|
||||||
|
слепка дня 7 приняты, `ods.order_errors` осталась пустой. У заказа
|
||||||
|
`20260608-0007`, который прежде приходил с отрицательным итогом, теперь
|
||||||
|
`5490.00 − 823.50 + 299.00 = 4965.50`.
|
||||||
|
|
||||||
|
При исполнении #124 21 августа 2026 года адресная пересборка мира прошла через
|
||||||
|
`SQLExecuteQueryOperator`: `pull_batch` и `parse_batch` завершились успешно. В
|
||||||
|
`system.query_log` на первой ноде осталось ровно по одному завершённому запросу
|
||||||
|
пользователя `etl` для забора в STG, записи годных строк и записи брака.
|
||||||
|
|
||||||
|
Дисциплина источника замерена 22 августа 2026 года при исполнении #137.
|
||||||
|
Во всех 3034 парах строк с равным `updated_at` деловые поля совпали
|
||||||
|
побайтово, а по слепкам метка ни разу не убывала.
|
||||||
|
|
||||||
|
Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
|
||||||
|
исполнении #165. После двух слепков физическая таблица содержала 3356 версий
|
||||||
|
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним
|
||||||
|
слепком заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из
|
||||||
|
сырья. В обеих сверках расхождений нет.
|
||||||
|
|
||||||
|
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
|
||||||
|
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
||||||
|
офсеты коммитятся, а сбой забора не двигает позицию мира. Числа — [ADR
|
||||||
|
0008](../../adr/0008-order-ingestion.md), раздел «Что проверено».
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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).
|
||||||
|
|
||||||
|
> Опись хранит только то, чего движение мира не меняет.
|
||||||
|
> Хеш кладём всегда, счётчик — только когда назван его читатель.
|
||||||
|
|
||||||
|
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
|
||||||
|
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
|
||||||
|
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же
|
||||||
|
байты») опись начинает сторожить.
|
||||||
|
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
|
||||||
|
итоговой судьбе заказов дня. В строке закрытого дня поле `orders` хранит
|
||||||
|
число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных
|
||||||
|
днях закрыты N − 7 (день d закрывается слепком d + 6, а последний
|
||||||
|
отправленный слепок несёт день N − 2). Их читатель — техническая
|
||||||
|
проверка `classes_vs_inventory` из DQ-звена этапа 4.
|
||||||
|
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
|
||||||
|
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
|
||||||
|
не выходит; генератор сторожит хеш, транспорт — счёт событий.
|
||||||
|
- **Опись описывает мир, а не доставку.** Работа генератора кончается на
|
||||||
|
Kafka: дошли ли байты до `ods.order` — вопрос стенда и его
|
||||||
|
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка
|
||||||
|
приёма заказов — число заведётся вместе с ней.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Заказ и его слепок
|
||||||
|
|
||||||
|
Резолюция развилки [«Генератор слепков: где живёт и чем связан с
|
||||||
|
событиями»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/71).
|
||||||
|
|
||||||
|
## Откуда берётся заказ
|
||||||
|
|
||||||
|
Заказ и событие `purchase` — не два порождения, а две проекции одного факта
|
||||||
|
мира. Корзина, цены, купон и номер заказа посчитаны торговой половиной
|
||||||
|
дня-функции; заказная половина берёт заказы дня готовой структурой — вторым
|
||||||
|
выходом `commerce`, — навешивает на них жизнь заказа и собирает слепок. В
|
||||||
|
подпоток `COMMERCE` не добавляется ни одного нового броска: мир не сдвигается,
|
||||||
|
согласованность двух источников не удерживается, а получается по построению.
|
||||||
|
|
||||||
|
Следствия, которые уже решены соседями и здесь только связываются:
|
||||||
|
|
||||||
|
- у всякого заказа изначально ровно одно событие `purchase`; заказ без события
|
||||||
|
в трекере — не отдельная порода, а класс B, и делает его событийная сторона
|
||||||
|
выбрасыванием события после присвоения номера
|
||||||
|
([классы расхождений](fate.md));
|
||||||
|
- обе проекции используют один номер заказа: `order_id` = клиентский
|
||||||
|
`purchaseID`;
|
||||||
|
читаемый номер «день и порядковый номер покупки» описан в
|
||||||
|
[ADR 0017](../../adr/0017-order-numbering.md); нумеруются все
|
||||||
|
покупки, дошедшие до потока дня, — до всяких потерь;
|
||||||
|
- скидка заказа выводится из промокода события по таблице «код → скидка» —
|
||||||
|
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
|
||||||
|
|
||||||
|
В модели строка заказа в базе источника создаётся синхронно с покупкой,
|
||||||
|
поэтому день рождения заказа и день создания строки совпадают.
|
||||||
|
|
||||||
|
**Деньги заказа** складываются целыми копейками: `items_total` — сумма
|
||||||
|
позиций, посчитанная торговой половиной, то же число, что уехало клиентским
|
||||||
|
`purchaseRevenue`, но у заказа с вычеркнутой позицией он меньше на её полную
|
||||||
|
стоимость ([судьба заказа](fate.md)); `discount` — процент промокода от
|
||||||
|
оставшегося `items_total`, округлённый вниз: склад сначала определяет, какие
|
||||||
|
позиции есть в заказе, затем бэкенд считает скидку от итоговой корзины;
|
||||||
|
`delivery` — бросок заказной стороны по таблице целых весов, единственные
|
||||||
|
деньги заказа, которых нет ни в одном событии; `total` = `items_total` −
|
||||||
|
`discount` + `delivery`. Скидка не превышает `items_total`, поэтому итог
|
||||||
|
неотрицателен по построению. Отсюда и правило витрин «деньги считаем по
|
||||||
|
бэкенду»: про скидку и доставку клиент не знает вовсе.
|
||||||
|
|
||||||
|
Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой
|
||||||
|
`ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что
|
||||||
|
это разные источники; *независимая модель бэкенда* (заказ первичен, событие —
|
||||||
|
эхо) — кто купил, решает воронка, а воронка — это трафик, то есть опрокидывание
|
||||||
|
всего генератора; *слепок собирает SQL стенда из событий* — второй источник
|
||||||
|
исчезает вместе с уроком «две версии правды».
|
||||||
|
|
||||||
|
## Слепок и его доставка
|
||||||
|
|
||||||
|
**Запуск.** Третья команда того же пакета — `snapshot --day D [--days N]`,
|
||||||
|
свой приёмник, топик `orders`. Два источника — два запуска: трекер и бэкенд
|
||||||
|
видны глазами как два производителя, каждый со своим топиком. Один прогон с
|
||||||
|
двумя выходами отклонён: экономии он не даёт (со сдвигом отправки окно слепка
|
||||||
|
и сыгранный день не пересекаются вовсе), а правило «приёмник выбирается тем,
|
||||||
|
что для него назвали» ломает. Отклонены также: *отдельный пакет и образ* —
|
||||||
|
библиотека на двоих ради одной команды; *генератор пишет слепок файлом, в
|
||||||
|
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
|
||||||
|
топиком `hits`* — убивает два режима приёма.
|
||||||
|
|
||||||
|
**Сборка окна.** Слепок дня D берет заказы, рожденные в дни D−6…D, а несет из
|
||||||
|
них те, что уже попали в выгрузку: механика опоздания живет в
|
||||||
|
[судьбе заказа](fate.md). Окно собирается переигровкой этих семи дней: заказы
|
||||||
|
дня — производная всей воронки дня, дешёвого пути к ним нет. Цена — семь
|
||||||
|
проигрышей дня (~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)).
|
||||||
|
|
||||||
|
Отрицательные копейки канонический сериализатор отвергает через `ValueError`
|
||||||
|
до сборки записи: [контракт строгого
|
||||||
|
приёма](ingestion.md#граница-строгого-приёма) их не допускает, а появление
|
||||||
|
такого значения означает ошибку арифметики источника.
|
||||||
|
|
||||||
|
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
|
||||||
|
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
|
||||||
|
его даром, а хешу слепка в описи нужен именно названный порядок.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Заказы в стартовом мире
|
||||||
|
|
||||||
|
Резолюция развилки [«Места заказов в стартовом
|
||||||
|
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
||||||
|
Стартовый мир — не полный мир, а мир, остановленный на границе суток 7|8.
|
||||||
|
Генератор играет два источника с разными темпами — поток Метрики и ночную
|
||||||
|
выгрузку магазина; разные темпы дают всё остальное.
|
||||||
|
|
||||||
|
Даг `world_initialize` играет восемь дней одним прогоном
|
||||||
|
`batch --day 0 --days 8`; слепки отправляет второй запуск — команда `snapshot`
|
||||||
|
того же диапазона (два источника — два запуска,
|
||||||
|
[слепок и его доставка](snapshot.md)). Со сдвигом отправки уезжают **слепки
|
||||||
|
дней 0…6**, затем даг `orders_ingest` принимает их в хранилище. Слепок дня 7
|
||||||
|
снят на границе суток и уедет первым же ходом мира. Особого режима у стартового
|
||||||
|
мира нет: правило отправки живёт в одном месте — в проигрывателе; генератору
|
||||||
|
это решение не стоит ничего.
|
||||||
|
|
||||||
|
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
|
||||||
|
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
|
||||||
|
**График выручки заваливается к правому краю** и дозаполняется, пока мир едет.
|
||||||
|
Это не издержка стенда, а главный наблюдаемый эффект второго источника: ночная
|
||||||
|
выгрузка отстаёт, свежие дни предварительны. На свежем стенде лаба сверки
|
||||||
|
видит целый день `awaiting_order` — норма, а не поломка; как это назвать
|
||||||
|
менти — за витринами этапа 4.
|
||||||
|
|
||||||
|
Цена принята с открытыми глазами: у части пар стартового мира поздний
|
||||||
|
назначенный заказ падает на день 7, и до первого хода мира этих пар в
|
||||||
|
`dds.identity_map` нет. Опись пар не считает
|
||||||
|
([что хранит опись](inventory.md)), поэтому красной проверки из этого не
|
||||||
|
выходит. Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на
|
||||||
|
свежем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем
|
||||||
|
ровно там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* —
|
||||||
|
число верно ровно до первого хода мира; *отменить сдвиг отправки* —
|
||||||
|
`awaiting_order` пропал бы навсегда; *подогнать план под горизонт* — мир
|
||||||
|
перестал бы быть чистой функцией зерна.
|
||||||
@@ -0,0 +1,871 @@
|
|||||||
|
# Хранилище: слои и конвенции
|
||||||
|
|
||||||
|
Документ описывает сторону ClickHouse: как называются объекты, какие служебные
|
||||||
|
колонки у них общие, чем нарезаны и сколько живут данные, как устроен приём и из
|
||||||
|
каких файлов собирается DDL. Здесь же карта таблиц, которая растёт с этапами, и
|
||||||
|
раздел «Что проверено» — чему в этом тексте верить и на каком основании.
|
||||||
|
|
||||||
|
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
|
||||||
|
keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка
|
||||||
|
`Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное
|
||||||
|
событие с таблицей ошибок, поверхность актуального состояния и три матвью.
|
||||||
|
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой
|
||||||
|
чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего
|
||||||
|
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью.
|
||||||
|
Тот же этап принёс первый справочник — словарь товаров из общего с генератором
|
||||||
|
CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а
|
||||||
|
подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат
|
||||||
|
публичным договором события `dds.event_v` над точным состоянием ODS, а следом
|
||||||
|
получил две физические модели слоя — заказ и сессию: их собирает заменой
|
||||||
|
партиций даг `dds_transform`. Сессия среди них особая: в потоке такой сущности
|
||||||
|
нет, стенд режет её из событий сам.
|
||||||
|
Дальше по тексту устройство описано так, как оно проектируется; построенное от
|
||||||
|
заложенного отличает карта таблиц в конце.
|
||||||
|
|
||||||
|
Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
|
||||||
|
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
|
||||||
|
события — в [описании выгрузки](../formats/clickstream-event.md). Хранилище
|
||||||
|
строится по описанию выгрузки, как в бою строится по документации источника.
|
||||||
|
Целевая картина всего стенда — [спека «Боевой реализм стенда
|
||||||
|
(v2)»](../specs/2026-07-30-stand-v2-realism.md).
|
||||||
|
|
||||||
|
## Имена объектов
|
||||||
|
|
||||||
|
Имя объекта заканчивается тем, что это за объект:
|
||||||
|
|
||||||
|
| Суффикс | Что это |
|
||||||
|
|---|---|
|
||||||
|
| `_rep` | локальная таблица шарда, движок семейства `Replicated*` |
|
||||||
|
| `_dist` | `Distributed` поверх одноимённой локальной |
|
||||||
|
| `_kafka` | таблица на движке `Kafka` |
|
||||||
|
| `_file` | таблица на движке `File` — чтец файла |
|
||||||
|
| `_mv` | материализованное представление |
|
||||||
|
| `_v` | обычное представление, публичная поверхность чтения |
|
||||||
|
|
||||||
|
Суффикс носит каждый физический объект. Голого имени у таблицы не существует:
|
||||||
|
запрос к `stg.hits_raw` даёт громкую ошибку «нет такой таблицы» — а под голым
|
||||||
|
именем в документах и разговоре понимается сущность, у которой этих объектов
|
||||||
|
несколько. Единственное исключение — словари: у них воплощение одно, шардировать
|
||||||
|
нечего, и суффикс ничего не различал бы. В зоне `dic` обе формы стоят рядом и
|
||||||
|
правило видно целиком: `dic.products_file` — чтец файла, `dic.products` — сам
|
||||||
|
словарь.
|
||||||
|
|
||||||
|
Распространённая конвенция, где голое имя означает локальную таблицу, а
|
||||||
|
распределённая получает суффикс `_all`, ошибается иначе: забытый суффикс тихо
|
||||||
|
возвращает данные одного шарда. Правило спеки «проверки и контрольные суммы —
|
||||||
|
только по `Distributed`» такую тишину переживает плохо.
|
||||||
|
|
||||||
|
Второе свойство — в списке по алфавиту объекты группируются по сущности, а не по
|
||||||
|
технологии: все четыре объекта топика `hits` стоят рядом, потому что различаются
|
||||||
|
хвостом, а не началом имени. Речь про дерево в клиенте и про `ORDER BY name`:
|
||||||
|
порядок выдачи `SHOW TABLES` документация не оговаривает.
|
||||||
|
|
||||||
|
Начало имени — сущность, и берётся она в разных слоях из разных мест. В STG имя
|
||||||
|
приходит от транспорта: слой хранит то, что доехало по топику, и зовётся именем
|
||||||
|
топика — `hits_raw` от `hits`, `orders_raw` от `orders`. В типизированных слоях
|
||||||
|
имя приходит от предметной области и стоит в единственном числе: `event`,
|
||||||
|
`order`, `session`. Граница между «как привезли» и «что это такое»
|
||||||
|
проходит по STG, и имена её показывают.
|
||||||
|
|
||||||
|
В DDS публичный договор сущности заканчивается на `_v`. Потребительские запросы
|
||||||
|
и витрины называют только представление; `_rep` и `_dist` служат загрузке,
|
||||||
|
диагностике и обслуживанию. Представление перечисляет колонки явно, даже если
|
||||||
|
его запрос — простая проекция над `_dist`: договор не выдаёт устройство
|
||||||
|
кластера и случайно добавленные в физическую таблицу колонки. У события
|
||||||
|
физической копии в DDS нет, но правило то же: `dds.event_v` — его публичный
|
||||||
|
договор.
|
||||||
|
|
||||||
|
Право доступа здесь намеренно шире договора. Роль `bi_reader` может читать
|
||||||
|
любые объекты ODS, DDS и DM, но физические объекты от этого не становятся
|
||||||
|
публичной поверхностью. Конвенцию держат явный список колонок, документация и
|
||||||
|
ревью, а не запрет на физические таблицы.
|
||||||
|
|
||||||
|
Широкое право принято в [ADR 0007](../adr/0007-clickhouse-access.md): узкая
|
||||||
|
граница поверх `Distributed` потребовала бы отдельной механики. Механизм
|
||||||
|
ClickHouse и отклонённые варианты записаны в
|
||||||
|
[исследовании](../research/2026-08-25-clickhouse-distributed-view-security.md).
|
||||||
|
|
||||||
|
## Раскладка по шардам
|
||||||
|
|
||||||
|
Пишем только в `_dist`. Локальные таблицы остаются для чтения и обслуживания —
|
||||||
|
операций с партициями, ручной переобработки. Правило не про удобство: при записи
|
||||||
|
через распределённую таблицу раскладку определяет ключ шардирования, то есть
|
||||||
|
свойство данных, а при записи в локальную — то, какая нода случайно выполняла
|
||||||
|
код. Отсюда урок стенда: какая нода читала топик, меняется между прогонами
|
||||||
|
(видно в колонке `consumer_host`), а куда легли данные — нет.
|
||||||
|
|
||||||
|
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
|
||||||
|
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
|
||||||
|
выбран против перекоса: структурированный числовой идентификатор распределяется
|
||||||
|
по остатку от деления неравномерно. У сырья выбора нет — строку иначе не
|
||||||
|
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
|
||||||
|
шард.
|
||||||
|
|
||||||
|
Ключи у слоёв разные, и это имеет наблюдаемое следствие: сырая строка и
|
||||||
|
разобранное из неё событие почти всегда оказываются на разных шардах.
|
||||||
|
Пошардовые счётчики STG и ODS поэтому не сходятся и сходиться не должны —
|
||||||
|
сверять слои можно только через `_dist`.
|
||||||
|
|
||||||
|
Разные ключи у слоёв ставят капкан на вставке `INSERT INTO цель_dist SELECT …
|
||||||
|
FROM источник_dist`. Заводское значение `parallel_distributed_insert_select` —
|
||||||
|
2: если обе таблицы на одном кластере, вставка исполняется локально на каждом
|
||||||
|
шарде, минуя ключ шардирования цели. Строки наследуют раскладку источника, и
|
||||||
|
ошибки при этом нет — расхождение видно только пересчётом ключей по
|
||||||
|
`hostName()`. Так половина заказов и легла на оба шарда, где
|
||||||
|
`ReplacingMergeTree` через границу шарда дубли не схлопывает (замер — «Что
|
||||||
|
проверено», #137). Лекарство — явное `parallel_distributed_insert_select = 0`
|
||||||
|
в `SETTINGS` такой вставки: раскладку делает инициатор, по ключу цели
|
||||||
|
(`sql/ods/order_parse.sql`). Капкан ждёт и сборки DDS: они тоже будут читать
|
||||||
|
`_dist` и писать в `_dist` с другим ключом.
|
||||||
|
|
||||||
|
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из
|
||||||
|
раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё
|
||||||
|
прочее — через `GLOBAL`. Значит `dds.session_dist` и `dds.identity_map_dist`
|
||||||
|
шардируются по `cityHash64(client_id)`. Объекты DDS с зерном заказа сохраняют
|
||||||
|
ко-локацию по `cityHash64(order_id)`; ключи остальных частей модели и витрин
|
||||||
|
появляются вместе с ними.
|
||||||
|
|
||||||
|
Замена партиций не используется для `ods.order`: версии прошлых дней
|
||||||
|
доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR
|
||||||
|
0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка
|
||||||
|
понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее
|
||||||
|
разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким
|
||||||
|
объектом, а не переносить на ODS заранее.
|
||||||
|
|
||||||
|
Первым такой донор понадобился заказу DDS, и правило на нём видно целиком:
|
||||||
|
`dds.order_stage_rep` объявлен через `AS dds.order_rep` и шардируется тем же
|
||||||
|
`cityHash64(order_id)`. Совпадение структуры требует сама операция, а совпадение
|
||||||
|
ключа — устройство замены: она идёт по локальным таблицам, шард за шардом, и
|
||||||
|
строка, попавшая в доноре не на свой шард, при подмене пропала бы с одного шарда
|
||||||
|
и задвоилась на другом. Доводы целиком — в
|
||||||
|
[`sql/ddl/45-dds-tables.sql`](../../sql/ddl/45-dds-tables.sql).
|
||||||
|
|
||||||
|
## Служебные колонки
|
||||||
|
|
||||||
|
Собственные колонки не повторяют имён виртуальных. Виртуальные даёт движок:
|
||||||
|
`_topic`, `_partition`, `_offset`, `_timestamp` у Kafka, `_shard_num` у
|
||||||
|
`Distributed` и прочие. Если положить на диск колонку с таким же именем, в
|
||||||
|
матвью перестанет читаться, что дано движком, а что положено нами, — а это
|
||||||
|
ровно то различие, ради которого метаданные доставки и хранятся. Поэтому они
|
||||||
|
ложатся под именами `kafka_topic`, `kafka_partition`, `kafka_offset`,
|
||||||
|
`kafka_timestamp`, рядом — `consumer_host`, имя читавшей ноды: виртуальные
|
||||||
|
колонки его не несут, а после записи в `Distributed` он уже невосстановим.
|
||||||
|
|
||||||
|
Типы у них такие: `kafka_topic` и `consumer_host` — `LowCardinality(String)`,
|
||||||
|
значений мало и они повторяются; `kafka_partition` и `kafka_offset` — `UInt64`.
|
||||||
|
С `kafka_timestamp` сложнее, и форма его решена на стенде. Меток времени движок
|
||||||
|
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и
|
||||||
|
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
|
||||||
|
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
|
||||||
|
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а слой сырья
|
||||||
|
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
|
||||||
|
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы
|
||||||
|
либо падение приёма на первом сообщении, либо тихие нули за 1970 год.
|
||||||
|
|
||||||
|
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
|
||||||
|
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
|
||||||
|
на шарде-получателе, то есть назвал бы не ту ноду, которая читала топик, —
|
||||||
|
колонка молча отвечала бы на другой вопрос.
|
||||||
|
|
||||||
|
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
|
||||||
|
не проверяет, поэтому там оказываются и целые сообщения, и мусор. События ниже
|
||||||
|
разбирают матвью 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,
|
||||||
|
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и
|
||||||
|
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради
|
||||||
|
которого он заведён: повтор доставки в сырье обязан быть виден.
|
||||||
|
|
||||||
|
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
|
||||||
|
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
|
||||||
|
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
|
||||||
|
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
|
||||||
|
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
|
||||||
|
повтор доставки. У `ods.order` версию задаёт `snapshot_date` — дата слепка
|
||||||
|
источника; `_load_ts` только показывает, когда конкретная строка приехала.
|
||||||
|
|
||||||
|
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
|
||||||
|
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
|
||||||
|
`DateTime64(3, 'UTC')`; ClickHouse их не создаёт и добавляет рядом собственную
|
||||||
|
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
|
||||||
|
|
||||||
|
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
|
||||||
|
Airflow. Переделать разобранное руками можно вставкой из сырья с фильтром по
|
||||||
|
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
|
||||||
|
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
|
||||||
|
`_load_id`; дневные партиции ODS он не заменяет.
|
||||||
|
|
||||||
|
Имя согласовано с каноном служебных полей соседнего учебного стенда на
|
||||||
|
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
|
||||||
|
технических колонок — распространённая запись,
|
||||||
|
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
|
||||||
|
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
|
||||||
|
|
||||||
|
Общего идентификатора пачки загрузки (`_load_id`) нет. У потока событий нет ни
|
||||||
|
пачки, ни `run_id`, и колонка была бы пустой формальностью. У заказов читатель
|
||||||
|
назван: `_load_id` равен `run_id` Airflow и переносится из STG в годную строку
|
||||||
|
ODS и в таблицу ошибок. Нужен ли он выше ODS, решается вместе с моделью DDS.
|
||||||
|
|
||||||
|
## Актуальные события
|
||||||
|
|
||||||
|
`ods.event_rep` хранит физически принятые версии события в
|
||||||
|
`ReplacingMergeTree(_load_ts)`, а `ods.event_dist` служит для записи и
|
||||||
|
диагностики доставки. Обычное чтение этой пары может показать несколько строк
|
||||||
|
одного `WatchID`, пока фоновые слияния их не схлопнули.
|
||||||
|
|
||||||
|
`ods.event_v` — обычное представление над `ods.event_dist FINAL`. Оно сохраняет
|
||||||
|
имена, типы и зерно события, но всегда возвращает актуальную версию. Актуальное
|
||||||
|
состояние читают через него, а не повторяют `FINAL` в каждом запросе.
|
||||||
|
`dds.event_v` отвечает за другое: даёт событию язык бизнес-модели, в том
|
||||||
|
числе snake_case-имена и расшифровку кодов.
|
||||||
|
|
||||||
|
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
|
||||||
|
исключение: источник объявляет `Array(Float64)`, а материализованное
|
||||||
|
представление ODS разбирает числа сырого JSON сразу в
|
||||||
|
`Array(Decimal(18, 2))`. DDS наследует этот тип без нового преобразования.
|
||||||
|
Прямой разбор подтвержден документацией ClickHouse через Context7 и запросом
|
||||||
|
к ClickHouse 26.3; сравнение с сырьем подтвердило: копейки всех покупок целы.
|
||||||
|
|
||||||
|
## Версии заказов
|
||||||
|
|
||||||
|
`ods.order_rep` хранит принятые версии в
|
||||||
|
`ReplacingMergeTree(snapshot_date)`: ключ сортировки — `order_id`, партиция —
|
||||||
|
`toDate(created_at)`. `ods.order_dist` шардирует по
|
||||||
|
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
|
||||||
|
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
|
||||||
|
выбрал одного победителя.
|
||||||
|
|
||||||
|
Физическая пара нужна для загрузки и диагностики. Обычное чтение показывает
|
||||||
|
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
|
||||||
|
`ods.order_v` служит поверхностью точного текущего состояния для следующих
|
||||||
|
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
|
||||||
|
DDS. `snapshot_date` в нём одновременно дата наблюдения и колонка версии. Она
|
||||||
|
не доказывает полноту принятой порции, поэтому ODS не публикует партиции по
|
||||||
|
дате слепка. Модель версионного ODS записана в
|
||||||
|
[ADR 0010](../adr/0010-order-versions-in-ods.md), выбор колонки версии описан в
|
||||||
|
[ADR 0014](../adr/0014-order-version-snapshot-date.md), пакетный приём описан в
|
||||||
|
[спецификации приёма заказов](orders/ingestion.md). Контракт следующего слоя
|
||||||
|
задан в [модели заказа в DDS](dds/order.md).
|
||||||
|
|
||||||
|
## Часовые пояса
|
||||||
|
|
||||||
|
Пояс — линза, а не свойство значения. `DateTime` хранит одно число, секунды от
|
||||||
|
начала эпохи; пояс решает лишь, какие часы по этому числу покажут время и в
|
||||||
|
какие сутки оно попадёт.
|
||||||
|
|
||||||
|
**Линза называется явно — в типе колонки либо в вызове функции.** Третий
|
||||||
|
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там,
|
||||||
|
где линза что-то решает — в объявлении хранимой колонки и в выражении,
|
||||||
|
считающем дату, — его не остаётся. Прописать этот пояс своей рукой —
|
||||||
|
`<timezone>` в конфигурации ноды или `TZ` контейнеру — было бы той же болезнью
|
||||||
|
с другим умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах
|
||||||
|
и в разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
|
||||||
|
поменяв его. Правило стоит на источнике пояса, а не на функции:
|
||||||
|
`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и
|
||||||
|
работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя
|
||||||
|
пишется тогда, когда нужна другая линза, чем у колонки:
|
||||||
|
`toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика».
|
||||||
|
|
||||||
|
**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок
|
||||||
|
выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна,
|
||||||
|
значит `DateTime('UTC')`; служебные метки `_load_ts` и `kafka_timestamp`
|
||||||
|
абсолютны тоже — `DateTime64(3, 'UTC')`. DDS и витрины обслуживают человека с
|
||||||
|
дашбордом, поэтому время там лежит местным, в поясе счётчика (`Europe/Samara`,
|
||||||
|
UTC+4), и пересчёт идёт один раз при наполнении слоя: автор отчёта пояса не
|
||||||
|
пишет, он берёт готовую колонку. `Date` хранит только номер дня — чьи это
|
||||||
|
сутки, в нём не записано. Поэтому время по `EventDate` считают, назвав пояс
|
||||||
|
руками; промах виден сразу: часы суток уезжают за границы 0–23.
|
||||||
|
|
||||||
|
Объявить местным и ODS — `DateTime('Europe/Samara')` — соблазнительно: байты те
|
||||||
|
же, меняется одна линза, и `toDate(UTCEventTime)` начинает совпадать с
|
||||||
|
`EventDate` всегда. Отвергнуто потому, что колонка зовётся `UTCEventTime` и в
|
||||||
|
настоящей выгрузке Метрики она в UTC, а стёртое расхождение — тот самый урок,
|
||||||
|
ради которого мир сделан с поясом счётчика.
|
||||||
|
|
||||||
|
**Линза выбирается дважды, и второй раз упустить легко.** На чтении — показать
|
||||||
|
метку или свести её к дате. На записи — превратить строку в число: суффикс `Z`
|
||||||
|
на проводе зоны не даёт, маска разбора съедает его буквой, и
|
||||||
|
`parseDateTimeOrNull` без третьего аргумента трактует показания часов по поясу
|
||||||
|
сессии, а тот по умолчанию серверный. Тип колонки тут не помогает: разбор
|
||||||
|
отдаёт готовое число, и колонка кладёт его как есть — пояс приёмника решал бы
|
||||||
|
судьбу строки, а не числа. Механика и выбор функции — [ADR
|
||||||
|
0005](../adr/0005-event-ingestion.md).
|
||||||
|
|
||||||
|
**День берётся из `EventDate`.** Дата в поясе счётчика уже посчитана
|
||||||
|
генератором и лежит колонкой, так что суточные срезы группируются по ней и
|
||||||
|
пояса не упоминают вовсе. Почему у ночных событий `toDate(UTCEventTime)` с ней
|
||||||
|
расходится, объясняет [ADR 0015](../adr/0015-counter-timezone.md).
|
||||||
|
|
||||||
|
## Путь реплицированных таблиц в keeper
|
||||||
|
|
||||||
|
Шаблон — `/clickhouse/tables/{shard}/{database}/{table}`. База в пути
|
||||||
|
обязательна: без неё одноимённые таблицы разных слоёв получат один и тот же узел
|
||||||
|
в keeper и подерутся. Макрос `{uuid}` не используем, хотя он тоже развёл бы
|
||||||
|
пути: он завязан на движок базы `Atomic` и делает путь нечитаемым, а на учебном
|
||||||
|
стенде возможность открыть `system.zookeeper` и увидеть осмысленный путь — сама
|
||||||
|
по себе половина урока про то, чем занят keeper.
|
||||||
|
|
||||||
|
## Приём: поток и его свойства
|
||||||
|
|
||||||
|
Раздел — про события. У заказов приём устроен иначе: слепок забирает по команде
|
||||||
|
даг `orders_ingest`, а не подписанная навсегда матвью — [ADR
|
||||||
|
0008](../adr/0008-order-ingestion.md) и [спецификация приёма
|
||||||
|
заказов](orders/ingestion.md).
|
||||||
|
|
||||||
|
Цепочка одна: чтец топика → матвью → сырьё STG → матвью разбора → событие и
|
||||||
|
таблица ошибок ODS.
|
||||||
|
|
||||||
|
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
|
||||||
|
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
|
||||||
|
`ON CLUSTER` и макросов в имени не содержит. Разные группы дали бы каждой ноде
|
||||||
|
полную копию топика, и это отдельная сцена для лабы, а не рабочий режим. Имя
|
||||||
|
кластера в `ON CLUSTER` и в движке `Distributed` — `clickstream_cluster`, оно
|
||||||
|
задано в `infra/clickhouse/config.d/cluster.xml`.
|
||||||
|
|
||||||
|
**Источник матвью разбора — `stg.hits_raw_dist`, а не локальная таблица.**
|
||||||
|
У матвью две привязки: источник, на вставку в который она срабатывает, и цель,
|
||||||
|
куда пишет. Распределённая таблица — лицо слоя, локальная — его хранилище;
|
||||||
|
потребитель слоя цепляется к лицу. Практически это значит, что разбор идёт на
|
||||||
|
той же ноде, что читала Kafka, в момент вставки первой матвью — до раскладки по
|
||||||
|
шардам.
|
||||||
|
|
||||||
|
**Вставка фоновая, и окно потери мы принимаем.** Вставка в распределённую
|
||||||
|
таблицу кладёт блок в локальный спул и сразу возвращает управление, а Kafka
|
||||||
|
коммитит офсеты по факту работы матвью — то есть по факту записи в спул. Топик
|
||||||
|
уже считает сообщение прочитанным, хотя на шарде его ещё нет: умри нода в этом
|
||||||
|
промежутке — сообщения не перечитаются.
|
||||||
|
|
||||||
|
Закрывает окно настройка `distributed_foreground_insert = 1`, и на ETL-вставках
|
||||||
|
Airflow она стоит — там это обычный `SETTINGS` у запроса. На пути приёма её нет,
|
||||||
|
и по трём причинам. Вставку выполняет фоновый поток Kafka-движка, своего запроса
|
||||||
|
у него не бывает, так что настройка уровня запроса доехала бы только профилем
|
||||||
|
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
|
||||||
|
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
|
||||||
|
как при фоновом первая нода продолжает принимать и копит спул для соседа.
|
||||||
|
Платится при этом не одно ожидание на блок, а три распределённые вставки — сырьё,
|
||||||
|
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
|
||||||
|
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
|
||||||
|
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
|
||||||
|
компромисс полезнее показать, чем спрятать за галочкой.
|
||||||
|
|
||||||
|
Ещё одно место, где сырьё хранит не всё приехавшее: запись с пустым значением и
|
||||||
|
запись-надгробие проходят молча, не оставляя строки. Свойство измерено и принято
|
||||||
|
осознанно — подробности в разделе «Что проверено».
|
||||||
|
|
||||||
|
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
|
||||||
|
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
|
||||||
|
противоположное: нода умерла после записи на шард, но до коммита офсетов, и при
|
||||||
|
перечитывании сообщение приедет второй раз. Сказать про такой приём «хотя бы
|
||||||
|
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
|
||||||
|
|
||||||
|
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
|
||||||
|
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня
|
||||||
|
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения.
|
||||||
|
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к
|
||||||
|
сырому слою не относится.
|
||||||
|
|
||||||
|
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
|
||||||
|
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
|
||||||
|
Удвоение сырья проходит мимо неё только потому, что при повторном чтении
|
||||||
|
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
|
||||||
|
отсутствии механизма.
|
||||||
|
|
||||||
|
**Матвью разбора две, и их условия обязаны делить поток без зазора и без
|
||||||
|
нахлёста.** Одна забирает годные строки в `ods.event_dist`, вторая — брак в
|
||||||
|
`ods.event_errors_dist`. Строка, подошедшая обеим, задвоится; не подошедшая ни
|
||||||
|
одной — исчезнет молча. Держится это формой: второе условие пишется буквальным
|
||||||
|
отрицанием первого, а сам предикат NULL не возвращает ни в одной своей части, —
|
||||||
|
иначе трёхзначная логика даст строку, которую не возьмёт ни `условие`, ни
|
||||||
|
`NOT условие`. Обнуляемый разбор в предикате поэтому есть, но заканчивается
|
||||||
|
`IS NOT NULL`, а сравнения дают 0 или 1. Функции предиката не должны и бросать
|
||||||
|
исключений: упавшая матвью
|
||||||
|
роняет вставку и останавливает потребление до починки
|
||||||
|
([ADR 0005](../adr/0005-event-ingestion.md)).
|
||||||
|
|
||||||
|
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырья
|
||||||
|
срок жизни трое суток, а ODS хранит всё, поэтому равенство «сырьё = события +
|
||||||
|
ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня
|
||||||
|
задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не
|
||||||
|
смотреть на оповещения
|
||||||
|
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым
|
||||||
|
опытом при исполнении #43, на управляемой пачке: отправили N сообщений —
|
||||||
|
получили N строк сырья и N в сумме событий и ошибок. Постоянной целью такой
|
||||||
|
опыт не становится, и почему — в [карте
|
||||||
|
проверок](testing.md), раздел «Интеграционная проверка постоянной целью не
|
||||||
|
становится». Счёт актуальных событий идёт через `ods.event_v`, которое скрывает
|
||||||
|
`FINAL`: голый `count()` по `ReplacingMergeTree` зависит от того, сколько
|
||||||
|
мержей успело пройти, и спека это прямо запрещает (раздел 6).
|
||||||
|
|
||||||
|
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
|
||||||
|
против [описи мира](../../data/world-inventory.json), лежащей в git
|
||||||
|
(`make check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в
|
||||||
|
этом вся разница: опись описывает восемь дней стартового мира, счёт обрамлён
|
||||||
|
их датами, повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не
|
||||||
|
помеха — ODS хранит всё. Обе стороны равенства зафиксированы: одна кодом
|
||||||
|
генератора, другая файлом в git. У межслойной сверки такой опоры нет ни с
|
||||||
|
одной стороны.
|
||||||
|
|
||||||
|
## Срок жизни сырья
|
||||||
|
|
||||||
|
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно
|
||||||
|
отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты,
|
||||||
|
после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же
|
||||||
|
колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). В DDL
|
||||||
|
значение проставлено явно, чтобы поведение не зависело от умолчания версии.
|
||||||
|
|
||||||
|
Нарезать сырьё по модельному дню события было бы соблазнительно — он единица
|
||||||
|
переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
|
||||||
|
снимается целиком, только когда в нём истекли все строки, а в партиции модельного
|
||||||
|
дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста,
|
||||||
|
самая свежая строка удержит весь кусок, и данные переживут срок неограниченно.
|
||||||
|
|
||||||
|
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
|
||||||
|
возраста — но не более чем на сутки, потому что партицию закрывает календарный
|
||||||
|
день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно
|
||||||
|
трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что
|
||||||
|
«уходит само» здесь обещано, а «уходит вовремя» — нет.
|
||||||
|
|
||||||
|
Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а
|
||||||
|
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё
|
||||||
|
режется своими координатами: и разбор полётов, и переобработка фильтруют по
|
||||||
|
`_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
|
||||||
|
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
|
||||||
|
|
||||||
|
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
|
||||||
|
D0 и к реальному календарю не привязана; пакетный режим проигрывает две недели
|
||||||
|
модельного мира за минуты реальных. Поэтому у переобработки и у гигиены диска
|
||||||
|
разные часы, и обслуживают их разные средства. Декларативный TTL по модельной
|
||||||
|
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
|
||||||
|
эталонные дни прямо на входе.
|
||||||
|
|
||||||
|
В бою слой сырья иногда собирают на движке `Null` — тогда он не хранится вовсе.
|
||||||
|
Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не
|
||||||
|
ноль.
|
||||||
|
|
||||||
|
## Таблица ошибок
|
||||||
|
|
||||||
|
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
|
||||||
|
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у
|
||||||
|
брака нет разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у
|
||||||
|
строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и
|
||||||
|
сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним,
|
||||||
|
разбираться к моменту разбирательства будет уже нечем. Снятие — целыми кусками
|
||||||
|
(`ttl_only_drop_parts`), как у сырья и по той же причине: строки в партиции дня
|
||||||
|
загрузки разного возраста не более чем на сутки, и куску незачем переживать
|
||||||
|
срок из-за самой свежей строки.
|
||||||
|
|
||||||
|
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
|
||||||
|
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
|
||||||
|
качества не на что опереться. Сами классы, их порядок и довод, почему порядок
|
||||||
|
обязателен, — в [ADR 0005](../adr/0005-event-ingestion.md).
|
||||||
|
|
||||||
|
Движок — обычный `ReplicatedMergeTree`, без замены версий: схлопывать брак не по
|
||||||
|
чему, у него нет ключа сущности. `ORDER BY` — `(error_class, kafka_partition,
|
||||||
|
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
|
||||||
|
доставки.
|
||||||
|
|
||||||
|
`ods.order_errors` держит тот же диагностический минимум и `_load_id`
|
||||||
|
запуска. У заказов три класса по приоритету: `not_an_object`,
|
||||||
|
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
|
||||||
|
разрастается до имени отдельного поля. Точная граница приёма — в
|
||||||
|
[спецификации заказов](orders/ingestion.md).
|
||||||
|
|
||||||
|
## Справочники
|
||||||
|
|
||||||
|
Справочные данные живут в базе `dic` — вне цепочки STG → ODS → DDS → DM. В
|
||||||
|
хранилище их никто не производит: содержимое приходит из файла репозитория, а
|
||||||
|
читают его несколько слоёв сразу. Отсюда правило принадлежности: читать зону
|
||||||
|
вправе любой слой, писать — никто. Почему зона, а не прописка в слое модели, и
|
||||||
|
почему словарь читает подложку, а не файл, — [ADR
|
||||||
|
0012](../adr/0012-dictionary-home.md).
|
||||||
|
|
||||||
|
Первый и пока единственный справочник — каталог товаров: подложка
|
||||||
|
`dic.products_file` над CSV репозитория и словарь `dic.products` поверх неё.
|
||||||
|
Распределённых пар у зоны нет: обе ноды читают свой смонтированный файл и
|
||||||
|
держат свою копию словаря в памяти. Словарь обновляется сам, окном `LIFETIME`,
|
||||||
|
и потому после правки каталога ноды какое-то время отвечают по-разному.
|
||||||
|
Разогнать его раньше срока — `SYSTEM RELOAD DICTIONARY ON CLUSTER`; это
|
||||||
|
административная операция, роль `etl` права на неё не получает.
|
||||||
|
|
||||||
|
Деньги каталога живут в трёх единицах: в CSV — целые копейки, словарь отдаёт
|
||||||
|
`Decimal(18, 2)`, `productPrice` события — целые рубли. Поэтому точную сумму
|
||||||
|
заказа берут из `purchaseRevenue`, а не складывают округленные цены товаров
|
||||||
|
([описание выгрузки](../formats/clickstream-event.md)).
|
||||||
|
|
||||||
|
Устройство обоих объектов — почему такой движок, такая форма пути, такой
|
||||||
|
пользователь и такое окно — расписано в
|
||||||
|
[`sql/ddl/05-dic-catalog.sql`](../../sql/ddl/05-dic-catalog.sql).
|
||||||
|
|
||||||
|
## Раскладка SQL
|
||||||
|
|
||||||
|
Исполняемые дагами запросы лежат по правилу
|
||||||
|
`sql/<слой>/<объект>_<роль>.sql`. Слой — слой цели запроса: забор заказов в
|
||||||
|
сырьё живёт в `sql/stg/orders_raw_load.sql`, разбор — в
|
||||||
|
`sql/ods/order_parse.sql`. Даг называет файлы, передаёт параметры и задаёт
|
||||||
|
порядок выполнения, но не хранит текст преобразований. Два выражения разбора
|
||||||
|
делят локальный макрос Jinja в том же файле; подключаемого SQL-фрагмента между
|
||||||
|
ними нет.
|
||||||
|
|
||||||
|
SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода
|
||||||
|
обработчик DAG читает список DDL-файлов, загружает содержимое каждого
|
||||||
|
названного SQL-файла и кладёт его в сериализованный граф. При исполнении Airflow
|
||||||
|
разворачивает в этом тексте Jinja; параметры ClickHouse передаются отдельно и
|
||||||
|
не подставляются строкой. Каталог `sql/` смонтирован во все службы Airflow
|
||||||
|
только для чтения.
|
||||||
|
|
||||||
|
### DDL
|
||||||
|
|
||||||
|
Файлы лежат в `sql/ddl/` и применяются по порядку имён: источник и цель
|
||||||
|
создаются до представления или матвью, которые их используют.
|
||||||
|
|
||||||
|
| Файл | Что в нём |
|
||||||
|
|---|---|
|
||||||
|
| `00-databases.sql` | базы слоёв и зона справочников |
|
||||||
|
| `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё |
|
||||||
|
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников |
|
||||||
|
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
|
||||||
|
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
|
||||||
|
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
|
||||||
|
| `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций |
|
||||||
|
| `50-dds-views.sql` | публичные договоры сущностей DDS |
|
||||||
|
| `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций |
|
||||||
|
| `60-dm-views.sql` | публичные договоры витрин DM |
|
||||||
|
|
||||||
|
Справочники идут сразу за базами: зона `dic` не зависит ни от одного слоя, и
|
||||||
|
правила порядка слоёв к ней не применяются.
|
||||||
|
|
||||||
|
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
|
||||||
|
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому
|
||||||
|
что наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся
|
||||||
|
матвью приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает
|
||||||
|
читать топик, когда к нему привязывают первую матвью; создай её раньше
|
||||||
|
разбора — и всё, что доедет в зазоре, ляжет в сырьё и не попадёт в ODS
|
||||||
|
никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден,
|
||||||
|
поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse
|
||||||
|
снесены, а данные Kafka целы, — то есть на обычной отладке.
|
||||||
|
|
||||||
|
Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
|
||||||
|
`lifecycle`: один файл — одна последовательная задача
|
||||||
|
`SQLExecuteQueryOperator`, а выражения внутри разделяет сам оператор. Всё
|
||||||
|
`ON CLUSTER`, кроме чтеца заказов — он объявлен только на этой ноде. Перед DDL
|
||||||
|
даг создаёт `hits` с двумя партициями и `orders` с одной
|
||||||
|
([ADR 0008](../adr/0008-order-ingestion.md)). Этот порядок не даёт `hits`
|
||||||
|
автоматически создаться с одной партицией. Брокер автосоздание разрешает, а
|
||||||
|
потребитель librdkafka внутри ClickHouse его не просит. Оба конца измерены;
|
||||||
|
результаты приведены в разделе «Что проверено». Успех дага означает больше,
|
||||||
|
чем применённый DDL: оба стартовых
|
||||||
|
источника приняты, а позиция мира установлена последней задачей. Полный граф и
|
||||||
|
состояния отказа описаны в [спеке жизненного цикла](../specs/2026-08-21-world-lifecycle.md).
|
||||||
|
|
||||||
|
Повторный `make up` поверх готового мира проходит зелёным без изменений:
|
||||||
|
`world_initialize` ветвится раньше DDL. Поэтому изменённый объект обычным
|
||||||
|
подъёмом не применяется. В разработке меняют или пересоздают только затронутый
|
||||||
|
объект, партицию либо базу слоя. Если нужен целиком канонический мир,
|
||||||
|
`make rebuild-storage` запускает `world_recreate`: тот удаляет прикладные базы
|
||||||
|
`stg`, `dm`, `dds`, `ods`, `dic`, позиции групп потребителей и прикладные топики,
|
||||||
|
затем вызывает `world_initialize`. Службы и их тома остаются на месте, а позиция
|
||||||
|
возвращается к концу стартовых дней. Полный `make clean` для изменения DDL не
|
||||||
|
нужен. Когда какая область очистки уместна перед проверкой, задаёт
|
||||||
|
[карта проверок](testing.md).
|
||||||
|
|
||||||
|
## Карта таблиц
|
||||||
|
|
||||||
|
Ниже — объекты, уже построенные этапами 2–4; всё перечисленное лежит в
|
||||||
|
`sql/ddl/`.
|
||||||
|
|
||||||
|
| База | Объект | Что это |
|
||||||
|
|---|---|---|
|
||||||
|
| `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
|
||||||
|
| `stg` | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки |
|
||||||
|
| `stg` | `stg.hits_raw_mv` | наполняет сырьё из чтеца |
|
||||||
|
| `stg` | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью |
|
||||||
|
| `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` |
|
||||||
|
| `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие |
|
||||||
|
| `ods` | `ods.event_v` | актуальная версия события с полями источника |
|
||||||
|
| `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём |
|
||||||
|
| `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки |
|
||||||
|
| `ods` | `ods.order_rep` / `_dist` | типизированные версии заказа |
|
||||||
|
| `ods` | `ods.order_v` | текущая версия заказа на языке источника |
|
||||||
|
| `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
|
||||||
|
| `dds` | `dds.event_v` | событие на языке бизнес-модели без физической копии |
|
||||||
|
| `dds` | `dds.order_rep` / `_dist` | заказ в текущем состоянии, партиция — день заказа |
|
||||||
|
| `dds` | `dds.order_stage_rep` / `_dist` | двойник заказа: в нём день собирается перед заменой партиции |
|
||||||
|
| `dds` | `dds.order_v` | публичный договор заказа |
|
||||||
|
| `dds` | `dds.session_rep` / `_dist` | визит, нарезанный слоем; партиция — день сессии |
|
||||||
|
| `dds` | `dds.session_stage_rep` / `_dist` | двойник сессии: в нём день собирается перед заменой партиции |
|
||||||
|
| `dds` | `dds.session_v` | публичный договор сессии |
|
||||||
|
| `dds` | `dds.identity_map_rep` / `_dist` | известная пара куки с пользователем; таблицы полного пересчёта |
|
||||||
|
| `dds` | `dds.identity_map_v` | точный публичный договор карты идентичностей с `FINAL` |
|
||||||
|
| `dm` | `dm.revenue_daily_rep` / `_dist` | дневная выручка по категориям |
|
||||||
|
| `dm` | `dm.revenue_daily_stage_rep` / `_dist` | таблица-двойник, донор дневных партиций выручки |
|
||||||
|
| `dm` | `dm.revenue_daily_v` | публичный договор дневной выручки |
|
||||||
|
| `dm` | `dm.purchase_vs_orders_rep` / `_dist` | сверка покупок с заказами, партиция — день заказа |
|
||||||
|
| `dm` | `dm.purchase_vs_orders_stage_rep` / `_dist` | донор дневных партиций сверки |
|
||||||
|
| `dm` | `dm.purchase_vs_orders_v` | публичный договор сверки с `NULL` для отсутствующей стороны |
|
||||||
|
| `dm` | `dm.daily_traffic_rep` / `_dist` | версии дневных счетчиков трафика |
|
||||||
|
| `dm` | `dm.daily_traffic_v` | точные текущие счетчики трафика с `FINAL` |
|
||||||
|
| `dm` | `dm.dq_summary_rep` / `_dist` | текущее качество дня по атомарным проверкам |
|
||||||
|
| `dm` | `dm.dq_summary_stage_rep` / `_dist` | общий донор дневных партиций DQ-сводки |
|
||||||
|
| `dm` | `dm.dq_summary_v` | публичная сводка качества дней |
|
||||||
|
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
|
||||||
|
| `dic` | `dic.products` | словарь товаров поверх подложки |
|
||||||
|
|
||||||
|
Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два
|
||||||
|
`INSERT SELECT` одной задачи `parse_batch` из файла
|
||||||
|
`sql/ods/order_parse.sql`.
|
||||||
|
|
||||||
|
В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие
|
||||||
|
остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции;
|
||||||
|
карта пишет полный набор пар в версионную таблицу. В DM выручка и сверка
|
||||||
|
заменяют партиции изменяемых дней, а трафик пишет полную историю новой версией.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
Документ на каждом шагу опирается на поведение ClickHouse, а местами и Kafka.
|
||||||
|
Поэтому утверждения о них разведены на три группы: насколько фразе можно верить,
|
||||||
|
должно быть видно из текста, а не зависеть от того, хорошо ли автор помнит
|
||||||
|
документацию. Сверка с
|
||||||
|
документацией — через MCP Context7, 5 августа 2026 года; то же разведение для
|
||||||
|
механики приёма — в [ADR 0005](../adr/0005-event-ingestion.md).
|
||||||
|
|
||||||
|
**Проверка `ods.event_v` 18 августа 2026 года.** Документация ClickHouse через
|
||||||
|
MCP Context7 подтвердила обычное представление с `FINAL` как способ скрыть
|
||||||
|
схлопывание от потребителя. На закреплённом ClickHouse 26.3 в ODS добавили
|
||||||
|
вторую физическую версию одного события: обычный счёт вырос на один, а
|
||||||
|
`ods.event_v` остался равен `ods.event_dist FINAL`. Имена и типы всех колонок
|
||||||
|
представления совпали с распределённой таблицей.
|
||||||
|
|
||||||
|
**Проверка словаря товаров 19 августа 2026 года (#95).** Context7 подтвердил
|
||||||
|
DDL-словарь с файловым источником внутри `user_files`, хешированную раскладку
|
||||||
|
для строкового ключа и отключение фонового обновления через `LIFETIME(0)`.
|
||||||
|
На закреплённом ClickHouse 26.3 цена известного `sku` после правки CSV и
|
||||||
|
`SYSTEM RELOAD DICTIONARY ON CLUSTER`
|
||||||
|
изменилась на обеих нодах; после отката и повторной команды вернулась обратно.
|
||||||
|
|
||||||
|
**Проверка зоны справочников 20 августа 2026 года (#105).** Подложка на движке
|
||||||
|
`File` перечитывает файл на каждом запросе, словарь поверх неё обновляется сам
|
||||||
|
внутри окна `LIFETIME`, а собранный с нуля стенд поднимает оба объекта и отдаёт
|
||||||
|
словарь читателям. Замеры целиком — в
|
||||||
|
[ADR 0012](../adr/0012-dictionary-home.md), раздел «Что проверено»; там же
|
||||||
|
разобрано, почему отказ в праве на движок называет не то право, которого не
|
||||||
|
хватает.
|
||||||
|
|
||||||
|
**Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и
|
||||||
|
`system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку
|
||||||
|
значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT`
|
||||||
|
на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`.
|
||||||
|
На живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов
|
||||||
|
лежали на обоих шардах, после явного нуля и повторного разбора того же
|
||||||
|
`_load_id` — ни одного. Вершина версий уцелела: деловые поля `ods.order_v`
|
||||||
|
сошлись с эталоном, посчитанным прямо из сырья.
|
||||||
|
|
||||||
|
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
|
||||||
|
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
|
||||||
|
в предикате приёма заказов. Остальное снято на закреплённом ClickHouse 26.3.
|
||||||
|
|
||||||
|
Три находки касаются не заказов, а самого ClickHouse, и знать их стоит любому,
|
||||||
|
кто пишет здесь строгий разбор:
|
||||||
|
|
||||||
|
- **`toDateOrNull` календарь не проверяет.** `'2026-02-30'` он молча превращает
|
||||||
|
в `2026-03-02`, `'2026-13-01'` — в `1970-01-01`. Для строгого приёма он
|
||||||
|
поэтому не годится: нужен `parseDateTimeInJodaSyntaxOrNull(…, 'yyyy-MM-dd',
|
||||||
|
'UTC')`, который на обеих строках даёт `NULL`.
|
||||||
|
- **Маска разбора формы не держит.** `parseDateTime64InJodaSyntaxOrNull` по
|
||||||
|
маске `yyyy-MM-dd'T'HH:mm:ss.SSS'Z'` берёт и `2026-6-3T14:21:07.123Z` — без
|
||||||
|
ведущих нулей. Форму приходится сверять отдельно, регулярным выражением;
|
||||||
|
зато календарь маска проверяет честно (30 февраля, 13-й месяц, 25-й час
|
||||||
|
дают `NULL`), как и лишние или недостающие знаки долей секунды и смещение
|
||||||
|
`+00:00` вместо `Z`.
|
||||||
|
- **У временных типов есть потолок, и он ниже, чем кажется.** `DateTime64(3)`
|
||||||
|
заканчивается на `2299-12-31`, `DateTime` — на `2106-02-07`; строки за
|
||||||
|
потолком разбор отдаёт как `NULL`. Это ловит опыт, который берёт «заведомо
|
||||||
|
далёкий» год: 2999-й не разбирается вовсе.
|
||||||
|
|
||||||
|
Про сам приём заказов снято следующее. Предикат формы провода разложил все
|
||||||
|
13 661 строку сырья, приехавшую при исполнении #93, без исключений и без
|
||||||
|
`NULL`: 13 653 годных и 8 брака, сумма сошлась с общим счётом по каждому
|
||||||
|
`_load_id`, пересечение ветвей — ноль. Все восемь строк брака — версии одного
|
||||||
|
заказа с отрицательным `total`; это дефект генератора, разобранный в
|
||||||
|
[спецификации приёма](orders/ingestion.md), «Что проверено». На управляемой
|
||||||
|
порции из пяти строк (две версии одного заказа плюс по одной строке каждого
|
||||||
|
класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с
|
||||||
|
ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL`
|
||||||
|
через распределённую таблицу вернул одну строку — ту, что несла позднейшую
|
||||||
|
версию, — тогда как физический счёт показывал две. Повтор задачи с тем же
|
||||||
|
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок
|
||||||
|
записала тот же брак второй раз, как и обещано спецификацией приёма.
|
||||||
|
|
||||||
|
Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у
|
||||||
|
запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, —
|
||||||
|
на одном и том же срезе из пяти строк две вставки дали 4 и 5. Сколько чего
|
||||||
|
легло, спрашивают у самих таблиц по `_load_id`.
|
||||||
|
|
||||||
|
**Сверено с документацией.** Собственная колонка с именем виртуальной делает
|
||||||
|
виртуальную недоступной. При вставке в `Distributed` шард выбирается по ключу
|
||||||
|
шардирования; фоновый режим — умолчание, а `distributed_foreground_insert = 1`
|
||||||
|
завершает вставку только после записи на все шарды. У семейства `Replicated*`
|
||||||
|
есть дедупликация одинаковых блоков. Голый `count()` по `ReplacingMergeTree`
|
||||||
|
зависит от того, сколько мержей прошло. `ttl_only_drop_parts` снимает кусок
|
||||||
|
целиком и только когда истекли все строки в нём, а сам TTL исполняется на
|
||||||
|
фоновых мержах. Kafka-движок начинает читать топик, когда к нему привязывают
|
||||||
|
матвью, и одна группа потребителей на кластер спасает от дублей. Таблицы с
|
||||||
|
одинаковым путём в keeper становятся репликами друг друга, а макрос `{uuid}`
|
||||||
|
завязан на движок базы `Atomic`. `ON CLUSTER` ждёт все хосты и бросает по
|
||||||
|
таймауту; `CREATE ... IF NOT EXISTS` на существующем объекте не бросает.
|
||||||
|
`parseDateTime` и его родня принимают пояс необязательным последним аргументом,
|
||||||
|
а без него берут пояс сессии — он же по умолчанию серверный. У колонки с
|
||||||
|
объявленным поясом значения приводятся к нему; у колонки без объявленного
|
||||||
|
`session_timezone` перекрывает серверную настройку на выводе, но тип, с которым
|
||||||
|
считают функции, остаётся прежним (сверка 8 августа 2026 года).
|
||||||
|
|
||||||
|
**Проверено на стенде.** Опыты прогнаны на живом кластере: пять при исполнении
|
||||||
|
#37 (четыре 5 августа 2026 года, пятый 6 августа), пять при исполнении #43
|
||||||
|
(7 августа) и четыре при #63 (8 августа). Все подтвердили то, что здесь
|
||||||
|
написано.
|
||||||
|
|
||||||
|
- Разбор строки берёт пояс у сессии, а не из строки. Под
|
||||||
|
`session_timezone = 'Europe/Samara'` одна и та же строка
|
||||||
|
`2026-06-01T01:24:09Z` по маске `%Y-%m-%dT%H:%i:%SZ` дала 1780262649 без
|
||||||
|
третьего аргумента и 1780277049 с аргументом `'UTC'` — ровно четыре часа
|
||||||
|
разницы. Тип результата без аргумента — `Nullable(DateTime('Europe/Samara'))`.
|
||||||
|
Отсюда имя пояса в разборе: суффикс `Z` маска съедает и выбрасывает.
|
||||||
|
- `toDate` берёт пояс у типа своего аргумента. Из одного момента:
|
||||||
|
по `DateTime('UTC')` — `2026-05-31`, по `DateTime('Europe/Samara')` —
|
||||||
|
`2026-06-01`. Отсюда форма правила: имя пояса нужно там, где его не несёт тип.
|
||||||
|
- У колонки без объявленного пояса глаз и `GROUP BY` расходятся. Замер снят до
|
||||||
|
правки типов и на нынешнем стенде не повторяется — колонка уже с поясом.
|
||||||
|
Событие
|
||||||
|
`WatchID = 113504893317`, `EventDate` = `2026-06-05`: без настроек колонка
|
||||||
|
показана `2026-06-04 20:58:56`, под `session_timezone = 'Europe/Samara'` —
|
||||||
|
`2026-06-05 00:58:56`, а `toDate(UTCEventTime)` в обоих случаях
|
||||||
|
`2026-06-04`. То есть вывод колонки идёт по поясу сессии, а функция — по
|
||||||
|
поясу типа, и тип на сессию не смотрит. Родной клиент и HTTP ведут себя
|
||||||
|
одинаково.
|
||||||
|
- С объявленным поясом расхождение уходит. Стенд поднят с нуля уже по
|
||||||
|
конвенции; событие `WatchID = 384218330540`, `EventDate` = `2026-06-01`:
|
||||||
|
колонка `DateTime('UTC')` показана `2026-05-31 23:37:00` и без настроек, и
|
||||||
|
под `session_timezone = 'Europe/Samara'`, по родному клиенту и по HTTP.
|
||||||
|
`toDate(UTCEventTime)` даёт `2026-05-31`, `toDate(UTCEventTime,
|
||||||
|
'Europe/Samara')` — `2026-06-01`, `EventDate` — `2026-06-01`: расхождение
|
||||||
|
`toDate(UTCEventTime)` с `EventDate` осталось, это мир, а не пояс колонки.
|
||||||
|
|
||||||
|
- Матвью с источником-`Distributed` срабатывает на вставку именно в эту
|
||||||
|
распределённую таблицу, до раскладки по шардам. Обе матвью разбора стоят над
|
||||||
|
`stg.hits_raw_dist`, а пишет в неё матвью приёма — и события доезжают до
|
||||||
|
`ods.event`; значит блок она видит. В документации ClickHouse случая нет
|
||||||
|
вовсе, до 7 августа утверждение держалось на опыте владельца.
|
||||||
|
- Упавшая матвью роняет вставку и останавливает потребление до починки.
|
||||||
|
Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за
|
||||||
|
двадцать секунд (сброс блока идёт за 7,5) в сырьё не приехало ничего, а
|
||||||
|
офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение
|
||||||
|
доехало само, без повторной отправки, отставание ушло в ноль, событие
|
||||||
|
разобралось. Сносить надо именно распределённую таблицу: вставка в
|
||||||
|
`Distributed` кладёт блок в спул и сразу возвращает управление, так что на
|
||||||
|
сносе локальной ошибка всплыла бы фоном и утверждение показалось бы
|
||||||
|
опровергнутым.
|
||||||
|
- `_load_ts` в `ods.event` — это метка исходной строки сырья, а не время
|
||||||
|
разбора. Сверено по `WatchID` на двух тысячах событий модельного дня,
|
||||||
|
залитого дважды: у каждого события метка совпала с меткой одной из двух его
|
||||||
|
доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди
|
||||||
|
доставок» ноль, то есть `now64()` в матвью разбора нет.
|
||||||
|
- Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново
|
||||||
|
созданные матвью разбора при живом чтеце: сообщение, отправленное сразу
|
||||||
|
после, легло в сырьё и не попало в ODS никуда — ни в событие, ни в ошибки;
|
||||||
|
то же сообщение через минуту разобралось штатно. Воспроизведено дважды
|
||||||
|
7 августа 2026 года. Это тот же зазор, о котором предупреждает нумерация
|
||||||
|
файлов DDL, только приходит он с другой стороны — не при первом создании, а
|
||||||
|
при замене матвью на работающем стенде. Практический вывод один: правишь
|
||||||
|
матвью — не верь ближайшей отправке, повтори её. Чем именно держится
|
||||||
|
задержка, не измерено; наблюдение записано как наблюдение.
|
||||||
|
- Форма ключа `ods.event` принимается такой, как её задумала спека: выражение
|
||||||
|
`intHash32(ClientID)` стоит в ключе сортировки `ReplacingMergeTree`, а
|
||||||
|
`SAMPLE BY` — по тому же выражению. Вопрос стоял открытым в разделе 11
|
||||||
|
мастер-спеки; ответ — DDL применяется и таблица работает.
|
||||||
|
|
||||||
|
- `RawBLOB` даёт ровно одну строку на каждое непустое сообщение. Три сообщения
|
||||||
|
с ключами, поставленные в очередь до одного сброса продюсера, стали тремя
|
||||||
|
строками с тремя разными офсетами: пачка продюсера границы сообщений не
|
||||||
|
стирает. Запасной формат `LineAsString` не понадобился. Про границу «непустое»
|
||||||
|
— сразу ниже.
|
||||||
|
- Меток времени у Kafka-движка две, и разрядность у них разная: `_timestamp` —
|
||||||
|
`Nullable(DateTime)`, `_timestamp_ms` — `Nullable(DateTime64(3))`. Отсюда
|
||||||
|
форма `kafka_timestamp` в разделе о служебных колонках.
|
||||||
|
- `DEFAULT hostName()`, объявленный только на локальной таблице, вычисляется на
|
||||||
|
шарде-получателе. Строки, вставленные с ноды 1 и уехавшие на ноду 2, несут в
|
||||||
|
этой колонке ноду 2, а в соседней, заполненной явным `hostName()` во
|
||||||
|
вставляющем `SELECT`, — ноду 1. Довод за то, чтобы служебные колонки
|
||||||
|
заполняла матвью выражением, держится. Отсюда же и невосстановимость: после
|
||||||
|
записи в `Distributed` имя читавшей ноды взять больше неоткуда — своей
|
||||||
|
колонкой оно не сохранено, а умолчание назовёт получателя.
|
||||||
|
- `DROP PARTITION` и `REPLACE PARTITION` по `Distributed` не работают: обе
|
||||||
|
операции отвечают кодом 48, «Table engine Distributed doesn't support
|
||||||
|
partitioning», и оба шарда остаются нетронутыми.
|
||||||
|
- Автосоздания топиков ClickHouse не просит, и переключателей здесь два. На
|
||||||
|
брокере автосоздание разрешено: `auto.create.topics.enable=true`, и это
|
||||||
|
умолчание образа, а не наша настройка. На клиенте — выключено: чтец с матвью,
|
||||||
|
наведённые на несуществующий топик, ждали его с ошибкой «Broker: Unknown topic
|
||||||
|
or partition», и топик не появился. Значит, от тихого топика с одной партицией
|
||||||
|
стенд бережёт клиентское умолчание, а не брокер.
|
||||||
|
|
||||||
|
Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись
|
||||||
|
Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`)
|
||||||
|
читаются, двигают офсет потребителя и не дают строки вовсе — ни в сырьё, ни в
|
||||||
|
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа
|
||||||
|
2026 года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, —
|
||||||
|
в сырьё приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при
|
||||||
|
чём: `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на
|
||||||
|
том же наборе даёт ровно те же две строки и тот же офсет.
|
||||||
|
|
||||||
|
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
|
||||||
|
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
|
||||||
|
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
|
||||||
|
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
|
||||||
|
самом сырье, `kafka_offset` лежит там колонкой, и дырка в офсетах — это он.
|
||||||
|
|
||||||
|
Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS
|
||||||
|
<локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
|
||||||
|
и на распределённой таблице, могло бы вычислиться до раскладки по шардам. То
|
||||||
|
есть вывод опыта надёжен именно для умолчания, живущего только на локальной
|
||||||
|
таблице. Это вычитано, а не измерено. На устройство приёма оговорка не влияет:
|
||||||
|
служебные колонки мы заполняем выражением при любом ответе.
|
||||||
|
|
||||||
|
**Проверка замены партиций 26 августа 2026 года (#151).** Context7 подтвердил
|
||||||
|
договор `REPLACE PARTITION`: `ON CLUSTER` в синтаксисе есть, а источник и цель
|
||||||
|
обязаны совпадать структурой, ключом партиции, ключом сортировки, первичным
|
||||||
|
ключом и политикой хранения. На закреплённом ClickHouse 26.3 замена по локальным
|
||||||
|
таблицам с `ON CLUSTER` прошла на обеих нодах, а сборка стартового мира в
|
||||||
|
двойник разложила 1662 заказа строго по ключу: шард 1 получил только чётные
|
||||||
|
`cityHash64(order_id)`, шард 2 — только нечётные.
|
||||||
|
|
||||||
|
**Проверка карты идентичностей 26 августа 2026 года (#153).** Context7
|
||||||
|
подтвердил версию в `ReplacingMergeTree` и точное чтение через `FINAL`. Два
|
||||||
|
полных прогона оставили 3180 физических строк и 1590 пар в `_v`; все пары
|
||||||
|
подтверждены покупкой и заказом, а строк на чужом шарде нет.
|
||||||
|
|
||||||
|
Тем же днём измерены ещё три поведения ClickHouse.
|
||||||
|
|
||||||
|
- **Параметр в `ALTER … PARTITION` не подставляется.** Запрос с `{day:Date}` в
|
||||||
|
выражении партиции отвечает кодом 62, синтаксической ошибкой; литерал
|
||||||
|
проходит. Отсюда единственное место в репозитории, где значение попадает в
|
||||||
|
запрос подстановкой Jinja, а не параметром ClickHouse
|
||||||
|
([`sql/dds/order_replace.sql`](../../sql/dds/order_replace.sql)).
|
||||||
|
- **`TRUNCATE` — отдельное право, в `ALTER` оно не входит.** Роль `etl`, у
|
||||||
|
которой есть `ALTER ON dds.*`, получает на `TRUNCATE` код 497, а
|
||||||
|
`ALTER TABLE … DROP PARTITION ALL` на той же таблице проходит. Двойник
|
||||||
|
поэтому чистится снятием партиций: у операции нашлась форма, на которую право
|
||||||
|
уже выдано, и расширять доступ не понадобилось.
|
||||||
|
- **`JSONExtract` читает `Decimal` прямо из строки JSON.** Цена на проводе —
|
||||||
|
строка с двумя знаками (`"1289.99"`), и разбор в
|
||||||
|
`Array(Tuple(sku String, qty UInt32, price Decimal(18, 2)))` даёт число без
|
||||||
|
промежуточного `toDecimal64`. Проверено на живом слепке; честность разбора
|
||||||
|
сторожит инвариант бэкенда — сумма «цена × количество» совпала с `items_total`
|
||||||
|
у всех 1662 заказов.
|
||||||
|
- **`JSONExtract` читает `Decimal` и из числа JSON.** Массив
|
||||||
|
`purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается
|
||||||
|
сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на
|
||||||
|
ClickHouse 26.3; все суммы после ODS совпали с сырьем до копейки.
|
||||||
|
|
||||||
|
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
|
||||||
|
матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Superset: витрины глазами
|
||||||
|
|
||||||
|
Документ описывает зону BI: куда Superset ходит за данными и за своими
|
||||||
|
метаданными, из чего собран стендовый дашборд, как снять свежий экспорт после
|
||||||
|
правки в интерфейсе и почему правка руками живет только до перезапуска.
|
||||||
|
|
||||||
|
Состав сюжетов и колонок витрин здесь не повторяется: он живет в
|
||||||
|
[спеке](../specs/2026-07-30-stand-v2-realism.md), раздел 7, и в
|
||||||
|
[доке слоя DM](dm.md).
|
||||||
|
|
||||||
|
## Устройство зоны
|
||||||
|
|
||||||
|
Superset читает ClickHouse пользователем `bi` **на ноде 2** — так задано
|
||||||
|
подключением `ClickHouse` (`clickhousedb://bi@clickhouse-02:8123`). Нода 2
|
||||||
|
здесь — ловушка правильных ошибок из
|
||||||
|
[спеки](../specs/2026-07-30-stand-v2-realism.md), раздел «Роли нод»: DDL идет
|
||||||
|
с ноды 1, и забытый `ON CLUSTER` виден в дашборде.
|
||||||
|
|
||||||
|
Пароля в подключении нет. Его отдает штатное хранилище паролей Superset
|
||||||
|
(`SQLALCHEMY_CUSTOM_PASSWORD_STORE` в `infra/superset/superset_config.py`),
|
||||||
|
которое читает `CLICKHOUSE_BI_PASSWORD` из окружения. Поэтому файл подключения
|
||||||
|
безопасно лежит в git, и экспорт из интерфейса не должен возвращать пароль
|
||||||
|
обратно в YAML.
|
||||||
|
|
||||||
|
Свои метаданные — подключения, датасеты, графики, дашборды, пользователи —
|
||||||
|
Superset держит в базе `superset` служебного `postgres-metadata`, рядом с базой
|
||||||
|
Airflow. В ClickHouse он не пишет ничего.
|
||||||
|
|
||||||
|
## Дерево импорта
|
||||||
|
|
||||||
|
`infra/superset/import/` — это дашборд стенда в виде файлов. Каталог
|
||||||
|
смонтирован в контейнер как `/app/import`.
|
||||||
|
|
||||||
|
| Файл | Что описывает |
|
||||||
|
|---|---|
|
||||||
|
| `metadata.yaml` | версия формата, `1.0.0`; больше в нем ничего нет |
|
||||||
|
| `databases/clickhouse.yaml` | подключение к ClickHouse: URI без пароля и постоянный uuid |
|
||||||
|
| `datasets/ClickHouse/*.yaml` | три витрины: `revenue_daily_v`, `purchase_vs_orders_v`, `daily_traffic_v` |
|
||||||
|
| `charts/*.yaml` | три графика: столбики выручки, сводная таблица сверки, линии трафика |
|
||||||
|
| `dashboards/business_stories.yaml` | дашборд «Бизнес-сюжеты» и раскладка панелей |
|
||||||
|
|
||||||
|
Связаны файлы не путями, а uuid. Датасет называет `database_uuid`, график —
|
||||||
|
`dataset_uuid`, а дашборд не называет графики вовсе: единственная связь с ними
|
||||||
|
лежит в его `position`, где узел `CHART-*` несет `meta.uuid` нужного графика.
|
||||||
|
График, которого нет в `position`, импортируется молча и не появляется нигде.
|
||||||
|
|
||||||
|
Uuid у стенда розданы рукой: общее начало
|
||||||
|
`19600000-0000-4000-8000-`, дальше номер объекта в последнем блоке — `1`–`3`
|
||||||
|
у датасетов, `101`–`103` у графиков, `201` у дашборда
|
||||||
|
(`…-000000000101`). Экспорт из интерфейса приносит свои, сгенерированные;
|
||||||
|
их заменяют на эти, иначе повторный импорт заведет вторую копию объекта вместо
|
||||||
|
обновления первой.
|
||||||
|
|
||||||
|
Датасет несет русские имена колонок, формат денег и метрику — а график только
|
||||||
|
ссылается на них по имени. Значит, подпись или формат правятся в одном месте, а
|
||||||
|
не в каждом графике.
|
||||||
|
|
||||||
|
Датасеты объявлены физическими (`schema: dm`, `sql: null`), хотя за именем стоит
|
||||||
|
представление ClickHouse. Импорт в источник не ходит и разницы между таблицей и
|
||||||
|
представлением не знает, а виртуальный датасет со своим SQL просто повторил бы
|
||||||
|
запрос, который уже живет в `sql/ddl/60-dm-views.sql`.
|
||||||
|
|
||||||
|
## Импорт при каждой инициализации
|
||||||
|
|
||||||
|
Последняя строка `infra/superset/init.sh`:
|
||||||
|
|
||||||
|
```
|
||||||
|
superset import-directory /app/import --overwrite
|
||||||
|
```
|
||||||
|
|
||||||
|
Служба `superset-init` отрабатывает перед подъемом самого Superset, то есть при
|
||||||
|
каждом `make up`. Без `--overwrite` второй прогон падал бы на первом же
|
||||||
|
знакомом uuid; с ним объект обновляется на месте, и повторная инициализация
|
||||||
|
проходит без ошибок.
|
||||||
|
|
||||||
|
Отсюда правило зоны: **стендовый дашборд не расширяют руками в интерфейсе.**
|
||||||
|
Правка живет до следующей инициализации, а потом молча заменяется тем, что
|
||||||
|
лежит в git. Пробовать интерфейс менти никто не мешает — но все, что должно
|
||||||
|
пережить перезапуск, приезжает файлом.
|
||||||
|
|
||||||
|
Это правило относится только к стендовым объектам. Свои датасеты, графики и
|
||||||
|
дашборды менти живут в той же базе метаданных Superset, а импорт трогает
|
||||||
|
только объекты с известными ему uuid. Поэтому свои объекты переживают
|
||||||
|
`make up` и `make rebuild-storage`. Их стирает только `make clean` или
|
||||||
|
адресный сброс базы метаданных по рецепту ниже.
|
||||||
|
|
||||||
|
## Как снять свежий экспорт
|
||||||
|
|
||||||
|
Маршрут один: правишь на живом стенде, забираешь экспорт, кладешь дерево в git.
|
||||||
|
|
||||||
|
1. Собрать или поправить график и дашборд в интерфейсе — на стенде, а не в
|
||||||
|
файлах. Ключи `params` графиков велики, недокументированы и меняются от
|
||||||
|
версии к версии; неверный ключ не ошибается, а рисует пустоту. Интерфейс
|
||||||
|
здесь единственный надежный оракул.
|
||||||
|
2. Снять экспорт и достать его из контейнера:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose exec superset superset export-dashboards -f /tmp/export.zip
|
||||||
|
docker compose cp superset:/tmp/export.zip /tmp/export.zip
|
||||||
|
unzip -d /tmp/superset-export /tmp/export.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Внутри архива — один каталог `dashboard_export_<дата>` ровно той же формы,
|
||||||
|
что и дерево импорта. Команда выгружает все дашборды сразу; на стенде он
|
||||||
|
один.
|
||||||
|
3. Вычистить экспортный сор перед коммитом:
|
||||||
|
- выбросить экспортный `databases/` — наш `clickhouse.yaml` остается как
|
||||||
|
есть, с постоянным uuid и без пароля;
|
||||||
|
- оставить в `metadata.yaml` одну строку `version: 1.0.0`;
|
||||||
|
- убрать из графиков `params.datasource`, `params.slice_id` и
|
||||||
|
`params.dashboards` — это идентификаторы строк того Superset, где
|
||||||
|
снимали, а импорт выводит связи из uuid;
|
||||||
|
- убрать `query_context` — снимок запроса оттуда же. Импорт его не
|
||||||
|
требует и обратно не заполняет: после переимпорта он остается пустым, на
|
||||||
|
отрисовку это не влияет;
|
||||||
|
- перезаписать русские строки читаемыми. Экспорт кодирует их
|
||||||
|
`\uXXXX`-последовательностями, и после каждого круга их приходится
|
||||||
|
раскодировать заново: имена графиков и подписи колонок читает менти, и в
|
||||||
|
файле, и в диффе. Годится любой повторный дамп YAML с
|
||||||
|
`allow_unicode=True`;
|
||||||
|
- вернуть постоянные uuid из схемы выше.
|
||||||
|
4. Скопировать вычищенное дерево в `infra/superset/import/` и проверить
|
||||||
|
импортом **на пустых метаданных** — иначе прогон проверит только обновление
|
||||||
|
уже стоящих объектов, а молчаливый пропуск нового спрячется за старым.
|
||||||
|
Активы Superset лежат в базе `superset` службы `postgres-metadata`, поэтому
|
||||||
|
адресный сброс — эта база:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose stop superset
|
||||||
|
docker compose exec postgres-metadata sh -c \
|
||||||
|
'psql -U "$POSTGRES_USER" -d postgres \
|
||||||
|
-c "DROP DATABASE superset WITH (FORCE)" \
|
||||||
|
-c "CREATE DATABASE superset OWNER \"$SUPERSET_METADATA_USER\""'
|
||||||
|
docker compose run --rm superset-init
|
||||||
|
docker compose up -d superset
|
||||||
|
```
|
||||||
|
|
||||||
|
Базу приходится создавать тут же: скрипт `infra/postgres/init/` заводит ее
|
||||||
|
один раз, при первой инициализации тома Postgres. `make rebuild-storage`
|
||||||
|
для этой проверки не годится — он гоняет `world_recreate` в ClickHouse и
|
||||||
|
метаданных Superset не касается. `make clean` сбросит и их, но заодно снесет
|
||||||
|
весь стенд, включая метаданные Airflow в той же службе.
|
||||||
|
|
||||||
|
После импорта дашборд смотрят глазами: это единственное доказательство —
|
||||||
|
см. ловушку ниже.
|
||||||
|
|
||||||
|
## Ловушки
|
||||||
|
|
||||||
|
**Импорт молчит.** График, чей `dataset_uuid` не разрешился, отбрасывается без
|
||||||
|
единого слова; дашборд, у которого не сошлись ссылки на графики,
|
||||||
|
пропускается. Ошибки уходят в журнал, и `init.sh`
|
||||||
|
завершается нулем в любом случае. Поэтому импорт проверяют глазами или
|
||||||
|
запросом к API — но не тем, что команда отработала.
|
||||||
|
|
||||||
|
**`--overwrite` синхронизирует датасет разрушительно.** Колонки и метрики
|
||||||
|
берутся из YAML целиком: добавленная в интерфейсе метрика или подпись колонки
|
||||||
|
исчезнет при следующем старте контейнера. Правка датасета — это правка файла.
|
||||||
|
|
||||||
|
## Что проверяет стенд
|
||||||
|
|
||||||
|
`make check-services` входит в Superset, находит подключение `ClickHouse`,
|
||||||
|
сверяет его uuid и безопасный URI и требует схему `dm` — то есть подтверждает
|
||||||
|
живое подключение и хранилище пароля. Сами графики и дашборд ни одна цель не
|
||||||
|
проверяет: их смотрят глазами после переинициализации. Полная карта целей — в
|
||||||
|
[доке проверок](testing.md).
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
# Проверки: карта целей
|
||||||
|
|
||||||
|
Документ отвечает на два вопроса. Первый: что именно утверждает каждая цель
|
||||||
|
`make` и сколько стоит её прогон. Второй, ради которого документ и заведён:
|
||||||
|
куда положить новую проверку — так, чтобы это решалось по карте, а не чтением
|
||||||
|
скриптов.
|
||||||
|
|
||||||
|
Зона ответственности у документа одна — проверки. Что именно они стерегут,
|
||||||
|
описано в других местах: устройство хранилища — в
|
||||||
|
[storage.md](storage.md), замысел стенда — в спеке [«Боевой реализм стенда
|
||||||
|
(v2)»](../specs/2026-07-30-stand-v2-realism.md).
|
||||||
|
|
||||||
|
## Ось: кого спрашивают
|
||||||
|
|
||||||
|
Цели различаются не ценой, а тем, к кому обращён вопрос. ClickHouse отвечает
|
||||||
|
сам и за миллисекунды. Airflow отвечает через такт планировщика. Superset —
|
||||||
|
через сессию и обход метаданных. Быстрота выходит следствием этого различия, а
|
||||||
|
не критерием деления.
|
||||||
|
|
||||||
|
Совпадение цены и назначения здесь побочное, и это видно по дырам, которые
|
||||||
|
каждая цель оставляет соседке. `make smoke` не заметит перепутанных местами
|
||||||
|
макросов `shard`: обе ноды здоровы и порты отвечают. `make check-clickhouse` не
|
||||||
|
заметит потерянного подключения Superset: он про ClickHouse и только.
|
||||||
|
|
||||||
|
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
|
||||||
|
против описи — вопрос к ClickHouse: строки в `ods.event` считает сам
|
||||||
|
сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене
|
||||||
|
они подошли бы смоуку.
|
||||||
|
|
||||||
|
## Карта целей
|
||||||
|
|
||||||
|
Цели живут за двумя дверями. В корне — цели стенда; в `generator/` — цели
|
||||||
|
генератора: он отдельный пакет со своим `pyproject.toml`, локом и образом, и
|
||||||
|
спрашивают его отдельно.
|
||||||
|
|
||||||
|
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
|
||||||
|
`make smoke` и `make check-services` перемерены 28 августа 2026 года,
|
||||||
|
`make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
|
||||||
|
корневой `make lint` — 9 августа, `make test` — 30 августа; остальные стоят с
|
||||||
|
замера 6 августа.
|
||||||
|
|
||||||
|
| Цель | Откуда | Что утверждает | Стенд | Цена |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
||||||
|
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||||
|
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
|
||||||
|
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с |
|
||||||
|
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
|
||||||
|
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||||
|
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
||||||
|
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
|
||||||
|
|
||||||
|
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
|
||||||
|
разными дверями охватывают разное: корневая не видит ни одного файла
|
||||||
|
генератора, генераторная — ни одного файла стенда. Человек в корне видит
|
||||||
|
зелёное и может решить, что зелёный весь репозиторий.
|
||||||
|
|
||||||
|
Две тонкости, которых не видно из таблицы:
|
||||||
|
|
||||||
|
- **Версия ruff у корневой цели закреплена в самом вызове** (`uvx ruff@0.16.1`)
|
||||||
|
и равна той, что в `generator/uv.lock`. Равенство держится руками: обновили
|
||||||
|
лок — поправьте и вызов, сверять их некому.
|
||||||
|
- **Охват держат аргументы вызова, а не устройство дерева.** Python,
|
||||||
|
положенный вне двух названных в таблице путей, не проверит ни одна из дверей.
|
||||||
|
|
||||||
|
**Корневого `typecheck` нет, и это решение, а не пропуск.** Проверке типов мало
|
||||||
|
прочитать файл: чтобы понять `from airflow.sdk import dag`, ей нужен
|
||||||
|
установленный Airflow, а он живёт в образе, не на машине — 132 пакета ради двух
|
||||||
|
файлов пробников (замер 9 августа 2026 года, `uv pip compile`). Цена решения
|
||||||
|
называется честно: две настоящие находки `ty` в `dags/test_kafka.py` постоянного
|
||||||
|
сторожа не получают. Вопрос вернётся на этапе 5, когда придут настоящие даги:
|
||||||
|
тогда окружение окупится, и условие возврата — именно это, а не «стало
|
||||||
|
неудобно».
|
||||||
|
|
||||||
|
Цена самого `make up` с пустых томов — 8 м 18 с до переезда жизненного цикла в
|
||||||
|
Airflow. В цену целей она не входит. На готовом мире подъём больше не
|
||||||
|
пересчитывает данные: `world_initialize` зеленеет без изменений.
|
||||||
|
|
||||||
|
`make rebuild-storage` тоже не проверка. Команда через Airflow удаляет
|
||||||
|
прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL,
|
||||||
|
заливает стартовый мир и возвращает его пульт к концу стартовых дней. Службы и
|
||||||
|
их тома она сохраняет, поэтому возврат к каноническим данным не требует
|
||||||
|
холодного запуска всего стенда.
|
||||||
|
|
||||||
|
## Правило: смоук обязан оставаться быстрым
|
||||||
|
|
||||||
|
`make smoke` — быстрая проверка для регулярного прогона: её гоняют не
|
||||||
|
задумываясь, и потому она обязана укладываться в секунды. Тяжёлые проверки в
|
||||||
|
неё включать не следует: тяжёлая — та, из-за которой смоук перестаёт быть
|
||||||
|
быстрым. Поодиночке это единицы и десятки секунд, вместе — минуты. Такие
|
||||||
|
проверки живут в `make check-services`.
|
||||||
|
|
||||||
|
На практике дорого обходится не работа, а ожидание службы: такт планировщика
|
||||||
|
Airflow, сессия Superset. `make check-clickhouse` создаёт таблицы, вставляет
|
||||||
|
строки и гоняет распределённые DDL — и укладывается в семь секунд, потому что
|
||||||
|
ClickHouse отвечает сразу.
|
||||||
|
|
||||||
|
Первый абзац — правило, второй — наблюдение, по которому тяжёлую проверку
|
||||||
|
узнают заранее, не замеряя.
|
||||||
|
|
||||||
|
## Какую проверку когда запускать
|
||||||
|
|
||||||
|
Деление целей ценно ровно до тех пор, пока оно не превратилось в «гонять
|
||||||
|
всегда всё».
|
||||||
|
|
||||||
|
Состояние перед проверкой восстанавливается в самой узкой достаточной области:
|
||||||
|
сначала объект или партиция, затем база слоя. Если нужен целиком чистый
|
||||||
|
прикладной мир, `make rebuild-storage` пересобирает его базы, топики, позиции
|
||||||
|
потребителей и данные. `make clean` нужен, только когда меняется или сломано
|
||||||
|
постоянное состояние вне этих баз либо проверяется сам запуск с пустых томов.
|
||||||
|
Требование к обоснованию полного сброса задаёт `AGENTS.md`. Неизвестную поломку
|
||||||
|
сначала исследуют: полный сброс уничтожает её следы.
|
||||||
|
|
||||||
|
| Когда | Что гонять |
|
||||||
|
|---|---|
|
||||||
|
| Правка в работе | `make config-test` и `make smoke` |
|
||||||
|
| PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` |
|
||||||
|
| Приёмка этапа | все три цели на стенде; запуск с пустых томов — только для изменений начальной сборки или постоянного состояния служб |
|
||||||
|
|
||||||
|
Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test`
|
||||||
|
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
|
||||||
|
дорогая. Правки дагов и Superset — корневой `make lint`.
|
||||||
|
|
||||||
|
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
|
||||||
|
для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
|
||||||
|
узнать, тот ли получается мир, его надо получить. Место выбрано по той же
|
||||||
|
оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не
|
||||||
|
нужен, — и
|
||||||
|
краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
|
||||||
|
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
|
||||||
|
поломкой хранилища, а не забытой пересборкой.
|
||||||
|
|
||||||
|
## Порогов по времени здесь нет
|
||||||
|
|
||||||
|
Цена в таблице — замеренное число с датой замера, а не назначенный порог.
|
||||||
|
Автоматической проверки времени в репозитории нет и заводить её не следует.
|
||||||
|
Число, вписанное в проверку, становится законом, которого никто не выбирал:
|
||||||
|
[ADR 0004](../adr/0004-resource-limits.md) разбирает ровно этот случай — оценка
|
||||||
|
из спеки попала жёстким порогом в `make smoke`, `make smoke` стал критерием
|
||||||
|
приёмки каждого этапа, и дальше решения сверялись уже с порогом, а не с
|
||||||
|
исходным доводом. Спека генератора формулирует ту же позицию прямо:
|
||||||
|
«наблюдаемость без порогов».
|
||||||
|
|
||||||
|
Смотрит на время человек. `make smoke` и `make check-services` печатают его
|
||||||
|
сами — последней строкой `ИТОГ: пройдено N, ошибок M, время T с`. Замер при
|
||||||
|
приёмке делается руками и называется в теле PR.
|
||||||
|
|
||||||
|
## Проверка, которая не умеет краснеть, бесполезна
|
||||||
|
|
||||||
|
Правило, которое стоит держать в голове, правя любую проверку. Проверка,
|
||||||
|
никогда не видевшая своей поломки, доказывает только то, что она умеет печатать
|
||||||
|
«ЗЕЛЁНО». Убедиться дешевле всего руками: сломайте то, что она стережёт —
|
||||||
|
остановите `prometheus`, удалите служебную таблицу пробника на второй ноде, — и
|
||||||
|
посмотрите, покраснеет ли прогон и назовёт ли виновника. Не покраснел —
|
||||||
|
проверка не работает, и чинить надо её, а не стенд.
|
||||||
|
|
||||||
|
## Интеграционная проверка постоянной целью не становится
|
||||||
|
|
||||||
|
Проверка, которая не спрашивает стенд, а **кормит** его — шлёт сообщения,
|
||||||
|
вставляет строки, портит данные нарочно, — постоянной целью `make` не живёт.
|
||||||
|
Такую проверку гоняют один раз при исполнении, убеждаются, что механизм
|
||||||
|
работает, и оставляют след записью в теле PR. Регулярная жизнь стенда — это
|
||||||
|
жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он
|
||||||
|
становится темой урока.
|
||||||
|
|
||||||
|
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырья срок
|
||||||
|
жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в
|
||||||
|
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и
|
||||||
|
их. Хуже явного мусора: явный видно.
|
||||||
|
|
||||||
|
Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.**
|
||||||
|
Дешевле всего это выходит партицией — опыт играет модельный день за границей
|
||||||
|
оси мира, его строки ложатся в собственную партицию `EventDate`, и она
|
||||||
|
сносится по окончании. Заодно менти видит партицию как единицу работы — тот
|
||||||
|
же механизм, на котором стоит переобработка дня X.
|
||||||
|
|
||||||
|
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
|
||||||
|
что стенд произвёл сам: подневный счёт событий против описи мира ловит и
|
||||||
|
сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге,
|
||||||
|
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки
|
||||||
|
Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
||||||
|
|
||||||
|
Правило, которое такая проверка обязана выдержать и которое стоит держать в
|
||||||
|
голове для следующей: **на растущих данных утверждать можно только про
|
||||||
|
зафиксированный кусок.** Мир растёт — менти переиграет день, этап 5 добавит
|
||||||
|
следующий, — и всякое утверждение про таблицу целиком однажды покраснеет
|
||||||
|
законно, то есть впустую. Как это сделано здесь, написано в самом скрипте.
|
||||||
|
|
||||||
|
## Корректность процессов живёт в дагах DQ, а не в целях `make`
|
||||||
|
|
||||||
|
Цели `make` отвечают на вопрос «стенд собран и работает». Вопрос «данные в нём
|
||||||
|
правильные» — работа отдельных дагов качества данных: они приходят вместе со
|
||||||
|
следующими уровнями хранилища и заберут многое из того, что просится проверкой
|
||||||
|
сейчас. Поэтому находка «это стоило бы проверять регулярно» сначала проверяется
|
||||||
|
вопросом, не туда ли ей: если она про свойства данных — про полноту дня, про
|
||||||
|
свежесть слоя, про сходимость витрины с источником, — её дом даг, а не
|
||||||
|
`check-clickhouse`.
|
||||||
|
|
||||||
|
Разница видна и менти, и она содержательная. Цель `make` он зовёт руками,
|
||||||
|
когда чинит стенд; даг DQ идёт по расписанию рядом с остальным конвейером,
|
||||||
|
пишет историю проверок и разбирается как обычная задача Airflow — то есть учит
|
||||||
|
тому, как качество данных устроено в бою.
|
||||||
|
|
||||||
|
## Устройство скриптов
|
||||||
|
|
||||||
|
| Цель | Скрипт |
|
||||||
|
|---|---|
|
||||||
|
| `make config-test` | `scripts/config-test.sh` |
|
||||||
|
| `make smoke` | `scripts/stand-smoke.sh` |
|
||||||
|
| `make check-services` | `scripts/stand-services.sh` |
|
||||||
|
| `make check-clickhouse` | `scripts/check-clickhouse.sh` |
|
||||||
|
|
||||||
|
Особняком стоит `scripts/run-dag.sh`: это не проверка, а общий REST-переход от
|
||||||
|
`make` к Airflow. `make up` запускает через него `world_initialize`, а
|
||||||
|
`make rebuild-storage` — `world_recreate`; сценарий ждёт появления дага в API,
|
||||||
|
а затем до минуты повторяет только стартовый ответ `Dag with dag_id not found`.
|
||||||
|
Прочие ответы и сетевые ошибки он не повторяет: при потерянном ответе запуск мог
|
||||||
|
уже создаться. Сценарий печатает идентификаторы дага и прогона и возвращает
|
||||||
|
вызывающему его итог. Ожидание доставки событий и запуск `orders_ingest` теперь
|
||||||
|
видны задачами `world_initialize`, а не спрятаны в shell.
|
||||||
|
|
||||||
|
Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две
|
||||||
|
проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается.
|
||||||
|
Оттуда же приходят два решения, которые видно по счёту прогонов:
|
||||||
|
|
||||||
|
- **`check_containers_survived` стоит в конце обеих целей.** Убитый за память
|
||||||
|
контейнер Docker поднимает сам, и проверка здоровья об этом промолчит
|
||||||
|
(ADR 0004). Стенд нагружает `check-services`, а увидеть последствия нужно и
|
||||||
|
тому, кто гонял один смоук. Это единственная проверка, которая считается
|
||||||
|
дважды: 21 у смоука плюс 7 у `check-services` — это 27 разных проверок.
|
||||||
|
- **Зависимости машины считает только смоук.** «На машине есть Docker, curl и
|
||||||
|
jq» — вопрос к машине, а не к службам, и на оси он стоит рядом с «стенд
|
||||||
|
собран». Для `check-services` это условие запуска: без них он не начнёт
|
||||||
|
работу и громко скажет об этом, но ЗЕЛЁНО за это не печатает.
|
||||||
|
|
||||||
|
Вход в Airflow `check-services` выполняет заново — общих переменных у двух
|
||||||
|
скриптов нет. Отдельной проверкой этот вход тоже не считается: то же самое
|
||||||
|
утверждает смоук.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
**Калибровка заказной стороны 30 августа 2026 года при исполнении #194.**
|
||||||
|
Пересборки эталонного мира не будет (#192), поэтому доли заказной стороны
|
||||||
|
сверены замером внутренностей генератора — окно 28 модельных дней,
|
||||||
|
канонический seed, без транспорта, — тем же приемом, каким `test_plan.py` и
|
||||||
|
`test_commerce.py` меряют мир. Команда (из `generator/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python - <<'PY'
|
||||||
|
from collections import Counter
|
||||||
|
from clickstream_generator import day, inventory, orders, world
|
||||||
|
from clickstream_generator.seeds import CANONICAL_SEED
|
||||||
|
|
||||||
|
WINDOW = 28
|
||||||
|
class_totals: Counter[str] = Counter()
|
||||||
|
outcome_totals: Counter[int] = Counter()
|
||||||
|
delivery_totals: Counter[int] = Counter()
|
||||||
|
delay_totals: Counter[int] = Counter()
|
||||||
|
total_orders = 0
|
||||||
|
|
||||||
|
for d in range(WINDOW):
|
||||||
|
today = day.stream(CANONICAL_SEED, d)
|
||||||
|
for key, value in inventory._order_class_counts(today).items():
|
||||||
|
class_totals[key] += value
|
||||||
|
money = today.orders
|
||||||
|
total_orders += len(money)
|
||||||
|
for value in money.outcome.tolist():
|
||||||
|
outcome_totals[value] += 1
|
||||||
|
for value in money.delivery.tolist():
|
||||||
|
delivery_totals[value] += 1
|
||||||
|
for value in money.snapshot_delay.tolist():
|
||||||
|
delay_totals[value] += 1
|
||||||
|
|
||||||
|
print(total_orders, dict(class_totals), dict(outcome_totals),
|
||||||
|
dict(delivery_totals), dict(delay_totals))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
6973 заказов за 28 дней. Доли из таблицы сверки (после приоритета классов):
|
||||||
|
отменено 5,15% (ориентир мастер-спеки ~5%, в пределах; из них 3,03%
|
||||||
|
оплачен-потом-отменен и 2,12% отменен неоплаченным — веса `world.py`
|
||||||
|
95/3/2); дубль `purchase` 1,89% (ориентир ~2%, в пределах); дельта суммы
|
||||||
|
1,32% (ориентир 1–2%, в пределах); потеря `purchase` 1,78% при броске 3% —
|
||||||
|
вычеты штатные: назначенные планом покупки из броска исключены (мост
|
||||||
|
склейки), часть случаев уходит в отмену приоритетом; наблюдаемая доля
|
||||||
|
принята решением владельца (#194). Стоимость доставки — 35,5% / 49,2%
|
||||||
|
/ 15,4%, совпадает с весами `DELIVERY_KOPECKS_WEIGHTS`. Опоздание слепков
|
||||||
|
(`ORDER_SNAPSHOT_DELAY_WEIGHTS`) — доля заказов с δ>0 составила 8,39%
|
||||||
|
(ориентир мастер-спеки ~10%, в пределах). Скрипт замера в репозиторий не
|
||||||
|
входит; вывод — в отчете issue #194.
|
||||||
|
|
||||||
|
**Перезамер 30 августа 2026 года при исполнении #192.** Команды запускались
|
||||||
|
из `generator/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
time make inventory
|
||||||
|
uv run python -m clickstream_generator batch --day 0 --days 8 \
|
||||||
|
--file /tmp/stage7-events.jsonl
|
||||||
|
for snapshot_day in 1 2 3 4 5 6 7; do
|
||||||
|
uv run python -m clickstream_generator snapshot --day "$snapshot_day" \
|
||||||
|
--file "/tmp/stage7-snapshot-${snapshot_day}.jsonl"
|
||||||
|
done
|
||||||
|
wc -c /tmp/stage7-events.jsonl /tmp/stage7-snapshot-{1,2,3,4,5,6,7}.jsonl
|
||||||
|
time make test
|
||||||
|
```
|
||||||
|
|
||||||
|
Опись пересобралась за 20,515 с. Файловый приемник записал 460 731 812 байт:
|
||||||
|
458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0
|
||||||
|
за 176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов
|
||||||
|
стало 437 против 407, и потяжелел сам мир — та же пересборка восьми дней
|
||||||
|
стоила 13 с, теперь 20,5. Эта правка добавила один тест — проигрыш дня.
|
||||||
|
|
||||||
|
**Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
|
||||||
|
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
|
||||||
|
30 секунд почти целиком уходил на запуск второго приложения. Увеличивать его
|
||||||
|
не стали: Superset не обещает постоянного времени `create_app()`.
|
||||||
|
|
||||||
|
После входа проверка делает три GET-запроса к уже работающему Superset: находит
|
||||||
|
`ClickHouse`, сверяет UUID и безопасный URI, затем требует схему `dm`. Так без
|
||||||
|
второго запуска проверяются сохраненное подключение и хранилище пароля. Форму
|
||||||
|
API сверили через Context7 и с исходным кодом Superset 6.1.0. `superset test-db`
|
||||||
|
отклонен, потому что тоже запускает приложение; прямой SQL к `dbs` не проверяет
|
||||||
|
соединение.
|
||||||
|
|
||||||
|
Проверка больше не подтверждает, что сам Superset использует Postgres для
|
||||||
|
метаданных. Короткий запрос к Postgres показывает лишь готовность базы, а REST
|
||||||
|
API - подключение к ClickHouse. Для `check-services` этого достаточно.
|
||||||
|
|
||||||
|
После перезапуска Superset три прогона прошли 7/7 за 71, 67 и 70 секунд.
|
||||||
|
`make config-test` прошел. `make smoke` дал 21/21 за 25 секунд; его код не
|
||||||
|
менялся, прежний замер был 17 секунд. Неверный UUID дал 5/1: красной стала
|
||||||
|
только проверка метаданных, пароль в вывод не попал.
|
||||||
|
|
||||||
|
**Приемка #156 27 августа 2026 года.** `make rebuild-storage` занял 7 минут.
|
||||||
|
На чистом прикладном мире `world_initialize` сам довел позицию до `8`, дождался
|
||||||
|
`etl_pipeline` и оставил заполненными DDS и обе витрины. После пересборки прошли
|
||||||
|
`make smoke` 21/21 и `make check-clickhouse` 10/10. Перед стендовыми сценариями
|
||||||
|
прошли `make config-test` и `make lint`.
|
||||||
|
|
||||||
|
Два прогона `world_next_day` не изменили слои; один ручной `etl_pipeline` без
|
||||||
|
параметров догнал оба дня. Полный прогон `world_live_day` занял 27 м 2 с и
|
||||||
|
завершился только после успешных DDS и DM.
|
||||||
|
|
||||||
|
Временная синтаксическая поломка `sql/dm/daily_traffic_rebuild.sql` дала
|
||||||
|
красную цепочку: DDS завершился успешно, а `daily_traffic`, DM, `etl_pipeline`
|
||||||
|
и `world_live_day` покраснели. После возврата исправного SQL ручной
|
||||||
|
`etl_pipeline` без параметров завершился успешно на позиции `12` и восстановил
|
||||||
|
слои.
|
||||||
|
|
||||||
|
Для проверки пакетного барьера `stg.hits_raw_mv` временно отсоединили командой
|
||||||
|
`DETACH` на обеих нодах. После `world_next_day` отставание Kafka составило
|
||||||
|
46 367 (19 128 + 27 239). Во время ожидания матвью вернули командой `ATTACH`.
|
||||||
|
Журнал готовности записал повторные опросы с отставанием 46 367, затем 27 239
|
||||||
|
и 0; после этого очередь `ods.event_dist` содержала 0 файлов на обеих нодах,
|
||||||
|
и ETL завершился успешно. Проверка `EXISTS` снова дала `1` на обеих нодах,
|
||||||
|
позиция равна `13`.
|
||||||
|
|
||||||
|
После пересборки `make check-services` дважды дал 5/1: только импорт Superset
|
||||||
|
не уложился в существующий тайм-аут 30 с. Диагностический пробник импорта с
|
||||||
|
ожиданием 90 с прошел 3/0. Финальный штатный повтор прошел 7/7 за 129 с.
|
||||||
|
|
||||||
|
**Перезамер 21 августа 2026 года при исполнении #124.** Образ Airflow собран с
|
||||||
|
`apache-airflow-providers-clickhousedb==1.0.0`,
|
||||||
|
`apache-airflow-providers-common-sql==2.0.1` и
|
||||||
|
`clickhouse-connect==1.6.0`; `pip check` не нашёл несовместимых зависимостей.
|
||||||
|
Адресная пересборка мира через `make rebuild-storage` заняла 3 м 5 с: все шесть
|
||||||
|
задач DDL, `pull_batch` и `parse_batch` завершились успешно. После неё прошли
|
||||||
|
смоук 21/21 за 17 с, проверка ClickHouse 10/10 и межсервисная проверка 7/7 за
|
||||||
|
100 с.
|
||||||
|
|
||||||
|
Первый `make up` после замены образа попал в короткий рассинхрон запуска
|
||||||
|
Airflow: API уже сообщил о здоровье и отдал `world_initialize` по GET, но POST
|
||||||
|
нового запуска ещё ответил `Dag with dag_id not found`. Ошибок импорта не было;
|
||||||
|
через несколько секунд повторный `make up` завершился успешно и не менял
|
||||||
|
готовый мир.
|
||||||
|
|
||||||
|
**Перезамер 21 августа 2026 года при исполнении #114.** После добавления
|
||||||
|
`kafka-exporter` и источника ClickHouse смоук прошёл 21/21 за 16–17 секунд,
|
||||||
|
`check-services` — 7/7 за 93 секунды. При остановленном экспортёре смоук дал
|
||||||
|
19/2: назвал нездоровый сервис и отсутствие одной из четырёх целей. После
|
||||||
|
возврата сервиса прогон снова дал 21/21.
|
||||||
|
|
||||||
|
После переноса #119 поверх этого состава повторный `make up` сохранил готовый
|
||||||
|
мир, смоук прошёл 21/21, проверка ClickHouse — 10/10, межсервисная — 7/7.
|
||||||
|
|
||||||
|
**Приёмка логики #119 до переноса поверх мониторинга 21 августа 2026 года.**
|
||||||
|
`make rebuild-storage` через Airflow прошёл за 3 м 9 с: позиция прошла задачи
|
||||||
|
`-1` → `0` → `8`, топики получили новые идентификаторы, группы дочитали новые
|
||||||
|
топики без отставания, а ODS принял 401 185 событий без брака и 2 851
|
||||||
|
актуальный заказ. Задача `build_generator_image` заняла по 1,5 с в двух
|
||||||
|
прогонах.
|
||||||
|
Повторный `make up` занял 2 м 41 с вместе со штатным повторным запуском служб
|
||||||
|
инициализации; `world_initialize` завершился за один цикл опроса и оставил
|
||||||
|
счётчики неизменными. Отдельно проверены красные отказы `world_initialize` при
|
||||||
|
`-1` и `world_next_day` при отсутствующей позиции, `0` и `-1`; `lifecycle`
|
||||||
|
отказано в чтении `system.users`, но разрешено чтение `system.settings`.
|
||||||
|
|
||||||
|
Итоговый холодный `make up` из пустых томов занял 9 м 39 с после 25 с на
|
||||||
|
`make clean`. Даг создал отсутствующую позицию и закончил на `8`; ODS снова
|
||||||
|
получил 401 185 событий без брака и 2 851 заказ. У `hits` два раздела, у
|
||||||
|
`orders` один, отставание обеих групп равно нулю. На тогдашнем составе стенда
|
||||||
|
прошли смоук 20/20, проверка ClickHouse 10/10 и межсервисная проверка 7/7.
|
||||||
|
|
||||||
|
Оборванную очистку проверили после записи `world_position=-1`: остановили
|
||||||
|
первую ноду ClickHouse, и `world_recreate` покраснел на удалении баз. Следующий
|
||||||
|
`make up` тоже покраснел и оставил `-1`. После возврата ноды повторный
|
||||||
|
`make rebuild-storage` восстановил мир.
|
||||||
|
|
||||||
|
**Прототип 21 августа 2026 года при исследовании #110.** Предшествующая
|
||||||
|
shell-версия узкой пересборки за 2 м 25 с пересоздала прикладные базы, отправила
|
||||||
|
401 185 событий и 6 656 версий заказов и вернула позицию с 11 на 8. Замер
|
||||||
|
оставлен как ориентир цены, но не подтверждает нынешнюю реализацию Airflow: тот
|
||||||
|
вариант не очищал Kafka и был отклонён как второй оркестратор мира.
|
||||||
|
|
||||||
|
**Перезамер 20 августа 2026 года при исполнении #96, до переезда в Airflow.**
|
||||||
|
`make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за
|
||||||
|
3 м 35 с. Отправка семи слепков заняла около 28 секунд.
|
||||||
|
|
||||||
|
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и пульта
|
||||||
|
мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время
|
||||||
|
ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й
|
||||||
|
секунде, обработчик DAG стал здоров примерно на 458-й. Числа здесь называют
|
||||||
|
цену нынешнего стенда, а не выносят вердикт о регрессии.
|
||||||
|
|
||||||
|
**Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой
|
||||||
|
проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за
|
||||||
|
24 секунды. Это цена всей цели, а не одной новой строки.
|
||||||
|
|
||||||
|
**Замер 9 августа 2026 года при появлении корневого `lint` (#65).** Три прогона
|
||||||
|
подряд — 0,39, 0,36 и 0,39 секунды на трёх файлах стенда; в таблице 0,4 с. На
|
||||||
|
машине с непрогретым кешем первый прогон дороже: `uvx` сначала скачивает ruff.
|
||||||
|
|
||||||
|
Тогда же снято, что двери не перекрываются: `ruff check --show-files` из корня
|
||||||
|
перечисляет ровно три файла стенда, из `generator/` — только файлы генератора.
|
||||||
|
Корневой конфиг правила генератору не подменяет: конфиг ищется вверх по дереву
|
||||||
|
от файла, и ближний выигрывает.
|
||||||
|
|
||||||
|
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
|
||||||
|
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка:
|
||||||
|
18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka.
|
||||||
|
Повторный `make up` на живом стенде — 1 м 5 с: мир заливается заново, и это
|
||||||
|
не оплошность — `WatchID` те же, повтор схлопнет `ReplacingMergeTree`.
|
||||||
|
Смоук — 9 с, `check-clickhouse` — 8 с с новой девятой проверкой. `make test`
|
||||||
|
вырос до 71 с: 58 с прежних тестов плюс 13 с на пересборку восьми дней для
|
||||||
|
сверки с описью. Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
|
||||||
|
#41 и #43.
|
||||||
|
|
||||||
|
Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и
|
||||||
|
проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в
|
||||||
|
`ods.event_rep` — проверка покраснела, показала недостающий день и назвала
|
||||||
|
адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца
|
||||||
|
топика». Затем положили в сырьё заведомо негодную строку — брак появился, и
|
||||||
|
диагноз сменился на второй, с признаком для различения: «сойдётся недостача с
|
||||||
|
числом брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки
|
||||||
|
опыта убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт
|
||||||
|
вернулся к 401 185, и это заодно показало дедупликацию: повтор дня не удвоил
|
||||||
|
счёт под `FINAL`.
|
||||||
|
|
||||||
|
Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и
|
||||||
|
совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону:
|
||||||
|
смоук дал 8 секунд дважды (без проверки Kafka, до её появления, — 5 и 6),
|
||||||
|
`check-services` — 43 и 44. В таблице стоит большее из замеренных.
|
||||||
|
|
||||||
|
Перемер 7 августа 2026 года — приёмка этапа 2 целиком: `make clean` и `make up`
|
||||||
|
подряд заняли 2 м 58 с и залили стартовый мир, дальше по порядку все три цели
|
||||||
|
на стенде и три без него. Смоук дал 9 секунд, `check-clickhouse` — 8,
|
||||||
|
`make test` — 71. `check-services` — 59 и 48; в таблицу по прежнему правилу
|
||||||
|
пошло большее. Против 44 секунд накануне цель подорожала, и причину назвать
|
||||||
|
нечем: между замерами и стартовый мир в хранилище, и холодный подъём перед
|
||||||
|
первым прогоном. Число здесь — замер с датой, а не бюджет, и повод разбираться
|
||||||
|
появится, когда за него кто-нибудь начнёт держаться.
|
||||||
|
|
||||||
|
До деления `scripts/stand-smoke.sh` шёл 48 секунд на 25 проверок, из них
|
||||||
|
42 секунды съедали шесть: Kafka с машины, два запуска пробников Airflow и три
|
||||||
|
проверки Superset. После деления те же 25 проверок разошлись по двум целям:
|
||||||
|
19 в смоуке и 6 в `check-services`. Содержание ни одной из них не менялось.
|
||||||
|
|
||||||
|
Двадцатая проверка смоука — единственная новая: Kafka спрашивают с машины через
|
||||||
|
отображённый порт. Деление оставило дыру, которой раньше не было. Про Airflow,
|
||||||
|
Superset, Prometheus и Grafana смоук стучится с машины в отображённый порт, а
|
||||||
|
про Kafka после переезда знал только «контейнер здоров» — а это вердикт
|
||||||
|
проверки состояния из `compose.yaml`, и та спрашивает брокер изнутри по
|
||||||
|
внутреннему слушателю. Внешняя дверь оставалась непроверенной до
|
||||||
|
`check-services` с его минутой, хотя генератор пишет в Kafka именно с
|
||||||
|
машины. Стоит проверка 2,6 секунды, и почти всё это — старт JVM в разовом
|
||||||
|
контейнере; ожидание ответа ограничено пятнадцатью секундами, впятеро больше
|
||||||
|
замеренного.
|
||||||
|
|
||||||
|
Что обе разделённые цели умеют краснеть, проверено руками в тот же день. Со
|
||||||
|
снятым `prometheus` смоук дал три ошибки и ненулевой код возврата. С
|
||||||
|
подменённым ожидаемым UUID подключения Superset так же покраснел
|
||||||
|
`check-services`. Новую проверку Kafka проверили её собственной поломкой:
|
||||||
|
брокеру объявили адрес `kafka-nowhere:29092`, оставив внутренний слушатель
|
||||||
|
целым, — проверка состояния контейнера осталась зелёной, а смоук покраснел
|
||||||
|
именно на этой строке. Краснеют они и когда на машине не хватает команды из
|
||||||
|
списка зависимостей.
|
||||||
|
|
||||||
|
Семантика счётчиков Docker `OOMKilled` и `RestartCount`, на которой держится
|
||||||
|
`check_containers_survived`, снята отдельными контейнерами и записана в
|
||||||
|
ADR 0004, раздел «Что проверено».
|
||||||
|
|
||||||
|
Разбор Bash из `make config-test` срезан 6 августа 2026 года, и вместе с ним —
|
||||||
|
двенадцать строк обхода репозитория со списком файлов. Проверка была сломана
|
||||||
|
с рождения: `bash -n` со списком файлов разбирает только первый, остальные
|
||||||
|
уходят ему в аргументы, — то есть из пяти скриптов проверялся один, и за всё
|
||||||
|
время цели этого никто не заметил. Заводить её заново незачем: скрипты стенда
|
||||||
|
запускаются с той же машины, и синтаксическая ошибка вылезает при первом же
|
||||||
|
запуске с номером строки. Разбор файлов DAG остался и по другому основанию —
|
||||||
|
их на машине не запускает никто.
|
||||||
@@ -2,17 +2,21 @@
|
|||||||
|
|
||||||
Документ собран из контракта схемы генератора
|
Документ собран из контракта схемы генератора
|
||||||
(`generator/src/clickstream_generator/schema.py`). Руками не править —
|
(`generator/src/clickstream_generator/schema.py`). Руками не править —
|
||||||
пересобрать: `make docs`.
|
пересобрать: `make docs` из каталога `generator/`.
|
||||||
|
|
||||||
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
|
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
|
||||||
Многозначное лежит в параллельных массивах одной длины, плюс одно сырое
|
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
|
||||||
JSON-поле `ecommerce`. Отдельной сущности «визит» в выгрузке нет — визиты
|
`ecommerce`. Длина у массивов общая **внутри группы**, а не по всему
|
||||||
|
событию: `purchase*` — по элементу на заказ (у нас всегда один), `product*` —
|
||||||
|
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
|
||||||
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
|
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
|
||||||
|
|
||||||
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
|
Имена и типы колонок описывают сторону источника. Обычно ODS принимает их без
|
||||||
нормализует у себя: то же имя в нашем стиле ждёт в столбце «Нормализованное
|
изменений. Исключение — `purchaseRevenue`: источник объявляет Float64, а ODS
|
||||||
имя». Это имя источника, приведённое к snake_case, а не имя атрибута в
|
хранит деньги в Decimal. Точный локальный тип назван в документации
|
||||||
модели данных: слой DDS складывает свою модель и называет атрибуты по ней.
|
хранилища. Имя в нашем стиле ждет в столбце «Нормализованное имя». Это имя
|
||||||
|
источника, приведенное к snake_case, а не имя атрибута в модели данных: слой
|
||||||
|
DDS складывает свою модель и называет атрибуты по ней.
|
||||||
Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора;
|
Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора;
|
||||||
у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан
|
у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан
|
||||||
контрактом.
|
контрактом.
|
||||||
@@ -21,6 +25,10 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
|
|||||||
`add_to_cart` несёт один товар, `purchase` — состав заказа и блок
|
`add_to_cart` несёт один товар, `purchase` — состав заказа и блок
|
||||||
`purchase*`. У остальных событий они пусты.
|
`purchase*`. У остальных событий они пусты.
|
||||||
|
|
||||||
|
Деньги: `productPrice` — целые рубли, округление формата. Точная сумма
|
||||||
|
заказа живёт в `purchaseRevenue` и в сыром `ecommerce`, поэтому пересчитать
|
||||||
|
выручку по разобранным массивам нельзя — цены каталога бывают с копейками.
|
||||||
|
|
||||||
Всего колонок: 47.
|
Всего колонок: 47.
|
||||||
|
|
||||||
## Идентификаторы и время
|
## Идентификаторы и время
|
||||||
@@ -31,8 +39,8 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
|
|||||||
| 2 | `VisitID` | `UInt64` | `uint64` | `visit_id` | id визита от генератора — эталон лабы: собери сессии сам и сравни |
|
| 2 | `VisitID` | `UInt64` | `uint64` | `visit_id` | id визита от генератора — эталон лабы: собери сессии сам и сравни |
|
||||||
| 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется |
|
| 3 | `ClientID` | `UInt64` | `uint64` | `client_id` | анонимный id браузера — кука; по хешу от неё таблица шардируется |
|
||||||
| 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один |
|
| 4 | `CounterID` | `UInt32` | `uint32` | `counter_id` | id счётчика: на стенде константа, сайт один |
|
||||||
| 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события; по ней режется партиция |
|
| 5 | `EventDate` | `Date` | `datetime64[D]` | `event_date` | дата события в часовом поясе счётчика; по ней режется партиция. Дату из `UTCEventTime` не выводить: у ночных событий она на сутки другая |
|
||||||
| 6 | `UTCEventTime` | `DateTime` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики |
|
| 6 | `UTCEventTime` | `DateTime('UTC')` | `datetime64[s]` | `utc_event_time` | время события в UTC — единственная метка времени, как у Метрики; сутки же считаются в поясе счётчика, поэтому `toDate(UTCEventTime)` ≠ `EventDate` |
|
||||||
| 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах |
|
| 7 | `ClientTimeZone` | `Int16` | `int16` | `client_timezone` | смещение часового пояса клиента от UTC, в минутах |
|
||||||
| 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет |
|
| 8 | `EventType` | `LowCardinality(String)` | `object` | `event_type` | тип события: pageview, add_to_cart, purchase — добавка стенда, у Метрики такого поля нет |
|
||||||
| 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт |
|
| 9 | `Sign` | `Int8` | `int8` | `sign` | всегда 1: колонка формата, исправлений записей генератор не шлёт |
|
||||||
@@ -84,7 +92,7 @@ JSON-поле `ecommerce`. Отдельной сущности «визит» в
|
|||||||
| № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий |
|
| № | Колонка | Тип ClickHouse | Тип numpy | Нормализованное имя | Комментарий |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| 37 | `purchaseID` | `Array(String)` | `object` | `purchase_id` | номер заказа; у события purchase — один элемент |
|
| 37 | `purchaseID` | `Array(String)` | `object` | `purchase_id` | номер заказа; у события purchase — один элемент |
|
||||||
| 38 | `purchaseRevenue` | `Array(Float64)` | `float64` | `purchase_revenue` | выручка заказа глазами клиента; Float64, как у Метрики — на этом держится урок о расхождениях с бэкендом |
|
| 38 | `purchaseRevenue` | `Array(Float64)` | `float64` | `purchase_revenue` | выручка заказа глазами клиента; источник объявляет Float64, ODS хранит точный Decimal |
|
||||||
| 39 | `purchaseCurrency` | `Array(String)` | `object` | `purchase_currency` | валюта заказа |
|
| 39 | `purchaseCurrency` | `Array(String)` | `object` | `purchase_currency` | валюта заказа |
|
||||||
| 40 | `purchaseCoupon` | `Array(String)` | `object` | `purchase_coupon` | купон заказа, если был применён |
|
| 40 | `purchaseCoupon` | `Array(String)` | `object` | `purchase_coupon` | купон заказа, если был применён |
|
||||||
| 41 | `productID` | `Array(String)` | `object` | `product_id` | id товаров события |
|
| 41 | `productID` | `Array(String)` | `object` | `product_id` | id товаров события |
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Учебные задания: витрины слоя DM и дашборд в Superset
|
||||||
|
|
||||||
|
Пять домашних заданий — про витрины слоя DM. Стенд привозит слой
|
||||||
|
наполовину: витрины трех сюжетов дашборда и контроль качества построены,
|
||||||
|
остальные витрины строит менти. Каждое из них — самостоятельное ТЗ: что за
|
||||||
|
витрина, какое у нее зерно, из чего она собирается и как проверить, что
|
||||||
|
собралась верно.
|
||||||
|
|
||||||
|
Шестое задание — необязательная лаба в Superset. В ней менти собирает свой
|
||||||
|
дашборд поверх пяти витрин.
|
||||||
|
|
||||||
|
Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на
|
||||||
|
вопросы «почему так», задание отвечает на вопрос «что сделать».
|
||||||
|
|
||||||
|
## Порядок
|
||||||
|
|
||||||
|
Задания идут по нарастанию: каждое следующее опирается на то, что уже
|
||||||
|
усвоено.
|
||||||
|
|
||||||
|
| № | Результат | Чему учит |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | [`events_enriched_v`](events-enriched.md) | слой — это контракт, а не копия данных |
|
||||||
|
| 2 | [`top_pages_daily`](top-pages-daily.md) | зерно сменилось — витрина материализуется и встает в конвейер |
|
||||||
|
| 3 | [`session_overview`](session-overview.md) | зерно решает, на какие вопросы витрина уже не ответит |
|
||||||
|
| 4 | [`dq_errors_daily`](dq-errors-daily.md) | у брака свой день и свой срок жизни |
|
||||||
|
| 5 | [`utm_effectiveness`](utm-effectiveness.md) | `declared_` в имени — предупреждение, а не украшение |
|
||||||
|
| 6 | [Свой дашборд в Superset](superset-dashboard.md) — необязательная лаба | свои объекты BI живут отдельно от стендовых |
|
||||||
|
|
||||||
|
## Общие правила
|
||||||
|
|
||||||
|
Они одни для пяти заданий по витринам, поэтому записаны здесь, а не
|
||||||
|
повторяются в каждом ТЗ.
|
||||||
|
|
||||||
|
**Читать — через `_v`.** Публичный договор сущности DDS — представление с
|
||||||
|
суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только
|
||||||
|
его; физические `_rep` и `_dist` служат загрузке и обслуживанию. Соседнюю
|
||||||
|
домашку витрина источником тоже не берет: витрина слоя DM читает договор
|
||||||
|
DDS, и задания остаются независимыми. Исключение одно и оно оговорено в
|
||||||
|
задании 4: у таблиц брака публичного договора нет.
|
||||||
|
|
||||||
|
**Писать — только в `_dist`.** При записи в локальную таблицу раскладку
|
||||||
|
определяет то, какая нода случайно выполняла код, а не свойство данных.
|
||||||
|
Правило и его следствия — [дока хранилища](../architecture/storage.md),
|
||||||
|
«Раскладка по шардам».
|
||||||
|
|
||||||
|
**Форма — по зерну.** Витрина, меняющая зерно источника, материализуется:
|
||||||
|
`dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` / `_stage_dist` и
|
||||||
|
публичное `dm.<имя>_v` с явным списком колонок. Витрина, сохраняющая зерно,
|
||||||
|
остается обычным представлением. Довод — [дока DM](../architecture/dm.md),
|
||||||
|
«Форма: по зерну, не по цене запроса».
|
||||||
|
|
||||||
|
**Ключ шардирования — твое решение.** Ни одно ТЗ ключа не называет.
|
||||||
|
Правило выбора и требование к донору — [дока
|
||||||
|
хранилища](../architecture/storage.md), «Раскладка по шардам»: донор
|
||||||
|
объявляется через `AS <цель>_rep` и шардируется тем же ключом, что цель,
|
||||||
|
иначе замена партиции потеряет строку на одном шарде и задвоит на другом.
|
||||||
|
Названный ключ надо уметь защитить: почему он не дает перекоса и почему
|
||||||
|
более сложный тут ничего не добавляет. Образец такой защиты —
|
||||||
|
`dm.revenue_daily_dist` в [`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
|
||||||
|
|
||||||
|
**Где лежит код.** DDL домашек — свой файл `sql/ddl/70-labs.sql`: он
|
||||||
|
применяется после стендовых по порядку имени, и твои объекты не мешаются
|
||||||
|
со стендовыми. У агрегатов рядом со стендовыми ложатся еще три запроса —
|
||||||
|
`sql/dm/<витрина>_scope.sql` спрашивает у хранилища, какие дни пересобирает
|
||||||
|
запуск, `_rebuild.sql` собирает их в донор, `_replace.sql` заменяет
|
||||||
|
партицию, — и задача в
|
||||||
|
[`dags/dm_transform.py`](../../dags/dm_transform.py). У разминочного
|
||||||
|
представления нет ничего из этого: весь его код — одно `CREATE VIEW`.
|
||||||
|
|
||||||
|
**Образец на все случаи** — витрина `revenue_daily`: у нее есть все пять
|
||||||
|
объектов, донор, сборка, замена дневных партиций и задача в конвейере.
|
||||||
|
Списывать с нее устройство можно и нужно; списывать состав колонок — нет,
|
||||||
|
он у каждой витрины свой.
|
||||||
|
|
||||||
|
## Самопроверка
|
||||||
|
|
||||||
|
Даг `homework_check` запускается рукой, расписания у него нет. Задача
|
||||||
|
витрины красная, пока витрины нет или счет не сходится, и зеленая, когда
|
||||||
|
собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет:
|
||||||
|
это инструмент лабы, а не история качества стенда.
|
||||||
|
|
||||||
|
Проверка пяти заданий по витринам устроена одинаково и золотых констант не
|
||||||
|
содержит: рядом с витриной независимым запросом собирается тот же ответ —
|
||||||
|
прямо из источника витрины, минуя ее саму. У четырех заданий источник эталона
|
||||||
|
тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания 4 это
|
||||||
|
таблицы брака ODS, потому что в DDS у брака представительства нет. Обе стороны
|
||||||
|
соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число строк,
|
||||||
|
где стороны разошлись или одной стороны нет. Ноль — зеленый.
|
||||||
|
Полное соединение здесь обязательно: при обычном пропавший день или
|
||||||
|
пропавший ключ просто не попал бы в результат и остался бы незамечен.
|
||||||
|
|
||||||
|
Раздел «Как проверить себя» каждого ТЗ по витрине называет деловой ключ и
|
||||||
|
сравниваемые величины. Тот же прием в стендовом исполнении — [даг
|
||||||
|
`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
|
||||||
|
|
||||||
|
**Предупреждение.** Запросы самой проверки лежат в `sql/dq/` открыто, и
|
||||||
|
имена называют витрину и роль сверки. Проверка на инвариантах неизбежно
|
||||||
|
близка к ответу: чтобы сверить витрину, надо ее пересчитать. Прятать эти
|
||||||
|
файлы никто не станет — но открыв их до того, как задание сделано, ты
|
||||||
|
обменяешь урок на готовый SQL.
|
||||||
|
Смотреть туда стоит после своей сборки: сравнить два решения полезно,
|
||||||
|
списать одно — нет.
|
||||||
|
|
||||||
|
## Уборка
|
||||||
|
|
||||||
|
Черновики и опыты за собой сносить: правило проверок стенда —
|
||||||
|
[дока тестирования](../architecture/testing.md). Снесенная витрина обязана
|
||||||
|
снова покраснить свою задачу в `homework_check` — если не покраснила,
|
||||||
|
проверка ничего и не проверяла.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Задание 4: `dm.dq_errors_daily`
|
||||||
|
|
||||||
|
**Чему учит:** у брака свой день и свой срок жизни. Витрина качества живет
|
||||||
|
по времени загрузки, а не по модельному дню, и переживает собственный
|
||||||
|
источник — поэтому «пересобрать все» здесь означает «стереть историю».
|
||||||
|
|
||||||
|
## Роль витрины
|
||||||
|
|
||||||
|
Сводка приемки: сколько строк за день не прошли разбор и по какой причине.
|
||||||
|
Читают ее, когда качество потока просело: сначала смотрят, вырос ли класс
|
||||||
|
брака, потом идут за сырым текстом в саму таблицу ошибок.
|
||||||
|
|
||||||
|
Витрина отвечает на вопрос «сколько и чего», а не «что именно сломалось».
|
||||||
|
Разбирательство с конкретной строкой начинается в `ods.event_errors_dist` и
|
||||||
|
`ods.order_errors_dist`, где рядом с классом лежат сырой текст и координаты
|
||||||
|
доставки.
|
||||||
|
|
||||||
|
## Источник: единственное исключение из правила `_v`
|
||||||
|
|
||||||
|
Общее правило домашек — читать через публичный договор `_v`. У таблиц брака
|
||||||
|
такого договора нет, и заводить его ради этой витрины не надо: брак — не
|
||||||
|
сущность бизнес-модели, а диагностика слоя приема. Читать нужно
|
||||||
|
распределенные таблицы напрямую:
|
||||||
|
|
||||||
|
- `ods.event_errors_dist` — брак разбора событий;
|
||||||
|
- `ods.order_errors_dist` — брак разбора слепка заказов.
|
||||||
|
|
||||||
|
Именно `_dist`, а не `_rep`: локальная таблица вернет строки одного шарда, и
|
||||||
|
витрина тихо покажет половину брака. Ошибки при этом не будет — ошибка
|
||||||
|
появится позже, когда по витрине примут решение.
|
||||||
|
|
||||||
|
## Зерно и форма
|
||||||
|
|
||||||
|
Зерно — **день загрузки × источник × класс брака**. Зерно источника —
|
||||||
|
строка брака, значит зерно сменилось и витрина материализуется:
|
||||||
|
`dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
|
||||||
|
публичное `dm.dq_errors_daily_v`. Ключ партиции — день загрузки.
|
||||||
|
|
||||||
|
День здесь — `toDate(_load_ts)`, то есть настоящее время, когда строка
|
||||||
|
приехала в хранилище, а не модельный день мира. Модельного дня у брака нет
|
||||||
|
и быть не может: он живет в содержимом, а содержимое как раз и не
|
||||||
|
разобралось.
|
||||||
|
|
||||||
|
## Состав колонок
|
||||||
|
|
||||||
|
| Колонка | Тип | Что это |
|
||||||
|
|---|---|---|
|
||||||
|
| `load_date` | `Date` | день загрузки, в который приехал брак |
|
||||||
|
| `source` | `LowCardinality(String)` | `event` или `order` |
|
||||||
|
| `error_class` | `LowCardinality(String)` | класс брака из таблицы ошибок |
|
||||||
|
| `error_rows` | `UInt64` | число забракованных строк |
|
||||||
|
| `_load_id` | `String` | запуск, собравший день |
|
||||||
|
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||||
|
|
||||||
|
Две похожие колонки рядом различаются намеренно: `load_date` — про день,
|
||||||
|
когда приехал брак, `_load_ts` — про запуск, который собрал витрину. Первая
|
||||||
|
деловая, вторая служебная, и совпадать они не обязаны.
|
||||||
|
|
||||||
|
Классы у двух источников свои и не пересекаются по смыслу, поэтому колонка
|
||||||
|
`source` в зерне обязательна: без нее одинаковое имя класса у событий и у
|
||||||
|
заказов слилось бы в одну строку.
|
||||||
|
|
||||||
|
## Ритм пересборки: витрина переживает источник
|
||||||
|
|
||||||
|
Таблицы брака живут месяц (`TTL`), сырье — трое суток. Витрина живет без
|
||||||
|
срока: сводка «сколько брака было в марте» нужна и после того, как сами
|
||||||
|
строки марта удалены.
|
||||||
|
|
||||||
|
Отсюда правило прогона: пересобираются **только те дни загрузки, которые
|
||||||
|
еще есть в источнике**. Спроси у источника список живых дней и замени
|
||||||
|
партиции ровно этих дней. Соблазн «собрать все заново в донор и подменить
|
||||||
|
таблицу целиком» здесь стоит дороже обычного: истекшие по `TTL` дни исчезли
|
||||||
|
бы из витрины навсегда, а вместе с ними — единственная оставшаяся память о
|
||||||
|
них.
|
||||||
|
|
||||||
|
Текущий день загрузки не замерзает до конца суток: поток событий идет, и
|
||||||
|
брак в него еще добавится. Это нормально, день просто пересоберется на
|
||||||
|
следующем прогоне.
|
||||||
|
|
||||||
|
## Ключ шардирования
|
||||||
|
|
||||||
|
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||||
|
шардам». Витрина крошечная: классов брака у событий и заказов вместе меньше
|
||||||
|
десятка. Учти это в защите ключа — иногда правильный ответ звучит как
|
||||||
|
«перекос здесь ничего не стоит, и вот почему».
|
||||||
|
|
||||||
|
## Как проверить себя
|
||||||
|
|
||||||
|
Деловой ключ — пара `source` и `error_class` внутри `load_date`. Область
|
||||||
|
проверки — дни загрузки, которые еще живы хотя бы в одной таблице ошибок:
|
||||||
|
день за границей `TTL` сверять не с чем, и его отсутствие в источнике не
|
||||||
|
ошибка.
|
||||||
|
|
||||||
|
Собери прямой счет по обеим таблицам ошибок, соедини с
|
||||||
|
`dm.dq_errors_daily_v` полным внешним соединением по ключу и посчитай
|
||||||
|
строки, где ключа нет с одной из сторон или где расходится `error_rows`.
|
||||||
|
Ноль — зеленый.
|
||||||
|
|
||||||
|
Отдельно: сумма `error_rows` по живым дням равна общему числу строк в
|
||||||
|
`ods.event_errors_dist` плюс `ods.order_errors_dist`. Если эта сумма вышла
|
||||||
|
примерно вдвое меньше — витрина почти наверняка собрана с `_rep` вместо
|
||||||
|
`_dist` и видит один шард из двух.
|
||||||
|
|
||||||
|
**Пустая витрина — верный ответ.** На чистом стенде брака нет, обе стороны
|
||||||
|
пусты, расхождений ноль, задача зеленая. Проверка, которая требует хотя бы
|
||||||
|
одной строки, на здоровом стенде краснела бы всегда и приучала бы себя
|
||||||
|
игнорировать.
|
||||||
|
|
||||||
|
## Приемка
|
||||||
|
|
||||||
|
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||||
|
ключом цели;
|
||||||
|
- [ ] источники — `_dist`-таблицы обоих видов брака;
|
||||||
|
- [ ] прогон заменяет партиции только живых дней источника и не трогает
|
||||||
|
истекшие;
|
||||||
|
- [ ] задача `dq_errors_daily` в `homework_check` зеленая — в том числе на
|
||||||
|
чистом стенде, где брака нет;
|
||||||
|
- [ ] задача витрины добавлена в `dm_transform`.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Задание 1: `dm.events_enriched_v`
|
||||||
|
|
||||||
|
**Чему учит:** слой — это контракт, а не копия данных. Витрина, которая не
|
||||||
|
меняет зерно источника, остается представлением, и копии событий ради
|
||||||
|
других имен колонок не заводит ни один боевой склад.
|
||||||
|
|
||||||
|
Это разминка. Строк она не хранит, в конвейер не встает, ключа шардирования
|
||||||
|
у нее нет. Задача одна: собрать плоское событие для BI и не поддаться
|
||||||
|
соблазну разложить его на диск.
|
||||||
|
|
||||||
|
## Роль витрины
|
||||||
|
|
||||||
|
Договор события в DDS широкий — 44 колонки, почти весь формат выгрузки:
|
||||||
|
часть из них технические координаты источника, часть — параллельные массивы
|
||||||
|
товаров. Аналитику в Superset нужен плоский набор деловых полей и пара
|
||||||
|
производных, которые иначе каждый пишет заново в каждом запросе.
|
||||||
|
|
||||||
|
Витрина отвечает на вопрос «что вообще происходило»: с нее начинают, когда
|
||||||
|
надо посмотреть на события глазами человека, а не разбирать широкую строку
|
||||||
|
источника.
|
||||||
|
|
||||||
|
Источником для витрин следующих заданий она не служит. Витрина слоя DM
|
||||||
|
читает публичный договор DDS — так устроены и стендовые витрины, и потому
|
||||||
|
задания независимы: соседнюю домашку можно делать первой, а можно не делать
|
||||||
|
вовсе.
|
||||||
|
|
||||||
|
## Зерно и форма
|
||||||
|
|
||||||
|
Зерно — **событие**: одна строка витрины равна одной строке `dds.event_v`.
|
||||||
|
Зерно источника сохранено, значит форма — обычное представление
|
||||||
|
`CREATE VIEW dm.events_enriched_v`, без физической таблицы, без донора и
|
||||||
|
без задачи в `dm_transform`. Пересобирать нечего.
|
||||||
|
|
||||||
|
Источник — `dds.event_v`, и только он.
|
||||||
|
|
||||||
|
## Состав колонок
|
||||||
|
|
||||||
|
Явный список обязателен: договор не должен расширяться сам, когда в
|
||||||
|
источник добавят колонку.
|
||||||
|
|
||||||
|
**Время и посетитель:** `event_date`, `event_time`, `event_hour`,
|
||||||
|
`client_id`, `event_id`.
|
||||||
|
|
||||||
|
**Действие:** `event_type` (`pageview`, `add_to_cart`, `purchase`), `url`,
|
||||||
|
`page_path`, `referer`, `referer_host`, `title`.
|
||||||
|
|
||||||
|
**Атрибуция:** `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`,
|
||||||
|
`utm_term`.
|
||||||
|
|
||||||
|
**Паспорт визита:** `device_category`, `browser`, `operating_system`,
|
||||||
|
`region_country`, `region_city`.
|
||||||
|
|
||||||
|
**Торговый блок, массивами как в источнике:** `product_id`, `product_name`,
|
||||||
|
`product_category`, `product_price`, `product_quantity`,
|
||||||
|
`product_event_type`, `purchase_id`, `purchase_revenue`, `purchase_coupon`.
|
||||||
|
|
||||||
|
Три колонки в источнике не лежат и считаются здесь:
|
||||||
|
|
||||||
|
| Колонка | Как считается | Зачем |
|
||||||
|
|---|---|---|
|
||||||
|
| `event_hour` | `toHour(event_time)` | суточный профиль спрашивают в каждом отчете |
|
||||||
|
| `page_path` | путь `url` без домена и параметров | группировка по странице без разбора строки в каждом запросе |
|
||||||
|
| `referer_host` | домен `referer` | «откуда пришли» одним значением, а не полным адресом |
|
||||||
|
|
||||||
|
Служебных `_load_id` и `_load_ts` у витрины нет, и это не забывчивость. Их
|
||||||
|
нет и у `dds.event_v`: событие в DDS не грузят, а читают из ODS, поэтому
|
||||||
|
координат собственной загрузки у него не бывает. Служебная колонка называет
|
||||||
|
запуск, который положил строку на диск, — здесь на диск никто ничего не
|
||||||
|
кладет.
|
||||||
|
|
||||||
|
## Ловушка задания
|
||||||
|
|
||||||
|
Массивы `product_*` тянет развернуть через `ARRAY JOIN` — так плоская
|
||||||
|
таблица становится по-настоящему плоской. Разворачивать нельзя, и вреда
|
||||||
|
здесь два.
|
||||||
|
|
||||||
|
Очевидный: у события с тремя позициями получилось бы три строки, зерно
|
||||||
|
витрины перестало бы совпадать с зерном источника, а вместе с ним развалилось
|
||||||
|
бы и правило формы — такую витрину уже пришлось бы материализовать.
|
||||||
|
|
||||||
|
Неочевидный и куда более разрушительный: `ARRAY JOIN` — соединение
|
||||||
|
внутреннее, и строку с пустым массивом он выбрасывает. Торговый блок пуст у
|
||||||
|
всех событий, кроме корзин и покупок, то есть у подавляющего большинства.
|
||||||
|
Так «плоская таблица событий» превращается в таблицу товарных позиций и
|
||||||
|
теряет девятнадцать строк из двадцати — а по имени и по составу колонок она
|
||||||
|
по-прежнему выглядит таблицей событий. Ошибка не падает и ничего не
|
||||||
|
подсвечивает.
|
||||||
|
|
||||||
|
Массивы едут в договор как есть; разворачивает их тот, кому нужна позиция
|
||||||
|
заказа, — и делает это уже в своей витрине, с другим зерном и другим именем.
|
||||||
|
|
||||||
|
## Как проверить себя
|
||||||
|
|
||||||
|
Деловой ключ — `event_id` внутри дня. Собери прямой ответ из `dds.event_v`
|
||||||
|
за тот же день, соедини с витриной полным внешним соединением и посчитай
|
||||||
|
строки, где ключа нет с одной из сторон или где различаются значения
|
||||||
|
деловых колонок. Ноль — зеленый.
|
||||||
|
|
||||||
|
Отдельно проверяются три производные колонки: `event_hour`, `page_path` и
|
||||||
|
`referer_host` должны совпасть с тем же выражением, посчитанным по
|
||||||
|
`dds.event_v` на лету.
|
||||||
|
|
||||||
|
Если строк в витрине оказалось в разы меньше, чем в источнике, а остались в
|
||||||
|
ней одни корзины и покупки — это развернутые массивы: `ARRAY JOIN` выбросил
|
||||||
|
все события с пустым торговым блоком.
|
||||||
|
|
||||||
|
## Приемка
|
||||||
|
|
||||||
|
- [ ] `dm.events_enriched_v` создано, физических таблиц под ним нет;
|
||||||
|
- [ ] список колонок в представлении явный;
|
||||||
|
- [ ] число строк и набор `event_id` совпадают с `dds.event_v` за каждый
|
||||||
|
прожитый день;
|
||||||
|
- [ ] задача `events_enriched` в `homework_check` зеленая;
|
||||||
|
- [ ] задачи в `dm_transform` **нет** — и не должно быть.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Задание 3: `dm.session_overview`
|
||||||
|
|
||||||
|
**Чему учит:** агрегат хранит слагаемые, а не готовые средние и доли.
|
||||||
|
Среднее по строкам витрины не складывается никогда, а уникальных нельзя
|
||||||
|
складывать даже тогда, когда на сегодняшних данных сумма сходится.
|
||||||
|
|
||||||
|
## Роль витрины
|
||||||
|
|
||||||
|
Дневной портрет визитов в разрезе устройств: сколько было сессий, какие они
|
||||||
|
были длинные, сколько страниц смотрели и как часто доходили до покупки.
|
||||||
|
Вопрос витрины — «как люди ходят по магазину с телефона и с настольного
|
||||||
|
компьютера, и чем эти визиты отличаются».
|
||||||
|
|
||||||
|
Сессия на стенде — расчетная сущность, ее уже нарезал слой DDS. Собирать
|
||||||
|
визиты заново из событий не нужно и нельзя: это была бы вторая нарезка,
|
||||||
|
которая тихо разойдется с первой.
|
||||||
|
|
||||||
|
Витрина с таким именем была и на прошлом стенде, но зерно у нее сменилось,
|
||||||
|
и сменилось не по вкусу. Там визитов как сущности не было, и витрина
|
||||||
|
нарезала их сама, строкой на визит. Здесь визит — сущность слоя DDS, и
|
||||||
|
витрина, повторяющая его зерно, оказалась бы обычным представлением по
|
||||||
|
[правилу формы](../architecture/dm.md): копией сессий с другими именами
|
||||||
|
колонок. Чтобы задание осталось про дневной агрегат, витрина поднялась на
|
||||||
|
зерно выше — до дня и среза.
|
||||||
|
|
||||||
|
## Зерно и форма
|
||||||
|
|
||||||
|
Зерно — **день сессии × категория устройства**. Источник —
|
||||||
|
`dds.session_v`, у него зерно визита, значит зерно сменилось и витрина
|
||||||
|
материализуется: `dm.session_overview_rep` / `_dist`, донор `_stage_rep` /
|
||||||
|
`_stage_dist`, публичное `dm.session_overview_v`. Ключ партиции — день.
|
||||||
|
|
||||||
|
Категория устройства взята из сессии как есть: у куки она постоянна, и слой
|
||||||
|
DDS уже перевел код источника в слово. Словарь расшифровки шире, чем мир
|
||||||
|
стенда: слов в нем четыре, а встречаются три — `desktop`, `mobile` и
|
||||||
|
`tablet`. Строку под `tv` заводить незачем, она возникнет сама, если такие
|
||||||
|
визиты появятся.
|
||||||
|
|
||||||
|
## Состав колонок
|
||||||
|
|
||||||
|
| Колонка | Тип | Что это |
|
||||||
|
|---|---|---|
|
||||||
|
| `report_date` | `Date` | день сессии |
|
||||||
|
| `device_category` | `LowCardinality(String)` | категория устройства |
|
||||||
|
| `sessions` | `UInt64` | число визитов |
|
||||||
|
| `visitors` | `UInt64` | число разных `client_id` |
|
||||||
|
| `bounce_sessions` | `UInt64` | визиты ровно из одного события |
|
||||||
|
| `purchase_sessions` | `UInt64` | визиты, в которых была хотя бы одна покупка |
|
||||||
|
| `duration_seconds_total` | `UInt64` | суммарная длительность визитов |
|
||||||
|
| `events_total` | `UInt64` | событий во всех визитах |
|
||||||
|
| `pageviews` | `UInt64` | просмотров страниц |
|
||||||
|
| `cart_adds` | `UInt64` | добавлений в корзину |
|
||||||
|
| `purchases` | `UInt64` | покупок |
|
||||||
|
| `_load_id` | `String` | запуск, собравший день |
|
||||||
|
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||||
|
|
||||||
|
Средней длительности и доли отказов в витрине нет намеренно. Хранить
|
||||||
|
`avg_duration_seconds` было бы удобнее ровно один раз — пока смотрят одну
|
||||||
|
строку. Тот, кто захочет среднее по дню целиком, сложит средние по трем
|
||||||
|
устройствам и получит неправду: средние не складываются, они взвешиваются.
|
||||||
|
Пара «`duration_seconds_total` и `sessions`» дает верный ответ на любом
|
||||||
|
срезе, а деление стоит потребителю один вызов. То же и с долей отказов:
|
||||||
|
храним `bounce_sessions` и `sessions`, долю считает тот, кто спрашивает.
|
||||||
|
|
||||||
|
С `visitors` история тоньше, и в ней главная ловушка задания. Складывать
|
||||||
|
уникальных по строкам нельзя: один и тот же посетитель, попавший в две
|
||||||
|
строки, будет посчитан дважды. Проверь на стенде — и увидишь, что сумма
|
||||||
|
`visitors` по устройствам за день **сходится** с `dm.daily_traffic_v`.
|
||||||
|
|
||||||
|
Это не отменяет правила, а показывает, откуда берется совпадение: в мире
|
||||||
|
стенда устройство приписано куке на всю жизнь ([словарь
|
||||||
|
понятий](../../CONTEXT.md), «Паспорт куки»), поэтому одна кука лежит ровно
|
||||||
|
в одной строке дня, и пересекаться строкам не в чем.
|
||||||
|
|
||||||
|
Совпадение это — свойство мира, а не свойство витрины. Появятся визиты
|
||||||
|
одной куки с двух устройств — сумма поедет, и ни одна проверка об этом не
|
||||||
|
скажет: отчет просто начнет тихо завышать аудиторию. Отсюда правило,
|
||||||
|
которое стоит унести с задания: складывать уникальных нельзя даже там, где
|
||||||
|
сегодня получается. Число посетителей дня спрашивают у
|
||||||
|
`dm.daily_traffic_v`, где оно посчитано на своем зерне.
|
||||||
|
|
||||||
|
## Ритм пересборки
|
||||||
|
|
||||||
|
Источник — сессии: день замерзает, как только прожит. Объем прогона тот же,
|
||||||
|
что в задании 2, — хвост от первого несобранного прожитого дня; механика та
|
||||||
|
же: донор и замена дневных партиций.
|
||||||
|
|
||||||
|
## Ключ шардирования
|
||||||
|
|
||||||
|
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||||
|
шардам». Здесь у измерения всего три встречающихся значения, и распределены
|
||||||
|
они неровно: планшеты — тонкая полоса, а телефоны и настольные машины держат
|
||||||
|
основную массу. Прежде чем брать хеш от одного `device_category`, посчитай,
|
||||||
|
сколько строк ляжет на каждый шард, и назови, чем тебя устраивает результат.
|
||||||
|
|
||||||
|
## Как проверить себя
|
||||||
|
|
||||||
|
Деловой ключ — `device_category` внутри дня. Собери прямой агрегат из
|
||||||
|
`dds.session_v` за день, соедини с `dm.session_overview_v` полным внешним
|
||||||
|
соединением и посчитай строки, где ключа нет с одной из сторон или где
|
||||||
|
расходится любой счетчик. Ноль — зеленый.
|
||||||
|
|
||||||
|
Две проверки покрупнее ловят грубую ошибку сразу:
|
||||||
|
|
||||||
|
- сумма `sessions` за день равна числу строк `dds.session_v` этого дня:
|
||||||
|
ни один визит не потерян и ни один не задвоен;
|
||||||
|
- сумма `events_total` за день равна сумме `events_total` по сессиям того
|
||||||
|
же дня.
|
||||||
|
|
||||||
|
Если суммы событий сходятся, а сумма сессий больше числа визитов — почти
|
||||||
|
наверняка витрина собрана из событий, а не из сессий.
|
||||||
|
|
||||||
|
## Приемка
|
||||||
|
|
||||||
|
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||||
|
ключом цели;
|
||||||
|
- [ ] `dm.session_overview_v` перечисляет колонки явно;
|
||||||
|
- [ ] источник витрины — `dds.session_v`, своей нарезки визитов в ней нет;
|
||||||
|
- [ ] средних и долей в физической таблице нет;
|
||||||
|
- [ ] задача `session_overview` в `homework_check` зеленая;
|
||||||
|
- [ ] задача витрины добавлена в `dm_transform`.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Задание 6 (необязательное): свой дашборд в Superset
|
||||||
|
|
||||||
|
**Чему учит:** довести свои витрины до потребителя — завести в Superset
|
||||||
|
датасеты поверх публичных представлений, подобрать графики под вопросы и
|
||||||
|
собрать из них один связный экран.
|
||||||
|
|
||||||
|
Эта лаба идет после заданий 1–5 и опирается на их результат. Ее можно
|
||||||
|
пропустить: основной предмет стенда — ClickHouse, а здесь вся работа — в
|
||||||
|
интерфейсе BI.
|
||||||
|
|
||||||
|
## Роль дашборда
|
||||||
|
|
||||||
|
Собери обзор трафика и поведения посетителей. Он должен отвечать на три
|
||||||
|
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
|
||||||
|
каналы дали результат, были ли ошибки приема данных.
|
||||||
|
|
||||||
|
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
|
||||||
|
собери из пяти витрин, которые ты построил в заданиях. Копировать раскладку
|
||||||
|
стендового дашборда не нужно: выбери форму, в которой ответы читаются без
|
||||||
|
пояснений рядом с экраном.
|
||||||
|
|
||||||
|
## Свои объекты — отдельно
|
||||||
|
|
||||||
|
Не добавляй графики в стендовый дашборд и не правь его датасеты. При
|
||||||
|
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
|
||||||
|
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
|
||||||
|
таких объектов описан в [доке Superset](../architecture/superset.md), «Импорт
|
||||||
|
при каждой инициализации».
|
||||||
|
|
||||||
|
Заведи отдельные датасеты и графики, собери из них свой дашборд. Назови его,
|
||||||
|
например, «Мои витрины DM». Это то же правило, по которому DDL домашек лежит
|
||||||
|
в `sql/ddl/70-labs.sql`: стенд привозит свои объекты, менти создает свои.
|
||||||
|
|
||||||
|
## Датасеты
|
||||||
|
|
||||||
|
Заведи в подключении `ClickHouse` пять физических датасетов схемы `dm`:
|
||||||
|
|
||||||
|
- `events_enriched_v`;
|
||||||
|
- `top_pages_daily_v`;
|
||||||
|
- `session_overview_v`;
|
||||||
|
- `dq_errors_daily_v`;
|
||||||
|
- `utm_effectiveness_v`.
|
||||||
|
|
||||||
|
## Состав дашборда
|
||||||
|
|
||||||
|
Каждая из пяти витрин должна появиться хотя бы в одном графике. Точный тип
|
||||||
|
графика выбери сам. На дашборде покажи следующее:
|
||||||
|
|
||||||
|
| Витрина | Что показать |
|
||||||
|
|---|---|
|
||||||
|
| `events_enriched_v` | число событий по часу и типу события |
|
||||||
|
| `top_pages_daily_v` | страницы с просмотрами, посетителями и добавлениями в корзину |
|
||||||
|
| `session_overview_v` | сравнение устройств по визитам, отказам и визитам с покупкой |
|
||||||
|
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
|
||||||
|
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
|
||||||
|
|
||||||
|
Для долей используй слагаемые из витрин. Например, доля отказов считается
|
||||||
|
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
|
||||||
|
долям для такого графика не подходит — урок задания 3 остается в силе.
|
||||||
|
|
||||||
|
Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
|
||||||
|
|
||||||
|
## Как проверить себя
|
||||||
|
|
||||||
|
Эта лаба проверяется глазами. У расположения панелей и понятных подписей нет
|
||||||
|
счетного инварианта, поэтому задачи для нее в `homework_check` нет.
|
||||||
|
|
||||||
|
Открой список дашбордов и проверь:
|
||||||
|
|
||||||
|
- «Бизнес-сюжеты» остался отдельным стендовым дашбордом;
|
||||||
|
- твой дашборд открывается без ошибок и содержит график от каждого из пяти
|
||||||
|
датасетов;
|
||||||
|
- подписи, единицы измерения и легенды читаются без обращения к SQL;
|
||||||
|
- прямой трафик не исчез из графика UTM, а заявленная выручка не выдана за
|
||||||
|
серверную;
|
||||||
|
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
|
||||||
|
|
||||||
|
Последний проход сделай как читатель: найди на экране самый заметный канал,
|
||||||
|
страницу и устройство, затем проверь, понятно ли из подписей, что именно
|
||||||
|
сравнивается. Если ответ приходится угадывать, поправь график или его название.
|
||||||
|
|
||||||
|
## Приемка
|
||||||
|
|
||||||
|
- [ ] пять датасетов читают публичные представления витрин заданий 1–5;
|
||||||
|
- [ ] создан отдельный дашборд, стендовые объекты не изменены;
|
||||||
|
- [ ] каждая витрина представлена хотя бы одним графиком;
|
||||||
|
- [ ] дашборд проверен глазами по разделу выше.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Задание 2: `dm.top_pages_daily`
|
||||||
|
|
||||||
|
**Чему учит:** зерно сменилось — значит витрина материализуется, живет
|
||||||
|
дневными партициями и встает в конвейер. Первая настоящая витрина: пять
|
||||||
|
объектов, донор, замена партиции, задача в `dm_transform`.
|
||||||
|
|
||||||
|
## Роль витрины
|
||||||
|
|
||||||
|
Популярность страниц по дням: сколько раз страницу открыли, сколько разных
|
||||||
|
посетителей ее видели и что на ней делали кроме просмотра.
|
||||||
|
|
||||||
|
Торговое событие садится на ту страницу, где случилось: корзина — на
|
||||||
|
карточку товара, покупка — на страницу подтверждения заказа. Поэтому у
|
||||||
|
карточки товара рядом стоят просмотры и корзины, и их отношение —
|
||||||
|
конверсия карточки — главный вопрос к этой витрине: какие товары кладут в
|
||||||
|
корзину, едва открыв, а какие смотрят и уходят.
|
||||||
|
|
||||||
|
Воронки «просмотр → корзина → покупка» здесь нет и быть не может: корзина и
|
||||||
|
покупка живут на разных страницах, и связать их шаги в одну цепочку на
|
||||||
|
зерне страницы не из чего. Путь одного человека собирают из событий или из
|
||||||
|
сессий, а витрина страниц отвечает про страницу.
|
||||||
|
|
||||||
|
## Зерно и форма
|
||||||
|
|
||||||
|
Зерно — **день события × страница**. Источник — события, у него зерно
|
||||||
|
события, значит зерно сменилось и витрина материализуется:
|
||||||
|
`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
|
||||||
|
публичное `dm.top_pages_daily_v`.
|
||||||
|
|
||||||
|
Страница — это `page_path`, путь `url` без домена и параметров запроса;
|
||||||
|
считается он здесь же, из колонки `url` источника. Полный адрес в зерно не
|
||||||
|
берется: с параметрами запроса одна и та же страница рассыпалась бы на
|
||||||
|
сотни строк, и витрина перестала бы отвечать на свой вопрос.
|
||||||
|
|
||||||
|
Ключ партиции — день: он же единица пересборки.
|
||||||
|
|
||||||
|
Источник — `dds.event_v`, публичный договор события. Витрина задания 1
|
||||||
|
источником не служит: витрина слоя DM читает договор DDS, как и стендовые
|
||||||
|
витрины, и не зависит от того, выполнена ли соседняя домашка.
|
||||||
|
|
||||||
|
## Состав колонок
|
||||||
|
|
||||||
|
| Колонка | Тип | Что это |
|
||||||
|
|---|---|---|
|
||||||
|
| `report_date` | `Date` | день события |
|
||||||
|
| `page_path` | `LowCardinality(String)` | путь страницы |
|
||||||
|
| `pageviews` | `UInt64` | число событий `pageview` на странице |
|
||||||
|
| `visitors` | `UInt64` | число разных `client_id` на странице |
|
||||||
|
| `cart_adds` | `UInt64` | число событий `add_to_cart` на странице |
|
||||||
|
| `purchases` | `UInt64` | число событий `purchase` на странице |
|
||||||
|
| `_load_id` | `String` | запуск, собравший день |
|
||||||
|
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||||
|
|
||||||
|
Счетчики условные: строку дают все три типа событий, а различает их
|
||||||
|
`countIf`. Отфильтровать источник по `event_type = 'pageview'`, как
|
||||||
|
подсказывает имя витрины, — значит получить витрину из одних просмотров:
|
||||||
|
страницы останутся на месте, а `cart_adds` и `purchases` у всех строк
|
||||||
|
обнулятся. Ошибка тихая: витрина выглядит собранной, и главный ее вопрос —
|
||||||
|
конверсия карточки — молча отвечает «ноль везде».
|
||||||
|
|
||||||
|
## Ритм пересборки
|
||||||
|
|
||||||
|
Источник — события: день замерзает сразу, как только прожит. Значит
|
||||||
|
пересобирать нужно только то, что еще не собрано: хвост дней, которых в
|
||||||
|
витрине нет, — от первого несобранного прожитого дня до последнего
|
||||||
|
прожитого. Готовые дни трогать незачем, а вот дыру, оставленную частичным
|
||||||
|
отказом, объем прогона обязан подобрать: ищи не последний собранный день, а
|
||||||
|
первый несобранный.
|
||||||
|
|
||||||
|
Механика — сборка отрезка в донор одной вставкой и замена дневных партиций
|
||||||
|
по одной, как у `revenue_daily`.
|
||||||
|
|
||||||
|
## Ключ шардирования
|
||||||
|
|
||||||
|
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||||
|
шардам». Подумай, чем эта витрина отличается от `revenue_daily`: там кроме
|
||||||
|
дня было одно измерение из шести близких по объему значений, здесь —
|
||||||
|
страницы, которых много и которые по популярности очень неровные.
|
||||||
|
|
||||||
|
## Как проверить себя
|
||||||
|
|
||||||
|
Деловой ключ — `page_path` внутри дня. Собери прямой агрегат из
|
||||||
|
`dds.event_v` за день, соедини с `dm.top_pages_daily_v` полным внешним
|
||||||
|
соединением по ключу и посчитай строки, где ключа нет с одной из сторон или
|
||||||
|
где расходится любой из четырех счетчиков. Ноль — зеленый.
|
||||||
|
|
||||||
|
Две проверки попроще, ими удобно ловить грубую ошибку до полного
|
||||||
|
соединения:
|
||||||
|
|
||||||
|
- сумма `pageviews` за день равна числу событий `pageview` этого дня в
|
||||||
|
`dds.event_v`;
|
||||||
|
- сумма `purchases` за день равна числу событий `purchase` этого дня.
|
||||||
|
|
||||||
|
Проверки ловят разное. Фильтр `WHERE event_type = 'pageview'` первая из них
|
||||||
|
пропускает: отбор строк по типу не меняет счет событий того же типа.
|
||||||
|
Расхождение покажет только вторая — вместе с `cart_adds` обнулятся и
|
||||||
|
`purchases`. Проверку выбирают по тому, умеет ли она покраснеть на твоей
|
||||||
|
ошибке, а не по тому, как быстро она пишется: [проверка, которая не умеет
|
||||||
|
краснеть, бесполезна](../architecture/testing.md).
|
||||||
|
|
||||||
|
`visitors` складывать по строкам нельзя: один посетитель ходит по разным
|
||||||
|
страницам и в сумме посчитается несколько раз. Число посетителей дня
|
||||||
|
спрашивают у `dm.daily_traffic_v`, а не у этой витрины.
|
||||||
|
|
||||||
|
## Приемка
|
||||||
|
|
||||||
|
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||||
|
ключом цели;
|
||||||
|
- [ ] `dm.top_pages_daily_v` перечисляет колонки явно, включая `_load_id` и
|
||||||
|
`_load_ts`;
|
||||||
|
- [ ] объем прогона выводится из состояния витрины, а не задается руками;
|
||||||
|
- [ ] задача `top_pages_daily` в `homework_check` зеленая;
|
||||||
|
- [ ] задача витрины добавлена в `dm_transform`, повторный прогон конвейера
|
||||||
|
результат не меняет.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Задание 5: `dm.utm_effectiveness`
|
||||||
|
|
||||||
|
**Чему учит:** приставка `declared_` в имени колонки — предупреждение, а не
|
||||||
|
украшение. Она говорит: это заявка клиента, а не деньги отчетности. Урок
|
||||||
|
здесь дает конфликт: суммы не сойдутся, и раскапывать почему предстоит
|
||||||
|
самому.
|
||||||
|
|
||||||
|
Правило стенда: **поведение и атрибуцию считаем по трекеру, деньги — по
|
||||||
|
бэкенду**. Эта витрина — единственное разрешенное исключение: атрибуции по
|
||||||
|
UTM у бэкенда нет, метки живут только в кликстриме. Раз считать иначе
|
||||||
|
нечем, клиентская сумма берется, но называется своим именем.
|
||||||
|
|
||||||
|
## Роль витрины
|
||||||
|
|
||||||
|
Эффективность рекламных каналов по дням: сколько трафика привел канал,
|
||||||
|
сколько корзин и покупок в нем случилось и на какую сумму эти покупки, по
|
||||||
|
словам клиента.
|
||||||
|
|
||||||
|
## Атрибуция: по событию, а не по визиту
|
||||||
|
|
||||||
|
Метки едут на каждом событии, и витрина группирует события по их
|
||||||
|
собственным меткам. Модели «первое касание» или «последнее касание» здесь
|
||||||
|
нет: их построение — отдельная тема, а это витрина трафика по меткам.
|
||||||
|
|
||||||
|
`dds.session_v` при этом несет UTM первого события визита, и ответ «какой
|
||||||
|
канал привел» из сессии и из этой витрины будет разным. Это не
|
||||||
|
ошибка ни одной из сторон — это два разных вопроса, и в отчете их надо
|
||||||
|
различать.
|
||||||
|
|
||||||
|
## Прямой трафик из витрины не выбрасывается
|
||||||
|
|
||||||
|
Соблазн отфильтровать строки без меток велик: витрина же про рекламу.
|
||||||
|
Отфильтровать нельзя по двум причинам. Первая деловая: прямой трафик —
|
||||||
|
обычно самый крупный «канал» магазина, и отчет, где его нет, врет о доле
|
||||||
|
рекламы. Вторая техническая: как только часть событий выпала, итог витрины
|
||||||
|
перестал сходиться с итогом источника, и проверить сборку стало нечем.
|
||||||
|
|
||||||
|
Пустые метки остаются пустыми строками, как в источнике. Своего слова для
|
||||||
|
«прямого захода» витрина не изобретает: чем меньше выдуманных значений, тем
|
||||||
|
меньше расшифровывать читателю.
|
||||||
|
|
||||||
|
## Зерно и форма
|
||||||
|
|
||||||
|
Зерно — **день события × `utm_source` × `utm_medium` × `utm_campaign`**.
|
||||||
|
Зерно источника — событие, значит витрина материализуется:
|
||||||
|
`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
|
||||||
|
публичное `dm.utm_effectiveness_v`. Ключ партиции — день.
|
||||||
|
|
||||||
|
Источник — `dds.event_v`, публичный договор события: витрина слоя DM читает
|
||||||
|
договор DDS, а не соседнюю витрину.
|
||||||
|
|
||||||
|
## Состав колонок
|
||||||
|
|
||||||
|
| Колонка | Тип | Что это |
|
||||||
|
|---|---|---|
|
||||||
|
| `report_date` | `Date` | день события |
|
||||||
|
| `utm_source` | `LowCardinality(String)` | источник кампании |
|
||||||
|
| `utm_medium` | `LowCardinality(String)` | канал кампании |
|
||||||
|
| `utm_campaign` | `String` | имя кампании |
|
||||||
|
| `visitors` | `UInt64` | число разных `client_id` |
|
||||||
|
| `pageviews` | `UInt64` | просмотров страниц |
|
||||||
|
| `add_to_carts` | `UInt64` | добавлений в корзину |
|
||||||
|
| `purchases` | `UInt64` | покупок |
|
||||||
|
| `declared_revenue` | `Decimal(18, 2)` | выручка покупок глазами клиента |
|
||||||
|
| `_load_id` | `String` | запуск, собравший день |
|
||||||
|
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||||
|
|
||||||
|
`declared_revenue` складывает `purchase_revenue[1]` событий `purchase`.
|
||||||
|
Индекс не описка: блок `purchase*` едет параллельными массивами, и у события
|
||||||
|
покупки в них ровно один элемент — контракт источника обещает один номер
|
||||||
|
заказа и одну объявленную сумму на событие. Тем же индексом берет свою
|
||||||
|
сторону стендовая сверка `purchase_vs_orders`.
|
||||||
|
|
||||||
|
Приводить тип не нужно: в договоре `purchase_revenue` уже
|
||||||
|
`Array(Decimal(18, 2))`. `Float64` эта сумма была только в выгрузке
|
||||||
|
источника, и ODS перевел ее в точные деньги на приеме — сложение Decimal
|
||||||
|
точное, и где стоит `CAST`, до или после `sum`, на результат не влияет.
|
||||||
|
Витрина просто хранит то, что сложила, в `Decimal(18, 2)`.
|
||||||
|
|
||||||
|
`utm_campaign` оставлена обычной строкой: имен кампаний со временем
|
||||||
|
становится много, и словарь `LowCardinality` на растущем множестве
|
||||||
|
перестает окупаться.
|
||||||
|
|
||||||
|
## Ритм пересборки
|
||||||
|
|
||||||
|
Источник — события: день замерзает сразу. Объем прогона и механика те же,
|
||||||
|
что в задании 2: хвост несобранных дней, донор, замена дневных партиций.
|
||||||
|
|
||||||
|
## Ключ шардирования
|
||||||
|
|
||||||
|
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||||
|
шардам». Зерно здесь составное, из трех колонок, и решение «взять хеш от
|
||||||
|
одной из них» надо защищать отдельно: назови, какая доля строк придется на
|
||||||
|
самое частое значение выбранной колонки.
|
||||||
|
|
||||||
|
## Конфликт, ради которого витрина и написана
|
||||||
|
|
||||||
|
Собери за один и тот же день две суммы: `sum(declared_revenue)` из своей
|
||||||
|
витрины и `sum(revenue)` из `dm.revenue_daily_v`. Они не сойдутся, и это
|
||||||
|
верное поведение обеих витрин.
|
||||||
|
|
||||||
|
Базы у двух сумм при этом сравнимые: `revenue_daily` складывает цену позиций
|
||||||
|
на количество и ни скидки, ни доставки в себя не берет — ровно то, что видит
|
||||||
|
клиент. Значит все расхождение до копейки объясняется тем, что случилось с
|
||||||
|
заказом, а не тем, как считали деньги. Ищи ответы среди таких причин:
|
||||||
|
|
||||||
|
- отмененный заказ из кликстрима никуда не девается: событие покупки
|
||||||
|
случилось, а `revenue_daily` считает только оплаченные;
|
||||||
|
- событие покупки может потеряться по дороге, а заказ при этом доехать —
|
||||||
|
и наоборот, событие приехать дважды;
|
||||||
|
- клиент назвал сумму, отличную от серверной суммы позиций: генератор
|
||||||
|
делает это намеренно у одного-двух заказов из ста.
|
||||||
|
|
||||||
|
Как эти же расхождения выглядят на зерне заказа, показывает стендовая
|
||||||
|
витрина `dm.purchase_vs_orders_v` с ее классами расхождений: это готовый
|
||||||
|
ответ на вопрос «а сколько именно и из-за чего». Запиши свой вывод —
|
||||||
|
в двух-трех фразах, своими словами.
|
||||||
|
|
||||||
|
Проверять сходимость этих двух сумм не нужно и нельзя: они и не должны
|
||||||
|
сходиться.
|
||||||
|
|
||||||
|
## Как проверить себя
|
||||||
|
|
||||||
|
Деловой ключ — тройка `utm_source`, `utm_medium`, `utm_campaign` внутри
|
||||||
|
дня. Собери прямой агрегат из `dds.event_v` за день, соедини с
|
||||||
|
`dm.utm_effectiveness_v` полным внешним соединением и посчитай строки, где
|
||||||
|
ключа нет с одной из сторон или где расходится любая из пяти величин. Ноль —
|
||||||
|
зеленый.
|
||||||
|
|
||||||
|
Две проверки покрупнее ловят самые частые ошибки:
|
||||||
|
|
||||||
|
- сумма `pageviews`, `add_to_carts` и `purchases` за день равна числу
|
||||||
|
событий соответствующего типа в `dds.event_v` за тот же день;
|
||||||
|
- сумма `declared_revenue` за день равна сумме `purchase_revenue[1]` по всем
|
||||||
|
событиям `purchase` этого дня.
|
||||||
|
|
||||||
|
Обе не сойдутся, если прямой трафик отфильтрован, и разойдутся ровно на его
|
||||||
|
долю — по этому следу отфильтрованные строки и находят. `visitors`, как и в
|
||||||
|
задании 3, по строкам не складывается: один посетитель приходит по разным
|
||||||
|
меткам.
|
||||||
|
|
||||||
|
## Приемка
|
||||||
|
|
||||||
|
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||||
|
ключом цели;
|
||||||
|
- [ ] строки без UTM-меток в витрине остались;
|
||||||
|
- [ ] `declared_revenue` хранится в `Decimal(18, 2)`;
|
||||||
|
- [ ] расхождение с `dm.revenue_daily_v` объяснено письменно;
|
||||||
|
- [ ] задача `utm_effectiveness` в `homework_check` зеленая;
|
||||||
|
- [ ] задача витрины добавлена в `dm_transform`.
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# Формат дневного слепка заказов на проводе
|
||||||
|
|
||||||
|
Дата исследования: 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`:
|
||||||
|
это день создания строки
|
||||||
|
источника, а не доказательство дня бизнес-события.
|
||||||
|
|
||||||
|
Минимальное решение — не добавлять `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`
|
||||||
|
или с хвостовым мусором. Поэтому один и тот же результат разбора можно
|
||||||
|
использовать и для типизированной строки, и для маршрутизации ошибки; нулевая
|
||||||
|
дата не нужна.
|
||||||
|
|
||||||
|
> Актуальный стенд: версию `ods.order` задаёт `snapshot_date`
|
||||||
|
> ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Следующий
|
||||||
|
> абзац фиксирует исходный вывод исследования и не описывает текущий контракт.
|
||||||
|
|
||||||
|
`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).
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Соединение двух Distributed-таблиц на стенде
|
||||||
|
|
||||||
|
Дата исследования: 2026-08-22. Тикет #129, хвост раздела 11 мастер-спеки.
|
||||||
|
|
||||||
|
Учебный результат: менти видит на своём кластере, где именно выполняется
|
||||||
|
JOIN двух Distributed-таблиц, чем GLOBAL отличается от обычного соединения и
|
||||||
|
почему «правильный» ключ шардирования в DDL ещё не гарантирует ко-локацию
|
||||||
|
данных.
|
||||||
|
|
||||||
|
Проверено двумя дорогами: читающими запросами на живом стенде
|
||||||
|
(ClickHouse 26.3.17.56, 2 шарда × 1 реплика; запросы помечены
|
||||||
|
`log_comment = 'r129-*'`, разбор — по `system.query_log` обеих нод) и по
|
||||||
|
документации ClickHouse через MCP Context7 (страницы «JOIN Clause →
|
||||||
|
Distributed JOIN», «Distributed table engine», «IN operators → distributed
|
||||||
|
subqueries», настройки `distributed_product_mode`,
|
||||||
|
`prefer_global_in_and_join`, `parallel_distributed_insert_select`).
|
||||||
|
|
||||||
|
## Короткий вывод
|
||||||
|
|
||||||
|
- Обычный JOIN и `IN` с Distributed-таблицей справа режутся настройкой
|
||||||
|
`distributed_product_mode = 'deny'` — это заводское значение, стенд его не
|
||||||
|
менял. Ошибка: `DISTRIBUTED_IN_JOIN_SUBQUERY_DENIED`.
|
||||||
|
- `GLOBAL JOIN` собирает правую часть один раз на ноде-инициаторе, кладёт во
|
||||||
|
временную таблицу `_data_…` и рассылает её на все шарды; каждый шард
|
||||||
|
соединяет свой локальный кусок левой таблицы с этой копией. Это видно в
|
||||||
|
журнале запросов второй ноды дословно.
|
||||||
|
- Режим `local` переписывает правую Distributed-таблицу на локальную. Ответ
|
||||||
|
верен только при настоящей ко-локации по ключу соединения; иначе он молча
|
||||||
|
меньше правильного — ошибки не будет.
|
||||||
|
- Если левая часть — подзапрос, а не голая таблица, ClickHouse 26.3 вообще не
|
||||||
|
раздаёт соединение по шардам: обе стороны стягиваются на инициатор и JOIN
|
||||||
|
выполняется там один раз. `GLOBAL` и `distributed_product_mode` при этом ни
|
||||||
|
на что не влияют.
|
||||||
|
- Ловушка `FULL OUTER`: при голой левой Distributed-таблице непарная строка
|
||||||
|
правой части попадает в ответ по разу с каждого шарда — на нашем кластере
|
||||||
|
дважды. С подзапросами с обеих сторон ответ верный.
|
||||||
|
- Находка стенда: ко-локация заказов по `cityHash64(order_id)` сломана — 1189
|
||||||
|
из 1662 заказов лежат на обоих шардах одинаковыми строками. Виновата
|
||||||
|
настройка `parallel_distributed_insert_select = 2` (заводское значение
|
||||||
|
в 26.3): `INSERT … SELECT` из Distributed в Distributed выполнился локально
|
||||||
|
на каждом шарде, и строки заказов унаследовали раскладку сырья — по хешу
|
||||||
|
сырой строки. События целы: все 401 185 строк лежат по своему хешу
|
||||||
|
`cityHash64(ClientID)`.
|
||||||
|
|
||||||
|
## Как выполняется соединение: три наблюдаемых плана
|
||||||
|
|
||||||
|
### Обычный JOIN голых Distributed-таблиц — запрещён
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT count() FROM ods.order_dist AS a
|
||||||
|
INNER JOIN ods.order_dist AS b ON a.order_id = b.order_id
|
||||||
|
```
|
||||||
|
|
||||||
|
Обе версии планировщика (`enable_analyzer` 0 и 1) отвечают:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Code: 288. DB::Exception: Double-distributed IN/JOIN subqueries is denied
|
||||||
|
(distributed_product_mode = 'deny'). You may rewrite query to use local
|
||||||
|
tables in subqueries, or use GLOBAL keyword, or set distributed_product_mode
|
||||||
|
to suitable value. (DISTRIBUTED_IN_JOIN_SUBQUERY_DENIED)
|
||||||
|
```
|
||||||
|
|
||||||
|
Причина по документации: запрос переписывается для каждого шарда, и правая
|
||||||
|
Distributed-таблица внутри такой переписки означала бы «каждый шард ещё раз
|
||||||
|
спрашивает весь кластер» — N шардов породили бы N×N подзапросов. Режим `deny`
|
||||||
|
запрещает это по умолчанию. Тот же запрет действует на `IN
|
||||||
|
(SELECT … FROM …_dist)` — поэтому в исследовательских запросах ниже стоит
|
||||||
|
`GLOBAL IN`.
|
||||||
|
|
||||||
|
### GLOBAL JOIN — правая часть собирается один раз и рассылается
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT count() FROM ods.order_dist AS a
|
||||||
|
GLOBAL INNER JOIN ods.order_dist AS b ON a.order_id = b.order_id
|
||||||
|
```
|
||||||
|
|
||||||
|
Журнал запросов второй ноды показывает оба шага (log_comment
|
||||||
|
`r129-bare-global`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
SELECT order_id FROM ods.order_rep -- шард отдаёт свой кусок правой части
|
||||||
|
SELECT count() FROM ods.order_rep
|
||||||
|
GLOBAL ALL INNER JOIN `_data_14241958685358625318_…` ON … -- шард соединяет локальную левую с разосланной копией
|
||||||
|
```
|
||||||
|
|
||||||
|
Инициатор один раз прочёл правую часть со всех шардов, собрал временную
|
||||||
|
таблицу в памяти, разослал её и слил частичные результаты. Цена GLOBAL —
|
||||||
|
память инициатора и рассылка по сети, поэтому он для малой правой части;
|
||||||
|
заказы (2851 строка) малы.
|
||||||
|
|
||||||
|
### Подзапрос слева выключает раздачу по шардам
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT count()
|
||||||
|
FROM (SELECT arrayJoin(purchaseID) AS pid FROM ods.event_dist
|
||||||
|
WHERE notEmpty(purchaseID)) AS e
|
||||||
|
INNER JOIN ods.order_dist AS o ON e.pid = o.order_id
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот запрос прошёл без ошибки даже при `deny`, и с `GLOBAL`, и во всех
|
||||||
|
режимах `distributed_product_mode` — план один и тот же: вторая нода получила
|
||||||
|
два независимых чтения (`SELECT … FROM ods.event_rep`,
|
||||||
|
`SELECT order_id FROM ods.order_rep`), а соединение выполнил инициатор,
|
||||||
|
прочитав целиком обе стороны (404 036 строк). Раздачи соединения по шардам
|
||||||
|
здесь нет вовсе, поэтому нет и «двойной распределённости», которую режим
|
||||||
|
`deny` запрещает.
|
||||||
|
|
||||||
|
## distributed_product_mode на живых данных
|
||||||
|
|
||||||
|
Самосоединение заказов, счёт пар строк. Правильные ответы посчитаны отдельно
|
||||||
|
как суммы квадратов кратностей: по `order_id` — 5229, по `user_id` — 6087.
|
||||||
|
|
||||||
|
| Ключ | GLOBAL | `local` | `allow` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `order_id` (заявленный ключ шардирования) | 5229 | **2851** | — |
|
||||||
|
| `user_id` (ко-локации нет и не заявлено) | 6087 | **3281** | 6087 |
|
||||||
|
|
||||||
|
`local` переписывает правую часть на локальную таблицу — в журнале второй
|
||||||
|
ноды виден дословный `… FROM ods.order_rep INNER JOIN ods.order_rep …`. Ответ
|
||||||
|
совпадает с суммой пошардовых соединений и верен только тогда, когда обе
|
||||||
|
строки каждой пары лежат на одном шарде. По `user_id` это ожидаемо не так. По
|
||||||
|
`order_id` ответ тоже неверен — потому что настоящая раскладка заказов ключу
|
||||||
|
не соответствует (см. ниже). `allow` разрешает наивный план «каждый шард
|
||||||
|
спрашивает весь кластер»: ответ верный, чтений — квадрат от числа шардов.
|
||||||
|
|
||||||
|
Документация даёт четыре режима (`deny`, `local`, `global`, `allow`) и
|
||||||
|
отдельную настройку `prefer_global_in_and_join`, которая молча превращает
|
||||||
|
обычные IN/JOIN в GLOBAL. Стенд ничего из этого не меняет: явный `GLOBAL` в
|
||||||
|
тексте запроса честнее незаметной настройки — читатель видит цену в самом
|
||||||
|
запросе.
|
||||||
|
|
||||||
|
## Ловушка FULL OUTER на кластере
|
||||||
|
|
||||||
|
Строка правой части, не нашедшая пары, добавляется в ответ каждым шардом:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT countIf(b_flag = 1) FROM ods.order_dist AS a
|
||||||
|
GLOBAL FULL OUTER JOIN
|
||||||
|
(SELECT 'zzz-no-such-order' AS oid, 1 AS b_flag) AS b ON a.order_id = b.oid
|
||||||
|
-- ответ: 2 (одна фиктивная строка вернулась дважды — по разу с каждого шарда)
|
||||||
|
```
|
||||||
|
|
||||||
|
Так ведут себя обе версии планировщика. Механика прямая: каждый шард получил
|
||||||
|
копию правой части, у каждого она не нашла пары в его локальном куске, и
|
||||||
|
каждый честно отдал её как непарную. Стоит обернуть левую часть в подзапрос —
|
||||||
|
соединение выполняется один раз на инициаторе, и ответ становится верным:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
… FROM (SELECT order_id FROM ods.order_dist) AS a GLOBAL FULL OUTER JOIN …
|
||||||
|
-- ответ: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Находка: ко-локация заказов сломана
|
||||||
|
|
||||||
|
DDL обещает раскладку по `cityHash64(order_id)`, но на живых данных половина
|
||||||
|
строк лежит не на своём шарде:
|
||||||
|
|
||||||
|
| Шард | на своём шарде | не на своём |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 696 | 725 |
|
||||||
|
| 2 | 750 | 680 |
|
||||||
|
|
||||||
|
1189 из 1662 заказов присутствуют на обоих шардах одинаковыми строками —
|
||||||
|
одна и та же версия, один и тот же `_load_id`. Пути реплик в keeper у шардов
|
||||||
|
разные, репликация ни при чём.
|
||||||
|
|
||||||
|
Причина — `parallel_distributed_insert_select = 2`, заводское значение
|
||||||
|
в 26.3. По документации: если выполняется `INSERT INTO distributed_a
|
||||||
|
SELECT … FROM distributed_b` и обе таблицы на одном кластере, запрос
|
||||||
|
выполняется локально на каждом шарде. Ровно это делает разбор заказов
|
||||||
|
(`sql/ods/order_parse.sql`: `INSERT INTO ods.order_dist … FROM
|
||||||
|
stg.orders_raw_dist`): каждый шард разобрал свой кусок сырья и записал его в
|
||||||
|
свою локальную таблицу, минуя ключ шардирования цели. Строки заказов
|
||||||
|
унаследовали раскладку STG — хеш сырой строки, то есть случайную относительно
|
||||||
|
`order_id`. Заказ, попавший в несколько дневных слепков, приезжает несколькими
|
||||||
|
разными сырыми строками и оседает на обоих шардах; ReplacingMergeTree
|
||||||
|
схлопывает дубли только внутри шарда.
|
||||||
|
|
||||||
|
Следствия уже наблюдаемы: `count()` по `ods.order_dist` даёт 2851 при 1662
|
||||||
|
заказах, а точное чтение работает только через `GROUP BY order_id` /
|
||||||
|
`argMax` — пошардовый `FINAL` через дубль на соседнем шарде не переступит.
|
||||||
|
События этой болезни не имеют: их пишет матвью приёма от Kafka-таблицы, не
|
||||||
|
из Distributed, и раскладка по `cityHash64(ClientID)` точна на всех 401 185
|
||||||
|
строках.
|
||||||
|
|
||||||
|
Починка — отдельный тикет, не этого исследования: разбору заказов нужно
|
||||||
|
явное `parallel_distributed_insert_select = 0` (строки поедут через ключ
|
||||||
|
шардирования цели), плюс пересборка уже разложенного. Тот же капкан ждёт
|
||||||
|
любую будущую сборку DDS вида `INSERT INTO …_dist SELECT … FROM …_dist`, у
|
||||||
|
которой ключ цели отличается от ключа источника.
|
||||||
|
|
||||||
|
## Выводы для identity_map (спека §5)
|
||||||
|
|
||||||
|
Сборка карты — соединение покупок из событий с заказами по
|
||||||
|
`purchaseID = order_id`. Ключ соединения не ключ ко-локации событий, поэтому
|
||||||
|
по политике раздела 6 спеки соединение обязано быть GLOBAL — и эмпирика это
|
||||||
|
подтверждает: обычный JOIN голых таблиц запрещён, `local` дал бы молча
|
||||||
|
неверный ответ. GLOBAL здесь дёшев: правая часть — заказы, их тысячи.
|
||||||
|
|
||||||
|
На практике левая часть сборки — подзапрос (события фильтруются по
|
||||||
|
`notEmpty(purchaseID)` и разворачиваются `arrayJoin`), а значит на 26.3
|
||||||
|
соединение выполнится один раз на инициаторе. `GLOBAL` в тексте при этом не
|
||||||
|
лишний: он выражает политику спеки, ничего не стоит и защищает запрос от
|
||||||
|
смены плана при другой форме запроса или версии сервера.
|
||||||
|
|
||||||
|
Запись результата в `dds.identity_map` (шардирование `cityHash64(client_id)`)
|
||||||
|
пойдёт тем же `INSERT … SELECT`. Источник — результат соединения, а не голая
|
||||||
|
Distributed-таблица, поэтому капкан `parallel_distributed_insert_select` на
|
||||||
|
эту вставку не распространяется; но проверку раскладки после первой сборки
|
||||||
|
стоит включить в приёмку — теперь ясно, что заявленный ключ и настоящая
|
||||||
|
раскладка умеют расходиться.
|
||||||
|
|
||||||
|
## Выводы для purchase_vs_orders_v (спека §7)
|
||||||
|
|
||||||
|
`FULL OUTER GLOBAL JOIN` для сверки корректен при одном условии: обе стороны —
|
||||||
|
подзапросы, и соединение выполняется один раз на инициаторе. Витрина это
|
||||||
|
условие выполняет естественно: покупки нужно развернуть из массива и
|
||||||
|
агрегировать (класс `duplicate_event` иначе не посчитать), заказы — привести
|
||||||
|
к последней версии. С голой Distributed-таблицей слева витрина удваивала бы
|
||||||
|
каждый заказ без события — весь класс `lost_event` — по числу шардов.
|
||||||
|
|
||||||
|
Прогон сверочной формы на живых данных (обе стороны — подзапросы с
|
||||||
|
`GROUP BY`): 1662 совпадения, 255 покупок без заказа (ждут слепка,
|
||||||
|
`awaiting_order`), 0 заказов без покупки, без задвоений. Обе величины
|
||||||
|
согласуются с прямыми счётами — форма пригодна как каркас витрины.
|
||||||
|
|
||||||
|
## Чем проверялось
|
||||||
|
|
||||||
|
Все запросы — читающие, от служебного пользователя внутри контейнеров
|
||||||
|
(`docker compose exec clickhouse-01 … clickhouse-client`), следы — в
|
||||||
|
`system.query_log` обеих нод по `log_comment` с префиксом `r129-`. Документные
|
||||||
|
утверждения (механика GLOBAL, режимы `distributed_product_mode`, поведение
|
||||||
|
`parallel_distributed_insert_select`) сверены с документацией ClickHouse через
|
||||||
|
MCP Context7; поведение FULL OUTER на кластере в документации не описано —
|
||||||
|
здесь источник только эмпирика.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Проталкивание условий сквозь представления над FINAL
|
||||||
|
|
||||||
|
Дата исследования: 2026-08-22. Тикет #128, карта #127.
|
||||||
|
|
||||||
|
Вопрос: пробрасывает ли ClickHouse условия запроса внутрь обычного
|
||||||
|
представления над таблицей с `FINAL` (`ods.event_v`) и внутрь вложенного
|
||||||
|
представления над ним (будущее `dds.event_v`) — или разворачивает
|
||||||
|
представление целиком и фильтрует потом.
|
||||||
|
|
||||||
|
## Короткий вывод
|
||||||
|
|
||||||
|
- Пробрасывает. Обычное представление — это подзапрос в `FROM`, и условие
|
||||||
|
внешнего запроса доезжает сквозь него до партиций и первичного ключа
|
||||||
|
таблицы. Проверено планом и счётчиком прочитанных строк.
|
||||||
|
- Второй уровень вложенности ничего не ломает: сквозь подзапрос над
|
||||||
|
`ods.event_v` с переименованными колонками условие доезжает так же —
|
||||||
|
срезание партиций совпадает с прямым запросом к `ods.event_dist FINAL`
|
||||||
|
один в один.
|
||||||
|
- Условие доезжает даже сквозь вычисленную колонку: фильтр по
|
||||||
|
`toStartOfWeek(EventDate)` анализируется индексом MinMax.
|
||||||
|
- Единственное, что `FINAL` отнимает, — автоматический перенос условия в
|
||||||
|
`PREWHERE`: настройка `optimize_move_to_prewhere_if_final` по умолчанию
|
||||||
|
выключена. Срезание по индексам при этом работает полностью; теряется
|
||||||
|
только оптимизация порядка чтения колонок внутри уже выбранных гранул.
|
||||||
|
- Для контракта `dds.event_v` это значит: цепочка обычных представлений
|
||||||
|
ODS → DDS годится, представление до фильтра не материализуется.
|
||||||
|
|
||||||
|
## Как проверяли
|
||||||
|
|
||||||
|
Живой стенд, ClickHouse 26.3.17.56, `enable_analyzer = 1` (умолчание).
|
||||||
|
Только читающие запросы с ноды 1: `EXPLAIN indexes = 1`,
|
||||||
|
`EXPLAIN actions = 1`, `SELECT` со сверкой по `system.query_log`.
|
||||||
|
Вложенное представление не создавали — состояние стенда не менялось;
|
||||||
|
второй уровень эмулирован эквивалентным подзапросом.
|
||||||
|
|
||||||
|
Устройство пары: `ods.event_rep` — `ReplicatedReplacingMergeTree(_load_ts)`,
|
||||||
|
`PARTITION BY EventDate`, `ORDER BY (CounterID, EventDate,
|
||||||
|
intHash32(ClientID), WatchID)`; на момент опыта 16 активных кусков в
|
||||||
|
8 партициях, 24 гранулы. `ods.event_v` — `SELECT * FROM ods.event_dist FINAL`.
|
||||||
|
|
||||||
|
## Опыт 1. Фильтр по ключу партиции сквозь представление
|
||||||
|
|
||||||
|
```sql
|
||||||
|
EXPLAIN indexes = 1
|
||||||
|
SELECT count() FROM ods.event_v WHERE EventDate = '2026-06-01';
|
||||||
|
```
|
||||||
|
|
||||||
|
План читает `ods.event_rep` с условием `EventDate in [20605, 20605]` во всех
|
||||||
|
трёх индексах — MinMax, Partition, PrimaryKey: `Parts: 2/16`,
|
||||||
|
`Granules: 3/24`. Прямой запрос `SELECT count() FROM ods.event_dist FINAL
|
||||||
|
WHERE EventDate = '2026-06-01'` даёт те же цифры. Представление ничего не
|
||||||
|
прячет от анализа индексов.
|
||||||
|
|
||||||
|
## Опыт 2. Второй уровень: подзапрос над представлением
|
||||||
|
|
||||||
|
Будущее `dds.event_v` — представление над `ods.event_v` с переименованием в
|
||||||
|
язык бизнес-модели. Эмуляция:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
EXPLAIN indexes = 1
|
||||||
|
SELECT count()
|
||||||
|
FROM (
|
||||||
|
SELECT WatchID AS watch_id, EventDate AS event_date,
|
||||||
|
lower(EventType) AS event_type
|
||||||
|
FROM ods.event_v
|
||||||
|
) WHERE event_date = '2026-06-01';
|
||||||
|
```
|
||||||
|
|
||||||
|
Условие по переименованной колонке доехало до таблицы: `Parts: 2/16`,
|
||||||
|
`Granules: 3/24` — цифры совпали с опытом 1. Два уровня разворачивания и
|
||||||
|
переименование колонок проталкиванию не мешают.
|
||||||
|
|
||||||
|
Отдельно проверена вычисленная колонка:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT count()
|
||||||
|
FROM (SELECT toStartOfWeek(EventDate) AS event_week FROM ods.event_v)
|
||||||
|
WHERE event_week = '2026-06-01';
|
||||||
|
```
|
||||||
|
|
||||||
|
В плане условие `toStartOfWeek(EventDate) in [20605, 20605]` стоит в индексе
|
||||||
|
MinMax и срезает `Parts: 0/16` — ноль корректен: при умолчании функции ни
|
||||||
|
одна неделя не начинается в этот понедельник. То есть даже фильтр по
|
||||||
|
выражению от ключевой колонки анализируется индексом, пока функция ему
|
||||||
|
понятна.
|
||||||
|
|
||||||
|
## Опыт 3. Фильтр по ключу сортировки
|
||||||
|
|
||||||
|
```sql
|
||||||
|
EXPLAIN indexes = 1
|
||||||
|
SELECT count() FROM ods.event_v WHERE CounterID = 42150607;
|
||||||
|
```
|
||||||
|
|
||||||
|
Условие `CounterID in [...]` дошло до PrimaryKey (`Search Algorithm: binary
|
||||||
|
search`). Гранулы не срезались (`24/24`) — но это свойство данных, а не
|
||||||
|
представления: счётчик один, он есть в каждой грануле.
|
||||||
|
|
||||||
|
## Опыт 4. PREWHERE и FINAL
|
||||||
|
|
||||||
|
По колонке вне ключа (`EventType = 'pageview'`, `EXPLAIN actions = 1`,
|
||||||
|
запрос с широким списком колонок):
|
||||||
|
|
||||||
|
- `ods.event_rep` без `FINAL` — условие уехало в `Prewhere filter`;
|
||||||
|
- `ods.event_rep FINAL` и `ods.event_v` — условие осталось обычным `Filter`
|
||||||
|
над шагом чтения.
|
||||||
|
|
||||||
|
Причина — настройка `optimize_move_to_prewhere_if_final`, на стенде `0`
|
||||||
|
(умолчание). Это цена самого `FINAL`, а не представления: прямой запрос с
|
||||||
|
`FINAL` ведёт себя так же.
|
||||||
|
|
||||||
|
## Опыт 5. Счётчик прочитанных строк
|
||||||
|
|
||||||
|
План — обещание; исполнение сверено по `system.query_log`
|
||||||
|
(`read_rows` начального запроса, сумма по кластеру):
|
||||||
|
|
||||||
|
| Запрос | read_rows |
|
||||||
|
|---|---|
|
||||||
|
| `ods.event_v` без фильтра | 401 185 |
|
||||||
|
| `ods.event_v WHERE EventDate = '2026-06-01'` | 50 626 |
|
||||||
|
| подзапрос над `ods.event_v`, фильтр по `event_date` | 50 626 |
|
||||||
|
|
||||||
|
Фильтр сквозь представление читает только партиции своего дня, вложенность
|
||||||
|
не добавляет ни строки. 401 185 — все физические строки обеих нод.
|
||||||
|
|
||||||
|
## Что говорит документация
|
||||||
|
|
||||||
|
Сверено через MCP Context7 22 августа 2026 года, библиотека
|
||||||
|
`/websites/clickhouse`.
|
||||||
|
|
||||||
|
- Обычное представление не хранит данных: при обращении его запрос
|
||||||
|
исполняется как подзапрос в `FROM` — `SELECT a, b, c FROM view`
|
||||||
|
эквивалентен `SELECT a, b, c FROM (SELECT ...)`
|
||||||
|
(`sql-reference/statements/create/view`).
|
||||||
|
- Проталкивание предикатов в подзапросы — штатная работа оптимизатора;
|
||||||
|
анализатор доводит фильтры до хранилища, включая срезание партиций и
|
||||||
|
первичного ключа (changelog 2026, настройки семейства
|
||||||
|
`allow_push_predicate_*`).
|
||||||
|
- `optimize_move_to_prewhere_if_final` управляет автоматическим `PREWHERE`
|
||||||
|
для запросов с `FINAL` (`settings/session-settings`); на стенде она в
|
||||||
|
умолчании `0`, что и видно в опыте 4.
|
||||||
|
- `do_not_merge_across_partitions_select_final = 1` заставляет `FINAL`
|
||||||
|
обрабатывать партиции независимо; с 2026 года значение `1` включает режим
|
||||||
|
безусловно, а автоматику решает отдельная настройка
|
||||||
|
`enable_automatic_decision_for_merging_across_partitions_for_final`
|
||||||
|
(changelog 2026). К `ods.event` режим применим: партиция — `EventDate`,
|
||||||
|
версии одного события всегда лежат в одном дне. На стенде настройка `0`,
|
||||||
|
и трогать её не понадобилось — вопрос тикета решился без неё.
|
||||||
|
- `FINAL` поверх подзапроса не работает — модификатор обязан стоять у самой
|
||||||
|
таблицы (`guides/.../analyzer`, «Fixing Invalid FINAL Usage»). Наша
|
||||||
|
цепочка это соблюдает: `FINAL` живёт в один слой, внутри `ods.event_v`.
|
||||||
|
|
||||||
|
## Вывод для контракта dds.event_v
|
||||||
|
|
||||||
|
Сомнение владельца — «представление материализуется до фильтра» — не
|
||||||
|
подтвердилось. Условия по ключу партиции и ключу сортировки доезжают до
|
||||||
|
индексов сквозь два уровня обычных представлений, переименование колонок и
|
||||||
|
анализируемые выражения; исполняется это, а не только планируется.
|
||||||
|
|
||||||
|
Цепочка обычных представлений ODS → DDS → DM годится как форма контракта.
|
||||||
|
Две оговорки в контракт:
|
||||||
|
|
||||||
|
1. `FINAL` внутри `ods.event_v` отключает автоматический `PREWHERE` — на
|
||||||
|
объёмах стенда это незаметно, но слабый фильтр по неключевой колонке
|
||||||
|
при широком `SELECT` читает больше, чем без `FINAL`. Это свойство
|
||||||
|
`FINAL`, представления его не усугубляют.
|
||||||
|
2. Индекс срезает только то, что умеет анализировать: фильтр по колонке,
|
||||||
|
которую `dds.event_v` вычислит нетривиальным выражением, до индекса
|
||||||
|
может не доехать. Ключевые для срезов колонки — прежде всего
|
||||||
|
`EventDate` — стоит отдавать в DDS переименованием, а не пересчётом.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Права DEFINER у представления над Distributed
|
||||||
|
|
||||||
|
Дата исследования: 2026-08-25. Тикет #150. ClickHouse 26.3.17.56.
|
||||||
|
|
||||||
|
## Вопрос
|
||||||
|
|
||||||
|
Почему `dds.event_v` с `SQL SECURITY DEFINER`, созданное пользователем
|
||||||
|
`lifecycle`, всё равно читает удалённый `ods.event_rep` как `bi`? Можно ли
|
||||||
|
оставить `bi` только с правом на публичное представление DDS, не заводя отдельный
|
||||||
|
маршрут или пользователя и не копируя данные?
|
||||||
|
|
||||||
|
## Короткий вывод
|
||||||
|
|
||||||
|
Простого поддерживаемого способа в ClickHouse 26.3 нет. `DEFINER` меняет
|
||||||
|
пользователя, чьи права проверяет локальный запрос представления, но сохраняет
|
||||||
|
в запросе имя его первоначального инициатора — `initial_user`. Движок
|
||||||
|
`Distributed` передаёт это имя на шарды, а межсерверный секрет делает его
|
||||||
|
пользователем удалённой сессии. Поэтому на координаторе источники читает
|
||||||
|
`lifecycle`, а на соседнем шарде физическую таблицу читает `bi`.
|
||||||
|
|
||||||
|
Это не заявленная семантика `SQL SECURITY DEFINER`: документация обещает, что
|
||||||
|
право на источник требуется определившему представление пользователю, а
|
||||||
|
вызывающему достаточно `SELECT` на представление. Поведение следует из
|
||||||
|
сочетания двух реализаций и выглядит как недокументированное ограничение или
|
||||||
|
дефект распределённого исполнения.
|
||||||
|
|
||||||
|
## Где меняется пользователь
|
||||||
|
|
||||||
|
`dds.event_v` разворачивается в запрос к `ods.event_v`, затем к
|
||||||
|
`ods.event_dist FINAL`. Для внутреннего запроса представления ClickHouse
|
||||||
|
создаёт новый контекст в
|
||||||
|
[`getSQLSecurityOverriddenContext`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/StorageInMemoryMetadata.cpp#L135-L175):
|
||||||
|
|
||||||
|
1. копирует исходный `ClientInfo` целиком, включая `initial_user = bi`;
|
||||||
|
2. затем отдельно вызывает `setUser` для определившего представление
|
||||||
|
пользователя `lifecycle`.
|
||||||
|
|
||||||
|
В контексте остаются два имени с разными задачами: `lifecycle` отвечает за
|
||||||
|
локальную проверку прав, а `bi` остаётся первоначальным пользователем запроса.
|
||||||
|
Второй `SQL SECURITY DEFINER` на `ods.event_v` повторяет те же два шага и
|
||||||
|
поэтому ничего не меняет.
|
||||||
|
|
||||||
|
## Что происходит на границе шарда
|
||||||
|
|
||||||
|
`Distributed` копирует `ClientInfo` из контекста и отправляет его на удалённую
|
||||||
|
ноду без замены `initial_user`
|
||||||
|
([`RemoteQueryExecutor.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/QueryPipeline/RemoteQueryExecutor.cpp#L415-L444)).
|
||||||
|
При межсерверном секрете имя входит в подписываемые данные запроса
|
||||||
|
([`Connection.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Client/Connection.cpp#L968-L994)).
|
||||||
|
Принимающая нода проверяет подпись и открывает сессию с
|
||||||
|
`AlwaysAllowCredentials{client_info.initial_user}`
|
||||||
|
([`TCPHandler.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Server/TCPHandler.cpp#L2303-L2356)).
|
||||||
|
|
||||||
|
Секрет здесь выполняет штатную функцию. Это не случайный побочный эффект.
|
||||||
|
Документация `Distributed` прямо говорит: если задан `<secret>`, `initial_user`
|
||||||
|
становится текущим пользователем удалённого запроса
|
||||||
|
([описание кластера](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/docs/en/engines/table-engines/special/distributed.md#L105-L118)).
|
||||||
|
Так права, роли и ограничения исходного пользователя одинаково действуют на
|
||||||
|
всех шардах: координатор подтверждает личность пользователя, но не превращает
|
||||||
|
его запрос в запрос своей служебной учётки. Протокол не передаёт отдельно
|
||||||
|
эффективного пользователя `DEFINER`, поэтому на стенде эта защита и
|
||||||
|
останавливает `bi` у `ods.event_rep`.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
### Настройка или другая форма представления
|
||||||
|
|
||||||
|
В 26.3 нет настройки, которая подменяет `initial_user` на определившего
|
||||||
|
представление пользователя только для чтения `Distributed`.
|
||||||
|
`push_external_roles_in_interserver_queries` пересылает роли именно
|
||||||
|
`initial_user`, не роли `DEFINER`
|
||||||
|
([исходник отправки](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/QueryPipeline/RemoteQueryExecutor.cpp#L425-L444)).
|
||||||
|
`distributed_ddl_use_initial_user_and_roles` относится только к заданиям
|
||||||
|
`ON CLUSTER`. На `SELECT` настройка не действует
|
||||||
|
([объявление настройки](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Core/ServerSettings.cpp#L1002-L1006)).
|
||||||
|
|
||||||
|
### Пользователь в существующем описании кластера
|
||||||
|
|
||||||
|
С межсерверным секретом ClickHouse выбирает специальную межсерверную
|
||||||
|
аутентификацию и не использует пароль из описания реплики
|
||||||
|
([`Connection::sendHello`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Client/Connection.cpp#L452-L476)).
|
||||||
|
Чтобы удалённый запрос всегда шёл как `lifecycle`, пришлось бы убрать секрет и
|
||||||
|
передавать его пароль. Тогда между нодами перестанет передаваться исходный
|
||||||
|
пользователь. Одна модель доступа сменится другой, менее прозрачной.
|
||||||
|
|
||||||
|
### `remote()` с явными реквизитами
|
||||||
|
|
||||||
|
Для `remote()` ClickHouse умеет заменить `initial_user` пользователем из
|
||||||
|
адреса
|
||||||
|
([`executeQuery.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Interpreters/ClusterProxy/executeQuery.cpp#L96-L120)).
|
||||||
|
Но представлению понадобятся адреса и реквизиты удалённого пользователя — в
|
||||||
|
тексте DDL либо в отдельной настройке. Это и есть отдельный маршрут доступа;
|
||||||
|
он сложнее нынешнего кластера и требует хранить ещё один секрет.
|
||||||
|
|
||||||
|
### Промежуточное локальное представление на шарде
|
||||||
|
|
||||||
|
Можно направить `Distributed` на локальное представление с `DEFINER`, но
|
||||||
|
удалённому пользователю всё равно нужен `SELECT` на это представление. Значит
|
||||||
|
появятся дополнительный объект и дополнительный грант, а `bi` увидит
|
||||||
|
поверхность одного шарда. Физическая таблица останется закрытой, но простота и
|
||||||
|
контракт «только публичная поверхность DDS» исчезнут.
|
||||||
|
|
||||||
|
### Ослабление проверки
|
||||||
|
|
||||||
|
- `SQL SECURITY NONE` снимает проверку источника, но устарело и позволяет
|
||||||
|
создателю такого представления исполнять произвольный запрос; ClickHouse
|
||||||
|
требует для этого отдельное право
|
||||||
|
([документация](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/docs/en/sql-reference/statements/create/view.md#L115-L136)).
|
||||||
|
- Право `bi` на физическую ODS-таблицу решает ошибку без новой механики, но
|
||||||
|
ослабляет исходную границу.
|
||||||
|
- Физическая или материализованная копия в DDS сохраняет границу, но добавляет
|
||||||
|
хранение и жизненный цикл данных ради переименования колонок.
|
||||||
|
|
||||||
|
## Решение для стенда
|
||||||
|
|
||||||
|
25 августа 2026 года выбрана простота: роль `bi_reader` читает ODS, DDS и DM
|
||||||
|
целиком. Потребительские запросы продолжают обращаться к `_v`. Этот договор
|
||||||
|
держат документация и ревью; границы прав задают гранты.
|
||||||
|
|
||||||
|
Отдельный межсерверный маршрут, служебная учётка и физическая поверхность DDS
|
||||||
|
отклонены: каждый вариант добавляет механику только ради защиты, которой стенд
|
||||||
|
не учит. Готовой настройки, которая сохранила бы и узкую границу, и простое
|
||||||
|
устройство, в закреплённой версии нет.
|
||||||
|
|
||||||
|
## Граница уверенности
|
||||||
|
|
||||||
|
Механизм подтверждён исходным кодом точной версии 26.3.17.56. Простую настройку
|
||||||
|
искали в настройках сервера и в путях формирования удалённого запроса этой же
|
||||||
|
версии. Уверенность в обоих выводах высокая. Официального
|
||||||
|
текста, который называет сочетание `DEFINER + Distributed + secret`
|
||||||
|
поддерживаемым ограничением или зарегистрированным дефектом, не найдено;
|
||||||
|
классификация как дефекта остаётся выводом из расхождения документации и кода.
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# Инвентаризация сюжетов лаб по слоям стенда
|
||||||
|
|
||||||
|
Это материал для спеки программы лаб (#202): полный список сюжетов без отбора. Спека решит, что оставить и в каком порядке поставить. Здесь для каждого участка стенда записано, чему на нем научится менти. Тикет — #201, карта — #182.
|
||||||
|
|
||||||
|
Каждый сюжет описан четырьмя строками: **урок**, **материал**, **форма** и **проверка**. Возможны три формы:
|
||||||
|
|
||||||
|
- *построить свое по ТЗ* — как в пяти домашних заданиях DM: рядом есть образец, но нет готового ответа;
|
||||||
|
- *вычитание* — удалить SQL готовой трансформации и написать его заново; даги остаются без изменений (уточнение владельца в комментарии к #148);
|
||||||
|
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а менти ищет причину и объясняет ее.
|
||||||
|
|
||||||
|
Существующие шесть заданий из `docs/labs/` входят в список как готовое ядро слоя DM. Идеи из тикетов #143, #145, #148, #149, #186, #187, #188 разнесены по подходящим участкам. В конце отдельно проверяется гипотеза «вычитание + DQ» для каждого потока нижних слоев.
|
||||||
|
|
||||||
|
## STG — прием и сырье
|
||||||
|
|
||||||
|
**1. Push против pull: два способа принять топик.**
|
||||||
|
Урок: способ приема зависит от источника. Поток постоянно читает подписанная матвью, а слепок забирает отдельный запрос по команде.
|
||||||
|
Материал: оба чтеца в `sql/ddl/10-stg-tables.sql` (у `orders_raw_kafka` нет ни матвью, ни `ON CLUSTER`, зато есть `kafka_commit_on_select`), матвью приема `sql/ddl/40-stg-views.sql`, забор `sql/stg/orders_raw_load.sql`, `dags/orders_ingest.py`; сравнение цены и наблюдаемости — заготовка спеки (`docs/specs/2026-07-30-stand-v2-realism.md`, раздел 12).
|
||||||
|
Форма: расследование двух живых путей на одном стенде.
|
||||||
|
Проверка: офсеты групп `clickstream_hits` и `clickstream_orders`, счет строк сырья по `_load_id`.
|
||||||
|
|
||||||
|
**2. Кто читал топик и куда легли данные.**
|
||||||
|
Урок: нода, которая читает топик, может меняться от прогона к прогону. Раскладка данных не меняется: ее определяет ключ шардирования — свойство самих данных.
|
||||||
|
Материал: колонка `consumer_host` в `stg.hits_raw_dist`; `docs/architecture/storage.md`, «Раскладка по шардам».
|
||||||
|
Форма: расследование данных.
|
||||||
|
Проверка: пересчет ключа шардирования по `hostName()` — строк на чужом шарде нет.
|
||||||
|
|
||||||
|
**3. Вторая consumer group — дубли.**
|
||||||
|
Урок: на кластер нужна одна группа потребителей. Если завести вторую, каждая нода получит полную копию топика.
|
||||||
|
Материал: `kafka_group_name` в `sql/ddl/10-stg-tables.sql`; сцена названа спекой (раздел 12).
|
||||||
|
Форма: расследование-опыт «сломай — увидь — верни» на переигранном дне.
|
||||||
|
Проверка: счет событий против описи мира (`make check-clickhouse`); уборка — снятием партиции `_load_ts` (правило из `docs/architecture/testing.md`).
|
||||||
|
|
||||||
|
**4. Дырка в офсетах.**
|
||||||
|
Урок: сырье может сохранить не все сообщения, которые пришли из источника. Пустое сообщение или зазор после пересоздания матвью видны только по разрыву в `kafka_offset`, поэтому пропажу не нужно искать глазами.
|
||||||
|
Материал: `docs/architecture/storage.md`, «Что проверено» (опыты 5 и 7 августа); колонка `kafka_offset` в сырье.
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: непрерывность офсетов внутри партиции.
|
||||||
|
|
||||||
|
**5. Две оси времени.**
|
||||||
|
Урок: переобработка данных и очистка диска живут по разным часам. Сырье разбивается на части и удаляется по `_load_ts`, а модельный день появляется только в ODS. Поэтому TTL по модельной дате работал бы неверно.
|
||||||
|
Материал: `sql/ddl/10-stg-tables.sql` (TTL трое суток, `ttl_only_drop_parts`), `docs/architecture/storage.md`, «Срок жизни сырья».
|
||||||
|
Форма: расследование-вопрос (почему нельзя нарезать сырье по `EventDate`).
|
||||||
|
Проверка: ответ сверяется с доводом доки; счетного инварианта нет.
|
||||||
|
|
||||||
|
## ODS — типизация и брак
|
||||||
|
|
||||||
|
**6. Предикат и его буквальное отрицание.**
|
||||||
|
Урок: годные строки и брак должны делить поток без пропусков и пересечений. NULL в предикате незаметно теряет строку, а исключение роняет вставку и останавливает чтение топика.
|
||||||
|
Материал: `sql/ddl/30-ods-views.sql` (пара матвью событий), `sql/ods/order_parse.sql` (макрос `order_wire_contract`), ADR 0005.
|
||||||
|
Форма: расследование — подложить в топик по строке каждого класса брака и найти их в `ods.event_errors_dist` / `ods.order_errors_dist`.
|
||||||
|
Проверка: «сырье среза = годные + брак» на управляемой пачке; строки опыта убрать за собой.
|
||||||
|
|
||||||
|
**7. Три времени и версия-наблюдение.**
|
||||||
|
Урок: у строки заказа есть четыре временные координаты. Версию сущности задает дата слепка источника, а не время загрузки. `FINAL` живет ровно в одном месте — за представлением `_v`.
|
||||||
|
Материал: `sql/ddl/20-ods-tables.sql`, `sql/ddl/30-ods-views.sql`, ADR 0010 и 0014, `docs/architecture/orders/ingestion.md` («Роль ODS»).
|
||||||
|
Форма: расследование — физические версии `ods.order_dist` против `ods.order_v`.
|
||||||
|
Проверка: эталон `argMax` по `updated_at` прямо из сырья (прием #165).
|
||||||
|
|
||||||
|
**8. Часовой пояс как способ смотреть на время.**
|
||||||
|
Урок: у ночных событий `toDate(UTCEventTime)` может не совпадать с `EventDate`. Это нормальное свойство данных. День нужно считать по готовой колонке и явно называть часовой пояс, в котором его рассматривают.
|
||||||
|
Материал: `docs/architecture/storage.md`, «Часовые пояса»; ADR 0015.
|
||||||
|
Форма: расследование данных.
|
||||||
|
Проверка: менти сам находит ночные события и объясняет расхождение.
|
||||||
|
|
||||||
|
**9. Сырое против разобранного: `ecommerce`.**
|
||||||
|
Урок: типизированный разбор JSON может молча подменить ошибку значением. Например, `qty` со значением «oops» превратится в 0. Поэтому правильность разбора нужно подтверждать инвариантом источника.
|
||||||
|
Материал: колонка `ecommerce` рядом с массивами `purchase_*` / `product_*` в `ods.event`; разбор `items` в `sql/dds/order_rebuild.sql`; спека, раздел 1.2 («кандидат будущей лабы»).
|
||||||
|
Форма: построить свое по ТЗ — разобрать `ecommerce` запросом и сверить с готовыми массивами того же события.
|
||||||
|
Проверка: построчная сверка с колонками-массивами.
|
||||||
|
|
||||||
|
## DDS — модель
|
||||||
|
|
||||||
|
**10. Лаба сессий: своя нарезка против эталона.**
|
||||||
|
Урок: сессия — расчетная сущность, границы которой определяет логика в хранилище. Самостоятельная нарезка может разойтись с эталоном. Если расхождение невозможно, задание сводится к `GROUP BY` по готовому полю.
|
||||||
|
Материал: `docs/architecture/dds/session.md`, `sql/dds/session_rebuild.sql` (два окна, полный порядок `(event_time, event_id)`, детерминированный чекан), эталон `VisitID` в `ods.event_v`.
|
||||||
|
Форма: построить свое — менти собирает нарезку сам до знакомства со стендовым кодом; либо вычитание `session_rebuild.sql` (раздел о гипотезе ниже).
|
||||||
|
Проверка: `dq_check` / `sessions_vs_reference` (зерно кука × день против `uniqExact(VisitID)`); затем сравнение со стендовой нарезкой по `session_id`.
|
||||||
|
|
||||||
|
**11. Поштучный разбор расхождения нарезки.**
|
||||||
|
Урок: счетная сверка показывает, сколько сессий разошлось. Чтобы понять, где именно отличаются границы, нужно вручную соединить свою сессию с эталонной по времени.
|
||||||
|
Материал: `sql/dq/sessions_vs_reference.sql`, `docs/architecture/dds/session.md`, «Сверка с эталоном» («поштучное сопоставление границ — материал лабы»).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: найденная кука, день и пауза, на которой решения разошлись.
|
||||||
|
|
||||||
|
**12. Склейка идентичностей во времени (#145).**
|
||||||
|
Урок: текущее знание о пользователе и знание на выбранную дату дают разные отчеты. Если использовать сведения из будущего, исторический график известной аудитории изменится.
|
||||||
|
Материал: `dds.identity_map_v` (пары без временной колонки — решение `docs/architecture/dds/identity.md`), момент связи выводится как минимум времени `purchase`-события, соединенного с заказом.
|
||||||
|
Форма: построить свое (запрос, без изменения стенда).
|
||||||
|
Проверка: два способа на одном наборе данных; расхождение объясняется письменно.
|
||||||
|
|
||||||
|
**13. Справочник регионов (#143).**
|
||||||
|
Урок: справочник содержит данные, которыми источник владеет вне хранилища. Если собрать словарь руками, можно разобраться в механике, которую готовый каталог стенда позволяет только использовать.
|
||||||
|
Материал: образец `sql/ddl/05-dic-catalog.sql` и ADR 0012; зацепка в контракте — `region_city_id` / `region_country_id` отдаются как есть (`dds.event_v`).
|
||||||
|
Форма: построить свое по образцу (свой CSV, подложка, словарь, `dictGet` в отчете).
|
||||||
|
Проверка: обогащенный отчет; неатомарность обновления словаря на двух нодах — дополнительное наблюдение (спека, раздел 12).
|
||||||
|
|
||||||
|
**14. Инвариант позиций: недостающая проверка.**
|
||||||
|
Урок: равенство «цена × количество = items_total» проверяет, не исказил ли молчаливый разбор данные. Сейчас это равенство проверяют вручную, хотя ему нужно постоянное место в контуре DQ.
|
||||||
|
Материал: комментарий в `sql/dds/order_rebuild.sql` («постоянное место такой проверки — контур DQ этапа 4; пока она делается руками»), образцы `sql/dq/*_vs_dds.sql`, `dags/dq_check.py`.
|
||||||
|
Форма: построить свое — новая проверка по образцу существующих пяти.
|
||||||
|
Проверка: проверка обязана уметь краснеть — испортить позицию руками и увидеть красное (`docs/architecture/testing.md`).
|
||||||
|
|
||||||
|
**15. Сверка слоя с источником по дню.**
|
||||||
|
Урок: трансформация выполняет заданное правило, но сама не замечает, что часть данных не приехала. Это обнаруживает отдельная сверка слоя с источником.
|
||||||
|
Материал: `docs/architecture/etl.md`, «Чего конвейер о себе не знает» (три слепых пятна и лекарство: счет заказов и поздний `updated_at` с обеих сторон).
|
||||||
|
Форма: построить свое — вторая самодельная DQ-проверка.
|
||||||
|
Проверка: снести часть дня в ODS-окне — проверка краснеет, конвейер сам не замечал.
|
||||||
|
|
||||||
|
## DM — витрины
|
||||||
|
|
||||||
|
**16. Пять домашек DM (готовое ядро).**
|
||||||
|
Урок: готовые ТЗ уже содержат пять отдельных уроков (`docs/labs/README.md`): `events_enriched_v` показывает, что слой задает контракт, а не копирует данные; `top_pages_daily` меняет зерно, материализует витрину и включает ее в конвейер; `session_overview` учит хранить слагаемые вместо средних и не складывать уникальные значения, даже если на текущих данных результат совпадает; `dq_errors_daily` показывает, что у брака есть собственный день и срок жизни, а витрина может пережить источник; `utm_effectiveness` объясняет, зачем в имени нужно предупреждение `declared_`.
|
||||||
|
Материал: пять готовых ТЗ в `docs/labs/README.md`.
|
||||||
|
Форма: построить свое по ТЗ.
|
||||||
|
Проверка: `dags/homework_check.py` + `sql/dq/*_vs_dds.sql` (полное внешнее соединение с независимым ответом).
|
||||||
|
|
||||||
|
**17. Свой дашборд в Superset (готовая необязательная лаба).**
|
||||||
|
Урок: пользовательские объекты BI хранятся отдельно от стендовых и переживают перезапуск. Стендовые объекты при импорте перезаписываются.
|
||||||
|
Материал: `docs/labs/superset-dashboard.md`, `docs/architecture/superset.md`.
|
||||||
|
Форма: построить свое.
|
||||||
|
Проверка: глазами — счетного инварианта у раскладки панелей нет.
|
||||||
|
|
||||||
|
**18. Инкремент против пересборки (#149, повышенная сложность).**
|
||||||
|
Урок: чтобы обновлять меньше данных, приходится хранить историю изменений. Полная перезагрузка эту историю стирает.
|
||||||
|
Материал: `sql/dm/daily_traffic_rebuild.sql` (полный пересчет), `docs/architecture/dm.md`, «Ритм пересборки» и отклоненный вариант; момент `first_matched_at` — из сюжета 12.
|
||||||
|
Форма: построить свое (инкрементальная альтернатива рядом со стендовой).
|
||||||
|
Проверка: бесплатный контроль — результат обязан совпасть с эталонной витриной `dm.daily_traffic_v`.
|
||||||
|
|
||||||
|
**19. Вычитание стендовых витрин.**
|
||||||
|
Урок: при сверке двух источников нужен `GLOBAL FULL OUTER JOIN` с подзапросами с обеих сторон, `join_use_nulls` и явный приоритет классов. Значение с отсутствующей стороны должно остаться `NULL`, а не превратиться в ноль.
|
||||||
|
Материал: `sql/dm/purchase_vs_orders_rebuild.sql` (и соседние `revenue_daily_*`, `daily_traffic_*`), `docs/architecture/dm.md`.
|
||||||
|
Форма: вычитание — с оговоркой: запросы `sql/dq/*_vs_dds.sql` лежат открыто и почти дословно повторяют рецепт витрины. Предупреждение из `docs/labs/README.md` («открыв их до того, как задание сделано, ты обменяешь урок на готовый SQL») здесь особенно важно.
|
||||||
|
Проверка: `dq_check`, группа `marts_vs_dds` + `classes_vs_inventory`.
|
||||||
|
|
||||||
|
## BI — Superset (сюжеты сверх готовой лабы)
|
||||||
|
|
||||||
|
**20. Ловушка ноды 2.**
|
||||||
|
Урок: забытый `ON CLUSTER` и представление над локальной таблицей могут проявиться только в дашборде. Superset намеренно читает ноду 2, а DDL запускается с ноды 1.
|
||||||
|
Материал: `docs/architecture/superset.md`, «Устройство зоны»; спека, «Роли нод».
|
||||||
|
Форма: расследование-опыт (создать объект без `ON CLUSTER`, открыть дашборд).
|
||||||
|
Проверка: своя же находка и починка.
|
||||||
|
|
||||||
|
## Поперек слоев
|
||||||
|
|
||||||
|
### Airflow-переобработка по дневным партициям
|
||||||
|
|
||||||
|
**21. Переиграть день X.**
|
||||||
|
Урок: объем повторного прогона определяется состоянием хранилища. После падения конвейеру не нужно помнить параметры прошлого запуска: он ищет первую несобранную дату, а не последнюю собранную.
|
||||||
|
Материал: `sql/dds/order_scope.sql` и `session_scope.sql` (окно + хвост от первой дыры, `GLOBAL NOT IN`), `dags/dds_transform.py` (день — отдельная размноженная задача замены), `docs/architecture/etl.md`.
|
||||||
|
Форма: расследование-опыт — снести партицию дня в `dds.session_rep`, запустить `etl_pipeline` без параметров, увидеть, как объем сам нашел дыру.
|
||||||
|
Проверка: `dq_check` зеленый, день вернулся тем же (детерминированный чекан).
|
||||||
|
|
||||||
|
**22. Атомарность — пошардовая.**
|
||||||
|
Урок: `REPLACE PARTITION` заменяет данные в локальных таблицах по одному шарду. Если одна из двух замен завершится ошибкой, половина дня останется старой. Сам конвейер этого не заметит, поэтому нужна отдельная сверка.
|
||||||
|
Материал: `sql/dds/order_replace.sql` (комментарий о границе обещания), `docs/architecture/etl.md`, «Чего конвейер о себе не знает»; правило донора — `sql/ddl/45-dds-tables.sql`.
|
||||||
|
Форма: расследование (в паре с сюжетом 15).
|
||||||
|
Проверка: `purchase_vs_orders_vs_dds` или самодельная сверка по дню.
|
||||||
|
|
||||||
|
### Сверка purchase vs orders (классы A–D)
|
||||||
|
|
||||||
|
**23. Воронка подтверждений и недосчет трекера (#186, класс B).**
|
||||||
|
Урок: со стороны трекера потерянное событие можно обнаружить только по разрыву воронки между `/confirmation` и `purchase`. Отсюда правило: поведение считаем по трекеру, деньги — по бэкенду.
|
||||||
|
Материал: `lost_event` в `dm.purchase_vs_orders_v`, события дня в `dds.event_v`; механика — `docs/architecture/orders/fate.md` («потеря точечная»).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: оцененный недосчет сходится с долей `lost_event` закрытых дней.
|
||||||
|
|
||||||
|
**24. Бизнес-дубль и наивная выручка (#187, класс D).**
|
||||||
|
Урок: движок удаляет технические дубли, а бизнес-дубли приходится определять самим — по `purchaseID`, а не по совпадению строк. `ReplacingMergeTree` не удалит дубль с новым `WatchID`, и это правильное поведение.
|
||||||
|
Материал: `duplicate_event`, свертка по `purchase_id[1]` в `sql/dm/purchase_vs_orders_rebuild.sql`; `fate.md` («дубль сюжетный»).
|
||||||
|
Форма: расследование — наивный `SUM` против дедуплицированного.
|
||||||
|
Проверка: разница равна вкладу дублей; сверка с `dm.revenue_daily_v`.
|
||||||
|
|
||||||
|
**25. Дельта суммы объясняется сравнением позиций (класс C).**
|
||||||
|
Урок: `amount_delta` обозначает конкретное расхождение, а не случайный шум. Его можно до копейки объяснить вычеркнутой позицией, если разобрать вложенный JSON и применить `ARRAY JOIN`. Ради этого навыка позиции и разбираются.
|
||||||
|
Материал: `amount_delta` в сверке, массивы `product_*` события против `item_*` заказа; `fate.md` («дельта — вычеркнутая позиция»).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: найденная позиция и ее полная стоимость равны дельте.
|
||||||
|
|
||||||
|
**26. Две дороги отмены и история версий (класс A).**
|
||||||
|
Урок: текущее состояние заказа не сохраняет его историю. Различить случаи «оплачен и отменен» и «отменен неоплаченным» можно только по следам: физическим версиям ODS до слияния и сырью STG, пока оно хранится трое суток.
|
||||||
|
Материал: `fate.md`, «Что из двух дорог видно»; `ods.order_dist`, `stg.orders_raw_dist`; выручка дня, которая выросла и убыла.
|
||||||
|
Форма: расследование с ограничением по времени — часть случаев неразличима принципиально, и признать это тоже урок.
|
||||||
|
Проверка: списки заказов по дорогам, сверенные с историей версий.
|
||||||
|
|
||||||
|
### Опоздания и закрытие дня
|
||||||
|
|
||||||
|
**27. Опоздавшие заказы и закрытое окно (#188).**
|
||||||
|
Урок: отчет за день зависит от момента расчета. День можно считать закрытым, когда источник уже не способен его изменить. Поэтому опись хранит счетчики классов только для закрытых дней.
|
||||||
|
Материал: `awaiting_order` в `dm.purchase_vs_orders_v`, задержка слепков D+1/D+2 (`fate.md`, «Опоздание»), `data/world-inventory.json` (счетчики `orders` только у дней с закрытым окном), возврат `awaiting_order`-дней в объем — `sql/dm/purchase_vs_orders_scope.sql`.
|
||||||
|
Форма: расследование по ходу проживаемых дней.
|
||||||
|
Проверка: вчерашний отчет, не сошедшийся утром, сходится завтра; разность слепков называет опоздавших.
|
||||||
|
|
||||||
|
**28. Дыхание выручки.**
|
||||||
|
Урок: значение выручки за день меняется, пока день внутри окна изменяемости, а после выхода из окна фиксируется. Эти изменения видны как повторные пересборки партиции.
|
||||||
|
Материал: `dm.revenue_daily_v` по ходу дней, окно из ярлыка `snapshot_date` (`sql/dm/revenue_daily_scope.sql`), K = 7 — константа мира.
|
||||||
|
Форма: расследование-наблюдение (снять один день несколько раз).
|
||||||
|
Проверка: за окном значение больше не меняется.
|
||||||
|
|
||||||
|
### Мониторинг и runbook
|
||||||
|
|
||||||
|
В репозитории пока нет runbook для случаев «keeper упал / DDL повис». Спека оставляет его в хвосте этапа 9 вместе с `system.text_log` (ADR 0002), потому что такой документ должен опираться на реальный опыт. Поэтому менти может написать runbook сам: по шагам сломать сценарий, записать наблюдения и оставить стенду документ, которого сейчас не хватает.
|
||||||
|
|
||||||
|
**29. Keeper — общая память кластера.**
|
||||||
|
Урок: без keeper останавливаются DDL и репликация. На дашборде это бывает видно раньше, чем в ошибках запросов. Читаемый путь в keeper помогает понять, какие данные он хранит и за что отвечает.
|
||||||
|
Материал: панель keeper и очередь DDL в `docs/architecture/monitoring/cluster.md`, `system.zookeeper` (читаемый путь — `docs/architecture/storage.md`, «Путь реплицированных таблиц»), `make smoke` / `make check-clickhouse`.
|
||||||
|
Форма: расследование-опыт «посмотрели — сломали — увидели — вернули» (остановить keeper), итог — свой runbook.
|
||||||
|
Проверка: возврат к зеленым целям `make`; шаги записаны.
|
||||||
|
|
||||||
|
**30. Повисший DDL.**
|
||||||
|
Урок: каждый запрос `ON CLUSTER` проходит через keeper. Очередь распределенных DDL показывает, дошел ли запрос до обеих нод и что ответила каждая из них.
|
||||||
|
Материал: `system.distributed_ddl_queue`, панель «Распределенный DDL» (`cluster.md`: единственная панель без `clusterAllReplicas` — и почему).
|
||||||
|
Форма: расследование-опыт (DDL при недоступной второй ноде, таймаут `ON CLUSTER`).
|
||||||
|
Проверка: запись в очереди со статусом по нодам; починка доводит до `Finished`.
|
||||||
|
|
||||||
|
**31. Мониторинг сам стоит денег.**
|
||||||
|
Урок: запросы Grafana попадают в `system.query_log` вместе с запросами людей. Самый частый клиент хранилища — сам мониторинг. Если отфильтровать его запросы, стоимость наблюдаемости станет невидимой.
|
||||||
|
Материал: `docs/architecture/monitoring/queries.md` (панель «Кто спрашивает», строки начала и конца, куски шардов в панели ошибок).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: собственное объяснение верха списка тяжелых запросов.
|
||||||
|
|
||||||
|
**32. Свежесть второго источника — пила, а не линия.**
|
||||||
|
Урок: возраст заказов растет в течение дня и резко уменьшается после нового слепка. Так устроен источник; это не признак деградации. Для панелей свежести, потока и брака выбираются разные слои данных. Если выбрать неверный слой, панель даст правдоподобный, но ложный ответ.
|
||||||
|
Материал: `docs/architecture/monitoring/data.md` (почему свежесть — ODS, поток и брак — сырье, окно доли брака).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: предсказать форму графика до взгляда на него.
|
||||||
|
|
||||||
|
### За границей лаб — опорные точки лекций
|
||||||
|
|
||||||
|
Спека (раздел 12) оставляет несколько сюжетов для лекций и не включает их в лабораторные работы: `Sign` и CollapsingMergeTree (почему на стенде `sum(Sign)` равен `count()`, а в бою нет), «как выбирают топологии в бою», идентичность «как в бою» (`setUserID`, вероятностная склейка, identity graph), анти-паттерны ключа шардирования. Последний сюжет уже входит в домашние задания DM как требование защитить выбранный ключ (`docs/labs/README.md`).
|
||||||
|
|
||||||
|
## Гипотеза «вычитание + DQ» для нижних слоев
|
||||||
|
|
||||||
|
В #148 предложено удалить SQL трансформаций потока и написать его заново. Владелец уточнил, что даги менять не нужно: это несущая инфраструктура. Обратную связь дают инварианты, которые не зависят от реализации слоя. Ниже каждый поток проверяется отдельно: что можно отдать на переписывание, что должно остаться, чем проверять результат и где заканчивается эталон.
|
||||||
|
|
||||||
|
На всю таблицу влияют два общих факта:
|
||||||
|
|
||||||
|
- `homework_check` покрывает только пять витрин DM. `dq_check` содержит пять проверок, но внешний эталон есть лишь у двух: `sessions_vs_reference` использует чужой чекан `VisitID` из `ods.event_v`, а `classes_vs_inventory` — опись мира. Три проверки `*_vs_dds` пересчитывают витрину из DDS по тому же рецепту. Они находят ошибку в сборке витрины, но не ошибку в DDS, потому что обе стороны читают один слой.
|
||||||
|
- Все сверки читают таблицы `_dist`, поэтому их агрегаты не зависят от раскладки (спека, раздел 6). Если строка попала не на свой шард, счетные проверки останутся зелеными. Это ловит только пересчет ключа шардирования по `hostName()` (прием из `docs/architecture/storage.md`, замер #137). Такой пересчет нужно явно включать в ТЗ каждого вычитания.
|
||||||
|
|
||||||
|
### По потокам
|
||||||
|
|
||||||
|
**События STG (прием).** На переписывание этот участок не отдается. Здесь почти нет трансформации: `stg.hits_raw_mv` перекладывает колонки (`sql/ddl/40-stg-views.sql`). При этом пересоздание матвью на работающем стенде незаметно пропускает ближайшие сообщения (замер в `storage.md`, «Что проверено»). Чтец, таблицы сырья и матвью остаются несущей стеной. Урок этого участка раскрывают расследования из сюжетов 1–5.
|
||||||
|
|
||||||
|
**События STG → ODS (разбор).** Вся логика — в DDL, в паре матвью `sql/ddl/30-ods-views.sql`; отдельных файлов событий в `sql/ods/` нет. Удаление и пересоздание этих матвью остановит прием и снова создаст зазор с пропущенными сообщениями. Поэтому обычное вычитание здесь не подходит. Рабочая форма — **«собери рядом»**: выполнить пакетный `INSERT SELECT` из сырья в собственную таблицу по срезу `_load_ts`, а затем построчно сверить результат с нетронутым `ods.event_v`. Эталоном служат стендовый ODS и подневный счет против описи (`make check-clickhouse`). Однако опись проверяет только количество и только для покрытых ею дней. Содержимое неопорных колонок остается без независимой проверки: неверно разобранные UTM или браузер нигде не покраснеют, потому что витрины сверяются с DDS, а DDS читает ODS. Срок работы тоже ограничен: сырье хранится трое суток.
|
||||||
|
|
||||||
|
**Заказы STG (забор, `sql/stg/orders_raw_load.sql`).** Формально трансформация вынесена в отдельный файл, но ее учебный смысл сводится к перекладке колонок, а цена ошибки велика. Офсеты коммитятся при чтении, поэтому неверный повтор унесет слепок до следующего переигрыша дня через пульт. Этот участок остается стеной. Эталон описи — хеш байтов слепка — проверяет топик, но не сам забор.
|
||||||
|
|
||||||
|
**Заказы STG → ODS (`sql/ods/order_parse.sql`).** Вычитание возможно с оговоркой. Это пакетный шаг, идемпотентный по `_load_id`: повтор не меняет `ods.order_v` («Что проверено» в `orders/ingestion.md`). Брак обрабатывается отдельной целью, а слепок можно вернуть переигрышем дня. Обратную связь менти собирает сам: проверяет равенство «сырье среза = годные + брак» по `_load_id`, а текущее состояние сверяет с `argMax` по `updated_at` из сырья. Ниже по течению результат проверяет `classes_vs_inventory` через DDS и витрину. Слабое место — ветвь брака. Чистый мир не производит брак, поэтому отрицание предиката останется непроверенным, пока менти сам не подложит по строке каждого класса. Готовый рецепт есть в `orders/ingestion.md`, «Риски и проверка»; после опыта данные нужно обязательно убрать.
|
||||||
|
|
||||||
|
**DDS сессия (`session_scope` / `rebuild` / `replace`).** Это лучший поток для вычитания: здесь гипотеза подтверждается без дополнительных условий. Эталон `VisitID` внешний, а `sessions_vs_reference.sql` считает чужой чекан и не повторяет алгоритм нарезки. Ошибиться можно в важных местах: в полном порядке окон, границе суток в `PARTITION BY`, детерминированном чекане и расчете объема из позиции мира. Эталон заканчивается на атрибутах строки. Сверка работает на зерне кука × день и проверяет только число сессий. Поэтому неверные границы, длительность, вход, выход или UTM могут пройти зеленым. `session_overview` из `homework_check` тоже согласится, потому что сверяет витрину с тем же слоем. Вторая ступень обратной связи — поштучная сверка границ по `VisitID` из сюжета 11, которую менти пишет сам.
|
||||||
|
|
||||||
|
**DDS заказ (`order_scope` / `rebuild` / `replace`).** Вычитание возможно при условии: сначала нужно построить недостающую проверку. Косвенный эталон уже есть. `classes_vs_inventory` ловит потерянные заказы и неверные статусы закрытых дней, а `purchase_vs_orders` проверяет `items_total`. Но независимо не проверяются разбор массивов `item_*` и время в поясе `Europe/Samara`. Выручка сойдется сама с собой, а счетчиков выручки в описи намеренно нет (спека, раздел 8). Стенд уже подсказывает решение: до вычитания нужно реализовать инвариант «цена × количество = items_total» из сюжета 14. Последовательность «сначала сторож, потом ломать» сама становится частью урока.
|
||||||
|
|
||||||
|
**DDS карта идентичностей (`identity_map_rebuild.sql`).** Для чистого вычитания этот поток не подходит: независимого эталона нет. `daily_traffic_vs_dds` читает карту с обеих сторон, поэтому пустая или задвоенная карта пройдет проверку. Счетчики идентичности в опись не входят (спека, раздел 8). Менти может сам написать слабые инварианты: `known_users <= visitors` и «каждая пара подтверждена покупкой и заказом». Они проверят допустимость пар, но не их полноту. Подходящие формы — «собери рядом и сверь со стендовой картой» либо вычитание вместе с самодельной проверкой, которая честно названа неполной.
|
||||||
|
|
||||||
|
**DM стендовые витрины.** Формально это самый защищенный участок: `dq_check` независимо пересчитывает каждую витрину. На деле проверка содержит готовый ответ: `revenue_daily_vs_dds.sql` дословно повторяет рецепт витрины. Вычитание возможно как сюжет 19, но только с предупреждением из `docs/labs/README.md` о цене подглядывания.
|
||||||
|
|
||||||
|
### Сводная таблица
|
||||||
|
|
||||||
|
| Поток | Отдать на переписывание | Несущая стена | Обратная связь | Где кончается эталон |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| События STG | ничего | чтец, сырье, `hits_raw_mv` | опись (счет) | все, кроме счета |
|
||||||
|
| События STG→ODS | «собери рядом», не вычитание | обе матвью, `*_errors` | стендовый ODS рядом, опись | содержимое неопорных колонок; окно сырья 3 суток |
|
||||||
|
| Заказы STG | ничего | чтец, забор | хеш слепка в описи | сам забор |
|
||||||
|
| Заказы STG→ODS | `order_parse.sql` | `*_errors`, чтец | баланс среза по `_load_id`, `argMax(updated_at)`, опись классов | ветвь брака — пока не подложен брак руками |
|
||||||
|
| DDS сессия | `session_*` (все три) | донор, DDL, даг | `sessions_vs_reference` (внешний эталон) | атрибуты строки — нужна поштучная сверка |
|
||||||
|
| DDS заказ | `order_*` (все три) | донор, DDL, даг | опись классов, `items_total` в сверке | разбор позиций и пояс — сначала сюжет 14 |
|
||||||
|
| DDS карта | только с оговорками | DDL, даг | нет независимой | почти сразу; форма — «собери рядом» |
|
||||||
|
| DM витрины | `revenue/pvo/traffic` | доноры, DDL, даг | `dq_check` целиком | ответ лежит в самой проверке |
|
||||||
|
|
||||||
|
### Вывод
|
||||||
|
|
||||||
|
Гипотеза «вычитание + DQ» подходит не для всех нижних слоев. Без оговорок она работает только для сессий DDS: там есть внешний эталон, настоящие места для ошибок и проверка, которая не содержит готового решения.
|
||||||
|
|
||||||
|
Для заказов DDS и разбора заказов ODS нужна последовательность «сначала построй сторожа, потом ломай». Недостающие инварианты — сюжеты 14 и 15, а также баланс среза — сами входят в учебный материал. Их нужно построить до вычитания.
|
||||||
|
|
||||||
|
STG и прием событий нельзя отдавать в форме «удали и перепиши»: код там служит несущей частью стенда. Для этих участков подходят расследования и форма «собери рядом».
|
||||||
|
|
||||||
|
У всех счетных проверок остается общая дыра — раскладка по шардам. Поэтому пересчет ключа по `hostName()` должен входить в ТЗ каждого вычитания отдельной строкой.
|
||||||
@@ -48,8 +48,10 @@
|
|||||||
## 1. Широкое событие кликстрима
|
## 1. Широкое событие кликстрима
|
||||||
|
|
||||||
Форма — хит Метрики из облачной выгрузки: одно событие = одна строка,
|
Форма — хит Метрики из облачной выгрузки: одно событие = одна строка,
|
||||||
многозначное — в параллельных массивах одной длины, плюс одно сырое
|
многозначное — в параллельных массивах, плюс одно сырое JSON-поле
|
||||||
JSON-поле `ecommerce`. Сессий в потоке нет — их менти собирает сам в DDS.
|
`ecommerce`. Одна длина у массивов общая внутри группы: `purchase*` — по
|
||||||
|
элементу на заказ, `product*` — по элементу на товар; между собой группы
|
||||||
|
разной длины. Сессий в потоке нет — их менти собирает сам в DDS.
|
||||||
|
|
||||||
### 1.1 Решения по именам и типам
|
### 1.1 Решения по именам и типам
|
||||||
|
|
||||||
@@ -97,7 +99,7 @@ JSON-поле `ecommerce`. Сессий в потоке нет — их мент
|
|||||||
| `ClientID` | UInt64 | анонимный id браузера (кука) — ключ шардирования |
|
| `ClientID` | UInt64 | анонимный id браузера (кука) — ключ шардирования |
|
||||||
| `CounterID` | UInt32 | константа стенда (один сайт) |
|
| `CounterID` | UInt32 | константа стенда (один сайт) |
|
||||||
| `EventDate` | Date | дата события |
|
| `EventDate` | Date | дата события |
|
||||||
| `UTCEventTime` | DateTime | единственная метка времени, как у Метрики |
|
| `UTCEventTime` | DateTime('UTC') | единственная метка времени, как у Метрики |
|
||||||
| `ClientTimeZone` | Int16 | смещение пояса клиента в минутах |
|
| `ClientTimeZone` | Int16 | смещение пояса клиента в минутах |
|
||||||
| `EventType` | LowCardinality(String) | `pageview` / `add_to_cart` / `purchase` |
|
| `EventType` | LowCardinality(String) | `pageview` / `add_to_cart` / `purchase` |
|
||||||
| `Sign` | Int8 | всегда 1: колонка формата, механика исправлений не реализована (см. 1.1) |
|
| `Sign` | Int8 | всегда 1: колонка формата, механика исправлений не реализована (см. 1.1) |
|
||||||
@@ -136,8 +138,9 @@ Ecommerce (заполнены только у торговых событий):
|
|||||||
| `ecommerce` | String — сырой JSON события, как отдаёт Метрика (кандидат будущей лабы: сырое против разобранного) |
|
| `ecommerce` | String — сырой JSON события, как отдаёт Метрика (кандидат будущей лабы: сырое против разобранного) |
|
||||||
|
|
||||||
`add_to_cart` несёт массивы `product*` с одним товаром; `purchase` — состав
|
`add_to_cart` несёт массивы `product*` с одним товаром; `purchase` — состав
|
||||||
заказа и блок `purchase*`. Выручка у клиента — во Float64, как у Метрики:
|
заказа и блок `purchase*`. Выручка приходит JSON-числом Float64, как у
|
||||||
это не недосмотр, а часть урока о расхождениях (см. раздел 4).
|
Метрики. ODS один раз нормализует деньги в `Array(Decimal(18, 2))`, не меняя
|
||||||
|
формат источника; DDS наследует тип хранения без нового вычисления.
|
||||||
|
|
||||||
### 1.3 Ключи и движки
|
### 1.3 Ключи и движки
|
||||||
|
|
||||||
@@ -170,8 +173,9 @@ Ecommerce (заполнены только у торговых событий):
|
|||||||
рендеренное «описание выгрузки» в доках — аналог документации Метрики.
|
рендеренное «описание выгрузки» в доках — аналог документации Метрики.
|
||||||
Сторона хранилища (DDL, SELECT матвью, трансформации, витрины) пишется по
|
Сторона хранилища (DDL, SELECT матвью, трансформации, витрины) пишется по
|
||||||
этой документации на своих этапах, как в бою хранилище адаптируется к
|
этой документации на своих этапах, как в бою хранилище адаптируется к
|
||||||
источнику; границу сторожат строгий приём (раздел 6) и contract-тест в
|
источнику; границу сторожит строгий приём (раздел 6). Вторым сторожем здесь
|
||||||
smoke — сравнение `system.columns` поднятого стенда со схемой генератора.
|
стояла сверка объявлений — `system.columns` поднятого стенда против схемы
|
||||||
|
генератора; она снята при исполнении #43 как ничего не добавляющая к соседу.
|
||||||
Без контракта 47 колонок, повторяясь примерно в семи местах, расходятся
|
Без контракта 47 колонок, повторяясь примерно в семи местах, расходятся
|
||||||
молча. Заодно это учебный артефакт: менти видит на живом примере, что
|
молча. Заодно это учебный артефакт: менти видит на живом примере, что
|
||||||
такое data contract.
|
такое data contract.
|
||||||
@@ -187,44 +191,67 @@ smoke — сравнение `system.columns` поднятого стенда с
|
|||||||
выручка дня 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-числами
|
||||||
лежит в одной партиции, дедуп работает.
|
Float64, а бэкенд передает деньги строками. На границе ODS оба источника
|
||||||
|
становятся `Decimal(18, 2)`: клиентское число разбирается сразу, без
|
||||||
|
промежуточного Float64 в хранилище.
|
||||||
|
Запись собирает явная функция существующего канонического сериализатора:
|
||||||
|
один словарь с вложенным списком и один `orjson.dumps`, без универсального
|
||||||
|
слоя кодеков.
|
||||||
|
Обоснование и проверка разбора — в
|
||||||
|
[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md).
|
||||||
|
|
||||||
|
- `ods.order` принимает версии заказа в
|
||||||
|
`ReplacingMergeTree(snapshot_date)`: `ORDER BY order_id`, партиция по дню
|
||||||
|
неизменного `created_at`, шардирование по `cityHash64(order_id)`. Дата
|
||||||
|
слепка служит колонкой версии: выживает строка с максимальной
|
||||||
|
`snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)).
|
||||||
|
Дата не доказывает полноту принятой порции, поэтому ODS не заменяет партицию
|
||||||
|
слепка целиком. Физическое чтение может видеть несколько версий;
|
||||||
|
`ods.order_v` возвращает точное текущее состояние. В DDS одна строка хранит
|
||||||
|
один заказ с позициями в типизированных массивах; физическая модель и
|
||||||
|
пакетное наполнение описаны в [доке заказа](../architecture/dds/order.md).
|
||||||
|
|
||||||
Пропущенный день ничего не ломает, следующий слепок самовосстанавливает.
|
Пропущенный день ничего не ломает, следующий слепок самовосстанавливает.
|
||||||
- Разбор 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. Каталог товаров
|
||||||
|
|
||||||
CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`,
|
CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `category`,
|
||||||
`brand`, `price`) — **словарь ClickHouse** из файла. Тот же файл использует
|
`brand`, `price`, `demand`) — **словарь ClickHouse**. Тот же файл использует
|
||||||
генератор — расхождений нет по построению. Даёт `dictGet` в витринах и
|
генератор — расхождений нет по построению. Даёт `dictGet` в витринах и
|
||||||
разговор о политике обновления словаря. На кластере файл монтируется в обе
|
разговор о политике обновления словаря. На кластере файл монтируется в обе
|
||||||
ноды, словарь создаётся ON CLUSTER.
|
ноды; над ним стоит подложка, а словарь читает её и живёт в зоне справочников
|
||||||
|
`dic` вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)).
|
||||||
|
Последняя колонка —
|
||||||
|
уровень спроса товара, заведена при исполнении #50 (спека генератора,
|
||||||
|
раздел 9): генератор решает по ней, что уходит из карточки в корзину.
|
||||||
|
|
||||||
## 4. Сверка `purchase` против заказов
|
## 4. Сверка `purchase` против заказов
|
||||||
|
|
||||||
@@ -237,9 +264,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
|
|
||||||
| | Расхождение | Механика в генераторе | Ориентир доли |
|
| | Расхождение | Механика в генераторе | Ориентир доли |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов |
|
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе |
|
||||||
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
|
| B | Потерянное событие | заказ есть, `purchase` не доехал | бросок 3%, наблюдаемых ~1,8%: часть маячков защищена парами склейки, часть уходит в отмену приоритетом |
|
||||||
| 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` >
|
||||||
@@ -247,9 +274,10 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
|
|
||||||
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
||||||
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
||||||
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
|
ориентир ~10%. Точные доли зафиксированы замером на каноническом мире
|
||||||
манифест хранит точные счётчики по каждому классу расхождений (отмены,
|
(28 дней, 2026-08-30, #194) — запись в «Что проверено»
|
||||||
потери, дубли).
|
[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
|
||||||
|
только по дням с закрытым окном (раздел 8).
|
||||||
|
|
||||||
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
||||||
механически дублирует B.
|
механически дублирует B.
|
||||||
@@ -259,7 +287,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
под именем `declared_*` там, где атрибуция без трекера невозможна (UTM);
|
под именем `declared_*` там, где атрибуция без трекера невозможна (UTM);
|
||||||
слово `declared` в имени — сигнал «это заявка клиента, не деньги
|
слово `declared` в имени — сигнал «это заявка клиента, не деньги
|
||||||
отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам
|
отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам
|
||||||
раскопает почему (Float64 против Decimal, промокод, доставка, отмены).
|
раскопает почему (состав позиций, промокод, доставка, отмены).
|
||||||
|
|
||||||
## 5. Анонимность и склейка идентичностей
|
## 5. Анонимность и склейка идентичностей
|
||||||
|
|
||||||
@@ -269,20 +297,28 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
- **Карта соответствий кука↔пользователь** строится трансформацией из уже
|
- **Карта соответствий кука↔пользователь** строится трансформацией из уже
|
||||||
существующего моста: `purchase`-событие (`ClientID`, `purchaseID`) ↔ заказ
|
существующего моста: `purchase`-событие (`ClientID`, `purchaseID`) ↔ заказ
|
||||||
(`order_id`, `user_id`). Ни новых полей, ни нового транспорта.
|
(`order_id`, `user_id`). Ни новых полей, ни нового транспорта.
|
||||||
- Форма и место карты: таблица `dds.identity_map`
|
- Форма и место карты: `dds.identity_map_rep` хранит пару `client_id` UInt64,
|
||||||
(`client_id` UInt64, `user_id` UInt64, `first_matched_at` DateTime),
|
`user_id` UInt64 и координаты собственной загрузки `_load_id`, `_load_ts` в
|
||||||
ReplacingMergeTree, `ORDER BY (client_id, user_id)`, шардирование по
|
`ReplicatedReplacingMergeTree(_load_ts)` с `ORDER BY (client_id, user_id)`.
|
||||||
`cityHash64(client_id)` — тем же выражением, что события (иначе ко-локации
|
`dds.identity_map_dist` шардирует по `cityHash64(client_id)` — тем же
|
||||||
нет); ко-локация делает обогащение витрин локальным;
|
выражением, что события; ко-локация позволяет обогащать витрины локально.
|
||||||
заказы при сборке карты подтягиваются через GLOBAL JOIN.
|
Публичный интерфейс `dds.identity_map_v` перечисляет колонки явно и прячет
|
||||||
|
`FINAL`. `first_matched_at` в стенде нет: у него нет читателя; история
|
||||||
|
склейки оставлена самостоятельной работе в issue #145.
|
||||||
|
- Карта пересобирается пакетно после модели заказов: все `purchase` из
|
||||||
|
`dds.event_v` соединяются с текущими заказами `dds.order_v` через
|
||||||
|
`GLOBAL JOIN`, статус заказа связь покупателя с кукой не отменяет. Каждый
|
||||||
|
запуск пишет полный текущий набор различных пар; предыдущий результат не
|
||||||
|
служит источником следующего.
|
||||||
- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») —
|
- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») —
|
||||||
параметр мира; значение фиксирует эта спека: 15%, детерминировано
|
параметр мира; значение фиксирует эта спека: 15%, детерминировано
|
||||||
от seed. Ядро лабы:
|
от seed. Ядро лабы:
|
||||||
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
|
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
|
||||||
Константа мира: каждый двухкуковый покупатель делает минимум по одному
|
Константа мира: каждый двухкуковый покупатель делает минимум по одному
|
||||||
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
|
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
|
||||||
(она строится только из покупок) и лаба не воспроизводится. Манифест
|
(она строится только из покупок) и лаба не воспроизводится. Число таких
|
||||||
хранит число именно таких пар.
|
пар растёт, пока мир едет, и в опись не кладётся
|
||||||
|
([что хранит опись](../architecture/orders/inventory.md)).
|
||||||
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
||||||
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
||||||
|
|
||||||
@@ -302,35 +338,66 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
- Роли нод: нода 1 — инициатор DDL и подключение Airflow; **Superset — на
|
- Роли нод: нода 1 — инициатор DDL и подключение Airflow; **Superset — на
|
||||||
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
|
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
|
||||||
VIEW поверх локальной таблицы проявляются в дашборде сами.
|
VIEW поверх локальной таблицы проявляются в дашборде сами.
|
||||||
- **Приём Kafka**: Kafka-таблицы и MV — на обеих нодах, одна consumer group,
|
- **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна
|
||||||
2 партиции на топик; MV пишут в Distributed-цели. Раскладку решает ключ:
|
consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели.
|
||||||
|
У заказов приём другого рода — пакетный забор без MV, чтец на одной ноде,
|
||||||
|
топик `orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)).
|
||||||
|
Раскладку в обоих случаях решает ключ:
|
||||||
события — по `cityHash64(ClientID)` (см. 1.3), заказы —
|
события — по `cityHash64(ClientID)` (см. 1.3), заказы —
|
||||||
`cityHash64(order_id)`, сырьё STG —
|
`cityHash64(order_id)`, сырьё STG — `cityHash64(сырой строки)`; полный
|
||||||
`cityHash64(сырой строки)`. Урок: «какая нода читала топик — меняется между
|
список и доводы — в [доке хранилища](../architecture/storage.md). Урок
|
||||||
прогонами, куда легли данные — нет».
|
«какая нода читала топик — меняется между прогонами, куда легли данные —
|
||||||
- **Приём строгий**: `input_format_skip_unknown_fields = 0`, обязательные
|
нет» живёт на событиях: при пакетном заборе читает та нода, которую
|
||||||
поля — без значений по умолчанию. Контракт присутствия: генератор выдаёт
|
спросили.
|
||||||
|
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
|
||||||
|
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
|
||||||
|
сообщения сверяется с контрактным; строка с NULL среди опорных колонок
|
||||||
|
или с разошедшимся набором ключей уходит в `*_errors`. Опорными выбраны те,
|
||||||
|
чья порча отравляет всё ниже по течению: идентификаторы события, визита и
|
||||||
|
посетителя, дата партиции и метка времени, по которой события упорядочиваются
|
||||||
|
внутри сессии. `CounterID` формально тоже в ключе сортировки, но на стенде он
|
||||||
|
константа, и NULL там взяться неоткуда. Остальные сорок две
|
||||||
|
достаются обычными типами — сорок семь проверок на NULL превратили бы матвью в
|
||||||
|
простыню, а присутствие и так целиком закрыто сверкой ключей. Сверка ключей —
|
||||||
|
не добавка: у массивов NULL не бывает, и пропавшее поле-массив иначе
|
||||||
|
неотличимо от пустого по смыслу. На входе разбора нет вовсе — Kafka-таблица
|
||||||
|
читает сообщение байтами, строгость целиком в матвью ODS
|
||||||
|
([ADR 0005](../adr/0005-event-ingestion.md)). Контракт присутствия: генератор выдаёт
|
||||||
**все 47 полей в каждом событии**; «пусто» — пустой массив, пустая строка
|
**все 47 полей в каждом событии**; «пусто» — пустой массив, пустая строка
|
||||||
или 0, а не отсутствие ключа в JSON. Так строгий приём уживается с
|
или 0, а не отсутствие ключа в JSON. Так строгий приём уживается с
|
||||||
полями, пустыми по смыслу (ecommerce у `pageview`, UTM у прямого захода). Несовпадение имени поля — громкая
|
полями, пустыми по смыслу (ecommerce у `pageview`, UTM у прямого захода). Несовпадение имени поля — громкая
|
||||||
ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы,
|
ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы,
|
||||||
опечатка иначе не падает.
|
опечатка иначе не падает.
|
||||||
- **Политика соединений**: по ключу ко-локации — обычное соединение с
|
- **Политика соединений**: по ключу ко-локации — обычное соединение с
|
||||||
комментарием, почему локальный результат корректен; по любому другому ключу
|
комментарием, почему локальный результат корректен; если публичные
|
||||||
— только явный 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` остаётся
|
переобработка нижележащих объектов — по дневным партициям
|
||||||
только в `make reset`. `DROP/REPLACE PARTITION` работает только по
|
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не
|
||||||
**локальным** таблицам ON CLUSTER, не по Distributed; замена через
|
применяется вовсе: обычная переобработка ограничена партицией, а полное
|
||||||
DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это
|
восстановление данных — базами слоёв в следующем пункте. `DROP/REPLACE
|
||||||
осознанная цена, не баг).
|
PARTITION` работает только по **локальным** таблицам ON CLUSTER, не по
|
||||||
- **Поздние заказы поглощает только ODS** (`ods.order_snapshot` — новая
|
Distributed; замена через DROP+INSERT неатомарна — дашборд в середине прогона
|
||||||
партиция дня слепка, без переделки старого); материализованное ниже —
|
честно моргает (это осознанная цена, не баг).
|
||||||
нет. Каждый прогон ETL перестраивает партиции последних K+1 дней у
|
- **Область очистки следует за областью состояния**: обычная разработка не
|
||||||
заказозависимых объектов (`dds.order` и производные, `dm.dq_summary`).
|
требует пустых томов. Сначала пересоздаётся затронутый объект, партиция или
|
||||||
|
база слоя; если нужен целиком чистый мир, пересоздаются прикладные базы
|
||||||
|
ClickHouse, топики и позиции их потребителей Kafka, затем заново заливаются
|
||||||
|
данные. Сами службы и их тома сохраняются. `make clean` нужен, только когда
|
||||||
|
требуется удалить состояние вне прикладного мира либо проверить запуск именно
|
||||||
|
с пустых томов. Перед таким сбросом агент называет это состояние и объясняет,
|
||||||
|
почему более узкой очистки недостаточно. «Так проще и надёжнее» не считается
|
||||||
|
обоснованием. Обязательный `make clean` перед каждой приёмкой отвергнут: он
|
||||||
|
стирает следы неисправности и превращает редкий холодный запуск в постоянную
|
||||||
|
цену разработки.
|
||||||
|
- **Поздние заказы поглощает ODS** как новую версию `order_id`. Как их
|
||||||
|
подхватывают материализованные объекты DDS и DM, решается вместе с их моделью,
|
||||||
|
а не при проектировании приёма.
|
||||||
Сессии перестраиваются только за текущий день: правило мира — сессия
|
Сессии перестраиваются только за текущий день: правило мира — сессия
|
||||||
режется по границе модельных суток, дневная партиция самодостаточна.
|
режется по границе модельных суток, дневная партиция самодостаточна.
|
||||||
- Для ETL-вставок — `distributed_foreground_insert = 1` (раньше называлась
|
- Для ETL-вставок — `distributed_foreground_insert = 1` (раньше называлась
|
||||||
@@ -362,30 +429,55 @@ README.
|
|||||||
|
|
||||||
## 7. Слои: карта таблиц v2
|
## 7. Слои: карта таблиц v2
|
||||||
|
|
||||||
| Слой | Объект | Что это |
|
| Где | Объект | Что это |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Kafka | `hits`, `orders` | два топика, по 2 партиции |
|
| Kafka | `hits`, `orders` | два топика: `hits` — 2 партиции, `orders` — одна |
|
||||||
| STG | `stg.kafka_hits`, `stg.hits_raw` + MV; то же для orders | сырые строки, Kafka Engine на обеих нодах |
|
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах |
|
||||||
| ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree |
|
| STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг |
|
||||||
| ODS | `ods.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа |
|
| ODS | `ods.event` (+`_errors`), `ods.event_v` | типизированные версии событий, брак и точное актуальное состояние |
|
||||||
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) |
|
| ODS | `ods.order` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние |
|
||||||
| DDS | `dds.v_event` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
|
| DDS | `dds.session_rep` / `_dist`, `dds.session_v` | физическая сборка сессий и её публичный интерфейс |
|
||||||
| DDS | `dds.order` | единственная дедуплицированная таблица заказа: партиция по дню заказа (`toDate(created_at)`), ReplacingMergeTree(`updated_at`), `ORDER BY order_id`, дедуп до последней версии — argMax в трансформации при сборке |
|
| DDS | `dds.event_v` | представление над `ods.event_v`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
|
||||||
| DDS | `dds.identity_map` | карта кука↔пользователь |
|
| DDS | `dds.order_rep` / `_dist`, `dds.order_v` | текущая модель заказа и её публичный интерфейс |
|
||||||
| DDS | словарь `products` | каталог из CSV |
|
| DDS | `dds.identity_map_rep` / `_dist`, `dds.identity_map_v` | физическая карта кука↔пользователь и её точный публичный интерфейс |
|
||||||
| DM | витрины `dm.v_*`, `dm.dq_summary` | см. ниже |
|
| DIC | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) |
|
||||||
|
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
|
||||||
|
|
||||||
Служебные колонки: `ods.event` и `ods.order_snapshot` получают метку приёма
|
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
|
||||||
`_ingested_at`; у `ods.event` та же колонка — колонка версии
|
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья —
|
||||||
ReplacingMergeTree. Таблицы `stg.*_raw` хранят виртуальные колонки Kafka
|
см. [доку хранилища](../architecture/storage.md).
|
||||||
(`_topic`, `_partition`, `_offset`, `_timestamp`) — без них урок «какая нода
|
Потребительские запросы и витрины читают сущности DDS через `_v` с явным
|
||||||
читала топик» ненаблюдаем. `stg.hits_raw` дополнительно хранит извлечённый
|
списком колонок; `_rep` и `_dist` служат загрузке, диагностике и обслуживанию.
|
||||||
`event_date` — им кормится переобработка дня X (при исчерпании retention
|
Договор держится конвенцией, а не правами: модель доступа описана в
|
||||||
|
[ADR 0007](../adr/0007-clickhouse-access.md), договор чтения — в
|
||||||
|
[доке хранилища](../architecture/storage.md), «Имена объектов».
|
||||||
|
|
||||||
|
Заказы принимаются **пакетным забором** ([ADR
|
||||||
|
0008](../adr/0008-order-ingestion.md), [ADR
|
||||||
|
0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у событий, но
|
||||||
|
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
|
||||||
|
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска.
|
||||||
|
Один следующий `task_id` двумя последовательными запросами пишет годные строки
|
||||||
|
и ошибки из того же среза. `ods.order` выбирает текущую версию по максимальной
|
||||||
|
`snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Слой
|
||||||
|
сырья остаётся точкой повтора и разбора одной принятой порции.
|
||||||
|
|
||||||
|
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
||||||
|
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию
|
||||||
|
заказа задаёт дата слепка; `_load_id` проходит через STG и обе цели ODS. Таблицы
|
||||||
|
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
|
||||||
|
без них урок «какая нода читала топик» ненаблюдаем.
|
||||||
|
Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое
|
||||||
|
разбирает ODS, где эта колонка и служит ключом партиции. Переобработка режется
|
||||||
|
по времени загрузки, координате самого слоя доставки. При исчерпании retention
|
||||||
Kafka день переигрывается генератором заново: снимок — кэш чистой функции,
|
Kafka день переигрывается генератором заново: снимок — кэш чистой функции,
|
||||||
см. [спеку генератора](2026-08-01-generator.md)).
|
см. [спеку генератора](2026-08-01-generator.md).
|
||||||
|
|
||||||
`dds.v_event` — первый на стенде пример правила «слой — это контракт, а не
|
`ods.event_v` сохраняет имена, типы и зерно ODS, но скрывает `FINAL` и
|
||||||
обязательно копия данных».
|
возвращает актуальную версию события. `dds.event_v` — другой контракт: оно
|
||||||
|
переводит событие на язык бизнес-модели, в том числе даёт snake_case-имена и
|
||||||
|
расшифровывает коды. Это первый на стенде пример правила «слой — это контракт,
|
||||||
|
а не обязательно копия данных».
|
||||||
|
|
||||||
Событие в DDS не дублируется: склейки четырёх источников больше нет, ODS уже
|
Событие в DDS не дублируется: склейки четырёх источников больше нет, ODS уже
|
||||||
широкий и типизированный; DDS хранит бизнес-сущности (сессия, заказ,
|
широкий и типизированный; DDS хранит бизнес-сущности (сессия, заказ,
|
||||||
@@ -394,34 +486,43 @@ Kafka день переигрывается генератором заново:
|
|||||||
|
|
||||||
### Витрины DM
|
### Витрины DM
|
||||||
|
|
||||||
- **`v_revenue_daily`** (выручка, только от заказов): `report_date`,
|
- **`revenue_daily_v`** (выручка, только от заказов): `report_date`,
|
||||||
`product_category` (через `dictGet` каталога + ARRAY JOIN позиций),
|
`product_category` (через `dictGet` каталога + ARRAY JOIN позиций),
|
||||||
`orders`, `units`, `revenue`, `aov`. Считается по заказам в статусе
|
`orders`, `units`, `revenue`, `aov`. Считается по заказам в статусе
|
||||||
`paid`; внутри окна K число дня «дышит».
|
`paid`; внутри окна K число дня «дышит».
|
||||||
- **`v_purchase_vs_orders`** (сверка): FULL OUTER GLOBAL JOIN по
|
- **`purchase_vs_orders_v`** (сверка): FULL OUTER GLOBAL JOIN по
|
||||||
`purchaseID = order_id`; колонки: `order_day`, `order_id`,
|
`purchaseID = order_id`; колонки: `order_day`, `order_id`,
|
||||||
`declared_revenue` (клиент), `items_total` (бэкенд, сравнимая база — не
|
`declared_revenue` (клиент), `items_total` (бэкенд, сравнимая база — не
|
||||||
`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` — остаток, не объяснённый
|
||||||
после приведения к сравнимой базе (округления Float64, ~1–2% заказов).
|
приведением к сравнимой базе: сравнением позиций он объясняется, и на этом
|
||||||
|
стоит урок класса C; создаёт его генератор намеренно
|
||||||
|
(~1–2% заказов, см. раздел 4).
|
||||||
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
|
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
|
||||||
слепка, а не расхождение: она получает служебный класс `awaiting_order`
|
слепка, а не расхождение: она получает служебный класс `awaiting_order`
|
||||||
(шестое значение `mismatch_class`, вне приоритетов расхождений). После
|
(шестое значение `mismatch_class`, вне приоритетов расхождений). После
|
||||||
закрытия окна K таких строк не остаётся — сироты исключены построением
|
закрытия окна K таких строк не остаётся — сироты исключены построением
|
||||||
(раздел 4).
|
(раздел 4).
|
||||||
- **`v_utm_effectiveness`** — остаётся клиентской (атрибуция по трекеру);
|
- **`utm_effectiveness_v`** — остаётся клиентской (атрибуция по трекеру);
|
||||||
счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется
|
счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется
|
||||||
`declared_revenue` по UTM.
|
`declared_revenue` по UTM.
|
||||||
- **`v_daily_traffic`** — расширяется парой «посетители» / «известные
|
- **`daily_traffic_v`** — расширяется парой «посетители» / «известные
|
||||||
пользователи» (обогащение через `dds.identity_map`, локальное соединение
|
пользователи». Обогащение идет явным `GLOBAL LEFT JOIN` через
|
||||||
по ключу ко-локации).
|
`dds.identity_map_v`: опыт на ClickHouse 26.3 показал, что публичные `_v`
|
||||||
- `v_events_enriched`, `v_top_pages_daily`, `v_session_overview`,
|
скрывают ко-локацию от локальной подстановки.
|
||||||
`v_dq_errors_daily` — переезжают на новую модель без смены роли: источник —
|
- `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`,
|
||||||
`dds.v_event`, не `ods.event`.
|
`dq_errors_daily_v` — переезжают на новую модель без смены роли. Источник
|
||||||
- `dm.dq_summary` переводится с TRUNCATE+INSERT на партиционную замену
|
и зерно у двух из них меняет сама новая модель, и это зафиксировано ТЗ
|
||||||
(политика «без TRUNCATE»).
|
домашек ([`docs/labs/`](../labs/README.md)):
|
||||||
|
`session_overview_v` читает `dds.session_v` и поднимается на зерно «день ×
|
||||||
|
срез», потому что визит стал сущностью DDS и витрина его зерна была бы
|
||||||
|
копией сессий; `dq_errors_daily_v` читает таблицы `ods.*_errors`, у
|
||||||
|
которых в DDS представительства нет. Остальные две читают `dds.event_v`,
|
||||||
|
а не `ods.event`.
|
||||||
|
- `dm.dq_summary` хранит текущее качество дня и обновляется партиционной
|
||||||
|
заменой (политика «без TRUNCATE»).
|
||||||
|
|
||||||
Дашборд Superset получает три новых сюжета: выручка по дням и категориям,
|
Дашборд Superset получает три новых сюжета: выручка по дням и категориям,
|
||||||
таблица сверки с классами расхождений, пара посетители/известные.
|
таблица сверки с классами расхождений, пара посетители/известные.
|
||||||
@@ -430,26 +531,34 @@ Kafka день переигрывается генератором заново:
|
|||||||
пачками), Postgres остаётся только служебной базой Airflow. Смешанность
|
пачками), Postgres остаётся только служебной базой Airflow. Смешанность
|
||||||
ландшафта выражена режимами и частотами, а не второй трубой.
|
ландшафта выражена режимами и частотами, а не второй трубой.
|
||||||
|
|
||||||
## 8. Эталонный мир и манифест
|
## 8. Эталонный мир и опись
|
||||||
|
|
||||||
В git хранится только манифест эталонного мира; сам снимок (14 модельных
|
В git хранится только опись эталонного мира; сам снимок генерируется на
|
||||||
дней) генерируется на месте — при `make up` и при проверках (решение
|
месте — при `make up` и при проверках (решение развилки «Производительность»
|
||||||
развилки «Производительность» карты #26, подробности —
|
карты #26, подробности — [спека генератора](2026-08-01-generator.md),
|
||||||
[спека генератора](2026-08-01-generator.md), раздел 5). Манифест несёт
|
раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней
|
||||||
|
снято на этапе 7. Опись несёт
|
||||||
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
|
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
|
||||||
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
||||||
сравни хеш» живут на нём. Политика версионирования артефакта (бывший туман
|
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
|
||||||
карты #10) закрыта этим же ходом: версионируется манифест.
|
карты #10) закрыта этим же ходом: версионируется опись.
|
||||||
Контрольные числа манифеста:
|
Контрольные числа описи:
|
||||||
|
|
||||||
- заказная сторона: заказы и выручка по дням; манифест хранит точные
|
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
|
||||||
счётчики по каждому классу расхождений (отмены, потери, дубли, дельты сумм) —
|
«слепок переснимается и даёт те же байты» опись сторожит наравне с днями;
|
||||||
самопроверка лабы сверки;
|
- заказная сторона: счётчики наблюдаемых классов расхождений после
|
||||||
- идентичность: uniq кук, uniq известных пользователей, число двухкуковых
|
приоритета; каждый показывает число заказов и хранится только для дня с
|
||||||
покупателей — лаба склейки получает самопроверку.
|
закрытым окном (читатель — техническая проверка `classes_vs_inventory`
|
||||||
|
звена DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для
|
||||||
|
общего числа заказов и выручки в описи пока нет: для них не назван
|
||||||
|
читатель;
|
||||||
|
- контрольные числа идентичности не заводятся: uniq известных пользователей
|
||||||
|
и число двухкуковых покупателей растут, пока мир едет, а счётчик без
|
||||||
|
названного читателя в опись не кладётся
|
||||||
|
([что хранит опись](../architecture/orders/inventory.md)).
|
||||||
|
|
||||||
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
|
Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
|
||||||
при пересборке.
|
[карты проверок](../architecture/testing.md).
|
||||||
|
|
||||||
## 9. Оценка объёма исполнения
|
## 9. Оценка объёма исполнения
|
||||||
|
|
||||||
@@ -460,10 +569,10 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Генератор | с нуля: модель v1 не переносится (другая модель данных, плюс известные проблемы производительности v1); широкое событие, таксономия, анонимность, N:1, заказы слепками, расхождения A–D, каталог; масштаб — ~4–5 тыс. строк с тестами | L |
|
| Генератор | с нуля: модель v1 не переносится (другая модель данных, плюс известные проблемы производительности v1); широкое событие, таксономия, анонимность, N:1, заказы слепками, расхождения A–D, каталог; масштаб — ~4–5 тыс. строк с тестами | L |
|
||||||
| Инфраструктура | compose: 2 ноды CH + keeper + остальной стенд; конфиги кластера, макросы; make/скрипты | M — ~10–12 файлов |
|
| Инфраструктура | compose: 2 ноды CH + keeper + остальной стенд; конфиги кластера, макросы; make/скрипты | M — ~10–12 файлов |
|
||||||
| SQL | 5 DDL-файлов (ON CLUSTER, Replicated*, Distributed) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
|
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
|
||||||
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка, ожидание дневного батча заказов — сенсор/Datasets), world_init/next_day, helpers | M — ~5–6 файлов |
|
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка), world_init/next_day, helpers; первый даг — переливка слепка — приходит раньше, этапом 3 | M — ~5–6 файлов |
|
||||||
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
|
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
|
||||||
| Эталонный мир | пересборка снимка на месте, манифест-счётчики, чек-скрипты | M–L |
|
| Эталонный мир | пересборка снимка на месте, счётчики описи, чек-скрипты | M–L |
|
||||||
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
|
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
|
||||||
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
|
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
|
||||||
|
|
||||||
@@ -482,7 +591,7 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
`cancelled`) — на статусе `paid` стоит «дыхание» выручки; сужение до пары
|
`cancelled`) — на статусе `paid` стоит «дыхание» выручки; сужение до пары
|
||||||
`created`/`cancelled` — запасной ход, если генератор заказов окажется дороже
|
`created`/`cancelled` — запасной ход, если генератор заказов окажется дороже
|
||||||
ожиданий. Связка: если срез 1 сработает, определение выручки в
|
ожиданий. Связка: если срез 1 сработает, определение выручки в
|
||||||
`v_revenue_daily` придётся сменить с «заказы в статусе `paid`» на «все
|
`revenue_daily_v` придётся сменить с «заказы в статусе `paid`» на «все
|
||||||
неотменённые заказы».
|
неотменённые заказы».
|
||||||
- **Отступление от порядка #15**: резолюция предписывала резать в порядке
|
- **Отступление от порядка #15**: резолюция предписывала резать в порядке
|
||||||
1 → 2 → 3, спека применяет 3 и 2, а 1 держит в резерве. Довод: срезы 3 и 2
|
1 → 2 → 3, спека применяет 3 и 2, а 1 держит в резерве. Довод: срезы 3 и 2
|
||||||
@@ -500,24 +609,48 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
2. DDL и генератор (слиты в один этап — DDL проверяется только настоящими
|
2. DDL и генератор (слиты в один этап — DDL проверяется только настоящими
|
||||||
данными): базы и таблицы событий ON CLUSTER, приём `hits` обеими нодами;
|
данными): базы и таблицы событий ON CLUSTER, приём `hits` обеими нодами;
|
||||||
широкое событие, таксономия, анонимность, N:1 (клиентская сторона
|
широкое событие, таксономия, анонимность, N:1 (клиентская сторона
|
||||||
целиком). В конце этапа фиксируется маленький «зерновой» мир для
|
целиком). В конце этапа фиксируется маленький стартовый мир для
|
||||||
стабильных приёмок следующих этапов (полная пересборка эталонного мира —
|
стабильных приёмок следующих этапов (полная пересборка эталонного мира —
|
||||||
отдельный этап 7).
|
отдельный этап 7).
|
||||||
3. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь.
|
3. Заказы и каталог: генератор слепков, STG/ODS заказа, словарь и даг приёма
|
||||||
4. Трансформации и витрины: сессии, identity_map, выручка, сверка A+C.
|
слепка ([ADR 0008](../adr/0008-order-ingestion.md)). Модель заказа в DDS
|
||||||
5. Airflow: `etl_pipeline` (партиционная переобработка, ожидание дневного
|
уехала этапу 4 — у служебной колонки и зерна читатель появляется там
|
||||||
батча заказов — сенсор/Datasets).
|
(нарезка этапа, #89).
|
||||||
6. Расхождения B+D и опоздания; счётчики манифеста.
|
4. Трансформации и витрины: модель DDS заказа, сессии, identity_map,
|
||||||
7. Эталонный мир: манифест и пересборка снимка, чек-скрипты; CI-генерация
|
выручка, сверка A+C.
|
||||||
на amd64 и arm64.
|
5. Airflow: `etl_pipeline` (партиционная переобработка).
|
||||||
|
6. Расхождения B+D и опоздания; счётчики описи.
|
||||||
|
7. Эталонный мир: опись и пересборка снимка, чек-скрипты. CI-генерация
|
||||||
|
снята (спека генератора, раздел 5).
|
||||||
8. Superset-дашборд v2.
|
8. Superset-дашборд v2.
|
||||||
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
||||||
и границы — ADR 0002.
|
и границы — ADR 0002. Этап идёт не подряд, см. ниже.
|
||||||
|
|
||||||
Критерий приёмки этапа — честный: `make up` работает и проходят
|
**Мониторинг не ждёт своего номера.** Зависимость у дашбордов формально только
|
||||||
smoke-проверки, а не «дашборд зелёный». Это минимальная планка; свои
|
от этапа 1, а «в хвосте» было выбором, не необходимостью. Довод за переезд
|
||||||
наблюдаемые критерии каждый этап получает при разбиении в /to-tickets. Документация правится в PR этапа
|
пришёл со второй стороны: дашборд — точка наблюдения не только для менти, но и
|
||||||
(правило AGENTS.md).
|
для владельца на приёмке этапов 4–8, а шесть вопросов дашборда «Данные» из
|
||||||
|
ADR 0002 с приёмочными совпадают почти целиком. Поэтому дашборды строятся
|
||||||
|
параллельно этапам 4–8. Состав от переезда не меняется, меняется срок.
|
||||||
|
|
||||||
|
В хвосте остаётся то, чему нужен прожитый опыт или дозревший мир: runbook
|
||||||
|
«keeper упал / DDL повис в очереди», `system.text_log` (ADR 0002 привязал его
|
||||||
|
к runbook), правила оповещения и урок «посмотрели → сломали → увидели →
|
||||||
|
вернули» (раздел 10).
|
||||||
|
|
||||||
|
Порогов панели не несут вовсе, и это правило, а не срок: на дашборд смотрят
|
||||||
|
глазами; красное и зелёное живёт в проверках — у целей `make` про стенд и у
|
||||||
|
дагов качества данных про данные.
|
||||||
|
|
||||||
|
Что гонять на приёмке этапа — строка в [карте
|
||||||
|
проверок](../architecture/testing.md), раздел «Какую проверку когда
|
||||||
|
запускать»: на стенде зелены все три цели, которым он нужен, а состояние перед
|
||||||
|
проверкой восстанавливается в самой узкой достаточной области. Запуск с пустых
|
||||||
|
томов обязателен только для изменения начальной сборки или постоянного состояния
|
||||||
|
служб, а не как общая гигиена этапа. Эти цели обещают, что этап не сломал стенд,
|
||||||
|
и молчат о его предмете — дашборд может быть не нарисован, а все три останутся
|
||||||
|
зелёными. Свои наблюдаемые критерии каждый этап получает при разбиении в
|
||||||
|
/to-tickets. Документация правится в PR этапа (правило AGENTS.md).
|
||||||
|
|
||||||
## 10. Границы: чего не делаем
|
## 10. Границы: чего не делаем
|
||||||
|
|
||||||
@@ -525,11 +658,19 @@ smoke-проверки, а не «дашборд зелёный». Это мин
|
|||||||
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
|
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
|
||||||
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
|
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
|
||||||
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
|
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
|
||||||
переносится. Спека даёт будущим лабам только опорные точки — контрольные
|
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг
|
||||||
числа манифеста (сверка, идентичность). Явное следствие: после этапа 9
|
самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и
|
||||||
стенд работает, но учебного пути на нём ещё нет.
|
конвейер, в который она встает, проектируются вместе. Сюда же —
|
||||||
|
необязательная лаба «свой дашборд в Superset» (#197): она стоит на
|
||||||
|
витринах домашек DM и продумана до готового ТЗ в этапе 8, тогда как
|
||||||
|
остальные лабы пока на уровне идей. Единый стиль блока
|
||||||
|
домашек — за работой учебного пути. Спека даёт будущим лабам только
|
||||||
|
опорные точки — контрольные числа описи (сверка, идентичность). Явное
|
||||||
|
следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет.
|
||||||
- Инкрементальный ETL (#8) — свой issue.
|
- Инкрементальный ETL (#8) — свой issue.
|
||||||
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
|
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
|
||||||
|
- TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные
|
||||||
|
записи, квоты, политики строк и аудит запросов — за границей учебного стенда.
|
||||||
- Полный словарь торговых событий Метрики (detail, remove, impressions),
|
- Полный словарь торговых событий Метрики (detail, remove, impressions),
|
||||||
пять уровней категорий, блоки `purchasedProduct*`/`impressions*`.
|
пять уровней категорий, блоки `purchasedProduct*`/`impressions*`.
|
||||||
- Механика `Sign`/CollapsingMergeTree — кандидат на потом (дом — поток
|
- Механика `Sign`/CollapsingMergeTree — кандидат на потом (дом — поток
|
||||||
@@ -546,18 +687,33 @@ smoke-проверки, а не «дашборд зелёный». Это мин
|
|||||||
|
|
||||||
## 11. Проверить при исполнении
|
## 11. Проверить при исполнении
|
||||||
|
|
||||||
- Поведение соединения двух Distributed-таблиц и `distributed_product_mode` —
|
Список убывает по мере постройки: проверенное уходит отсюда, а ответ с датой
|
||||||
эмпирически на стенде (хвост #14).
|
остаётся там, где на него опираются. Формат чтеца и форма виртуальной метки
|
||||||
- Kafka Engine на двух нодах в одной consumer group: ребаланс партиций между
|
времени закрыты при исполнении #37; форма ключа ODS и поведение матвью над
|
||||||
прогонами, отсутствие дублей при штатной работе.
|
`Distributed` — при исполнении #43, ответы в [доке
|
||||||
- Точная форма `ORDER BY` ODS-таблиц (выражение `intHash32` в ключе
|
хранилища](../architecture/storage.md), раздел «Что проверено»; запасной
|
||||||
ReplacingMergeTree).
|
именованный кортеж — там же в [ADR 0005](../adr/0005-event-ingestion.md).
|
||||||
|
|
||||||
|
Kafka Engine на двух нодах снят с этого списка при приёмке этапа 2 (7 августа
|
||||||
|
2026 года) — проверенным наполовину и осознанно. Что обе ноды читают топик и
|
||||||
|
обе партиции доезжают, показал #37. Дубли и раскладку партиций между прогонами
|
||||||
|
решено не проверять: дубль здесь возможен по устройству движка, окно названо и
|
||||||
|
разобрано в [доке хранилища](../architecture/storage.md), а в ODS его схлопывает
|
||||||
|
`ReplacingMergeTree`. Проверять то, что заведомо случается и заведомо
|
||||||
|
обезврежено, — работа без ответа на конце.
|
||||||
|
|
||||||
- Размер артефакта эталонного мира после пересборки.
|
- Размер артефакта эталонного мира после пересборки.
|
||||||
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
|
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
|
||||||
сверять через MCP Context7 (правило AGENTS.md).
|
сверять через MCP Context7 (правило AGENTS.md).
|
||||||
- Airflow 3.x: версия фиксируется на этапе 1 (каркас); DAG'и этапа 5 пишутся
|
- Airflow 3.x: версия фиксируется на этапе 1 (каркас); DAG'и пишутся под API
|
||||||
под API третьей версии (Datasets → Assets) — актуальные операторы и сенсоры
|
третьей версии (Datasets → Assets) — актуальные операторы проверить через
|
||||||
проверить через Context7.
|
Context7. Первый даг приходит этапом 3, а не 5 ([ADR
|
||||||
|
0008](../adr/0008-order-ingestion.md)).
|
||||||
|
- Пакетный забор слепка из Kafka: прямое чтение коммитит офсеты и возвращает
|
||||||
|
одну порцию. На стандартном мире около полутора тысяч заказов помещаются в
|
||||||
|
неё с запасом; это проверяемое допущение стенда, а не граница полноты слепка
|
||||||
|
([ADR 0008](../adr/0008-order-ingestion.md), [ADR
|
||||||
|
0010](../adr/0010-order-versions-in-ods.md)).
|
||||||
- Генератор: рабочее решение — Python с производительной архитектурой
|
- Генератор: рабочее решение — Python с производительной архитектурой
|
||||||
(батчевая генерация вместо посточной, быстрая JSON-сериализация,
|
(батчевая генерация вместо посточной, быстрая JSON-сериализация,
|
||||||
распараллеливание по модельным дням). Читаемость генератора для менти —
|
распараллеливание по модельным дням). Читаемость генератора для менти —
|
||||||
@@ -600,11 +756,28 @@ v2, этап 0).
|
|||||||
- сцена «разные consumer groups → дубли»;
|
- сцена «разные consumer groups → дубли»;
|
||||||
- словарь регионов из CSV той же машинерией, что каталог товаров (оживляет
|
- словарь регионов из CSV той же машинерией, что каталог товаров (оживляет
|
||||||
`RegionCityID`);
|
`RegionCityID`);
|
||||||
|
- неатомарное обновление словаря на кластере: ноды перезагружают его в
|
||||||
|
случайный момент внутри окна `LIFETIME`, фазы у них независимы, и после
|
||||||
|
правки каталога две ноды почти всё окно отвечают на один `dictGet`
|
||||||
|
по-разному. Обвязка готова ([ADR 0012](../adr/0012-dictionary-home.md)),
|
||||||
|
урок остаётся на выбор;
|
||||||
- лаба сессий: менти сначала собирает сессии сам, и только после — рассказ,
|
- лаба сессий: менти сначала собирает сессии сам, и только после — рассказ,
|
||||||
что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично
|
что с октября 2025 Метрика отдаёт `VisitID` прямо в хитах; частично
|
||||||
синтетическая постановка — осознанный приём;
|
синтетическая постановка — осознанный приём;
|
||||||
- лекция «`Sign` и CollapsingMergeTree»: почему на стенде `sum(Sign)` =
|
- лекция «`Sign` и CollapsingMergeTree»: почему на стенде `sum(Sign)` =
|
||||||
`count()`, а в бою — нет; частый вопрос на собеседованиях;
|
`count()`, а в бою — нет; частый вопрос на собеседованиях;
|
||||||
|
- два способа принять топик, рядом на одном стенде: **поток против слепка,
|
||||||
|
push против pull**. События тянет матвью на лету ([ADR
|
||||||
|
0005](../adr/0005-event-ingestion.md)), слепок заказов забирает пакетный шаг
|
||||||
|
по команде дага ([ADR 0008](../adr/0008-order-ingestion.md)); чтец в обоих
|
||||||
|
случаях байтовый, так что различает их ровно режим. Сравнение цены,
|
||||||
|
наблюдаемости и того, чем платит каждый режим, — как задание. Третий способ,
|
||||||
|
типизированный чтец с `kafka_handle_error_mode`, на стенде не живёт: он
|
||||||
|
отвергнут обоими ADR, и остаётся материалом для рассказа;
|
||||||
|
- матвью как рабочий механизм, а не диковина: события проходят из STG в ODS на
|
||||||
|
лету, заказы — пакетным заданием. `ods.order_v` показывает границу между
|
||||||
|
физическими версиями и точным текущим состоянием; сравнение `FINAL` с
|
||||||
|
альтернативными способами чтения остаётся материалом задания;
|
||||||
- лекция про идентичность «как в бою»: `setUserID` и first-party id,
|
- лекция про идентичность «как в бою»: `setUserID` и first-party id,
|
||||||
детерминированная против вероятностной склейки, identity graph,
|
детерминированная против вероятностной склейки, identity graph,
|
||||||
кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер
|
кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер
|
||||||
|
|||||||
+628
-122
@@ -1,6 +1,8 @@
|
|||||||
# Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости
|
# Генератор (этап 2): функциональный мир, детерминизм до байта, контракт схемы, числа скорости
|
||||||
|
|
||||||
Статус: Accepted — принята владельцем 2026-08-01 (PR #35).
|
Статус: Accepted — принята владельцем 2026-08-01 (PR #35); закрыта
|
||||||
|
2026-08-28 как документ этапа 2. Несущие решения — в ADR 0015–0017, новые
|
||||||
|
решения генератора — в ADR.
|
||||||
Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26.
|
Дата: 2026-08-01. Мандат — тикет #14 (этап 2, родитель #4), карта #26.
|
||||||
Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода
|
Развилки: модель мира (#27), детерминизм от зерна (#28), архитектура вывода
|
||||||
(#29), производительность (#30); исследование скорости (#31).
|
(#29), производительность (#30); исследование скорости (#31).
|
||||||
@@ -25,20 +27,47 @@
|
|||||||
касается этапа 7 — расширение подтверждено владельцем в резолюции
|
касается этапа 7 — расширение подтверждено владельцем в резолюции
|
||||||
«Производительности».
|
«Производительности».
|
||||||
|
|
||||||
|
## Чем меряется генератор
|
||||||
|
|
||||||
|
Рамка владельца, по которой принимались решения ниже. Она шире этапа 2 и
|
||||||
|
живёт дальше него: спеки следующих этапов ссылаются сюда, а не переписывают.
|
||||||
|
|
||||||
|
- **Генератор меряется тем, что приезжает на выход, а не устройством
|
||||||
|
внутри.** Он играет два источника с разными темпами: поток Метрики и
|
||||||
|
ночную выгрузку бэкенда магазина. Любое внутреннее решение отвечает перед
|
||||||
|
одним вопросом — правдоподобны ли данные на выходе.
|
||||||
|
- **Менти внутрь не смотрит.** Он видит внешние эффекты и по ним составляет
|
||||||
|
представление, как работает магазин и обо что там спотыкаются. Значит,
|
||||||
|
конструкция внутри оправдана только наблюдаемым эффектом: без него это
|
||||||
|
сложность, за которую никто не заплатил.
|
||||||
|
- **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств
|
||||||
|
мира сдвигает почти все числа разом — генератор случайных чисел выдаёт
|
||||||
|
другую последовательность. Опираться на конкретные числа поэтому нечем.
|
||||||
|
Хеши описи обещают не постоянство чисел, а то, что их сдвиг не пройдёт
|
||||||
|
молча: пересобрал опись — увидел diff.
|
||||||
|
- **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он
|
||||||
|
правкой генератора после взгляда на получившиеся распределения. Оборона,
|
||||||
|
которая дороже ошибки, не заводится.
|
||||||
|
- **Сложность изолирована нарочно.** Генератор вынесен отдельной службой,
|
||||||
|
чтобы его сложность не расползалась по стенду. Плата за изоляцию —
|
||||||
|
дешевизна правки: выбирается устройство, которое дёшево менять, а не то,
|
||||||
|
которое всё предусмотрело.
|
||||||
|
|
||||||
## Целевая картина одним взглядом
|
## Целевая картина одним взглядом
|
||||||
|
|
||||||
- **Мир — функция, не состояние.** Состав мира — чистая функция зерна;
|
- **Мир — функция, не состояние.** Состав мира — чистая функция зерна;
|
||||||
модельный день D — функция (зерно, D). Между прогонами живут только зерно
|
модельный день D — функция (зерно, D). Между прогонами живут только зерно
|
||||||
и позиция на оси времени.
|
и позиция на оси времени.
|
||||||
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
|
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
|
||||||
хешами манифеста. Транспорт (офсеты Kafka, темп) — вне обещания.
|
хешами описи. Транспорт (офсеты Kafka, темп) — вне обещания.
|
||||||
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
|
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
|
||||||
хранилище строится по рендеренной документации, границу сторожит
|
хранилище строится по рендеренной документации, границу сторожит строгий
|
||||||
contract-тест.
|
приём на стороне хранилища.
|
||||||
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
|
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
|
||||||
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
|
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
|
||||||
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней;
|
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок совпадает со
|
||||||
в git — только манифест; автоматический порог один — день ≤ 30 с.
|
стартовым миром — 8 дней;
|
||||||
|
в git — только опись мира; автоматических порогов по времени нет.
|
||||||
|
|
||||||
## 1. Модель мира
|
## 1. Модель мира
|
||||||
|
|
||||||
@@ -55,7 +84,7 @@
|
|||||||
торговые счётчики сложатся, когда торговое поведение определит #40.
|
торговые счётчики сложатся, когда торговое поведение определит #40.
|
||||||
- **Состав не замкнут: посетители появляются и затухают.** План состава
|
- **Состав не замкнут: посетители появляются и затухают.** План состава
|
||||||
задаёт календарь появления — у каждого посетителя есть дата первого
|
задаёт календарь появления — у каждого посетителя есть дата первого
|
||||||
визита и профиль возвратов, включая затухание: заметная доля кук
|
дня и профиль возвратов, включая затухание: заметная доля кук
|
||||||
одноразовая, как в живом трафике. Новые посетители появляются на всём
|
одноразовая, как в живом трафике. Новые посетители появляются на всём
|
||||||
протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная
|
протяжении оси: uniq(ClientID) растёт с горизонтом, дневная и накопленная
|
||||||
аудитории не сходятся в одно число. Приток — часть плана, а не мутация:
|
аудитории не сходятся в одно число. Приток — часть плана, а не мутация:
|
||||||
@@ -67,7 +96,7 @@
|
|||||||
ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от
|
ветвящийся по номеру дня (позиция в дереве — раздел 2), отдельный от
|
||||||
подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт
|
подпотока дня-функции. Аудитория дня — когорта D плюс возвраты когорт
|
||||||
последних дней: окно активности — хвост возвратов (константа мира —
|
последних дней: окно активности — хвост возвратов (константа мира —
|
||||||
раздел 9), отсчитанный от первого визита человека и общий на обе его
|
раздел 9), отсчитанный от первого дня человека и общий на обе его
|
||||||
куки (уточнение при исполнении #38, 2026-08-02: окно от рождения
|
куки (уточнение при исполнении #38, 2026-08-02: окно от рождения
|
||||||
каждой куки растянуло бы жизнь когорты вдвое, а с ней и загляд назад,
|
каждой куки растянуло бы жизнь когорты вдвое, а с ней и загляд назад,
|
||||||
которым ленивая форма и держится). За краем окна кука не
|
которым ленивая форма и держится). За краем окна кука не
|
||||||
@@ -89,13 +118,14 @@
|
|||||||
Равные шансы по всему окну означали бы вторую куку у давно ушедшего
|
Равные шансы по всему окну означали бы вторую куку у давно ушедшего
|
||||||
человека — и вчетверо меньше пар, реализованных внутри снимка.
|
человека — и вчетверо меньше пар, реализованных внутри снимка.
|
||||||
Каждой паре план назначает дни гарантированных заказов: по
|
Каждой паре план назначает дни гарантированных заказов: по
|
||||||
одному на куку, из дней визитов этой куки, на оси от D0 и позже;
|
одному на куку, из дней активности этой куки, на оси от D0 и позже;
|
||||||
человеку предыстории, чьё окно активности таких дней не оставляет,
|
человеку предыстории, чьё окно активности таких дней не оставляет,
|
||||||
пара не назначается. День-функция обязана назначенные заказы
|
пара не назначается. День-функция обязана назначенные заказы
|
||||||
реализовать; остальные покупки — вольные, их решает генератор торговых
|
реализовать; остальные покупки — вольные, их решает генератор торговых
|
||||||
событий (#40). Манифест считает пары, реализованные в горизонте
|
событий (#40). План считает пары, реализованные в запрошенном горизонте;
|
||||||
снимка. Условность в данных не видна: дни назначены той же
|
опись это число не хранит — оно растёт вместе с миром. Условность в данных
|
||||||
случайностью, просто брошенной планом один раз.
|
не видна: дни назначены той же случайностью, просто брошенной планом один
|
||||||
|
раз.
|
||||||
- **Своя ось модельного времени.** Ось событий начинается в
|
- **Своя ось модельного времени.** Ось событий начинается в
|
||||||
фиксированный день D0 (понедельник — см. раздел 5); реальный
|
фиксированный день D0 (понедельник — см. раздел 5); реальный
|
||||||
календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси
|
календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси
|
||||||
@@ -105,7 +135,8 @@
|
|||||||
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
||||||
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
||||||
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
||||||
«дышат»; включается по требованию, не постоянный фон.
|
«дышат»; включается по требованию — постоянным фоном идёт, только пока
|
||||||
|
включён выключатель пульта ([ADR 0009](../adr/0009-world-control.md)).
|
||||||
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
||||||
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
||||||
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
||||||
@@ -117,19 +148,19 @@
|
|||||||
Отклонено с доводами:
|
Отклонено с доводами:
|
||||||
|
|
||||||
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
|
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
|
||||||
дням, требует чекпоинтов, счётчики манифеста узнаваемы только постфактум;
|
дням, требует чекпоинтов, счётчики описи узнаваемы только постфактум;
|
||||||
ни один урок стенда на старении не стоит.
|
ни один урок стенда на старении не стоит.
|
||||||
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
|
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
|
||||||
выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
|
выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
|
||||||
- *Привязка модельного времени к реальному календарю* (T-1 с догоном):
|
- *Привязка модельного времени к реальному календарю* (T-1 с догоном):
|
||||||
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает
|
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает
|
||||||
даты эталонного мира зависимыми от даты запуска, манифест теряет
|
даты эталонного мира зависимыми от даты запуска, опись теряет
|
||||||
воспроизводимость.
|
воспроизводимость.
|
||||||
|
|
||||||
Отклонено при исполнении #38 (2026-08-02):
|
Отклонено при исполнении #38 (2026-08-02):
|
||||||
|
|
||||||
- *Разгон вместо предыстории* («первые дни малы — магазин запустился»):
|
- *Разгон вместо предыстории* («первые дни малы — магазин запустился»):
|
||||||
зерновой мир и половина снимка оказались бы на разгоне, недельная лаба
|
стартовый мир и половина снимка оказались бы на разгоне, недельная лаба
|
||||||
сравнивала бы несравнимые недели, «средний день ~50 тыс.» перестал бы
|
сравнивала бы несравнимые недели, «средний день ~50 тыс.» перестал бы
|
||||||
быть средним — пришлось бы двигать принятые числа раздела 5.
|
быть средним — пришлось бы двигать принятые числа раздела 5.
|
||||||
- *Материализованный план на горизонт*: горизонт становится обязательным
|
- *Материализованный план на горизонт*: горизонт становится обязательным
|
||||||
@@ -139,7 +170,7 @@
|
|||||||
константы — потолка одновременно живущих кук, которого в жизни нет и
|
константы — потолка одновременно живущих кук, которого в жизни нет и
|
||||||
который менти нечем объяснить.
|
который менти нечем объяснить.
|
||||||
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
|
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
|
||||||
гарантия — однажды манифест покраснеет, а чинить нечем, кроме смены
|
гарантия — однажды опись покраснеет, а чинить нечем, кроме смены
|
||||||
зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев,
|
зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев,
|
||||||
так что реализм этой лотереи невидим.
|
так что реализм этой лотереи невидим.
|
||||||
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
|
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
|
||||||
@@ -154,14 +185,14 @@
|
|||||||
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
|
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
|
||||||
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
|
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
|
||||||
Снимок при пересборке побайтово совпадает: канонический порядок ключей и
|
Снимок при пересборке побайтово совпадает: канонический порядок ключей и
|
||||||
строк; сверка — по хешам манифеста, а два локально пересобранных снимка
|
строк; сверка — по хешам описи, а два локально пересобранных снимка
|
||||||
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне
|
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне
|
||||||
обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала,
|
обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала,
|
||||||
`_ingested_at`, темп живого дня.
|
`_load_ts`, темп живого дня.
|
||||||
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
|
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
|
||||||
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL
|
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL
|
||||||
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет
|
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет
|
||||||
скрипт «пересгенерируй день N — сравни хеш с манифестом». Расхождение на
|
скрипт «пересгенерируй день N — сравни хеш с описью». Расхождение на
|
||||||
любой платформе — баг генератора, а не допуск.
|
любой платформе — баг генератора, а не допуск.
|
||||||
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
|
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
|
||||||
состава мира, ветвящийся по номеру дня на когорты плана (состав
|
состава мира, ветвящийся по номеру дня на когорты плана (состав
|
||||||
@@ -170,10 +201,14 @@
|
|||||||
целое, поэтому у предыстории своя ветвь состава, отдельная от оси
|
целое, поэтому у предыстории своя ветвь состава, отдельная от оси
|
||||||
(уточнение при исполнении #38, 2026-08-02);
|
(уточнение при исполнении #38, 2026-08-02);
|
||||||
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
||||||
торговые события, расхождения, опоздания — в фиксированном порядке.
|
торговые события, заказная и событийная стороны (судьба заказов и порча
|
||||||
|
событийного потока) — в фиксированном порядке (уточнение [судьбой
|
||||||
|
заказа](../architecture/orders/fate.md): прежние «расхождения» и
|
||||||
|
«опоздания» были названы по классам витрины, а компонент называет часть
|
||||||
|
мира).
|
||||||
По построению: параллельный прогон равен последовательному; продление
|
По построению: параллельный прогон равен последовательному; продление
|
||||||
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
|
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
|
||||||
только его часть снимка — в манифесте меняются хеши только затронутых
|
только его часть снимка — в описи меняются хеши только затронутых
|
||||||
дней, дифф двух локальных пересборок читаем.
|
дней, дифф двух локальных пересборок читаем.
|
||||||
- **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через
|
- **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через
|
||||||
Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей
|
Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей
|
||||||
@@ -188,7 +223,7 @@
|
|||||||
представление в клиентском `purchase` (урок мастер-спеки о расхождениях).
|
представление в клиентском `purchase` (урок мастер-спеки о расхождениях).
|
||||||
- **Канонический seed и паспорт мира.** Эталонный мир собирается одним
|
- **Канонический seed и паспорт мира.** Эталонный мир собирается одним
|
||||||
каноническим зерном — константой репозитория; свои зёрна менти крутит без
|
каноническим зерном — константой репозитория; свои зёрна менти крутит без
|
||||||
гарантий манифеста. Манифест хранит паспорт мира — зерно и версию
|
гарантий описи. Опись хранит паспорт мира — зерно и версию
|
||||||
генератора; чек-скрипты сверяют паспорт раньше счётчиков.
|
генератора; чек-скрипты сверяют паспорт раньше счётчиков.
|
||||||
- **Суточный профиль интенсивности задаёт день-функция.** Форма — волны:
|
- **Суточный профиль интенсивности задаёт день-функция.** Форма — волны:
|
||||||
ночной провал, обеденный и вечерний пики, различие будней и выходных;
|
ночной провал, обеденный и вечерний пики, различие будней и выходных;
|
||||||
@@ -207,7 +242,7 @@
|
|||||||
- *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно
|
- *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно
|
||||||
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
|
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
|
||||||
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная
|
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная
|
||||||
пересборка манифеста одним PR.
|
пересборка описи одним PR.
|
||||||
|
|
||||||
## 3. Контракт схемы
|
## 3. Контракт схемы
|
||||||
|
|
||||||
@@ -224,15 +259,18 @@
|
|||||||
менти несёт рендеренная таблица в доках, не модуль. Уточнение при
|
менти несёт рендеренная таблица в доках, не модуль. Уточнение при
|
||||||
исполнении (#36): нормализованное имя — имя источника, приведённое к
|
исполнении (#36): нормализованное имя — имя источника, приведённое к
|
||||||
нашему стилю, а не имя атрибута в модели данных. Слой DDS складывает свою
|
нашему стилю, а не имя атрибута в модели данных. Слой DDS складывает свою
|
||||||
модель и называет атрибуты по ней; `dds.v_event` эти имена берёт (раздел 7
|
модель и называет атрибуты по ней; `dds.event_v` эти имена берёт (раздел 7
|
||||||
мастер-спеки), но контракт их не диктует и тестами не сторожит.
|
мастер-спеки), но контракт их не диктует и тестами не сторожит.
|
||||||
- **Сторона хранилища пишется по документации, не генерируется.** DDL
|
- **Сторона хранилища пишется по документации, не генерируется.** DDL
|
||||||
`ods.event`, SELECT матвью, `dds.v_event`, трансформации — работа
|
`ods.event`, SELECT матвью, `dds.event_v`, трансформации — работа
|
||||||
следующих этапов по «описанию выгрузки», как в бою хранилище адаптируется
|
следующих этапов по «описанию выгрузки», как в бою хранилище адаптируется
|
||||||
к источнику. Границу сторожат два боевых механизма: строгий приём
|
к источнику. Границу сторожит боевой механизм — строгий приём
|
||||||
(`input_format_skip_unknown_fields = 0`, таблицы `*_errors` — раздел 6
|
(`Nullable`-разбор со сверкой набора ключей, таблицы `*_errors` — раздел 6
|
||||||
мастер-спеки) и contract-тест в smoke — сравнение `system.columns`
|
мастер-спеки). Сверка объявлений (`system.columns` поднятого стенда против
|
||||||
поднятого стенда со схемой генератора.
|
контракта) здесь стояла вторым механизмом и снята при исполнении #43:
|
||||||
|
сверх строгого приёма она ловила ровно одно — смену типа колонки, — а её
|
||||||
|
ловит и сверка разобранного события, причём на живых данных, а не на
|
||||||
|
объявлениях.
|
||||||
|
|
||||||
Отклонено с доводами:
|
Отклонено с доводами:
|
||||||
|
|
||||||
@@ -252,20 +290,49 @@
|
|||||||
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
|
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
|
||||||
упорядоченный поток канонических байтов — единственное место, где событие
|
упорядоченный поток канонических байтов — единственное место, где событие
|
||||||
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш
|
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш
|
||||||
для пересборки и проверок манифеста), Kafka пачкой — пакетный режим,
|
для пересборки и проверок описи), Kafka пачкой — пакетный режим,
|
||||||
Kafka с темпом ×60 — живой день. Новых топиков нет.
|
Kafka с темпом ×60 — живой день. Новых топиков нет.
|
||||||
|
- **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу
|
||||||
|
отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое
|
||||||
|
отдельным сообщением. Контракт транспорта, не деталь реализации — сторона
|
||||||
|
хранилища читает топик байтами и кладёт сообщение строкой
|
||||||
|
([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких
|
||||||
|
событий в одно сообщение сломала бы разбор целиком.
|
||||||
|
- **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`,
|
||||||
|
`UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: весь
|
||||||
|
смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте и
|
||||||
|
разбирает событие глазами, а число эпохи этот урок убивает. Колонка
|
||||||
|
`ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт
|
||||||
|
Метрика.
|
||||||
|
|
||||||
|
Оговорка про цену разбора, вписанная сюда 6 августа и оказавшаяся неверной:
|
||||||
|
здесь стояло, что `JSONExtract(raw, 'UTCEventTime', 'Nullable(DateTime)')`
|
||||||
|
«принимает ISO без плясок». Не принимает — на строке с суффиксом `Z` он
|
||||||
|
отдаёт NULL, и при исполнении #43 это увело бы в брак все события до
|
||||||
|
единого. Измерено на стенде 7 августа 2026 года; форма на проводе от этого
|
||||||
|
не меняется, меняется выражение разбора на стороне хранилища — `parseDateTime`
|
||||||
|
по буквально названному формату вместо `JSONExtract`
|
||||||
|
([ADR 0005](../adr/0005-event-ingestion.md)). То, что форма на проводе одна и
|
||||||
|
каноническая, здесь работает на хранилище: раз запись ровно одна, разбирать
|
||||||
|
её можно строго, не принимая заодно десяток чужих записей.
|
||||||
|
|
||||||
|
Форму реализует сериализатор (#41), хранилище (#43) читает то, что он
|
||||||
|
положил: порядок тикетов развёрнут 6 августа 2026 года, и отправитель идёт
|
||||||
|
первым. Записана форма всё равно здесь — иначе каждый выберет своё, и
|
||||||
|
разойдётся это уже после приёмки.
|
||||||
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
|
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
|
||||||
numpy-массивы и datetime сериализует нативно (заметка исследования #31).
|
numpy-массивы и datetime сериализует нативно (заметка исследования #31).
|
||||||
Смена библиотеки меняет канонические байты, поэтому проходит как
|
Смена библиотеки меняет канонические байты, поэтому проходит как
|
||||||
обновление зависимости: осознанная пересборка манифеста одним PR.
|
обновление зависимости: осознанная пересборка описи одним PR.
|
||||||
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
|
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
|
||||||
потерял — пересчитал. В git снимок не попадает (раздел 5).
|
потерял — пересчитал. В git снимок не попадает (раздел 5).
|
||||||
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
|
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
|
||||||
повторы: `WatchID` детерминированы, повтор — та же строка для
|
повторы: `WatchID` детерминированы, повтор — та же строка для
|
||||||
ReplacingMergeTree.
|
ReplacingMergeTree.
|
||||||
- **Эталонный снимок при старте стенда — через Kafka, пакетным режимом
|
- **Эталонный снимок при создании мира — через Kafka, пакетным режимом
|
||||||
проигрывателя.** Отдельный механизм заливки не строится: каждый `make up`
|
проигрывателя.** Отдельный механизм заливки не строится: `world_initialize`
|
||||||
бесплатно прогоняет весь конвейер и contract-тест на настоящих данных.
|
прогоняет весь конвейер на настоящих данных при первом `make up` и при явном
|
||||||
|
`make rebuild-storage`. На готовом мире повторный `make up` заливку пропускает.
|
||||||
Оговорка «если заливка уйдёт в десятки минут — вернуться к прямой
|
Оговорка «если заливка уйдёт в десятки минут — вернуться к прямой
|
||||||
загрузке» проверена при фиксации чисел: 14 × 50 тыс. ≈ 700 тыс. событий —
|
загрузке» проверена при фиксации чисел: 14 × 50 тыс. ≈ 700 тыс. событий —
|
||||||
расчётно минута-две, запас есть.
|
расчётно минута-две, запас есть.
|
||||||
@@ -274,7 +341,7 @@
|
|||||||
|
|
||||||
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
|
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
|
||||||
обещания детерминизма, retention конечен, в git топик не положишь, хеш с
|
обещания детерминизма, retention конечен, в git топик не положишь, хеш с
|
||||||
манифестом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
|
описью не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
|
||||||
мастер-спеки).
|
мастер-спеки).
|
||||||
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
|
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
|
||||||
через дедуп, канон держит единственный сериализатор, а не диск; файл
|
через дедуп, канон держит единственный сериализатор, а не диск; файл
|
||||||
@@ -292,18 +359,28 @@
|
|||||||
- **Средний модельный день — ~50 тыс. событий**; суточные волны с пиками до
|
- **Средний модельный день — ~50 тыс. событий**; суточные волны с пиками до
|
||||||
~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей —
|
~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей —
|
||||||
правдоподобный средний магазин.
|
правдоподобный средний магазин.
|
||||||
- **Эталонный снимок — 14 дней**: две полные календарные недели, D0 —
|
- **Эталонный снимок совпадает со стартовым миром — 8 дней**: понедельник
|
||||||
понедельник. Самая короткая длина, при которой есть замороженная зона за
|
по понедельник, полная неделя и один закрытый день за окном K = 7.
|
||||||
окном K = 7, дышащая зона и две волны недельной сезонности. Удлинение до
|
Обещание в 14 дней снято на этапе 7 (решение владельца 2026-08-30):
|
||||||
месяца — дешёвый ход (пересборка манифеста), если понадобится.
|
у дней 8–13 не нашлось читателя — лабы проигрывают мир дальше без описи,
|
||||||
- **В git — только манифест, снимок не хранится.** Снимок генерируется при
|
транспорт байты с описью не сверяет, а проверке «пересгенерируй день N —
|
||||||
`make up` и при проверках: артефакт — кэш чистой функции, кэш в git не
|
сравни хеш» день 3 годится так же, как день 13. Замеры и тесты, меряющие
|
||||||
хранят. Манифест несёт паспорт мира, счётчики и хеши по дням; проверки
|
мир на окне в две недели, не меняются: мир — функция без края, окну
|
||||||
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на нём.
|
замера опись не нужна. Удлинение описи остается дешевым ходом
|
||||||
Каждый `make up` — живая демонстрация детерминизма. Честная потеря —
|
(пересборка) и делается, когда читатель появится — например, если
|
||||||
|
растяжке `classes_vs_inventory` понадобится сверять больше одного
|
||||||
|
закрытого дня.
|
||||||
|
- **В git — только опись, снимок не хранится.** Снимок генерируется при
|
||||||
|
создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в
|
||||||
|
git не хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки
|
||||||
|
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на ней.
|
||||||
|
Первый `make up` пустого стенда — живая демонстрация детерминизма; готовый мир
|
||||||
|
повторный подъём не пересчитывает. Честная потеря —
|
||||||
страховка на случай платформенного бага: раньше менти с расходящимися
|
страховка на случай платформенного бага: раньше менти с расходящимися
|
||||||
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
|
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
|
||||||
манифеста; смягчение — CI гоняет генерацию на amd64 и arm64.
|
описи; смягчение — дисциплина целочисленной случайности (раздел 2):
|
||||||
|
расхождения amd64/arm64 сняты устройством, а не сторожем. CI ради одного
|
||||||
|
прогона не заводится (решение владельца 2026-08-30).
|
||||||
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
|
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
|
||||||
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
|
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
|
||||||
в пиковые часы сильных дней — до ~100. Число —
|
в пиковые часы сильных дней — до ~100. Число —
|
||||||
@@ -316,34 +393,38 @@
|
|||||||
Схема двухъярусная — урок ADR 0004: пороги впритык к расчёту на разном
|
Схема двухъярусная — урок ADR 0004: пороги впритык к расчёту на разном
|
||||||
железе кончаются ритуальным удалением проверки.
|
железе кончаются ритуальным удалением проверки.
|
||||||
|
|
||||||
Ориентиры на референсной машине — в спеке, без автоматики:
|
Ориентиры на референсной машине — в спеке, без автоматики. Где стоит замер,
|
||||||
|
там он с датой; остальное — порядок величины, пока не мерили:
|
||||||
|
|
||||||
| Операция | Ориентир |
|
| Операция | Ориентир |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Генерация одного дня | секунды |
|
| Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) |
|
||||||
| Пересборка эталонного мира (14 дней + манифест, без транспорта) | до минуты |
|
| Пересборка эталонного мира (8 дней + опись, без транспорта) | 20,5 с (замер 30 августа 2026 года) |
|
||||||
| Заливка снимка при `make up` (Kafka → матвью → ODS) | минуты |
|
| Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) |
|
||||||
| Лаг живого дня | секунды |
|
| Лаг живого дня | секунды |
|
||||||
|
|
||||||
**Автоматический порог один: полный день (50 тыс. событий) генерируется
|
**Автоматических порогов нет ни одного** — решение владельца 7 августа
|
||||||
≤ 30 с.** Расчёт по планке исследования (~2–5×10⁵ событий/с на ядро) — доли
|
2026 года при исполнении #42. Здесь стоял единственный: «полный день
|
||||||
секунды; порог держит машинный разброс ×2–5 и ловит деградацию на 1–2
|
генерируется ≤ 30 с». Назначен он был до того, как генератор написали, а
|
||||||
порядка: Faker в горячем цикле, случайная квадратичность. Реализация —
|
первый замер настоящего кода дал **1,7 секунды на день в 50 626 событий**
|
||||||
pytest-тест с маркером `perf` и таймаутом-обрубанием: обязателен в CI,
|
(машина стенда, 7 августа 2026 года). Порог оказался в восемнадцать раз выше
|
||||||
исключён из быстрой локальной петли, зовётся отдельной целью при правках
|
факта: зелен при любой правдоподобной регрессии, то есть не сторож, а
|
||||||
горячего цикла.
|
украшение. Как здесь говорят о времени, к тому дню уже решила [карта
|
||||||
|
целей](../architecture/testing.md): цена — замеренное число с датой, а не
|
||||||
|
назначенный предел.
|
||||||
|
|
||||||
Остальное — наблюдаемость без порогов: `make up` и smoke печатают тайминги
|
Наблюдаемость без порогов остаётся и делает всю работу: проигрыватель
|
||||||
(генерация и доставка отдельно), проигрыватель логирует лаг. Прототип-замер
|
печатает тайминги генерации и доставки раздельно, лаг живого дня — в его
|
||||||
до этапа 2 не нужен: числа назначены с запасом порядок и больше от планки
|
логе, а `world_initialize` гоняет генератор по восемь дней при первом создании
|
||||||
исследования, планка подтверждена локальной проверкой на машине стенда;
|
мира и явной пересборке. Замедлись он на порядок — операция встанет колом, и
|
||||||
первый замер настоящего кода — порог этапа 2.
|
это увидит первый же человек, который создаёт мир; повторный `make up` готовый
|
||||||
|
мир не пересчитывает.
|
||||||
|
|
||||||
Отклонено с доводами:
|
Отклонено с доводами:
|
||||||
|
|
||||||
- *Четыре жёстких CI-ворот на все бюджеты*: машинный разброс против порогов
|
- *Четыре жёстких CI-ворот на все бюджеты*: машинный разброс против порогов
|
||||||
впритык — повторение истории с памятью (ADR 0004); порог оставлен один,
|
впритык — повторение истории с памятью (ADR 0004). Оставленный было один
|
||||||
грубый, между «×5 шума» и «×100 беды».
|
грубый порог снят при исполнении #42 (выше).
|
||||||
- *Снимок в git* (статус-кво раздела 8 мастер-спеки): основание из v1 —
|
- *Снимок в git* (статус-кво раздела 8 мастер-спеки): основание из v1 —
|
||||||
медленный генератор — съедено детерминизмом и скоростью; остаётся только
|
медленный генератор — съедено детерминизмом и скоростью; остаётся только
|
||||||
раздутый репозиторий. *Снимок вложением релиза Gitea*: страховка без
|
раздутый репозиторий. *Снимок вложением релиза Gitea*: страховка без
|
||||||
@@ -377,81 +458,506 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
Внесены в мастер-спеку тем же коммитом, что и эта спека:
|
Внесены в мастер-спеку тем же коммитом, что и эта спека:
|
||||||
|
|
||||||
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
|
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
|
||||||
contract — хранилище пишется по документации, границу сторожит
|
contract — хранилище пишется по документации, границу сторожит строгий
|
||||||
contract-тест (раздел 3 здесь).
|
приём (раздел 3 здесь).
|
||||||
- **Раздел 8**: артефакт `data/startup_history/` в git заменён манифестом;
|
- **Раздел 8**: артефакт `data/startup_history/` в git заменён описью;
|
||||||
снимок генерируется на месте (раздел 5 здесь). Туман «политика
|
снимок генерируется на месте (раздел 5 здесь). Туман «политика
|
||||||
версионирования артефакта» закрыт этим же ходом: версионируется манифест.
|
версионирования артефакта» закрыт этим же ходом: версионируется опись.
|
||||||
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования
|
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования
|
||||||
производительности» закрыт числами раздела 5.
|
производительности» закрыт числами раздела 5.
|
||||||
- Мелкие согласования там, где текст опирался на артефакт в git: источник
|
- Мелкие согласования там, где текст опирался на артефакт в git: источник
|
||||||
переобработки при исчерпании retention Kafka (раздел 7), формулировка
|
переобработки при исчерпании retention Kafka (раздел 7), формулировка
|
||||||
этапа 7 (раздел 9).
|
этапа 7 (раздел 9).
|
||||||
|
|
||||||
|
Внесены при исполнении #40 (2026-08-02):
|
||||||
|
|
||||||
|
- **Раздел 1**: «в параллельных массивах одной длины» уточнено — длина общая
|
||||||
|
внутри группы, а `purchase*` и `product*` между собой разной длины.
|
||||||
|
- **Разделы 4 и 7**: механика класса `amount_delta` названа честнее.
|
||||||
|
Округления `Float64` сами по себе расхождения не дают: деньги считаются
|
||||||
|
целыми копейками, и дробное число в событии одно. Класс создаёт генератор
|
||||||
|
намеренно — так мастер-спека и допускала вторым вариантом.
|
||||||
|
|
||||||
## 8. Хвосты следующим этапам
|
## 8. Хвосты следующим этапам
|
||||||
|
|
||||||
|
- **Этап 3 (заказы бэкенда)**: заказу, у которого в клиентском событии стоит
|
||||||
|
промокод, бэкенд обязан дать скидку — иначе данные соврут. Номер заказа обе
|
||||||
|
стороны берут один и тот же (раздел 9).
|
||||||
|
- **Этап 4 (сверка)**: копейки в ценах каталога дают разрыв внутри события —
|
||||||
|
`productPrice` округлён форматом, `purchaseRevenue` точна. В сверку он сам
|
||||||
|
не попадает: клиент шлёт точную сумму, и она сходится с `items_total`
|
||||||
|
бэкенда. Класс `amount_delta` этап 4 всё равно создаёт намеренно —
|
||||||
|
мастер-спека (раздел 4) это и допускает; материал для разговора про Float64
|
||||||
|
теперь есть.
|
||||||
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
|
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
|
||||||
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
||||||
- **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт;
|
- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 —
|
||||||
CI-генерация на amd64 и arm64.
|
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
|
||||||
|
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
|
||||||
|
она переменной Airflow: отсутствие переменной означает, что стартовый мир
|
||||||
|
ещё не создан. В обычной жизни её ставит даг пульта, который сыграл день, —
|
||||||
|
значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла
|
||||||
|
используют `-1` для незавершённой очистки, `0` для чистого мира до генерации
|
||||||
|
и ставят первый несыгранный день только после приёма стартового мира
|
||||||
|
([ADR 0013](../adr/0013-world-lifecycle.md)).
|
||||||
|
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
||||||
|
хранилищу незачем. Полный `make clean` сносит метаданные Airflow вместе с
|
||||||
|
данными ClickHouse, а узкая пересборка явно сводит сохранённую позицию с
|
||||||
|
заново залитым миром.
|
||||||
|
Расхождение переменной с данными (менти почистил партицию руками) лечится
|
||||||
|
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
|
||||||
|
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
|
||||||
|
- **Этап 7 (эталонный мир)**: пересборка — это опись, не артефакт.
|
||||||
|
CI-генерация на amd64 и arm64 снята — резолюция в разделе 5.
|
||||||
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
||||||
готовая демонстрация идемпотентности конвейера.
|
готовая демонстрация идемпотентности конвейера.
|
||||||
|
|
||||||
## 9. Вопросы нарезки этапа 2 (#34): решения и остатки
|
## 9. Вопросы нарезки этапа 2 (#34): решения и остатки
|
||||||
|
|
||||||
Туман карты разложен нарезкой по тикетам; решения фиксируются здесь по
|
Журнал закрыт 28 августа 2026 года. Он хранит историю решений этапа 2; основания
|
||||||
мере исполнения.
|
модели времени, паспорта куки и номера заказа вынесены в
|
||||||
|
[ADR 0015](../adr/0015-counter-timezone.md),
|
||||||
|
[ADR 0016](../adr/0016-cookie-passport.md) и
|
||||||
|
[ADR 0017](../adr/0017-order-numbering.md). Новые решения генератора
|
||||||
|
записывают в ADR и в спеке этапа, на котором их принимают.
|
||||||
|
|
||||||
Решено при исполнении #38 (2026-08-02):
|
Журнал собран по темам, но дата и тикет остались у каждого решения. По
|
||||||
|
журналу видно, какие границы затрагивала каждая правка: иногда она меняла
|
||||||
|
сразу план состава, воронку и каталог. При #40 это было дешево — опись еще
|
||||||
|
не собрали; позже такой ход потребовал бы пересборки описи.
|
||||||
|
|
||||||
- **Числа притока и состава.** Приток — ~3 800 новых кук в средний день,
|
### План состава и числа мира
|
||||||
модулируется тем же недельным профилем, что трафик (иначе доля
|
|
||||||
новичков скакала бы по дням недели). Доля одноразовых кук — 75%;
|
|
||||||
возвращающиеся — в среднем 3–4 возврата, профиль убывающий: почти все
|
|
||||||
в первые 7–10 дней, тонкий хвост поздних возвратов и повторных
|
|
||||||
покупок — до края окна (цикл повторной покупки магазина — месяцы).
|
|
||||||
Хвост возвратов — окно активности человека от его первого визита,
|
|
||||||
общее на обе его куки, — и глубина предыстории: 90 дней (решение
|
|
||||||
владельца 2026-08-02: дольше квартала стенд никто не гоняет, а заказы
|
|
||||||
старых посетителей продолжаются весь прогон; плата — чуть меньше пар,
|
|
||||||
полностью реализованных внутри 14-дневного снимка). Покупатели
|
|
||||||
считаются людьми, не куками; доля покупателей — 5% людей когорты, а
|
|
||||||
людей в когорте почти столько же, сколько кук: вторые куки пар
|
|
||||||
добавляют меньше процента. Доля — число плана, а не торгового тикета: без него не
|
|
||||||
отобрать двухкуковые пары; #40 наследует его, не переоткрывая.
|
|
||||||
Сходимость с разделом 5: средняя кука активна ≈1,9 дня → дневная
|
|
||||||
аудитория ≈7 100 — середина вилки 6–8 тыс.; уникумов за 14 дней
|
|
||||||
снимка — под 70 тыс.: новые куки плюс возвраты предыстории (точные
|
|
||||||
числа — в измерении ниже; не путать с ~50 тыс. событий одного дня) —
|
|
||||||
рост `uniq(ClientID)` с горизонтом виден сразу; новых покупателей
|
|
||||||
~190 в день → конверсия ~2% на сессию.
|
|
||||||
- **D0 = 2026-06-01, понедельник** — решение владельца при нарезке
|
|
||||||
(2026-08-01). Дата недавняя, чтобы данные первые месяцы выглядели
|
|
||||||
свежими; привязки к реальному календарю у констант мира всё равно нет.
|
|
||||||
- **Измерено на собранном плане** (#38, канонический мир, 14 дней):
|
|
||||||
приток 3 828 кук в день в среднем, дневная аудитория 6 235–7 124 —
|
|
||||||
обе величины в вилках раздела 5. Накопленная аудитория за снимок —
|
|
||||||
68 тыс. кук: 53,6 тыс. новыми плюс 14,5 тыс. возвратами предыстории
|
|
||||||
(при нарезке возвраты предыстории оценили вдвое скромнее — отсюда
|
|
||||||
ходившая раньше оценка ≈60 тыс.). Пар, у которых оба назначенных
|
|
||||||
заказа попали внутрь 14 дней, — 170; остальные пары горизонт
|
|
||||||
переживают, их вторая кука приходит позже. Цифры пересчитываются
|
|
||||||
прогоном плана, событий для них не нужно.
|
|
||||||
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
|
|
||||||
все числа мира в одном месте, написанном как приглашение любопытному
|
|
||||||
менти крутить. Правка модуля — смена мира: чек манифеста честно
|
|
||||||
краснеет, манифест сторожит только канон. Вне модуля — лишь то, что
|
|
||||||
мира не меняет: своё зерно и транспортные флаги проигрывателя.
|
|
||||||
Отклонено: внешний конфиг и env-переопределения — переменная мира,
|
|
||||||
которую паспорт манифеста не видит; файл-конфиг в репозитории — по
|
|
||||||
смыслу равен модулю, но платит загрузчиком и валидацией (довод
|
|
||||||
раздела 3 против YAML).
|
|
||||||
|
|
||||||
Остаётся открытым, за тикетами:
|
- **Числа притока и состава** (#38, 2026-08-02). Приток — ~3 800 новых кук в
|
||||||
|
средний день, модулируется недельным профилем мира; трафик наследует эту волну
|
||||||
|
через дневную аудиторию, а не отдельное умножение (уточнение #39). Доля
|
||||||
|
одноразовых кук — 75%; возвращающиеся дают в среднем 3–4 возврата, почти все в
|
||||||
|
первые 7–10 дней, а тонкий хвост поздних возвратов и повторных покупок тянется
|
||||||
|
до края окна: цикл повторной покупки магазина измеряется месяцами. Хвост
|
||||||
|
возвратов — общее на обе куки окно активности человека от первого дня и
|
||||||
|
глубина предыстории: 90 дней (решение владельца 2026-08-02: дольше квартала
|
||||||
|
стенд никто не гоняет, а заказы старых посетителей продолжаются весь прогон;
|
||||||
|
плата — чуть меньше пар, полностью реализованных внутри 14-дневного снимка).
|
||||||
|
Покупатели считаются людьми, не куками; их доля — 5% людей когорты. Людей
|
||||||
|
почти столько же, сколько кук: вторые куки пар добавляют меньше процента. Доля
|
||||||
|
— число плана: без него не отобрать двухкуковые пары, и #40 его не
|
||||||
|
переоткрывает. Сходимость с разделом 5: средняя кука активна ≈1,9 дня, дневная
|
||||||
|
аудитория ≈7 100, уникумов за 14 дней — под 70 тыс. Это новые куки и возвраты
|
||||||
|
предыстории; их не надо путать с ~50 тыс. событий одного дня. Новых
|
||||||
|
покупателей ~190 в день, что дает конверсию ~2% на сессию.
|
||||||
|
- **Конфигурация мира — модуль чистых данных** (#38, 2026-08-02) рядом с
|
||||||
|
контрактом схемы: все числа мира собраны в одном месте, которое менти может
|
||||||
|
крутить. Правка модуля меняет мир, и проверка описи честно краснеет: опись
|
||||||
|
сторожит только канон. Вне модуля остается то, что мира не меняет, — свое
|
||||||
|
зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и
|
||||||
|
переопределения окружением — переменная мира, которой нет в паспорте описи;
|
||||||
|
файл-конфиг в репозитории равен модулю по смыслу, но платит загрузчиком и
|
||||||
|
валидацией (довод раздела 3 против YAML).
|
||||||
|
- **Покупатель перестает быть словом — двумя рычагами сразу** (#40, решение
|
||||||
|
владельца до реализации, 2026-08-02). Помеченный планом покупатель заметно
|
||||||
|
чаще доходит до заказа, дольше живет и чаще возвращается. Склонность покупать
|
||||||
|
— свойство человека, а ее след в анонимном кликстриме виден только как долгая
|
||||||
|
жизнь куки и повторные покупки. Один рычаг этого не дает: кука живет меньше
|
||||||
|
двух визитов за снимок, и второй покупке негде случиться. Рычаг жизни лежит в
|
||||||
|
плане (#38), рычаг конверсии — в воронке (#39); помеченный отличается на обоих
|
||||||
|
шагах — чаще доходит до корзины и реже бросает ее. Помеченному дано 18% до
|
||||||
|
корзины и 60% из корзины в оформление против 6% и 45% у обычного посетителя.
|
||||||
|
Обычная доля снижена с 8% до 6%: помеченные живут дольше и занимают большую
|
||||||
|
долю дневной аудитории, поэтому прежнее число увело бы общую конверсию за
|
||||||
|
вилку раздела 5. Рычаг метки не должен оплачиваться ростом конверсии; расти
|
||||||
|
внутри вилки аудитории миру можно. Вольные покупки по-прежнему решает день
|
||||||
|
(уточнение к разделу 1), но не одинаковой для всех случайностью. Отклонено:
|
||||||
|
*оставить одинаковый бросок* — покупатель остается именем без следа; *поднять
|
||||||
|
только конверсию* — короткой жизни не хватает для повторных покупок;
|
||||||
|
*разрешить покупать только помеченным* — их малая доля дала бы слишком мало
|
||||||
|
заказов, а ее рост завысил бы число двухкуковых пар. Промежуточные числа
|
||||||
|
пересчитаны при #50; актуальные — в таблице замера #50.
|
||||||
|
- **Счетчика покупок в плане состава не заводится** (#40, решение владельца до
|
||||||
|
реализации, 2026-08-02). Тикет просил свести с планом покупки и двухкуковые
|
||||||
|
пары, но раздел 1 обещает до генерации только состав — посетителей, приток и
|
||||||
|
пары. Торговые счетчики складываются из поведения дня. Квота заставила бы день
|
||||||
|
добирать точное число и сделала бы конверсию подозрительно ровной: условность
|
||||||
|
была бы видна именно там, куда смотрит лаба. Сходятся пары; покупки
|
||||||
|
проверяются после генерации по дню и вилке конверсии.
|
||||||
|
|
||||||
- интерфейс запуска генератора (CLI / цели make) и как он делит режимы
|
### Время, визиты и границы дня
|
||||||
проигрывателя; кто его зовёт в стенде — даги `world_init`/`next_day` из
|
|
||||||
оценки мастер-спеки (раздел 9) — и в каком контейнере он живёт (#41);
|
- **D0 = 2026-06-01, понедельник** (#38, решение владельца 2026-08-01). Недавняя
|
||||||
- как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки):
|
дата делает первые месяцы данных свежими, но к календарю запуска константа
|
||||||
с манифестным решением напрашивается мини-манифест зернового мира —
|
мира не привязана.
|
||||||
форма за #42.
|
- **Модельные сутки считаются в часовом поясе счетчика** (#39, решение владельца
|
||||||
|
до реализации, 2026-08-02), а местная волна гостя поворачивается —
|
||||||
|
[ADR 0015](../adr/0015-counter-timezone.md).
|
||||||
|
- **Слово «визит» остается за сессией** (#39, решение владельца до реализации,
|
||||||
|
2026-08-02) и полем `VisitID`. Одноименное понятие плана переименовано в «день
|
||||||
|
активности»: у куки он один на день, а визитов внутри дня бывает несколько.
|
||||||
|
- **Правила резки визитов** (#39, 2026-08-02; мастер-спека, раздел 1.2,
|
||||||
|
требовала их задокументировать): визит принадлежит одной куке; пауза дольше 30
|
||||||
|
минут рвет его надвое; граница модельных суток режет визит, события за полночь
|
||||||
|
в день не попадают. Единственное исключение — визит с заказом, обещанным
|
||||||
|
планом двухкуковых пар: его старт сдвигается назад, чтобы воронка уместилась в
|
||||||
|
сутки. Это принятое ограничение модели, а не недосмотр: обещание плана —
|
||||||
|
гарантия, на которой стоит лаба склейки, и ради нее свобода старта сужена.
|
||||||
|
Цена названа и мала: около 24 визитов из ~9,5 тыс. в день никогда не
|
||||||
|
начинаются в последние минуты суток. Правила лежат в докстринге `day.py`, а
|
||||||
|
тест заново собирает визиты и сверяет их с `VisitID`.
|
||||||
|
- **Недельная волна применяется один раз** (#39, 2026-08-02). Профиль ведет
|
||||||
|
приток, а трафик наследует его через дневную аудиторию; измеренный размах
|
||||||
|
трафика — ±7% против ±10% у притока. Второе умножение удвоило бы недельный
|
||||||
|
размах. Если недельной лабе не хватит сигнала, нужен отдельный слабый профиль
|
||||||
|
активности, а не повторное умножение: «приходит меньше новых людей» и «каждый
|
||||||
|
ходит меньше» — разные механизмы.
|
||||||
|
- **Торговый хвост умещается в сутки резервом, а не зажимом** (#40, решение
|
||||||
|
владельца до реализации, 2026-08-02). Событие покупки встает на несколько
|
||||||
|
секунд позже страницы подтверждения, корзина — позже карточки, а граница суток
|
||||||
|
режет вышедшее за нее. Визит с обещанным заказом уже был подвинут так, чтобы
|
||||||
|
воронка уместилась в сутки, но без учета торгового хвоста. Резерв расширен на
|
||||||
|
хвост: зажим к последней секунде сломал бы правило задержки ровно в
|
||||||
|
пограничном случае. Визит со срезанным полуночью подтверждением покупки не
|
||||||
|
дает.
|
||||||
|
- **Торговый хвост разводит соседние визиты** (#40, 2026-08-02): таймаут
|
||||||
|
считается от последнего события, а не страницы. Иначе лаба сессий склеила бы
|
||||||
|
два визита и разошлась с `VisitID`; решение поймано тестом сборки визитов.
|
||||||
|
- **Полночь забирает подтверждение вместе с покупкой** (#40, 2026-08-02).
|
||||||
|
Сначала резерв считался только для обещанных планом заказов, поэтому у вольной
|
||||||
|
покупки оставалось окно: подтверждение дожило до потока, а покупка вышла за
|
||||||
|
полночь. Это создало бы потерю на клиентской стороне по недосмотру. Теперь
|
||||||
|
запас есть у всякой страницы подтверждения: либо за границу суток уходят обе
|
||||||
|
строки, либо остаются обе. В каноническом мире этот случай за 14 дней не
|
||||||
|
выпал.
|
||||||
|
- **Задержка торгового события короче самой короткой паузы между страницами**
|
||||||
|
(#40, 2026-08-02). Событие корзины не обгоняет страницу кнопки и не выходит за
|
||||||
|
полночь: за ним в визите всегда есть страница, пережившая полночь.
|
||||||
|
|
||||||
|
### Каталог, география и паспорт куки
|
||||||
|
|
||||||
|
- **Каталог товаров заводится в #39, а не в #40** (#39, решение владельца до
|
||||||
|
реализации, 2026-08-02). Карточке нужен `Title`, а имя товара живет в
|
||||||
|
каталоге: без каталога #39 сделал бы лишь половину трафика.
|
||||||
|
`data/catalog/products.csv` (`sku`, `name`, `category`, `brand`, `price`)
|
||||||
|
общий для генератора и словаря ClickHouse; #40 получает его готовым. Решена
|
||||||
|
форма, а не длина: артикул — четыре буквы категории и четыре цифры, цена —
|
||||||
|
целые копейки, категорий шесть. Строки дописываются механически, поэтому ни
|
||||||
|
код, ни тесты их не считают; товар для карточки выбирается равномерно внутри
|
||||||
|
категории.
|
||||||
|
- **Ассортимент — непродовольственная розница** (#39, решение владельца до
|
||||||
|
реализации, 2026-08-02): товары для дома, текстиль, посуда, мелкая бытовая
|
||||||
|
техника, детское, одежда и обувь, ~150–200 sku. Довод не вкусовой: под этот
|
||||||
|
профиль приняты месячный цикл повторной покупки, 75% одноразовых кук и
|
||||||
|
конверсия ~2% на визит. Продуктовая сеть потребовала бы других чисел и
|
||||||
|
переоткрыла бы #38.
|
||||||
|
- **География — один регион присутствия** (#39, решение владельца до реализации,
|
||||||
|
2026-08-02): Поволжье с центром в Самаре, города региона и тонкий хвост
|
||||||
|
остальной страны. «Топ городов России» дал бы магазину с одним складом карту,
|
||||||
|
которой у него не бывает.
|
||||||
|
- **Паспорт куки хранит план состава на всю ее жизнь** (#39, решение владельца
|
||||||
|
до реализации, 2026-08-02); пара получает один город и разные устройства
|
||||||
|
(«телефон и ноутбук», мастер-спека, раздел 5), а броски паспорта стоят
|
||||||
|
последними — [ADR 0016](../adr/0016-cookie-passport.md). Прежние счетчики
|
||||||
|
пересчитаны при #40; актуальные числа — в таблице замера #50.
|
||||||
|
- **Справочники — таблицы-литералы** (#39, решение владельца до реализации,
|
||||||
|
2026-08-02) рядом с конфигурацией мира; Faker и mimesis не подключаются. Нужны
|
||||||
|
связки вроде «город → id региона → часовой пояс» и «телефон → Safari → iOS →
|
||||||
|
экран»: генератор строк их не дает, таблицу пришлось бы написать все равно, а
|
||||||
|
обновление зависимости молча меняло бы мир.
|
||||||
|
- **Гео-id — настоящие числа геобазы Яндекса** (#39, решение владельца до
|
||||||
|
реализации; проверка 2026-08-02). Id подтверждались обращением к живым
|
||||||
|
сервисам Яндекса по тому же номеру (`yandex.ru/pogoda/<id>`,
|
||||||
|
`yandex.ru/maps/225/russia/`) — страница открывает ожидаемое место. Оговорка
|
||||||
|
стоит в коде: опубликованной таблицы геобазы найти не удалось, а что
|
||||||
|
`RegionCityID` Метрики нумерует регионы той же геобазой — обоснованное
|
||||||
|
допущение, не подтвержденный источником факт. Выдуманные по памяти числа не
|
||||||
|
годятся ни в каком случае.
|
||||||
|
- **IP-адреса — нероутируемые диапазоны** (#39, решение владельца до реализации,
|
||||||
|
2026-08-02): сети RFC 5737 и 198.18/15 по ломтю на город, телефонам — CGNAT
|
||||||
|
100.64/10, как у настоящих операторов. Правдоподобные публичные адреса
|
||||||
|
принадлежат живым организациям, и в учебных данных им не место.
|
||||||
|
- **Вариант товара — справочник мира** (#40, 2026-08-02). Сырой `ecommerce`
|
||||||
|
несет `variant`, которого нет в плоских колонках; строки живут рядом со
|
||||||
|
справочниками устройств и городов, а не сочиняются на месте.
|
||||||
|
|
||||||
|
### Корзина и состав заказа
|
||||||
|
|
||||||
|
После приемки #40 (2026-08-02) на грилинге «просмотр → корзина» нашли два
|
||||||
|
точных равенства. В живых данных таких равенств нет: аналитик прочитает
|
||||||
|
их как склейку разметки вместо поведения людей.
|
||||||
|
|
||||||
|
- **Корзина шире заказа** (#40, решение владельца до реализации, 2026-08-02).
|
||||||
|
Посетитель кладет товары открытых карточек, а покупает не все. Иначе событие
|
||||||
|
корзины ничего не рассказывало бы сверх покупки: заказ был бы ее точной
|
||||||
|
копией. Карточка, открытая дважды, дает одну позицию — повторный просмотр
|
||||||
|
означает раздумье, а не второй товар. Количество штук — отдельный бросок:
|
||||||
|
обычно одна, изредка две-три. Отклонено: *заказ всегда из одного товара* —
|
||||||
|
список позиций как урок умирает, а на нем стоит единственный носитель навыка
|
||||||
|
«вложенный JSON» (мастер-спека, раздел 2); *досыпать неоткрытые товары* —
|
||||||
|
ломается шов #39; *бросать много позиций* — большинство заказов схлопывается в
|
||||||
|
один товар. Доля брошенных позиций позже пересмотрена в #50.
|
||||||
|
- **Событие корзины садится на карточку товара** (#40, решение владельца до
|
||||||
|
реализации, 2026-08-02), событие покупки — на подтверждение. В жизни корзину
|
||||||
|
шлет кнопка на карточке, а при нескольких товарах события иначе не разложить
|
||||||
|
по открытым карточкам.
|
||||||
|
- **Корзина отвязана от страницы `/cart`** (#40, 2026-08-02). До правки
|
||||||
|
множества визитов с событием корзины и со страницей `/cart` точно совпадали;
|
||||||
|
пересчитано при #50, актуальные числа — в таблице замера #50. Теперь положить
|
||||||
|
может любой визит, открывший карточку; `/cart` остается шагом воронки, а заказ
|
||||||
|
по-прежнему рисуется только после подтверждения. У визита на `/cart` всегда
|
||||||
|
есть хотя бы одна позиция — страница пустой корзины невозможна, — но открыть
|
||||||
|
корзину после добавления необязательно. Так в мире появляются те, кто положил
|
||||||
|
товар и не открыл корзину, — массовый сюжет магазина. Ошибка прошла нарезку,
|
||||||
|
потому что воронку #39 описали страницами, решение #40 — событиями, а вопрос
|
||||||
|
«какой визит кладет» заново не задали: страничное правило молча переехало на
|
||||||
|
уровень событий.
|
||||||
|
- **Намерение визита берется из шага воронки, а не из метки покупателя** (#50,
|
||||||
|
2026-08-02). Визит на `/cart` кладет почти все сравненное, прочий — редко.
|
||||||
|
Метка уже влияет через воронку; второе слагаемое посчитало бы ее дважды и
|
||||||
|
протянуло бы план состава через шов страниц и товаров. Плата: у визита без
|
||||||
|
намерения добавление не связано со свойством человека, но склонные покупать и
|
||||||
|
так преобладают среди дошедших до `/cart`.
|
||||||
|
- **Доля брошенных позиций опущена с 15% до 10%** (#50, 2026-08-02). Корзина
|
||||||
|
сузилась с 2,4 позиции до 1,7, и прежние 15% чаще выносили ее целиком:
|
||||||
|
спасенная позиция возвращала заказ к одному товару, и однопозиционных заказов
|
||||||
|
набиралось под 60%. Свойство сохранено, число перемерено — ровно так, как блок
|
||||||
|
#40 и оговаривал.
|
||||||
|
|
||||||
|
### Спрос товара и границы калибровки
|
||||||
|
|
||||||
|
- **Класть или нет решает и товар, а не только визит** (#40, 2026-08-02). Раньше
|
||||||
|
внутри воронки клали все карточки, вне ее — ни одной: конверсия по sku давала
|
||||||
|
чистый шум, и вопрос «какие товары кладут чаще» не имел ответа. Теперь в
|
||||||
|
каталоге три уровня спроса: магнит, обычный и залеживающийся, а их вероятности
|
||||||
|
— числа мира. Отклонено: *свой процент у каждого из 180 sku* — необоснованные
|
||||||
|
рукописные числа в механически дополняемом каталоге; *выводить спрос из цены*
|
||||||
|
— жесткая связь, в которой менти найдет только вложенное авторами правило.
|
||||||
|
- **Два ряда вероятностей, а не ряд с множителем** (#50, 2026-08-02): один для
|
||||||
|
визита с намерением, другой для смотрящего. У намеренного визита вероятности
|
||||||
|
высоки, иначе корзина и заказ схлопываются до одного товара; возле 100%
|
||||||
|
множитель съел бы разницу уровней и сделал бы конверсию уровней одинаковой.
|
||||||
|
Разрыв внутри рядов разный: у смотрящего товар решает почти все, а у
|
||||||
|
пришедшего покупать заслонен намерением.
|
||||||
|
- **Ни одному уровню спроса нельзя дать нулевую вероятность** (#50, 2026-08-02).
|
||||||
|
Ноль у залежавшегося товара вернул бы исходное равенство на четверти каталога:
|
||||||
|
добавление случалось бы только внутри визита с `/cart`. Отвязка либо относится
|
||||||
|
ко всему ассортименту, либо это не отвязка. Поэтому уровни разводятся не нулем
|
||||||
|
внизу второго ряда, а ослаблением залежавшегося товара в ряду визита с
|
||||||
|
намерением: пришедший покупать берет его реже ходового.
|
||||||
|
- **Уровень спроса — колонка `demand` в конце файла каталога** (#50,
|
||||||
|
2026-08-02), значения словами («магнит», «обычный», «залёживается»). Раздан он
|
||||||
|
одной колодой на весь каталог: доли по категориям расходятся сами (магнитов от
|
||||||
|
четырёх до девяти на тридцать строк). Раскладывать колоду поровну в каждую
|
||||||
|
категорию было бы той же неправдоподобной ровностью, какую тикет вычищал из
|
||||||
|
данных. Связь с ценой отклонена выше, и тест сторожит её отсутствие — по
|
||||||
|
четвертям цены и по медиане, потому что одни только края списка обманываются
|
||||||
|
четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом ещё
|
||||||
|
не построен (#37/#43) — колонку надо взять в его описание сразу, а не догонять
|
||||||
|
правкой; описи нужен хеш каталога (хвост #42) тем более, потому что теперь в
|
||||||
|
файле живёт ещё и поведение.
|
||||||
|
- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%**
|
||||||
|
(#50, 2026-08-02) и подошла к потолку вилки. Визитам вне `/cart` нужны свои
|
||||||
|
позиции, а визит с намерением должен класть почти все сравненное; обе границы
|
||||||
|
тянут число вверх, и опустить долю можно лишь ценой одной из них. Место до 9%
|
||||||
|
невелико, следующая правка торговой половины считается против него.
|
||||||
|
- **Границы правки — вилки раздела 5** (#40, 2026-08-02): аудитория 6–8 тыс.,
|
||||||
|
средний день около 50 тыс. событий, конверсия визита около 2%. Корзина и
|
||||||
|
состав заказа перемеряются вместе; сохраняется свойство — заказ уже корзины,
|
||||||
|
однопозиционных заказов около половины.
|
||||||
|
- **Вилка 5–9% — ориентир, а не граница** (#50, решение владельца 2026-08-02). У
|
||||||
|
живых магазинов эта доля гуляет широко; пока она правдоподобна, сама по себе
|
||||||
|
ничего не сторожит, и подгонять поведение под ее край не надо. Настоящий
|
||||||
|
предел считается в другой валюте — дневной бюджет событий, на котором стоит
|
||||||
|
опись #42. Если будущая правка упрется в него, двигать надо бюджет и его
|
||||||
|
причины, а не долю.
|
||||||
|
|
||||||
|
### Номер заказа, промокод и деньги
|
||||||
|
|
||||||
|
- **Номер заказа — день и порядковый номер покупки** (#40, решение владельца до
|
||||||
|
реализации, 2026-08-02): событийная и бэкенд-сторона берут один номер из плана
|
||||||
|
— [ADR 0017](../adr/0017-order-numbering.md); по нему соединяется сверка
|
||||||
|
(мастер-спека, раздел 4).
|
||||||
|
- **Промокод в событии есть, скидки в сумме нет** (#40, решение владельца до
|
||||||
|
реализации, 2026-08-02). Клиент шлет сумму позиций без скидки и доставки,
|
||||||
|
поэтому код объясняет разрыв с итогом заказа. Класса расхождения промокод не
|
||||||
|
дает: сверка приведена к `items_total`, где скидки нет (мастер-спека, раздел
|
||||||
|
7). Код объясняет одну из причин, по которым «суммы не сойдутся»
|
||||||
|
(мастер-спека, раздел 4). Так и в жизни: код на сайте знает корзину, а не
|
||||||
|
окончательный расчет. Таблица «код → скидка» — число мира, которое этап 3
|
||||||
|
берет готовым, а не сочиняет заново. Отклонено: *промокода нет никогда* —
|
||||||
|
колонка формата осталась бы мертвой.
|
||||||
|
- **Деньги: копейки внутри, рубли в колонках, дробь только в выручке** (#40,
|
||||||
|
решение владельца до реализации, 2026-08-02). Генератор считает целыми
|
||||||
|
копейками; цена товара в событии — целые рубли, как у Метрики (`productPrice`
|
||||||
|
там `Int64`); дробное число одно — `purchaseRevenue` типа `Float64`. Все 180
|
||||||
|
цен каталога были кратны рублю, и это делало урок про `Float64` беспредметным:
|
||||||
|
округлять нечего, копейки существовали только на словах. Поэтому часть цен
|
||||||
|
получила копейки (1 299,90 ₽ — обычная розница): `productPrice` округляется
|
||||||
|
самим форматом, а `purchaseRevenue` несет точную сумму. Разрыв живет внутри
|
||||||
|
события — считать выручку по разобранным массивам нельзя, и это настоящий урок
|
||||||
|
формата, а не придуманный.
|
||||||
|
|
||||||
|
### Событийный шов, формат и случайность
|
||||||
|
|
||||||
|
- **Шов для #40** (#39, 2026-08-02): день отдает выровненные по строкам ряды
|
||||||
|
страницы магазина и товара на карточке. По ним торговая половина знает, куда
|
||||||
|
повесить событие и какой товар посетитель видел: товар в корзине, которого
|
||||||
|
никто не открывал, был бы видимой ошибкой воронки. Назначенный заказ всегда
|
||||||
|
доходит до `/confirmation`, перед корзиной есть карточка. #39 не занимает
|
||||||
|
случайность #40: подпоток `COMMERCE` остается нетронутым.
|
||||||
|
- **Номера торговых событий — из торгового подпотока** (#40, 2026-08-02).
|
||||||
|
`WatchID` корзины и покупки рисует `COMMERCE`, поэтому правка торговли не
|
||||||
|
сдвигает трафиковые номера. Ряды подпотоков проверяются на непересечение:
|
||||||
|
уникальность нужна дедупликации при переигровке дня.
|
||||||
|
- **Массивы `purchase*` и `product*` разной длины между собой** (#40, решение
|
||||||
|
владельца до реализации, 2026-08-02). Внутри группы длина общая: `purchase*` —
|
||||||
|
по элементу на заказ, здесь всегда один, `product*` — по элементу на товар.
|
||||||
|
Мастер-спека (раздел 1) говорит о «параллельных массивах одной длины», и
|
||||||
|
прочесть это как общую длину всех массивов события легко — поэтому в описании
|
||||||
|
выгрузки сказано прямо.
|
||||||
|
- **Сырой `ecommerce` несет больше, чем плоские колонки** (#40, решение
|
||||||
|
владельца до реализации, 2026-08-02): бренд, вариант и `actionField` целиком.
|
||||||
|
Лаба «сырое против разобранного» (мастер-спека, раздел 1.2) имеет смысл,
|
||||||
|
только если в сыром есть сведения, которых нет в массивах. Строку собирает
|
||||||
|
канонический `orjson`-сериализатор, а не второй ручной сериализатор со своим
|
||||||
|
экранированием (рабочий выбор раздела 4).
|
||||||
|
|
||||||
|
### Замеры канонического мира
|
||||||
|
|
||||||
|
- **Измерено на собранном плане** (#38, перемерено при #40, 2026-08-02): приток
|
||||||
|
3 828 кук в день в среднем, дневная аудитория 6 637–7 595 — обе величины в
|
||||||
|
вилках раздела 5. Накопленная аудитория за 14 дней — 70,2 тыс. кук: 53,6 тыс.
|
||||||
|
новых и 16,6 тыс. возвратов предыстории. У 192 пар оба назначенных заказа
|
||||||
|
попали в снимок; остальные горизонт переживают. Числа считаются прогоном плана
|
||||||
|
без событий. Предыдущий замер пересчитан при #40; актуальные числа — здесь и в
|
||||||
|
таблице замера #50.
|
||||||
|
- **Числа дня** (#39, перемерено при #40, 2026-08-02): ≈1,4 визита на день
|
||||||
|
активности куки и ≈4,8 страницы на визит. Обычному посетителю дано 6% до
|
||||||
|
корзины, затем 45% и 55%; помеченному покупателю — 18% и 60%. Средний день: 9
|
||||||
|
879 визитов, 47 433 pageview, 4,80 страницы на визит, конверсия 2,4%.
|
||||||
|
|
||||||
|
**Измерено после правки #50** (2026-08-02; канонический мир, 14 дней; правый
|
||||||
|
столбец — до правки). Доли по уровням и состав заказа считаются по всему окну, а
|
||||||
|
не как среднее дневных долей: около 240 заказов в дне дают слишком шумную долю.
|
||||||
|
Трафиковая половина не сдвинулась ни на строку.
|
||||||
|
|
||||||
|
| величина | стало | было |
|
||||||
|
|---|---|---|
|
||||||
|
| дневная аудитория | 6 637–7 595, средняя 7 166 | без изменений |
|
||||||
|
| визитов в день | 9 879 | без изменений |
|
||||||
|
| событий в день, средних | 49 834 | 49 509 |
|
||||||
|
| просмотров карточек в день | 25 067 | без изменений |
|
||||||
|
| событий корзины в день | 2 161, в 1 306 визитах | 1 836, в 765 визитах |
|
||||||
|
| из них визиты без страницы `/cart` | 541 — 41,4% | 0 |
|
||||||
|
| визитов со страницей `/cart` без позиции | 0 | 0 |
|
||||||
|
| просмотров карточек, дошедших до корзины | 8,62% | 7,32% |
|
||||||
|
| конверсия карточки в корзину: магнит / обычный / залёживается | 13,1% / 9,2% / 5,8% | 7,7% у всех |
|
||||||
|
| событий корзины вне `/cart`: магнит / обычный / залёживается | 42,0% / 20,2% / 17,1% | 0 у всех |
|
||||||
|
| конверсия визита в покупку | 2,43% | без изменений |
|
||||||
|
| позиций на заказ | 1,74; однопозиционных 56,4% | 1,87; 50,3% |
|
||||||
|
| брошено позиций в покупающих корзинах | 7,25% | 12,0% |
|
||||||
|
|
||||||
|
Знаменатель «просмотров карточек» здесь — только просмотры страниц. При
|
||||||
|
постановке #50 в него попали и строки событий корзины: событие несет ту же
|
||||||
|
страницу, отчего вышло 26 903 просмотра и 6,82% вместо 25 067 и 7,32%. Величина
|
||||||
|
одна и та же, счет разный.
|
||||||
|
|
||||||
|
### Запуск, упаковка и приемники
|
||||||
|
|
||||||
|
Первые два решения владелец принял на грилинге 7 августа 2026 года при #41;
|
||||||
|
остальные приняты при исполнении #41 (2026-08-07).
|
||||||
|
|
||||||
|
- **Генератор живет в своем контейнере** (#41, 2026-08-07), и на этапе 5 даг
|
||||||
|
запускает контейнер, а не импортирует пакет в воркере. Так менти видит
|
||||||
|
отдельные окружение, образ и процесс задачи; зависимости не смешиваются с
|
||||||
|
Airflow. Это ступенька, из которой в бою вырастает `KubernetesPodOperator`, и
|
||||||
|
единственный вариант, где «генератор — отдельная и заменяемая сущность» виден
|
||||||
|
глазами. Хостовый `uv` остается для `make test`, `make lint` и `make
|
||||||
|
typecheck` из каталога `generator/`. Цена названа: сокет Docker в Airflow дает
|
||||||
|
доступ уровня root на хосте; для учебного стенда это принято, в бою так не
|
||||||
|
делают. Отклонено: *пакет в образе Airflow* — менти увидит лишь вызов функции,
|
||||||
|
а стенд заплатит сменой базового образа, контекста сборки и смешением
|
||||||
|
зависимостей; *`ExternalPythonOperator`* — изоляция есть и сокет не нужен, но
|
||||||
|
граница видна в конфигурации, а не в `docker ps`. Это запасной путь, если
|
||||||
|
сокет станет неприемлем.
|
||||||
|
- **Клиент Kafka — `confluent-kafka`** (#41, 2026-08-07). Он уже есть в образе
|
||||||
|
Airflow и пробнике `dags/test_kafka.py`, его использует официальный провайдер.
|
||||||
|
Клиент объявлен необязательной группой `[kafka]`, а приемник импортирует его
|
||||||
|
внутри функции, поэтому пакет живет и без Kafka — с файловым приемником. На
|
||||||
|
стенде остается один клиент вместо двух, а образ ставит его из файла
|
||||||
|
блокировки зависимостей.
|
||||||
|
- **Интерфейс запуска — `python -m clickstream_generator batch|live`** (#41,
|
||||||
|
2026-08-07). Параметры приходят аргументами или переменными окружения
|
||||||
|
(`GENERATOR_SEED`, `GENERATOR_DAY`, `GENERATOR_DAYS`, `GENERATOR_LIMIT`,
|
||||||
|
`GENERATOR_SPEED`, `GENERATOR_FILE`, `KAFKA_BOOTSTRAP_SERVERS`,
|
||||||
|
`KAFKA_TOPIC`), логи идут в стандартный вывод, итог виден кодом возврата.
|
||||||
|
Общие параметры — `--seed`, `--day`, `--days` и приемник `--file` либо
|
||||||
|
`--brokers` с `--topic`. Состояния нет: позицию ведет зовущий (раздел 8).
|
||||||
|
`--days` играет несколько дней одним запуском, чтобы стартовый мир не требовал
|
||||||
|
восьми запусков службы. Отклонено: *режимы через `--speed 0`* — у пакетного
|
||||||
|
режима есть пачка без ожидания, у живого — ожидание без пачки, а флаг прячет
|
||||||
|
разное устройство за числом; *приемник по умолчанию* — молчание может написать
|
||||||
|
в файл то, чего ждали в Kafka. Не назвать приемник — ошибка, назвать оба —
|
||||||
|
тоже.
|
||||||
|
- **Умолчание есть только у того, что описывает мир** (#41, решение владельца
|
||||||
|
2026-08-07). Каноническое зерно, `--days 1` и темп живого дня ×60 — свойства
|
||||||
|
модели: умолчание здесь означает тот же верный ответ. `--day`, адрес брокера,
|
||||||
|
топик и путь файла — факты стенда; без них прогон падает с ненулевым кодом до
|
||||||
|
первой строки. Генератор отдельный и переносимый, поэтому сведения об этом
|
||||||
|
стенде приносит зовущий — Compose, `make` или даг. Иначе забытый день мог бы
|
||||||
|
переиграть D0, выйти с успехом, подвинуть позицию и молча разойтись с миром.
|
||||||
|
Отличие от `--days 1` в том, что единица — минимальный верный ответ, а нулевой
|
||||||
|
день в забытом параметре — конкретный неверный. Переменные окружения — другой
|
||||||
|
канал зовущего, не умолчания; обвязка передает топик и день явно.
|
||||||
|
- **Ограниченная пачка — `--limit N` пакетного режима** (#41, 2026-08-07):
|
||||||
|
потолок событий на весь прогон. Она сочетается с файлом и дает быстро взять
|
||||||
|
срез, еще не приезжавший в ODS, для знакомства с конвейером. Менти не должен
|
||||||
|
ждать полный дневной поток, чтобы посмотреть путь данных. Число отправленного
|
||||||
|
печатается всегда и нужно проверкам #43. На живой день ограничение не
|
||||||
|
действует: ожидание там снимает ускорение.
|
||||||
|
- **Ключа у сообщения Kafka нет** (#41, 2026-08-07). Уникальный `WatchID` не
|
||||||
|
дает обещания порядка: сообщения с таким ключом не повторяются. Осмысленным
|
||||||
|
ключом был бы `ClientID`, но раскладку по разделам стенд намеренно оставляет
|
||||||
|
транспорту (раздел 2), и лаба о чтении топика живет тем, что она не
|
||||||
|
предрешена. Проверено 2026-08-07 по `CONFIGURATION.md` librdkafka и
|
||||||
|
`rd_kafka_conf_dump` библиотеки `confluent-kafka` 2.15.0: без ключа
|
||||||
|
`consistent_random` выбирает раздел, а `sticky.partitioning.linger.ms = 10`
|
||||||
|
временно прикрепляет соседние сообщения к одному разделу. Документация
|
||||||
|
умолчаний не называет, поэтому значения сняты с самой библиотеки. День идет
|
||||||
|
секунды и ложится в оба раздела, короткая пачка может уложиться в одно окно;
|
||||||
|
поэтому чтение обеими нодами проверяется полным днем и разово.
|
||||||
|
- **Приемников два, а режимов три** (#41, 2026-08-07). Kafka пачкой и Kafka с
|
||||||
|
темпом ×60 используют один приемник у разных зовущих; темп держит
|
||||||
|
проигрыватель, знающий время события. Приемник с ускорением снова знал бы о
|
||||||
|
содержимом — именно это запрещает правило глупых приемников из раздела 4.
|
||||||
|
- **Раскладка образа повторяет репозиторий** (#41, 2026-08-07): `/app` — корень,
|
||||||
|
пакет лежит в `/app/generator/src` и виден через `PYTHONPATH`, каталог — в
|
||||||
|
`/app/data`. Путь к каталогу считается от `catalog.py` вверх на `parents[3]`.
|
||||||
|
Обычная установка пакета перенесла бы модуль в `site-packages`, и тот же
|
||||||
|
подъем указал бы внутрь `.venv`: каталог был бы в образе, но модуль его не
|
||||||
|
нашел бы. Проверено в контейнере: день совпал побайтово с прогоном на машине.
|
||||||
|
|
||||||
|
### Опись мира и словарь
|
||||||
|
|
||||||
|
- **Опись мира одна** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7,
|
||||||
|
2026-08-30). Стартовый мир этапа 2 — первые восемь дней,
|
||||||
|
понедельник по понедельник: полная неделя и первый замкнутый цикл K = 7.
|
||||||
|
`data/world-inventory.json` — та самая опись, которую обещает раздел 5.
|
||||||
|
Этап 7 оставил ее восьмидневной: у хвоста в шесть дней не нашлось читателя
|
||||||
|
(резолюция — в разделе 5); вырастет она продолжением этого же файла, а не
|
||||||
|
вторым. В JSON лежат
|
||||||
|
паспорт мира — зерно, версия генератора и хеш каталога — и строки дней с
|
||||||
|
датой, числом событий и хешем. Опись собирает `make inventory`, свежесть
|
||||||
|
сторожит `test_inventory.py` тем же способом, что свежесть описания выгрузки.
|
||||||
|
Отклонено: *два файла, младший и полный* — две правды об одном мире и лишнее
|
||||||
|
слово в словаре.
|
||||||
|
- **Хеш каталога лежит в паспорте и объясняет расхождение** (#42, 2026-08-07).
|
||||||
|
Правка цены меняет мир так же молча, как правка кода. Хеши дней и без
|
||||||
|
каталожного хеша поймают смену мира; он отвечает на следующий вопрос — что
|
||||||
|
менялось: разошлись дни и каталог — CSV, только дни — код.
|
||||||
|
- **«Манифест» переименован в «опись мира», «зерновой мир» — в «стартовый мир»**
|
||||||
|
(#42, решение владельца 2026-08-07). Обычные русские слова убирают пару
|
||||||
|
«манифест / мини-манифест» и необходимость различать две сущности там, где
|
||||||
|
вещь одна.
|
||||||
|
|
||||||
|
### Открытое на момент заморозки
|
||||||
|
|
||||||
|
- **`ParsedParamsKey1` остается пустой** (#39, 2026-08-02) — отложенное решение,
|
||||||
|
а не дыра. Вариант A/B-теста был бы постоянным свойством куки, а не поведением
|
||||||
|
дня; его заводит тикет #47.
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
# Жизненный цикл мира: создание и пересоздание ведёт Airflow
|
||||||
|
|
||||||
|
Дата: 21 августа 2026 года. Решение и доводы — [ADR 0013](../adr/0013-world-lifecycle.md);
|
||||||
|
здесь устройство, которое из него следует.
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
До этой работы начало оси мира собирали трое. Compose создавал топики, применял
|
||||||
|
DDL и двумя одноразовыми службами заливал восемь дней. Shell-сценарии ждали,
|
||||||
|
пока события доедут до ODS, и разово дёргали даг приёма заказов. Airflow вёл
|
||||||
|
мир после стартовой заливки.
|
||||||
|
|
||||||
|
Граница владения не совпадала с процессом, и это видно по следствиям. Успешная
|
||||||
|
отправка в Kafka выдавалась за созданный мир: `world-init` завершался сразу
|
||||||
|
после записи в топик, до приёма сообщений хранилищем.
|
||||||
|
Повторный `make up` смешивал подъём служб с повторной заливкой данных. Узкая
|
||||||
|
пересборка хранилища сохраняла в Kafka сообщения и офсеты прежнего мира, потому
|
||||||
|
что чинила только ClickHouse. Позиция пульта правилась снаружи Airflow — тем,
|
||||||
|
кто миром не управляет.
|
||||||
|
|
||||||
|
**Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат
|
||||||
|
прикладная схема, данные мира и позиция модельного времени, — и Compose,
|
||||||
|
который держит только инфраструктуру. Зеленая ветка создания в
|
||||||
|
`world_initialize` подтверждает: схема создана, оба источника приняты,
|
||||||
|
следующий день известен, слои DDS и DM собраны.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Граница владения одна: **Compose отвечает за инфраструктуру; Airflow — за
|
||||||
|
прикладной мир.**
|
||||||
|
|
||||||
|
Compose держит долгоживущие службы, сети, тома, объявленные файлом учётные
|
||||||
|
записи ClickHouse, инициализацию метаданных Airflow и инициализацию Superset.
|
||||||
|
|
||||||
|
Airflow держит прикладные топики Kafka, канонический DDL прикладных баз `stg`,
|
||||||
|
`ods`, `dds`, `dm`, `dic`, данные стартового мира, их приём и переменную
|
||||||
|
`world_position`.
|
||||||
|
|
||||||
|
Одноразовых служб `kafka-init`, `clickhouse-init`, `world-init` и
|
||||||
|
`world-snapshots` в графе Compose не остаётся. Ни профиля, ни вызова через
|
||||||
|
`compose run` им не оставляют: спрятанная за профилем служба — тот же второй
|
||||||
|
оркестратор, только менее заметный.
|
||||||
|
|
||||||
|
`make` инфраструктурой и ограничивается: поднимает стенд, зовёт нужный даг
|
||||||
|
через REST API Airflow и ждёт результата. Shell больше не применяет DDL, не
|
||||||
|
запускает генератор и не правит позицию.
|
||||||
|
|
||||||
|
### Учётная запись жизненного цикла
|
||||||
|
|
||||||
|
Прикладную структуру создаёт и удаляет отдельная учётная запись `lifecycle` с
|
||||||
|
ролью `lifecycle_owner`, объявленная тем же файлом, что остальные учётные записи
|
||||||
|
([ADR 0007](../adr/0007-clickhouse-access.md)). Ей разрешено администрировать
|
||||||
|
прикладные базы — заводить и удалять их и объекты внутри, — читать их и
|
||||||
|
выполнять кластерный DDL. Ей разрешено чтение `system.settings`: без него
|
||||||
|
драйвер `clickhouse-connect` под официальным провайдером Airflow не открывает
|
||||||
|
соединение. Административные запросы к системным базам ей запрещены.
|
||||||
|
|
||||||
|
`etl` при этом не расширяется. Право удаления баз нужно редкой операции
|
||||||
|
восстановления и остаётся у `lifecycle`. Граница проверяема: при лишнем запросе
|
||||||
|
менти увидит в ошибке имя учётной записи и недостающее право.
|
||||||
|
|
||||||
|
### Что Airflow получает от репозитория
|
||||||
|
|
||||||
|
Каталог `/opt/airflow/world` — то, что нужно для создания мира: `generator/`,
|
||||||
|
`data/` и корневой `.dockerignore` смонтированы только для чтения. Из первого
|
||||||
|
собирается канонический образ генератора, из второго читается опись мира, а
|
||||||
|
последний не пускает в контекст сборки локальные окружения и кэши. Запросы
|
||||||
|
стенда приезжают прежним монтированием `sql/`.
|
||||||
|
|
||||||
|
Меняющиеся API сверены через Context7 и затем с установленными пакетами
|
||||||
|
стенда. Выбраны публичный REST API v2 и ждущий `TriggerDagRunOperator` Airflow,
|
||||||
|
сборка образа через `DockerHook.get_conn().build()`, futures `AdminClient` для
|
||||||
|
Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов ClickHouse и
|
||||||
|
`ClickHouseHook` для запросов, чей результат обрабатывает Python. Оператор сам
|
||||||
|
шаблонизирует файл и при `split_statements=True` исполняет выражения по порядку;
|
||||||
|
собственный разбор SQL дагу больше не нужен.
|
||||||
|
|
||||||
|
## Контракты дагов
|
||||||
|
|
||||||
|
Оба дага публичные: без расписания, не на паузе, `max_active_runs = 1`. Их
|
||||||
|
запускают руками из интерфейса и командой `make` через REST API.
|
||||||
|
|
||||||
|
### `world_initialize` — создать мир, если его нет
|
||||||
|
|
||||||
|
Даг безопасен по построению: на готовом мире он ничего не меняет.
|
||||||
|
|
||||||
|
1. Читает `world_position` и решает, что делать дальше: инициализировать,
|
||||||
|
успешно завершиться без изменений или покраснеть. Разбор состояний — в
|
||||||
|
таблице ниже.
|
||||||
|
2. Создаёт топики: `hits` с двумя партициями, `orders` с одной. Топики идут
|
||||||
|
**до** DDL: чтец ClickHouse начинает читать топик, когда над ним появляется
|
||||||
|
матвью, и топик, созданный брокером сам, приехал бы с одним разделом
|
||||||
|
([ADR 0008](../adr/0008-order-ingestion.md)).
|
||||||
|
3. Применяет канонический DDL из `sql/ddl/` по порядку имён файлов, с ноды 1,
|
||||||
|
учётной записью `lifecycle`. Каждый файл — отдельная последовательная
|
||||||
|
задача `SQLExecuteQueryOperator`; выражения внутри неё исполняются по
|
||||||
|
порядку с `split_statements=True`. Красная задача называет файл и не пускает
|
||||||
|
последующие. Правило «матвью приёма создаётся последней» остаётся за
|
||||||
|
раскладкой файлов и не переезжает в даг.
|
||||||
|
4. Собирает канонический образ генератора. Иначе мир мог бы приехать из старого
|
||||||
|
кода: сборкой образа при подъёме больше никто не занимается.
|
||||||
|
5. Играет стартовые дни одним прогоном `batch` того же образа.
|
||||||
|
6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего
|
||||||
|
сценария, и оно точное: сумма строк `ods.event_dist` за даты описи и строк
|
||||||
|
`ods.event_errors_dist` не меньше суммы событий из
|
||||||
|
[`data/world-inventory.json`](../../data/world-inventory.json). «Что-то
|
||||||
|
приехало» условием не считается.
|
||||||
|
7. Отправляет слепки заказов вторым прогоном образа — со сдвигом, дни 0…6
|
||||||
|
([заказы в стартовом мире](../architecture/orders/start-world.md)).
|
||||||
|
8. Дергает `orders_ingest` ждущим триггером и продолжает только после его
|
||||||
|
успеха.
|
||||||
|
9. Ставит позицию в `WORLD_STARTING_DAYS`: источники приняты, мир уже создан.
|
||||||
|
10. Последним шагом дергает `etl_pipeline` ждущим триггером. Конвейер
|
||||||
|
последовательно ждет `dds_transform` и `dm_transform`; отказ слоя красит
|
||||||
|
инициализацию.
|
||||||
|
|
||||||
|
Порядок задач связывает проверки: каждый шаг подтверждён предыдущим, а красная
|
||||||
|
задача называет место остановки. Ветка "мир уже существует" конвейер не
|
||||||
|
запускает: обычный повтор `make up` по-прежнему ничего не меняет.
|
||||||
|
|
||||||
|
### `world_recreate` — снести мир и создать заново
|
||||||
|
|
||||||
|
Сначала даг выполняет всю разрушительную часть, затем делегирует создание мира.
|
||||||
|
|
||||||
|
1. Ставит позицию в `-1`: прежний мир объявлен недействительным раньше, чем с
|
||||||
|
ним что-то произошло.
|
||||||
|
2. Удаляет прикладные базы ClickHouse `stg`, `dm`, `dds`, `ods`, `dic` — первым
|
||||||
|
делом, чтобы чтецы Kafka перестали существовать вместе с ними.
|
||||||
|
3. Удаляет позиции групп потребителей `clickstream_hits` и
|
||||||
|
`clickstream_orders`, затем топики `hits` и `orders`.
|
||||||
|
4. Ставит позицию в `0` — только после успешной очистки.
|
||||||
|
5. Дёргает `world_initialize` ждущим триггером и ждёт его результата.
|
||||||
|
|
||||||
|
**Общего модуля с шагами двух дагов нет.** Переиспользование здесь — это
|
||||||
|
делегирование: пересборка вызывает создание мира целиком. Такой модуль стал бы
|
||||||
|
третьим местом с той же последовательностью, а первое же расхождение между
|
||||||
|
дагами пришлось бы прятать в его параметрах.
|
||||||
|
|
||||||
|
### Сторожа у работников мира
|
||||||
|
|
||||||
|
`world_next_day` и `world_live_day` краснеют, если позиции нет либо она меньше
|
||||||
|
`WORLD_STARTING_DAYS`. Отказ называет прочитанное значение и следующий шаг:
|
||||||
|
поднять стенд или пересобрать хранилище. Выключатель `world_live` своего
|
||||||
|
сторожа не получает — он краснеет вслед за работником.
|
||||||
|
|
||||||
|
`orders_ingest` сторожа не получает вовсе: его зовёт сама инициализация, когда
|
||||||
|
готового мира ещё нет.
|
||||||
|
|
||||||
|
## Состояния позиции
|
||||||
|
|
||||||
|
`world_position` остаётся единственным состоянием пульта.
|
||||||
|
|
||||||
|
| Позиция | `world_initialize` | `world_recreate` | Работники |
|
||||||
|
|---|---|---|---|
|
||||||
|
| переменной нет | создаёт мир | очищает и создаёт | красный отказ |
|
||||||
|
| `-1` | красный отказ: очистка не завершена, повторите `make rebuild-storage` | очищает и создаёт | красный отказ |
|
||||||
|
| `0` | создаёт мир | очищает и создаёт | красный отказ |
|
||||||
|
| `WORLD_STARTING_DAYS` и больше | мир уже есть: пишет это в журнал и зеленеет | очищает и создаёт | работают |
|
||||||
|
|
||||||
|
Значения между `1` и `WORLD_STARTING_DAYS` штатно не возникают: позиция
|
||||||
|
становится либо `-1`, либо `0`, либо номером сыгранного дня за стартовым миром.
|
||||||
|
Попав на такое значение, `world_initialize` краснеет тем же отказом, что на
|
||||||
|
`-1`, и называет прочитанное число.
|
||||||
|
|
||||||
|
## Поведение при отказах
|
||||||
|
|
||||||
|
**Пересборка упала на очистке.** Позиция остаётся `-1`. Обычный `make up` эту
|
||||||
|
поломку не заглаживает: `world_initialize` краснеет и говорит, что мир
|
||||||
|
недействителен и пересборку надо повторить. Стенд обязан назвать незавершённую
|
||||||
|
очистку вслух и не продолжать работу молча.
|
||||||
|
|
||||||
|
**Очистка прошла, создание остановилось до установки позиции.** Позиция
|
||||||
|
остаётся `0`. Инициализация детерминирована, поэтому следующий `make up` просто
|
||||||
|
пробует её заново — с того же места и с тем же результатом.
|
||||||
|
|
||||||
|
**ETL упал после установки позиции.** Мир и оба источника уже готовы, поэтому
|
||||||
|
позиция остается в `WORLD_STARTING_DAYS`. После исправления запускают рукой
|
||||||
|
`etl_pipeline`: он сам выводит объем из состояния данных. Повторный
|
||||||
|
`world_initialize` здесь не поможет: при готовой позиции инициализация уйдет в
|
||||||
|
короткую ветку без изменений.
|
||||||
|
|
||||||
|
**Позицию в `WORLD_STARTING_DAYS` ставит только успех обоих источников.**
|
||||||
|
События доехали, слепки приняты, `orders_ingest` зелёный — тогда и только тогда
|
||||||
|
мир считается созданным.
|
||||||
|
|
||||||
|
**Порядок очистки Kafka входит в контракт.** Пока живы чтецы
|
||||||
|
ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо.
|
||||||
|
Поэтому сначала удаляются базы, затем топики.
|
||||||
|
|
||||||
|
**Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: сырьё
|
||||||
|
в STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на непустом
|
||||||
|
ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта
|
||||||
|
заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти
|
||||||
|
делает себе сам и тут же видит.
|
||||||
|
|
||||||
|
**Слоты Airflow.** Их четыре. При пересборке `world_recreate` ждет
|
||||||
|
`world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом
|
||||||
|
режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline`
|
||||||
|
и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот.
|
||||||
|
Одновременная пересборка добавляет ожидания и загоняет стенд в тупик.
|
||||||
|
Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live`
|
||||||
|
на паузу и даёт работникам доиграть. Предусловие остаётся в документации;
|
||||||
|
механизм блокировок стоил бы дороже этой строки.
|
||||||
|
|
||||||
|
## Границы
|
||||||
|
|
||||||
|
- **`make up` не мигратор схемы.** На готовом мире изменённый DDL не
|
||||||
|
применяется. Обновляет схему явная пересборка `world_recreate` либо
|
||||||
|
адресная правка объекта в разработке. Размен принят с открытыми глазами.
|
||||||
|
- **Ни блокировок, ни статуса, ни автоматической паузы.** Предусловие
|
||||||
|
обслуживания названо словами.
|
||||||
|
- **`etl` не расширяется**, `orders_ingest` не получает сторожа.
|
||||||
|
- **`make clean` сохраняет свой смысл**: полное удаление постоянного состояния
|
||||||
|
стенда. Ни в пересборку, ни в обычную проверку он не входит.
|
||||||
|
- **`make generate-batch` и `make generate-live` остаются** — с них снимаются
|
||||||
|
унаследованные зависимости от исчезающих одноразовых служб.
|
||||||
|
- Содержание DDL, код генератора и устройство слоёв не меняются.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
Цели `make` — обычные: `config-test` и `lint` без стенда, `smoke`,
|
||||||
|
`check-clickhouse` и `check-services` на поднятом. Сверх них проверяются
|
||||||
|
сценарии, которых у стенда раньше не было.
|
||||||
|
|
||||||
|
- **Повторный `make up` на живом мире** проходит быстро и ничего не меняет:
|
||||||
|
позиция та же, счётчики `check-clickhouse` те же, в журнале дага сказано, что
|
||||||
|
мир уже есть.
|
||||||
|
- **`make rebuild-storage`** возвращает канонический мир: позиция проходит
|
||||||
|
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы
|
||||||
|
заново, офсеты групп идут с нуля, DDS и DM собраны.
|
||||||
|
- **Красные пути проверяются своими отказами.** Сторож работника при
|
||||||
|
отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`;
|
||||||
|
оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки,
|
||||||
|
доказывает только то, что умеет печатать «ЗЕЛЁНО».
|
||||||
|
- **Отказ в правах читается.** Учётная запись `lifecycle` применяет весь DDL
|
||||||
|
без единой ошибки прав, а административный запрос к системной базе ей
|
||||||
|
отклоняют — и текст отказа называет учётку.
|
||||||
|
- **Один холодный `make clean && make up`** оправдан на итоговой приёмке и
|
||||||
|
только этой работой: она меняет самый первый подъём стенда, а его иначе не
|
||||||
|
увидеть. Обобщать этот случай не нужно — обычная проверка по-прежнему
|
||||||
|
обходится узкой областью очистки.
|
||||||
|
|
||||||
|
Замеры холодного и повторного `make up` и пересборки записываются в
|
||||||
|
[карту проверок](../architecture/testing.md), раздел «Что проверено».
|
||||||
|
|
||||||
|
## Отклонённые варианты
|
||||||
|
|
||||||
|
- **Спрятать одноразовые службы за профилем или звать их `compose run`.**
|
||||||
|
Дешевле всего в правках, но оставляет двух оркестраторов одного процесса —
|
||||||
|
ровно то, ради чего затевался переезд.
|
||||||
|
- **Оставить применение DDL и заливку мира в shell, а Airflow отдать только
|
||||||
|
позицию.** Тогда «один наблюдаемый процесс» остаётся обещанием: половина
|
||||||
|
графа живёт в журнале терминала.
|
||||||
|
- **Вынести общие шаги двух дагов в отдельный модуль.** Третье место для той же
|
||||||
|
последовательности; расхождение дагов пряталось бы в его параметрах.
|
||||||
|
- **Применять изменённый DDL при каждом `make up`.** Это мигратор схемы; ему
|
||||||
|
нужен отдельный разговор, в граф создания мира он не помещается.
|
||||||
|
- **Выдать права на удаление баз учётной записи `etl`.** Отвергнуто ещё
|
||||||
|
ADR 0013: конвейер не получает административные права из-за редкой операции.
|
||||||
|
- **Завести блокировку или статус обслуживания, чтобы пересборка не
|
||||||
|
сталкивалась с движением мира.** Механизм на редкий ручной случай дороже
|
||||||
|
предусловия, названного словами.
|
||||||
|
- **Сторож на `orders_ingest`.** Даг зовут до того, как мир существует; сторож
|
||||||
|
запретил бы саму инициализацию.
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# Программа учебных лаб
|
||||||
|
|
||||||
|
Эта спека собирает программу лаб из
|
||||||
|
[инвентаризации сюжетов](../research/2026-08-31-labs-inventory.md), выполненной
|
||||||
|
в тикете #201. Тикет спеки — #202, карта — #182.
|
||||||
|
|
||||||
|
Статус: спека принята владельцем; судьба идей-кандидатов решена здесь поименно.
|
||||||
|
|
||||||
|
## 1. Кому и зачем
|
||||||
|
|
||||||
|
Менти уже прошел большой теоретический курс ClickHouse объемом около 60 часов.
|
||||||
|
Он знает устройство системы в теории, но почти ничего не делал своими руками.
|
||||||
|
|
||||||
|
Поэтому в программе нет вводного блока с теорией. Здесь теория становится
|
||||||
|
ремеслом: менти руками осваивает слои хранилища, DQ, оркестрацию и
|
||||||
|
идемпотентность.
|
||||||
|
|
||||||
|
Обещание программы: менти проходит конвейер стенда автором, а не читателем.
|
||||||
|
|
||||||
|
## 2. Устройство программы: основная программа и факультативы
|
||||||
|
|
||||||
|
У частей программы два статуса, третьего нет.
|
||||||
|
|
||||||
|
**Основная программа** — обязательная часть. Менти проходит ее целиком и по
|
||||||
|
порядку, потому что каждая следующая ступень опирается на предыдущую. От
|
||||||
|
ступени к ступени растет самостоятельность: сначала менти получает готовые ТЗ с
|
||||||
|
готовыми сторожами (DQ-проверками), а затем строит сторожей сам.
|
||||||
|
|
||||||
|
**Факультативы** — необязательные лабы. Они собраны в тематические разделы,
|
||||||
|
чтобы менти мог одним взглядом выбрать интересное. Для каждого раздела указано,
|
||||||
|
после какой ступени основной программы он открывается.
|
||||||
|
|
||||||
|
В программе используются четыре формы заданий:
|
||||||
|
|
||||||
|
- *построить свое по ТЗ* — рядом есть образец, но готового ответа нет;
|
||||||
|
- *вычитание* — SQL готовой трансформации удаляется и пишется заново, а даги
|
||||||
|
остаются как несущая инфраструктура;
|
||||||
|
- *собери рядом* — менти строит свою копию результата рядом с нетронутым
|
||||||
|
стендовым и сверяет их построчно;
|
||||||
|
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а
|
||||||
|
менти ищет причину и объясняет ее.
|
||||||
|
|
||||||
|
## 3. Основная программа — семь ступеней
|
||||||
|
|
||||||
|
### Ступень 1. Построй пять витрин слоя DM
|
||||||
|
|
||||||
|
Первая часть программы опирается на шесть существующих заданий из
|
||||||
|
[`docs/labs/`](../labs/README.md). Пять ТЗ по витринам входят в первую ступень
|
||||||
|
основной программы и выполняются как есть. Их точечный пересмотр — работа
|
||||||
|
реализации. Шестое задание, про свой дашборд в Superset, остается факультативом
|
||||||
|
в разделе «Superset и BI». Набор уроков пяти витрин уже записан в их общем
|
||||||
|
описании: от слоя как договора до честных имен показателей.
|
||||||
|
|
||||||
|
Форма — построить свое по ТЗ. Обратную связь дает даг `homework_check`.
|
||||||
|
|
||||||
|
### Ступень 2. Свой отчет в Superset
|
||||||
|
|
||||||
|
Менти строит один график в Superset поверх витрины, которую сделал на первой
|
||||||
|
ступени. Урок — увидеть, как собственный результат из хранилища становится
|
||||||
|
отчетом в BI, не превращая знакомство с инструментом в отдельный большой
|
||||||
|
проект.
|
||||||
|
|
||||||
|
Форма — построить свое по ТЗ. Результат проверяется глазами. ТЗ для этой новой
|
||||||
|
ступени предстоит написать при реализации программы. Полный собственный дашборд
|
||||||
|
остается факультативом в разделе «Superset и BI».
|
||||||
|
|
||||||
|
### Ступень 3. Нарежь сессии сам
|
||||||
|
|
||||||
|
До того как открыть стендовый SQL нарезки сессий в DDS, менти самостоятельно
|
||||||
|
делит события на визиты. Сначала он сравнивает число своих сессий с эталоном
|
||||||
|
`VisitID`. Затем вручную соединяет свою сессию с эталонной и поштучно разбирает
|
||||||
|
хотя бы одно расхождение границ. Урок — счет называет проблему, но причину
|
||||||
|
приходится искать в отдельных строках.
|
||||||
|
|
||||||
|
Форма — построить свое по ТЗ и провести расследование. Обратная связь состоит
|
||||||
|
из счетной сверки с `VisitID` и ручного сопоставления границ.
|
||||||
|
|
||||||
|
### Ступень 4. Перепиши нарезку стенда
|
||||||
|
|
||||||
|
Менти удаляет и заново пишет SQL нарезки сессий DDS: расчет объема, пересборку
|
||||||
|
и замену. Урок — изменить расчетную сущность внутри готового конвейера и
|
||||||
|
проверить ее внешним эталоном. Это единственный поток, для которого
|
||||||
|
инвентаризация подтвердила вычитание без оговорок: здесь есть настоящие места
|
||||||
|
для ошибок, а проверка не содержит готового ответа.
|
||||||
|
|
||||||
|
Форма — вычитание. Обратную связь дают `dq_check` и поштучная сверка,
|
||||||
|
построенная на третьей ступени.
|
||||||
|
|
||||||
|
### Ступень 5. Сначала сторож, потом ломай: заказы
|
||||||
|
|
||||||
|
Сначала менти строит две недостающие DQ-проверки. Первая утверждает равенство
|
||||||
|
«цена × количество = items_total». Вторая сверяет слой с источником по дням. Их
|
||||||
|
менти строит по образцу пяти стендовых проверок в `sql/dq/`. Обе проверки
|
||||||
|
должны покраснеть на управляемой ошибке. Затем менти вручную подкладывает по
|
||||||
|
строке каждого класса брака, проверяет разбор и убирает эти строки за собой.
|
||||||
|
|
||||||
|
Только после этого он вычитает разбор заказов ODS `order_parse` и трансформацию
|
||||||
|
заказа DDS. Урок — сторожа строят до рискованного изменения, а не после первой
|
||||||
|
поломки.
|
||||||
|
|
||||||
|
Форма — построить свои проверки по ТЗ, провести управляемое расследование и
|
||||||
|
выполнить вычитание. Обратную связь дают две новые проверки, баланс годных и
|
||||||
|
ошибочных строк по срезу и существующие сверки заказов.
|
||||||
|
|
||||||
|
### Ступень 6. Разбери сырые события и сверь результат со стендом
|
||||||
|
|
||||||
|
Менти пакетно разбирает срез сырья событий в собственную таблицу и построчно
|
||||||
|
сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока
|
||||||
|
отдельно от живого приема и использовать стендовый результат как образец для
|
||||||
|
сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет
|
||||||
|
сообщения.
|
||||||
|
|
||||||
|
Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее
|
||||||
|
границы называются прямо: срок работы ограничен окном хранения сырья, а
|
||||||
|
содержимое неопорных колонок можно сравнить только со стендовым разбором, не с
|
||||||
|
независимым эталоном.
|
||||||
|
|
||||||
|
### Ступень 7. Мониторинг: куда смотреть
|
||||||
|
|
||||||
|
Сначала менти знакомится с панелями стенда. Затем проводит два безопасных
|
||||||
|
расследования. В первом он находит в `query_log`, что верх списка тяжелых
|
||||||
|
запросов занимает сам мониторинг. Во втором объясняет, почему свежесть второго
|
||||||
|
источника выглядит как пила, а не как линия, и выбирает правильный слой данных
|
||||||
|
для панели.
|
||||||
|
|
||||||
|
Форма — расследование. Обратная связь — найденные запросы и верное объяснение
|
||||||
|
формы графика и выбора слоя. Ломательные опыты остаются в факультативе «Что
|
||||||
|
делать, когда стенд сломался». Урок ступени — куда и на что смотреть в живой
|
||||||
|
системе.
|
||||||
|
|
||||||
|
**Общее правило вычитания:** ТЗ каждого вычитания на ступенях 4 и 5 включает
|
||||||
|
пересчет ключа шардирования по `hostName()`. Счетные сверки читают `_dist`,
|
||||||
|
поэтому учитывают строку даже на чужом шарде и остаются зелеными: они не видят
|
||||||
|
саму ошибку раскладки.
|
||||||
|
|
||||||
|
## 4. Факультативы — пять разделов
|
||||||
|
|
||||||
|
Ниже перечислены только названия сюжетов и их уроки. Материал, формы и способы
|
||||||
|
проверки остаются в [инвентаризации](../research/2026-08-31-labs-inventory.md).
|
||||||
|
|
||||||
|
### Почему выручка не сходится
|
||||||
|
|
||||||
|
Этот раздел рекомендован первым из факультативов. Он открывается после ступени
|
||||||
|
1 основной программы: материал живет на стенде с этапа 6.
|
||||||
|
|
||||||
|
- **Почему трекер недосчитывает покупки (#186).** Разрыв воронки подтверждений
|
||||||
|
показывает потерянные события и границу доверия к двум источникам.
|
||||||
|
- **Почему повтор покупки завышает выручку (#187).** Движок убирает технический
|
||||||
|
дубль строки, а повтор одной покупки приходится находить по деловому ключу.
|
||||||
|
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
|
||||||
|
разложить до конкретной вычеркнутой позиции и объяснить до копейки.
|
||||||
|
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
|
||||||
|
некоторые различия после истечения срока сырья уже не восстановить.
|
||||||
|
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день
|
||||||
|
становится окончательным лишь после того, как источник больше не может
|
||||||
|
изменить этот день.
|
||||||
|
- **Почему выручка за день еще меняется.** Внутри окна изменяемости выручка
|
||||||
|
движется, а после закрытия дня фиксируется.
|
||||||
|
|
||||||
|
### Тонкий анализ данных
|
||||||
|
|
||||||
|
Открывается после ступени 1 основной программы.
|
||||||
|
|
||||||
|
- **Что мы знали о пользователе в тот день (#145).** Текущее знание и знание на
|
||||||
|
дату дают разные отчеты; сведения из будущего меняют прошлое.
|
||||||
|
- **Добавь названия регионов в отчет (#143).** Менти собирает свой словарь по
|
||||||
|
образцу каталога и обогащает отчет через `dictGet`.
|
||||||
|
|
||||||
|
### Superset и BI
|
||||||
|
|
||||||
|
Открывается после ступени 2 основной программы.
|
||||||
|
|
||||||
|
- **Свой дашборд.** Готовая лаба
|
||||||
|
[`docs/labs/superset-dashboard.md`](../labs/superset-dashboard.md) учит
|
||||||
|
хранить пользовательские объекты отдельно от стендовых.
|
||||||
|
- **Почему дашборд не видит объект на второй ноде.** Забытый `ON CLUSTER` может
|
||||||
|
остаться незаметным до тех пор, пока дашборд не обратится ко второй ноде.
|
||||||
|
|
||||||
|
### Инженерия стенда
|
||||||
|
|
||||||
|
Открывается после ступени 1 основной программы. Сюжеты про переигрывание дня и
|
||||||
|
пошардовую атомарность раскроются полнее после ступени 4, когда менти уже
|
||||||
|
спускался в DDS.
|
||||||
|
|
||||||
|
- **Как принимать данные из Kafka: потоком или слепком.** Поток и слепок
|
||||||
|
требуют разных способов приема и дают разную наблюдаемость.
|
||||||
|
- **Почему данные попадают на свои шарды независимо от ноды чтения.**
|
||||||
|
Нода-потребитель может меняться, а раскладку строк определяет ключ
|
||||||
|
шардирования.
|
||||||
|
- **Почему вторая группа потребителей дублирует поток.** Еще одна группа
|
||||||
|
потребителей создает еще одну полную копию потока.
|
||||||
|
- **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах
|
||||||
|
показывает потерю, которую трудно заметить по самим данным.
|
||||||
|
- **Почему сырье удаляется по времени загрузки.** Очистка сырья живет по
|
||||||
|
времени загрузки, а не по модельному дню.
|
||||||
|
- **Куда попадают битые строки.** Годные строки и брак делят поток без
|
||||||
|
пропусков и пересечений; менти подкладывает по строке каждого класса брака
|
||||||
|
событий и находит их в `ods.event_errors_dist`.
|
||||||
|
- **Какая версия заказа считается текущей.** Версию заказа задает слепок
|
||||||
|
источника, а `FINAL` скрывается за представлением.
|
||||||
|
- **Почему один момент попадает в разные дни.** Один момент дает разные дни в
|
||||||
|
разных поясах, поэтому день и пояс нужно называть явно.
|
||||||
|
- **Проверь, как стенд разобрал JSON в `ecommerce`.** Типизированный разбор
|
||||||
|
может молча подменить ошибку значением, поэтому менти сам разбирает сырой
|
||||||
|
JSON и сравнивает результат с массивами стенда.
|
||||||
|
- **Как конвейер восстанавливает пропущенный день.** Конвейер сам находит дыру
|
||||||
|
и определяет объем повторной обработки по состоянию хранилища.
|
||||||
|
- **Что будет, если день заменился не на всех шардах.** Замена дня завершается
|
||||||
|
отдельно на каждом шарде, поэтому целостность результата подтверждает
|
||||||
|
отдельная сверка.
|
||||||
|
- **Как обновлять витрину без полной пересборки (#149) — повышенная
|
||||||
|
сложность.** Экономия на пересчете требует хранить историю изменений, которую
|
||||||
|
полная пересборка стирает.
|
||||||
|
|
||||||
|
### Что делать, когда стенд сломался
|
||||||
|
|
||||||
|
Открывается после ступени 7 основной программы; весь раздел — повышенная
|
||||||
|
сложность.
|
||||||
|
|
||||||
|
- **Что ломается вместе с keeper.** Остановка keeper связывает состояние DDL и
|
||||||
|
репликации с тем, что видно на панелях и в системных таблицах.
|
||||||
|
- **Где искать запрос DDL, который не завершился.** Очередь распределенного DDL
|
||||||
|
показывает путь запроса и ответ каждой ноды.
|
||||||
|
|
||||||
|
Итог раздела — runbook, которого сейчас не хватает стенду. Менти пишет его по
|
||||||
|
результатам опытов, и документ остается в проекте.
|
||||||
|
|
||||||
|
## 5. Границы вычитания по потокам
|
||||||
|
|
||||||
|
Подробное сравнение потоков и таблица доказательств остаются в
|
||||||
|
[инвентаризации](../research/2026-08-31-labs-inventory.md). Спека берет это
|
||||||
|
сравнение за основу и проводит свои границы. Без оговорок вычитанию отдаются
|
||||||
|
только сессии DDS. Заказы можно вычитать лишь после самодельных сторожей. Для
|
||||||
|
событий ODS выбрана форма «собери рядом»; вычитание здесь отклонено. Прием STG
|
||||||
|
и забор заказов остаются несущей стеной и доступны только для расследований.
|
||||||
|
Карту идентичностей спека решает не отдавать вычитанию; причины этого выбора
|
||||||
|
названы в следующем разделе.
|
||||||
|
|
||||||
|
## 6. Что не вошло
|
||||||
|
|
||||||
|
Вычитание стендовых витрин DM не входит в программу. Оно повторяет урок
|
||||||
|
готового ядра DM, а открытые сверочные запросы в `sql/dq/` почти дословно
|
||||||
|
содержат ответ.
|
||||||
|
|
||||||
|
Форма «собери рядом» для карты идентичностей тоже не входит. Независимого
|
||||||
|
эталона нет, а слабые инварианты проверяют допустимость связей, но не полноту.
|
||||||
|
Аналитический урок идентичности уже несет склейка во времени (#145) в
|
||||||
|
факультативе «Тонкий анализ данных».
|
||||||
|
|
||||||
|
Сюжеты, которые инвентаризация относит к лекциям, остаются лекциями: `Sign` и
|
||||||
|
CollapsingMergeTree, топологии в бою и идентичность «как в бою». Эта спека
|
||||||
|
оставляет их в
|
||||||
|
[списке опорных точек раздела 12 спеки v2](2026-07-30-stand-v2-realism.md#опорные-точки-для-будущих-лекций-и-лаб).
|
||||||
|
|
||||||
|
## 7. Судьба идей-кандидатов
|
||||||
|
|
||||||
|
Эти решения закрывают тикеты после приемки спеки:
|
||||||
|
|
||||||
|
- #143, справочник регионов — вошла в факультатив «Тонкий анализ данных».
|
||||||
|
- #145, склейка во времени — вошла в факультатив «Тонкий анализ данных».
|
||||||
|
- #148, домашки ниже DM и вычитание — вошла в основную программу как ступени
|
||||||
|
4–6. Форму для каждого потока уточнила инвентаризация #201: вычитание,
|
||||||
|
«сначала сторож» или «собери рядом».
|
||||||
|
- #149, инкрементальный `daily_traffic` — вошла в факультатив «Инженерия
|
||||||
|
стенда» с пометкой «повышенная сложность».
|
||||||
|
- #186, воронка подтверждений — вошла в факультатив «Почему выручка не
|
||||||
|
сходится».
|
||||||
|
- #187, бизнес-дубль — вошла в факультатив «Почему выручка не сходится».
|
||||||
|
- #188, опоздавшие заказы — вошла в факультатив «Почему выручка не сходится».
|
||||||
|
|
||||||
|
## 8. Отклоненные варианты оси
|
||||||
|
|
||||||
|
- **Чистое «снизу вверх»: STG → ODS → DDS → DM.** Оно начинает программу с
|
||||||
|
самого тяжелого по инфраструктуре участка. Внизу быстро кончается эталон
|
||||||
|
обратной связи, а вычитание вслепую заставляет переписывать слой, которым
|
||||||
|
менти еще не пользовался.
|
||||||
|
- **Два прохода: сначала расследования снизу вверх, потом постройка сверху
|
||||||
|
вниз.** Пролог из восьми расследований откладывает первый собственный
|
||||||
|
результат. Вместо этого расследования разнесены по факультативам и
|
||||||
|
открываются по мере прохождения основной программы.
|
||||||
|
- **Сюжетами поперек слоев.** Такой порядок ломает принятое решение, по
|
||||||
|
которому готовые задания открывают программу, и превращает путь в сетку
|
||||||
|
зависимостей.
|
||||||
|
|
||||||
|
## 9. Границы спеки и что дальше
|
||||||
|
|
||||||
|
Спека не переписывает существующие ТЗ в `docs/labs/` и общий README; точечный
|
||||||
|
пересмотр относится к реализации.
|
||||||
|
|
||||||
|
Реализация программы тоже находится за границей спеки. В нее входят новые ТЗ
|
||||||
|
для ступеней 2–7 основной программы и факультативов, а также отражение порядка
|
||||||
|
программы в `docs/labs/README.md`. Карту #182 приемка этой спеки закрыла;
|
||||||
|
исполнение ведет отдельная карта — тикет #206 (решение тикета #203).
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Образ генератора: тот самый «канонический контейнер», внутри которого спека
|
||||||
|
# обещает побайтовую воспроизводимость (раздел 2). Отсюда правило: пакеты
|
||||||
|
# ставятся только из `uv.lock` — второй список зависимостей писал бы на стенде
|
||||||
|
# не те байты, что сторожит `make test`.
|
||||||
|
#
|
||||||
|
# Контекст сборки — корень репозитория, а не `generator/`: в образ едет ещё и
|
||||||
|
# каталог товаров.
|
||||||
|
# База записана здесь и прибита к patch-версии: это канонический контейнер из
|
||||||
|
# раздела 2 спеки, и его база входит в тот же фиксированный набор, что `uv.lock`.
|
||||||
|
# Из `.env` её не берём — иначе описание воспроизводимой сборки распалось бы
|
||||||
|
# между Dockerfile и обвязкой запуска.
|
||||||
|
FROM python:3.14.7-slim
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.11.21 /uv /bin/uv
|
||||||
|
|
||||||
|
# Раскладка внутри образа повторяет раскладку репозитория: `/app` — его корень.
|
||||||
|
# Так надо не для красоты. Каталог товаров генератор ищет от своего модуля
|
||||||
|
# вверх по дереву (`catalog.py`, `parents[3]`): каталог — часть мира, а не
|
||||||
|
# запуска, и снаружи не настраивается (решение #39). Значит, взаимное
|
||||||
|
# расположение пакета и `data/` обязано сохраниться, иначе первый же импорт
|
||||||
|
# каталога упрётся в отсутствующий файл.
|
||||||
|
WORKDIR /app/generator
|
||||||
|
|
||||||
|
ENV UV_LINK_MODE=copy \
|
||||||
|
UV_PYTHON_DOWNLOADS=never
|
||||||
|
|
||||||
|
# Зависимости — своим слоем: он пересобирается, только когда меняется лок.
|
||||||
|
# `--extra kafka` ставит клиента, которым живёт приёмник Kafka; `--no-dev`
|
||||||
|
# оставляет за бортом pytest, ruff и ty — их дом на машине разработчика.
|
||||||
|
COPY generator/pyproject.toml generator/uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-dev --no-install-project --extra kafka
|
||||||
|
|
||||||
|
# Пакет в `.venv` не ставится: он лежит исходником и виден через `PYTHONPATH`.
|
||||||
|
# Поставленный не editable, он переехал бы в `site-packages`, и те же
|
||||||
|
# `parents[3]` указали бы внутрь `.venv` — каталог в образе есть, а модуль его
|
||||||
|
# не находит. Заодно правка кода не трогает слой зависимостей.
|
||||||
|
COPY generator/src ./src
|
||||||
|
COPY data /app/data
|
||||||
|
|
||||||
|
ENV PATH="/app/generator/.venv/bin:$PATH" \
|
||||||
|
PYTHONPATH="/app/generator/src" \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Не root: генератору хватает права читать своё и писать в сеть.
|
||||||
|
RUN useradd --create-home --uid 1000 generator
|
||||||
|
USER generator
|
||||||
|
|
||||||
|
# Параметры прогона передаёт зовущий — аргументами или окружением; итог он
|
||||||
|
# читает кодом возврата, а логи идут в стандартный вывод.
|
||||||
|
ENTRYPOINT ["python", "-m", "clickstream_generator"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
.PHONY: lint typecheck test demo docs inventory
|
||||||
|
|
||||||
|
# --- Проверки ---
|
||||||
|
|
||||||
|
lint:
|
||||||
|
uv run ruff check
|
||||||
|
uv run ruff format --check
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
uv run ty check
|
||||||
|
|
||||||
|
test:
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
# --- Пример из путеводителя ---
|
||||||
|
|
||||||
|
demo:
|
||||||
|
uv run python -m clickstream_generator.demo
|
||||||
|
|
||||||
|
# --- Сборка из кода ---
|
||||||
|
|
||||||
|
docs:
|
||||||
|
uv run python -m clickstream_generator.schema_doc \
|
||||||
|
../docs/formats/clickstream-event.md
|
||||||
|
|
||||||
|
inventory:
|
||||||
|
uv run python -m clickstream_generator.inventory \
|
||||||
|
../data/world-inventory.json
|
||||||
+66
-8
@@ -4,8 +4,20 @@
|
|||||||
образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения —
|
образцу облачной выгрузки Яндекс Метрики. Устройство и принятые решения —
|
||||||
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
|
спека [«Генератор (этап 2)»](../docs/specs/2026-08-01-generator.md).
|
||||||
|
|
||||||
Пока здесь контракт схемы события и план состава мира — событий генератор ещё
|
События уже есть и уже уезжают: день-функция отдаёт по паре (зерно, D)
|
||||||
не порождает.
|
упорядоченный поток трёх видов — просмотр страницы, корзина, покупка, —
|
||||||
|
канонический сериализатор превращает его в JSON, а проигрыватель гонит в файл
|
||||||
|
или в Kafka. Клиентская сторона на этом целая. Второй источник строится:
|
||||||
|
день отдаёт и заказы бэкенда — проекцию своих покупок с деньгами магазина, —
|
||||||
|
а судьба заказа и отправка слепка идут следующими тикетами.
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
Корневое зерно ветвится на подпотоки, из них складываются когорты, аудитория
|
||||||
|
дня, визиты, строки событий и заказы. План поднимает состав мира по требованию,
|
||||||
|
а день-функция проживает один день без накопленного состояния. Код этой
|
||||||
|
цепочки, ее схема и запуск D0 собраны в
|
||||||
|
[путеводителе](../docs/architecture/generator/README.md).
|
||||||
|
|
||||||
## Что где лежит
|
## Что где лежит
|
||||||
|
|
||||||
@@ -14,25 +26,71 @@
|
|||||||
- `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого
|
- `src/clickstream_generator/seeds.py` — иерархия зёрен: кто из какого
|
||||||
подпотока берёт случайность. На ней держится весь детерминизм.
|
подпотока берёт случайность. На ней держится весь детерминизм.
|
||||||
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в
|
- `src/clickstream_generator/plan.py` — план состава: кто есть в мире в
|
||||||
день D. Когорты, приток, двухкуковые пары и счётчики — до генерации
|
день D. Когорты, приток, двухкуковые пары, паспорта кук, человек за кукой
|
||||||
событий.
|
и счётчики — до генерации событий.
|
||||||
|
- `src/clickstream_generator/weights.py` — выбор по целым весам: один приём
|
||||||
|
на весь генератор, чтобы дисциплина целочисленной случайности не жила
|
||||||
|
копиями.
|
||||||
|
- `src/clickstream_generator/reference.py` — справочники: устройства,
|
||||||
|
города, источники трафика, карта сайта. Таблицы-литералы: доля живёт в
|
||||||
|
строке, которой принадлежит.
|
||||||
|
- `src/clickstream_generator/catalog.py` — каталог товаров из
|
||||||
|
`data/catalog/products.csv`, общего у генератора и словаря ClickHouse.
|
||||||
|
- `src/clickstream_generator/day.py` — день-функция: визиты, страницы,
|
||||||
|
атрибуция, устройство и гео. Там же правила резки визитов и шов, на
|
||||||
|
который садятся торговые события; прожитый день отдаёт обе половины —
|
||||||
|
поток событий и заказы.
|
||||||
|
- `src/clickstream_generator/commerce.py` — торговые события: что легло в
|
||||||
|
корзину, что из этого куплено, деньги, номера заказов и сырой `ecommerce`.
|
||||||
|
Своя случайность, поэтому правка торговли трафик не двигает.
|
||||||
|
- `src/clickstream_generator/orders.py` — заказы бэкенда: вторая проекция
|
||||||
|
покупки. Личность покупателя и деньги магазина — скидка по промокоду,
|
||||||
|
доставка, итог; своя случайность, ветвящаяся по дню рождения заказа.
|
||||||
|
- `src/clickstream_generator/ids.py` — номера событий: неповторяющиеся и
|
||||||
|
ниже 2^53. Обещание одно на обе половины дня, поэтому и живёт отдельно.
|
||||||
|
- `src/clickstream_generator/serialize.py` — канонический сериализатор:
|
||||||
|
единственное место, где событие целиком превращается в JSON. Порядок ключей,
|
||||||
|
все 47 колонок всегда и форма на проводе — ISO-8601.
|
||||||
|
- `src/clickstream_generator/sinks.py` — приёмники: файл (одно событие — одна
|
||||||
|
строка) и Kafka (одно событие — одно сообщение). Про содержимое они не
|
||||||
|
знают; там же довод, почему у сообщения нет ключа.
|
||||||
|
- `src/clickstream_generator/player.py` — проигрыватель: гонит дни в приёмник
|
||||||
|
пачкой или с темпом живого дня. Состояния не хранит.
|
||||||
|
- `src/clickstream_generator/cli.py` — интерфейс запуска: параметры
|
||||||
|
аргументами или окружением, логи в стандартный вывод, итог кодом возврата.
|
||||||
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
|
- `src/clickstream_generator/schema.py` — контракт схемы: чистые данные о
|
||||||
колонках выгрузки. Собственность генератора; из него выводятся сам
|
колонках выгрузки. Собственность генератора; из него выводятся сам
|
||||||
генератор, его валидация и описание выгрузки в доках.
|
генератор, его валидация и описание выгрузки в доках.
|
||||||
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
||||||
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
||||||
из контракта. Документ руками не правят — пересобирают.
|
из контракта. Документ руками не правят — пересобирают.
|
||||||
- `tests/` — инварианты контракта, свежесть описания и обещания плана:
|
- `src/clickstream_generator/inventory.py` — сборка описи мира
|
||||||
чистота от зерна, приток, гарантия двухкуковых пар.
|
([`data/world-inventory.json`](../data/world-inventory.json)): паспорт мира
|
||||||
|
и хеши восьми дней, которыми наполняется стенд, плюс счётчики классов
|
||||||
|
расхождений по заказам для дней с закрытым окном. Руками не правят —
|
||||||
|
пересобирают целью `make inventory`.
|
||||||
|
- `tests/` — инварианты контракта, свежесть описания и обещания мира:
|
||||||
|
чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
|
||||||
|
и сборка визитов по задокументированным правилам. Там же побайтовое
|
||||||
|
обещание, доведённое до диска: два прогона дня в файл дают тот же файл, а
|
||||||
|
строк в нём ровно столько, сколько событий.
|
||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
Из корня репозитория:
|
Проиграть день — [быстрый старт](../README.md#как-позвать-генератор) и `--help`.
|
||||||
|
|
||||||
|
Из этого каталога — цели генератора живут здесь, а не в корне:
|
||||||
|
|
||||||
- `make test` — тесты генератора;
|
- `make test` — тесты генератора;
|
||||||
- `make lint` — ruff: проверка и формат;
|
- `make lint` — ruff: проверка и формат;
|
||||||
- `make typecheck` — ty: проверка типов;
|
- `make typecheck` — ty: проверка типов;
|
||||||
- `make docs` — пересобрать описание выгрузки.
|
- `make demo` — пройти стадии канонического D0 на одном экране;
|
||||||
|
- `make docs` — пересобрать описание выгрузки;
|
||||||
|
- `make inventory` — пересобрать опись мира.
|
||||||
|
|
||||||
|
В корне репозитория `make lint` тоже есть, но он про код стенда — даги и
|
||||||
|
Superset. Одноимённые цели за разными дверями охватывают разное:
|
||||||
|
[карта проверок](../docs/architecture/testing.md).
|
||||||
|
|
||||||
Python и зависимости — через `uv`, версии закреплены в `uv.lock`: на этом
|
Python и зависимости — через `uv`, версии закреплены в `uv.lock`: на этом
|
||||||
держится обещание побайтовой воспроизводимости (спека, раздел 2).
|
держится обещание побайтовой воспроизводимости (спека, раздел 2).
|
||||||
@@ -6,11 +6,23 @@ readme = "README.md"
|
|||||||
# Верхняя граница — не педантизм: обещание побайтовой воспроизводимости
|
# Верхняя граница — не педантизм: обещание побайтовой воспроизводимости
|
||||||
# держится на зафиксированных версиях (спека генератора, раздел 2).
|
# держится на зафиксированных версиях (спека генератора, раздел 2).
|
||||||
requires-python = ">=3.14,<3.15"
|
requires-python = ">=3.14,<3.15"
|
||||||
dependencies = ["numpy>=2"]
|
dependencies = [
|
||||||
|
"numpy>=2",
|
||||||
|
"orjson>=3.11.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# Клиент Kafka — необязательная часть пакета: без него генератор пишет в файл
|
||||||
|
# и остаётся переносимым (спека генератора, раздел 9). Приёмник импортирует
|
||||||
|
# клиента внутри функции, поэтому пакет без этой группы живой.
|
||||||
|
kafka = ["confluent-kafka>=2.6"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8",
|
"pytest>=8",
|
||||||
|
# Тот же клиент нужен и на машине разработчика: иначе ty не разберёт
|
||||||
|
# импорт приёмника, а ruff и тесты видели бы половину кода.
|
||||||
|
"confluent-kafka>=2.6",
|
||||||
"ruff>=0.15",
|
"ruff>=0.15",
|
||||||
# Типчекер; молод (0.0.x), но версия закреплена в uv.lock. Настройка
|
# Типчекер; молод (0.0.x), но версия закреплена в uv.lock. Настройка
|
||||||
# сверена с доками Astral через Context7: запуск `uv run ty check`,
|
# сверена с доками Astral через Context7: запуск `uv run ty check`,
|
||||||
|
|||||||
@@ -3,4 +3,8 @@
|
|||||||
Сердце пакета — контракт схемы события (`schema`): чистые данные о колонках
|
Сердце пакета — контракт схемы события (`schema`): чистые данные о колонках
|
||||||
выгрузки. Из него выводятся сам генератор, его валидация и «описание
|
выгрузки. Из него выводятся сам генератор, его валидация и «описание
|
||||||
выгрузки» в доках (`schema_doc`); сторона хранилища пишется по описанию.
|
выгрузки» в доках (`schema_doc`); сторона хранилища пишется по описанию.
|
||||||
|
|
||||||
|
Мир собирается из четырёх слоёв: числа (`world`) и справочники
|
||||||
|
(`reference`) описывают его, план состава (`plan`) говорит, кто в нём есть в
|
||||||
|
день D, а день-функция (`day`) проживает этот день событиями.
|
||||||
"""
|
"""
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Точка входа пакета: `python -m clickstream_generator`.
|
||||||
|
|
||||||
|
Код возврата уходит наружу как есть — по нему судит о прогоне и контейнер, и
|
||||||
|
даг, который его запустит (спека генератора, раздел 9).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from clickstream_generator.cli import main
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Каталог товаров: CSV репозитория, общий у генератора и словаря ClickHouse.
|
||||||
|
|
||||||
|
Файл `data/catalog/products.csv` один на всех (мастер-спека, раздел 3):
|
||||||
|
генератор берёт из него имена и цены карточек, а хранилище поднимает над тем
|
||||||
|
же файлом словарь. Расхождений нет по построению — в бою так же живёт
|
||||||
|
справочник, выданный источником.
|
||||||
|
|
||||||
|
Ассортимент — непродовольственная розница (спека генератора, раздел 9):
|
||||||
|
числа мира приняты под неё, продуктовая сеть требовала бы других — возврат
|
||||||
|
раз в неделю, корзина в двадцать позиций.
|
||||||
|
|
||||||
|
Что решено формой файла, а не его длиной: колонки `sku,name,category,brand,
|
||||||
|
price,demand`; артикул — четыре латинские буквы категории, дефис и четыре
|
||||||
|
цифры; цена — целые копейки (деньги генератор считает целыми, спека,
|
||||||
|
раздел 2). Часть цен кратна рублю, часть несёт копейки — как в обычной
|
||||||
|
рознице (1 289,90 ₽). Без копеек урок про Float64 был бы беспредметным:
|
||||||
|
округлять нечего, и разрыв между `productPrice` и `purchaseRevenue`
|
||||||
|
пришлось бы выдумывать (спека генератора, раздел 9).
|
||||||
|
Строк в файле может быть сколько угодно: ни генератор, ни тесты их не
|
||||||
|
считают, а товар для карточки выбирается равномерно внутри категории.
|
||||||
|
|
||||||
|
**Уровень спроса** — привлекательность товара, а не частота его показа:
|
||||||
|
карточку любого товара открывают одинаково часто, но магнит из открытой
|
||||||
|
карточки уходит в корзину чаще, чем залежавшийся. Уровень приписан строке
|
||||||
|
в файле — он постоянная часть мира, а не бросок дня, — и приписан россыпью:
|
||||||
|
ни с ценой, ни с категорией он не связан. Связь с ценой была бы жёсткой, и
|
||||||
|
менти нашёл бы в данных ровно то, что мы в них вложили (спека генератора,
|
||||||
|
раздел 9). Сами вероятности живут в числах мира, здесь только имена уровней.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
# Путь от модуля к корню репозитория: генератор живёт в `generator/src/…`.
|
||||||
|
# Каталог не настраивается извне — он часть мира, а не запуска.
|
||||||
|
CATALOG_PATH = Path(__file__).resolve().parents[3] / "data" / "catalog" / "products.csv"
|
||||||
|
|
||||||
|
COLUMNS = ("sku", "name", "category", "brand", "price", "demand")
|
||||||
|
|
||||||
|
# Уровни спроса по убыванию желанности: порядок здесь — порядок чисел мира,
|
||||||
|
# которыми уровень превращается в вероятность (`world.SHOPPING_ADD_PERCENT`).
|
||||||
|
DEMAND_LEVELS = ("магнит", "обычный", "залёживается")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Category:
|
||||||
|
"""Категория ассортимента: имя в файле, буквы артикула, кусок адреса."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
prefix: str
|
||||||
|
slug: str
|
||||||
|
|
||||||
|
|
||||||
|
CATEGORIES = (
|
||||||
|
Category("Товары для дома", "HOME", "dlya-doma"),
|
||||||
|
Category("Текстиль", "TEXT", "tekstil"),
|
||||||
|
Category("Посуда", "POSU", "posuda"),
|
||||||
|
Category("Бытовая техника", "TECH", "tehnika"),
|
||||||
|
Category("Детские товары", "KIDS", "detskie"),
|
||||||
|
Category("Одежда и обувь", "WEAR", "odezhda"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Catalog:
|
||||||
|
"""Каталог, разложенный по массивам, плюс указатель на строки категории.
|
||||||
|
|
||||||
|
`grouped` — номера строк, сложенные по категориям подряд; `first` и
|
||||||
|
`count` говорят, где чей кусок. Так выбор товара внутри категории —
|
||||||
|
один целочисленный бросок, а порядок строк в файле ни на что не влияет.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sku: NDArray[np.object_]
|
||||||
|
name: NDArray[np.object_]
|
||||||
|
category: NDArray[np.int64]
|
||||||
|
brand: NDArray[np.object_]
|
||||||
|
price: NDArray[np.int64]
|
||||||
|
demand: NDArray[np.int64]
|
||||||
|
grouped: NDArray[np.int64]
|
||||||
|
first: NDArray[np.int64]
|
||||||
|
count: NDArray[np.int64]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def catalog() -> Catalog:
|
||||||
|
"""Каталог из файла; читается один раз — он часть постоянного мира."""
|
||||||
|
with CATALOG_PATH.open(encoding="utf-8", newline="") as source:
|
||||||
|
rows = list(csv.DictReader(source))
|
||||||
|
|
||||||
|
index = {category.name: number for number, category in enumerate(CATEGORIES)}
|
||||||
|
category = np.array([index[row["category"]] for row in rows], dtype=np.int64)
|
||||||
|
grouped = np.argsort(category, kind="stable")
|
||||||
|
count = np.bincount(category, minlength=len(CATEGORIES))
|
||||||
|
level = {name: number for number, name in enumerate(DEMAND_LEVELS)}
|
||||||
|
return Catalog(
|
||||||
|
sku=np.array([row["sku"] for row in rows], dtype=object),
|
||||||
|
name=np.array([row["name"] for row in rows], dtype=object),
|
||||||
|
category=category,
|
||||||
|
brand=np.array([row["brand"] for row in rows], dtype=object),
|
||||||
|
price=np.array([int(row["price"]) for row in rows], dtype=np.int64),
|
||||||
|
demand=np.array([level[row["demand"]] for row in rows], dtype=np.int64),
|
||||||
|
grouped=grouped,
|
||||||
|
first=np.concatenate(([0], np.cumsum(count)[:-1])),
|
||||||
|
count=count,
|
||||||
|
)
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Интерфейс запуска: `python -m clickstream_generator batch|live|snapshot`.
|
||||||
|
|
||||||
|
Зовущий — контейнер (спека генератора, раздел 9), и интерфейс сделан под него:
|
||||||
|
параметры приходят аргументами или переменными окружения, логи идут в
|
||||||
|
стандартный вывод, итог виден кодом возврата. Ни файла настроек, ни состояния
|
||||||
|
между запусками нет — позиция на оси принадлежит тому, кто зовёт.
|
||||||
|
|
||||||
|
Зовущих трое, и все трое видны в форме команд:
|
||||||
|
|
||||||
|
- даги `world_init` и `next_day` этапа 5 — по дню за запуск, приёмник Kafka;
|
||||||
|
- заливка стартового мира — восемь дней подряд одним запуском: `--days`;
|
||||||
|
- проверки хранилища (#43) — ограниченная пачка в файл: `--limit` и `--file`.
|
||||||
|
|
||||||
|
**Режимы разведены командами, а не флагом**, потому что различаются не темпом
|
||||||
|
в числе, а тем, что у них разное: у пакетного есть `--limit` и нет ожидания, у
|
||||||
|
живого есть `--speed` и нет пачки. Один флаг `--speed 0` прятал бы это
|
||||||
|
различие за числом, а команда называет его словом. Ограниченная пачка живому
|
||||||
|
дню не полагается: ждать там нечего — ожидание снимает ускорение.
|
||||||
|
|
||||||
|
**Третья команда — `snapshot`** — второй источник стенда: слепок заказов в свой
|
||||||
|
топик. У неё свой смысл `--day`: это день прогона, а уезжает слепок дня D−1
|
||||||
|
(правило сдвига живёт в проигрывателе). Прогон дня 0 не отправляет ничего —
|
||||||
|
на старте оси вчера нет.
|
||||||
|
|
||||||
|
**Приёмник выбирается тем, что для него назвали**: `--file` или `--brokers`.
|
||||||
|
Оба сразу — ошибка, ни одного — тоже: молча выбранный по умолчанию приёмник
|
||||||
|
однажды напишет в файл то, чего ждали в Kafka.
|
||||||
|
|
||||||
|
**Умолчания есть только у того, что описывает мир, а не окружение.** Зерно,
|
||||||
|
число дней и темп живого дня — свойства модели, они заданы спекой и тикетом,
|
||||||
|
и умолчание здесь — тот же самый ответ. Позиция на оси (`--day`), адрес
|
||||||
|
брокера, имя топика и путь файла — факты стенда, на котором нас запустили:
|
||||||
|
генератор их не знает и знать не должен, он отдельная и переносимая сущность.
|
||||||
|
Подставь он своё умолчание — забытый параметр превратился бы в неверный ответ,
|
||||||
|
поданный как успех.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from contextlib import closing
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from clickstream_generator import player
|
||||||
|
from clickstream_generator.seeds import CANONICAL_SEED
|
||||||
|
from clickstream_generator.sinks import FileSink, KafkaSink, Sink
|
||||||
|
|
||||||
|
DEFAULT_SPEED = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
"""Разобрать аргументы, проиграть дни, вернуть код возврата."""
|
||||||
|
parser = _parser()
|
||||||
|
options = parser.parse_args(argv)
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(message)s", stream=sys.stdout, force=True
|
||||||
|
)
|
||||||
|
|
||||||
|
_check(parser, options)
|
||||||
|
try:
|
||||||
|
with closing(_sink(parser, options)) as sink:
|
||||||
|
if options.command == "snapshot":
|
||||||
|
player.play_snapshots(
|
||||||
|
sink, seed=options.seed, first_day=options.day, days=options.days
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
player.play(
|
||||||
|
sink,
|
||||||
|
seed=options.seed,
|
||||||
|
first_day=options.day,
|
||||||
|
days=options.days,
|
||||||
|
limit=options.limit,
|
||||||
|
speed=options.speed,
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError) as failure:
|
||||||
|
logging.error("прогон не удался: %s", failure)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _parser() -> argparse.ArgumentParser:
|
||||||
|
"""Разбор командной строки; умолчания приходят из окружения."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="python -m clickstream_generator",
|
||||||
|
description="Проигрыватель модельных дней кликстрима в файл или Kafka.",
|
||||||
|
)
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
batch = commands.add_parser(
|
||||||
|
"batch", help="пачкой, без пауз: заливка снимка и переигровка дня"
|
||||||
|
)
|
||||||
|
_common(batch)
|
||||||
|
batch.set_defaults(speed=None)
|
||||||
|
batch.add_argument(
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=_env_int("GENERATOR_LIMIT", None),
|
||||||
|
metavar="N",
|
||||||
|
help="взять не больше N событий на весь прогон, а не день целиком",
|
||||||
|
)
|
||||||
|
|
||||||
|
live = commands.add_parser("live", help="живой день: темп модельного времени")
|
||||||
|
_common(live)
|
||||||
|
live.set_defaults(limit=None)
|
||||||
|
live.add_argument(
|
||||||
|
"--speed",
|
||||||
|
type=float,
|
||||||
|
default=_env_float("GENERATOR_SPEED", DEFAULT_SPEED),
|
||||||
|
metavar="X",
|
||||||
|
help=f"ускорение модельного времени (по умолчанию ×{DEFAULT_SPEED:.0f})",
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = commands.add_parser(
|
||||||
|
"snapshot", help="слепок заказов: прогон дня D отправляет слепок дня D−1"
|
||||||
|
)
|
||||||
|
_common(snapshot)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _common(parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""Что спрашивают у всех команд: какой мир, какие дни и куда."""
|
||||||
|
parser.add_argument(
|
||||||
|
"--seed",
|
||||||
|
type=int,
|
||||||
|
default=_env_int("GENERATOR_SEED", CANONICAL_SEED),
|
||||||
|
metavar="N",
|
||||||
|
help="зерно мира (по умолчанию каноническое)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--day",
|
||||||
|
type=int,
|
||||||
|
default=_env_int("GENERATOR_DAY", None),
|
||||||
|
metavar="D",
|
||||||
|
help="номер дня на оси мира (D0 — первый); у snapshot это день прогона,"
|
||||||
|
" а уезжает слепок дня D−1; умолчания нет — позицию ведёт зовущий",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--days",
|
||||||
|
type=int,
|
||||||
|
default=_env_int("GENERATOR_DAYS", 1),
|
||||||
|
metavar="N",
|
||||||
|
help="сколько дней подряд проиграть одним запуском",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--file",
|
||||||
|
type=Path,
|
||||||
|
default=_env("GENERATOR_FILE", None, Path),
|
||||||
|
metavar="ПУТЬ",
|
||||||
|
help="приёмник — файл: одна запись в строке",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--brokers",
|
||||||
|
default=os.environ.get("KAFKA_BOOTSTRAP_SERVERS"),
|
||||||
|
metavar="АДРЕС",
|
||||||
|
help="приёмник — Kafka: адреса брокеров через запятую",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--topic",
|
||||||
|
default=os.environ.get("KAFKA_TOPIC"),
|
||||||
|
metavar="ИМЯ",
|
||||||
|
help="топик Kafka; умолчания нет — имя топика принадлежит стенду, а не пакету",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check(parser: argparse.ArgumentParser, options: argparse.Namespace) -> None:
|
||||||
|
"""День на оси обязан быть назван — иначе прогон не начинается."""
|
||||||
|
if options.day is None:
|
||||||
|
parser.error(
|
||||||
|
"день на оси не назван: передайте --day или GENERATOR_DAY."
|
||||||
|
" Позицию ведёт зовущий — проигрыватель её не помнит"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sink(parser: argparse.ArgumentParser, options: argparse.Namespace) -> Sink:
|
||||||
|
"""Приёмник по названному: файл либо Kafka, но не оба и не ничего."""
|
||||||
|
if options.file and options.brokers:
|
||||||
|
parser.error("названы оба приёмника: оставьте --file либо --brokers")
|
||||||
|
if options.file:
|
||||||
|
return FileSink(options.file)
|
||||||
|
if options.brokers:
|
||||||
|
if not options.topic:
|
||||||
|
parser.error(
|
||||||
|
"топик не назван: передайте --topic или KAFKA_TOPIC."
|
||||||
|
" Имя топика — факт стенда, генератор его не знает"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return KafkaSink(options.brokers, options.topic)
|
||||||
|
except ImportError:
|
||||||
|
# Ловим там, где возникает: обёрнутый вокруг всего прогона,
|
||||||
|
# этот перехват однажды объявил бы «нет клиента Kafka» о чужой
|
||||||
|
# сорванной загрузке модуля.
|
||||||
|
parser.error(
|
||||||
|
"приёмник Kafka требует клиента: поставьте пакет с группой"
|
||||||
|
" зависимостей kafka (`uv sync --extra kafka`)"
|
||||||
|
)
|
||||||
|
parser.error("приёмник не назван: нужен --file либо --brokers")
|
||||||
|
|
||||||
|
|
||||||
|
def _env[T](name: str, fallback: T, kind: Callable[[str], T]) -> T:
|
||||||
|
"""Значение переменной окружения нужного типа; нет переменной — умолчание.
|
||||||
|
|
||||||
|
Пустая строка считается отсутствием: в compose так выглядит переменная,
|
||||||
|
которую не задали, — `${GENERATOR_DAYS:-}`. Мусор в переменной называется
|
||||||
|
вместе с её именем: из контейнера иначе не видно, чьё это значение.
|
||||||
|
"""
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if not value:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
return kind(value)
|
||||||
|
except ValueError:
|
||||||
|
raise SystemExit(f"переменная {name} не разбирается: {value!r}") from None
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(name: str, fallback: int | None) -> int | None:
|
||||||
|
return _env(name, fallback, int)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_float(name: str, fallback: float) -> float:
|
||||||
|
return _env(name, fallback, float)
|
||||||
@@ -0,0 +1,857 @@
|
|||||||
|
"""Торговые события: корзина и заказ на потоке дня.
|
||||||
|
|
||||||
|
Здесь торговая половина мира. Событие корзины садится на карточку товара —
|
||||||
|
в жизни его шлёт кнопка на карточке, а не открытие страницы корзины; при
|
||||||
|
нескольких товарах в заказе иначе его и не разложить: событий столько, со
|
||||||
|
скольких карточек положили. Событие покупки садится на страницу
|
||||||
|
подтверждения. Оба встают на несколько секунд позже своей страницы, а
|
||||||
|
граница модельных суток режет всё, что за неё вышло: визит, у которого
|
||||||
|
подтверждение срезано полуночью, покупки не даёт — заказа не было.
|
||||||
|
|
||||||
|
**Кладёт любой визит, открывший карточку.** Положить и корзину не открыть —
|
||||||
|
самый массовый сюжет магазина, поэтому событие корзины не привязано к
|
||||||
|
странице `/cart`: иначе факт добавления идеально предсказывался бы более
|
||||||
|
поздним просмотром страницы, а такого равенства в живых данных не бывает.
|
||||||
|
Решается каждая открытая карточка отдельно, а не визит целиком, и решают её
|
||||||
|
двое: намерение визита и уровень спроса товара (числа мира). Карточка,
|
||||||
|
открытая дважды, даёт одну позицию — повторный просмотр это раздумье, а не
|
||||||
|
второй товар.
|
||||||
|
|
||||||
|
**Корзина шире заказа.** Покупает посетитель не всё, что положил: часть
|
||||||
|
позиций остаётся брошенной. Иначе событие корзины не рассказывало бы ничего
|
||||||
|
сверх покупки — заказ был бы её точной копией, и сравнивать было бы нечего.
|
||||||
|
|
||||||
|
**Деньги считаются целыми копейками.** В колонку `productPrice` ложатся
|
||||||
|
целые рубли, как у Метрики, а дробное число в событии одно —
|
||||||
|
`purchaseRevenue`. Часть цен каталога несёт копейки, поэтому округление
|
||||||
|
видно: выручку по разобранным массивам не пересчитать, точная сумма живёт в
|
||||||
|
`purchaseRevenue` и в сыром `ecommerce`. Разрыв внутри одного события — это
|
||||||
|
настоящий урок формата, а не придуманный.
|
||||||
|
|
||||||
|
**Промокод в событии есть, скидки в сумме нет.** Выручка, которую шлёт
|
||||||
|
клиент, — сумма позиций без скидки и доставки: код на сайте знает корзину, а
|
||||||
|
не итог расчёта. Скидку по коду насчитывает бэкенд (этап 3), и таблица «код
|
||||||
|
→ скидка» лежит в числах мира, чтобы обе стороны брали одну.
|
||||||
|
|
||||||
|
**Номер заказа читаемый** — день модельного времени и порядковый номер
|
||||||
|
покупки в этом дне (`20260603-0042`). Он же `order_id` бэкенда: по нему
|
||||||
|
соединяется сверка (мастер-спека, раздел 4). Нумеруются все покупки,
|
||||||
|
дошедшие до потока дня, в порядке событий — до всяких потерь; поэтому номер
|
||||||
|
присваивается последним ходом, когда поток уже упорядочен.
|
||||||
|
|
||||||
|
**Второй выход — покупки дня.** Кроме потока событий торговая половина
|
||||||
|
отдаёт покупки готовой структурой: номер заказа, корзина, деньги клиента,
|
||||||
|
промокод и человек за кукой. Заказная сторона берёт их такими и ничего не
|
||||||
|
пересчитывает: заказ — вторая проекция той же покупки, поэтому согласие двух
|
||||||
|
источников не удерживается, а выходит по построению
|
||||||
|
(docs/architecture/orders/snapshot.md).
|
||||||
|
|
||||||
|
**Случайность — подпоток `COMMERCE`** (спека генератора, раздел 2): правка
|
||||||
|
торгового поведения не сдвигает трафиковый поток. Броски целые и векторные;
|
||||||
|
посточно собираются только строки — их numpy не умеет. Новых бросков заказы
|
||||||
|
сюда не добавляют: свои решения заказная сторона тянет из своего подпотока.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import IntEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import orjson
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from clickstream_generator import catalog, ids, reference, world
|
||||||
|
from clickstream_generator.reference import Page
|
||||||
|
from clickstream_generator.seeds import Component, day_stream
|
||||||
|
from clickstream_generator.weights import pick
|
||||||
|
|
||||||
|
# Таксономия: тип события и действие с товаром. Полный словарь торговых
|
||||||
|
# событий Метрики (detail, remove, impressions) стенд не берёт.
|
||||||
|
ADD_TO_CART = "add_to_cart"
|
||||||
|
PURCHASE = "purchase"
|
||||||
|
ADD_ACTION = "add"
|
||||||
|
PURCHASE_ACTION = "purchase"
|
||||||
|
|
||||||
|
# Копеек в рубле: внутри генератора деньги целые, в колонках — рубли.
|
||||||
|
KOPECKS = 100
|
||||||
|
|
||||||
|
_QUANTITY_CUMULATIVE = np.cumsum(world.ITEM_QUANTITY_WEIGHTS)
|
||||||
|
|
||||||
|
_PRODUCT_COLUMNS = (
|
||||||
|
"productID",
|
||||||
|
"productName",
|
||||||
|
"productCategory",
|
||||||
|
"productPrice",
|
||||||
|
"productQuantity",
|
||||||
|
"productEventType",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Baskets:
|
||||||
|
"""Корзины дня: позиции по корзинам и строка подтверждения заказа.
|
||||||
|
|
||||||
|
Позиция — товар, положенный в корзину: `anchor` — строка карточки, на
|
||||||
|
которую сядет событие, `product` — номер товара в каталоге. Позиции
|
||||||
|
лежат подряд по корзинам, внутри корзины — по времени; `basket` говорит,
|
||||||
|
чья позиция, а `first` и `count` — где чей кусок. `confirmation`
|
||||||
|
отвечает на вопрос, дошла ли корзина до заказа: строка подтверждения
|
||||||
|
или −1 у брошенной.
|
||||||
|
"""
|
||||||
|
|
||||||
|
anchor: NDArray[np.int64]
|
||||||
|
product: NDArray[np.int64]
|
||||||
|
basket: NDArray[np.int64]
|
||||||
|
first: NDArray[np.int64]
|
||||||
|
count: NDArray[np.int64]
|
||||||
|
confirmation: NDArray[np.int64]
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return self.confirmation.size
|
||||||
|
|
||||||
|
def positions_of(self, basket: int, kept: NDArray[np.bool_]) -> NDArray[np.int64]:
|
||||||
|
"""Позиции корзины, у которых стоит отметка: например, купленные."""
|
||||||
|
here = slice(self.first[basket], self.first[basket] + self.count[basket])
|
||||||
|
return self.first[basket] + np.flatnonzero(kept[here])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Draws:
|
||||||
|
"""Броски торгового подпотока: случайное решается один раз и разом.
|
||||||
|
|
||||||
|
По позициям корзин — сколько штук берут, какой это вариант товара,
|
||||||
|
дошла ли позиция до заказа и на сколько секунд событие корзины отстало
|
||||||
|
от карточки. По корзинам — промокод и задержка события покупки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
quantity: NDArray[np.int64]
|
||||||
|
variant: NDArray[np.int64]
|
||||||
|
kept: NDArray[np.bool_]
|
||||||
|
cart_delay: NDArray[np.int64]
|
||||||
|
coupon: NDArray[np.int64]
|
||||||
|
order_delay: NDArray[np.int64]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Purchases:
|
||||||
|
"""Покупки дня готовой структурой — второй выход торговой половины.
|
||||||
|
|
||||||
|
Ряды одной длины, по элементу на покупку, в порядке номеров заказа.
|
||||||
|
Корзина лежит парой рядов ячеек — номера товаров каталога и штуки; в
|
||||||
|
каждой ячейке свой массив, длиной в число позиций покупки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
order_id: tuple[str, ...]
|
||||||
|
# Человек за кукой, которая купила: у заказа он назовётся `user_id`.
|
||||||
|
person_id: NDArray[np.uint64]
|
||||||
|
product: tuple[NDArray[np.int64], ...]
|
||||||
|
quantity: tuple[NDArray[np.int64], ...]
|
||||||
|
# Промокод события строкой; у покупки без кода — пустая.
|
||||||
|
coupon: tuple[str, ...]
|
||||||
|
# Деньги клиента целыми копейками: сумма позиций без скидки и доставки,
|
||||||
|
# та самая, что уехала в событие как `purchaseRevenue`. Бэкенд назовёт
|
||||||
|
# её `items_total` — у двух источников свои имена одному числу.
|
||||||
|
revenue: NDArray[np.int64]
|
||||||
|
# Когда покупка подтверждена — та же абсолютная метка, что уехала в
|
||||||
|
# событие. У заказа она станет секундой `created_at`: строка в базе
|
||||||
|
# источника создаётся синхронно с покупкой.
|
||||||
|
moment: NDArray[np.datetime64]
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return self.person_id.size
|
||||||
|
|
||||||
|
|
||||||
|
class EventOutcome(IntEnum):
|
||||||
|
"""Что случилось с маячком purchase после создания заказа."""
|
||||||
|
|
||||||
|
DELIVERED = 0
|
||||||
|
LOST = 1
|
||||||
|
DUPLICATED = 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Events:
|
||||||
|
"""Строки одного вида торговых событий, готовые встать в поток.
|
||||||
|
|
||||||
|
`raw` — сырой `ecommerce` каждой строки ещё объектом: номер заказа в нём
|
||||||
|
появится, когда поток будет упорядочен, а строка станет байтами один
|
||||||
|
раз, каноническим сериализатором. `basket` говорит, чья корзина стоит за
|
||||||
|
строкой: по нему покупка находит свою после того, как поток упорядочен.
|
||||||
|
"""
|
||||||
|
|
||||||
|
columns: dict[str, NDArray[Any]]
|
||||||
|
page: NDArray[np.uint8]
|
||||||
|
product: NDArray[np.int64]
|
||||||
|
raw: list[dict[str, Any]]
|
||||||
|
basket: NDArray[np.int64]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Woven:
|
||||||
|
"""Что торговая половина отдает дню после вплетения событий."""
|
||||||
|
|
||||||
|
columns: dict[str, NDArray[Any]]
|
||||||
|
page: NDArray[np.uint8]
|
||||||
|
product: NDArray[np.int64]
|
||||||
|
purchases: Purchases
|
||||||
|
event_outcome: NDArray[np.int8]
|
||||||
|
# Для каждой покупки: назначен ли её заказ планом.
|
||||||
|
assigned_order: NDArray[np.bool_]
|
||||||
|
|
||||||
|
|
||||||
|
# День без единой корзины: покупок в нём нет, а форма у рядов есть.
|
||||||
|
_NO_PURCHASES = Purchases(
|
||||||
|
order_id=(),
|
||||||
|
person_id=np.empty(0, dtype=np.uint64),
|
||||||
|
product=(),
|
||||||
|
quantity=(),
|
||||||
|
coupon=(),
|
||||||
|
revenue=np.empty(0, dtype=np.int64),
|
||||||
|
moment=np.empty(0, dtype="datetime64[s]"),
|
||||||
|
)
|
||||||
|
_NO_EVENT_OUTCOMES = np.empty(0, dtype=np.int8)
|
||||||
|
_NO_ASSIGNED_ORDERS = np.empty(0, dtype=np.bool_)
|
||||||
|
|
||||||
|
|
||||||
|
def weave(
|
||||||
|
seed: int,
|
||||||
|
day: int,
|
||||||
|
columns: dict[str, NDArray[Any]],
|
||||||
|
page: NDArray[np.uint8],
|
||||||
|
product: NDArray[np.int64],
|
||||||
|
person: NDArray[np.uint64],
|
||||||
|
assigned_order: NDArray[np.bool_],
|
||||||
|
) -> Woven:
|
||||||
|
"""Вплетает торговые события в поток и отдаёт поток и покупки дня.
|
||||||
|
|
||||||
|
Строки приходят упорядоченными по времени и такими же уходят: торговые
|
||||||
|
события встают между ними, и поток пересобирается одним порядком. Второй
|
||||||
|
выход — покупки дня: то же самое, чем они уехали в события.
|
||||||
|
|
||||||
|
`person` и `assigned_order` выровнены по входящему потоку. Оба
|
||||||
|
признака приходят из плана состава: личность дает заказу `user_id`,
|
||||||
|
а назначение защищает мост склейки от потери purchase.
|
||||||
|
"""
|
||||||
|
rng = day_stream(seed, day, Component.COMMERCE)
|
||||||
|
baskets = _baskets(rng, page, product, columns["VisitID"])
|
||||||
|
if not len(baskets):
|
||||||
|
return Woven(
|
||||||
|
columns=columns,
|
||||||
|
page=page,
|
||||||
|
product=product,
|
||||||
|
purchases=_NO_PURCHASES,
|
||||||
|
event_outcome=_NO_EVENT_OUTCOMES,
|
||||||
|
assigned_order=_NO_ASSIGNED_ORDERS,
|
||||||
|
)
|
||||||
|
|
||||||
|
goods = catalog.catalog()
|
||||||
|
draws = _draws(rng, baskets)
|
||||||
|
revenue = _revenue(goods, baskets, draws)
|
||||||
|
coupons = _coupon_codes(draws)
|
||||||
|
events = (
|
||||||
|
_cart_events(columns, baskets, goods, draws),
|
||||||
|
_order_events(columns, baskets, goods, draws, revenue, coupons),
|
||||||
|
)
|
||||||
|
columns, page, product, basket = _stream(rng, columns, page, product, events)
|
||||||
|
purchases, assigned = _purchases(
|
||||||
|
columns, basket, baskets, draws, revenue, coupons, person, assigned_order
|
||||||
|
)
|
||||||
|
columns, page, product, event_outcome = _corrupt_events(
|
||||||
|
seed, day, columns, page, product, purchases, assigned
|
||||||
|
)
|
||||||
|
return Woven(
|
||||||
|
columns=columns,
|
||||||
|
page=page,
|
||||||
|
product=product,
|
||||||
|
purchases=purchases,
|
||||||
|
event_outcome=event_outcome,
|
||||||
|
assigned_order=assigned,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
|
||||||
|
"""Всё случайное в торговых событиях — целыми числами и векторно."""
|
||||||
|
positions = baskets.product.size
|
||||||
|
return _Draws(
|
||||||
|
quantity=1 + pick(rng, _QUANTITY_CUMULATIVE, positions),
|
||||||
|
variant=rng.integers(0, len(reference.PRODUCT_VARIANTS), positions),
|
||||||
|
kept=_kept(rng, baskets),
|
||||||
|
cart_delay=rng.integers(*world.TRADE_DELAY_SECONDS, positions),
|
||||||
|
coupon=_coupons(rng, len(baskets)),
|
||||||
|
order_delay=rng.integers(*world.TRADE_DELAY_SECONDS, len(baskets)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _baskets(
|
||||||
|
rng: np.random.Generator,
|
||||||
|
page: NDArray[np.uint8],
|
||||||
|
product: NDArray[np.int64],
|
||||||
|
visit: NDArray[np.uint64],
|
||||||
|
) -> _Baskets:
|
||||||
|
"""Что посетитель положил в корзину и дошёл ли до заказа.
|
||||||
|
|
||||||
|
Корзина есть у всякого визита, положившего хоть что-то, — не только у
|
||||||
|
дошедшего до страницы `/cart`. Кандидаты — карточки, открытые в визите,
|
||||||
|
по одной на товар: карточка, открытая дважды, даёт одну позицию, и
|
||||||
|
остаётся первая — тогда товар и кладут.
|
||||||
|
|
||||||
|
Намерение визита берётся из шага воронки: тот, кто дошёл до страницы
|
||||||
|
корзины, пришёл покупать. Позиция у него есть всегда — страница корзины
|
||||||
|
при пустой корзине невозможна, — и держится это высокой вероятностью, а
|
||||||
|
не принуждением: спасать приходится считаные проценты таких визитов.
|
||||||
|
Принуждай мы всех, корзина схлопнулась бы до одной позиции, а заказ до
|
||||||
|
одного товара.
|
||||||
|
"""
|
||||||
|
order = np.argsort(visit, kind="stable")
|
||||||
|
page, product, visit = page[order], product[order], visit[order]
|
||||||
|
|
||||||
|
# Строки визита лежат подряд, внутри визита — по времени: поток пришёл
|
||||||
|
# упорядоченным, а сортировка по визиту устойчивая.
|
||||||
|
started = np.concatenate(([True], visit[1:] != visit[:-1]))
|
||||||
|
number = np.cumsum(started) - 1
|
||||||
|
visits = int(started.sum())
|
||||||
|
|
||||||
|
goods = catalog.catalog()
|
||||||
|
cards = np.flatnonzero(page == Page.PRODUCT)
|
||||||
|
# Ключ «визит и товар»: `np.unique` отдаёт индексы первых вхождений,
|
||||||
|
# поэтому вторая карточка того же товара кандидата не добавляет.
|
||||||
|
key = number[cards] * goods.sku.size + product[cards]
|
||||||
|
opened = np.sort(cards[np.unique(key, return_index=True)[1]])
|
||||||
|
holder = number[opened]
|
||||||
|
|
||||||
|
shopping = np.zeros(visits, dtype=np.bool_)
|
||||||
|
shopping[number[page == Page.CART]] = True
|
||||||
|
taken = _taken(rng, goods.demand[product[opened]], shopping[holder])
|
||||||
|
# Кандидаты визита лежат подряд, визиты идут по порядку — поэтому начало
|
||||||
|
# каждого куска находится накопленной суммой.
|
||||||
|
opened_count = np.bincount(holder, minlength=visits)
|
||||||
|
opened_first = np.cumsum(opened_count) - opened_count
|
||||||
|
empty = shopping & (np.bincount(holder[taken], minlength=visits) == 0)
|
||||||
|
_at_least_one(rng, taken, opened_first, opened_count, empty)
|
||||||
|
|
||||||
|
positions, owner = opened[taken], holder[taken]
|
||||||
|
with_basket = np.unique(owner)
|
||||||
|
basket_of_visit = np.full(visits, -1, dtype=np.int64)
|
||||||
|
basket_of_visit[with_basket] = np.arange(with_basket.size)
|
||||||
|
|
||||||
|
# Подтверждение без корзины невозможно: до него визит прошёл страницу
|
||||||
|
# корзины, а у неё позиция есть всегда — поэтому номер корзины найдётся.
|
||||||
|
confirmed = np.flatnonzero(page == Page.CONFIRMATION)
|
||||||
|
confirmation = np.full(with_basket.size, -1, dtype=np.int64)
|
||||||
|
confirmation[basket_of_visit[number[confirmed]]] = order[confirmed]
|
||||||
|
|
||||||
|
basket = basket_of_visit[owner]
|
||||||
|
count = np.bincount(basket, minlength=with_basket.size)
|
||||||
|
return _Baskets(
|
||||||
|
anchor=order[positions],
|
||||||
|
product=product[positions],
|
||||||
|
basket=basket,
|
||||||
|
# Позиции лежат подряд, корзины идут по порядку визитов — начало
|
||||||
|
# каждого куска находится той же накопленной суммой.
|
||||||
|
first=np.cumsum(count) - count,
|
||||||
|
count=count,
|
||||||
|
confirmation=confirmation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _taken(
|
||||||
|
rng: np.random.Generator,
|
||||||
|
demand: NDArray[np.int64],
|
||||||
|
shopping: NDArray[np.bool_],
|
||||||
|
) -> NDArray[np.bool_]:
|
||||||
|
"""Дошла ли открытая карточка до корзины: решают намерение и товар.
|
||||||
|
|
||||||
|
Два ряда чисел мира по уровням спроса: один для визита, пришедшего
|
||||||
|
покупать, другой для того, кто просто смотрит. Товар решает у обоих, но
|
||||||
|
у смотрящего он решает почти всё — потому конверсия «карточка → корзина»
|
||||||
|
по уровням спроса и становится различимой.
|
||||||
|
"""
|
||||||
|
chance = np.where(
|
||||||
|
shopping,
|
||||||
|
np.array(world.SHOPPING_ADD_PERCENT)[demand],
|
||||||
|
np.array(world.BROWSING_ADD_PERCENT)[demand],
|
||||||
|
)
|
||||||
|
return rng.integers(0, 100, demand.size) < chance
|
||||||
|
|
||||||
|
|
||||||
|
def _kept(rng: np.random.Generator, baskets: _Baskets) -> NDArray[np.bool_]:
|
||||||
|
"""Какие позиции корзины дошли до заказа: часть остаётся брошенной.
|
||||||
|
|
||||||
|
Пустым заказ не бывает: если брошены все позиции, одна возвращается.
|
||||||
|
"""
|
||||||
|
kept = (
|
||||||
|
rng.integers(0, 100, baskets.product.size) >= world.ABANDONED_POSITION_PERCENT
|
||||||
|
)
|
||||||
|
empty = np.bincount(baskets.basket[kept], minlength=len(baskets)) == 0
|
||||||
|
_at_least_one(rng, kept, baskets.first, baskets.count, empty)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def _at_least_one(
|
||||||
|
rng: np.random.Generator,
|
||||||
|
chosen: NDArray[np.bool_],
|
||||||
|
first: NDArray[np.int64],
|
||||||
|
count: NDArray[np.int64],
|
||||||
|
empty: NDArray[np.bool_],
|
||||||
|
) -> None:
|
||||||
|
"""Возвращает один выбор тем группам, у которых бросок унёс все.
|
||||||
|
|
||||||
|
Приём общий у двух мест: у визита выбираются карточки, у корзины —
|
||||||
|
позиции, а правило одно — пустой ни та, ни другая быть не может. Какой
|
||||||
|
выбор вернуть, решает свой бросок: брать первый значило бы, что ранняя
|
||||||
|
карточка визита переживает любой бросок. Группа без членов сюда не
|
||||||
|
приходит: у визита со страницей корзины карточка была, у корзины —
|
||||||
|
позиция.
|
||||||
|
"""
|
||||||
|
chosen[first[empty] + rng.integers(0, count[empty])] = True
|
||||||
|
|
||||||
|
|
||||||
|
def _coupons(rng: np.random.Generator, baskets: int) -> NDArray[np.int64]:
|
||||||
|
"""Промокод корзины: номер строки в таблице кодов или −1, если кода нет."""
|
||||||
|
code = rng.integers(0, len(world.COUPONS), baskets)
|
||||||
|
return np.where(rng.integers(0, 100, baskets) < world.COUPON_PERCENT, code, -1)
|
||||||
|
|
||||||
|
|
||||||
|
def _coupon_codes(draws: _Draws) -> NDArray[np.object_]:
|
||||||
|
"""Промокод каждой корзины строкой; у корзины без кода — пустая.
|
||||||
|
|
||||||
|
Код нужен обеим сторонам: событие везёт его как есть, а бэкенд по нему
|
||||||
|
считает скидку заказа — по той же таблице чисел мира.
|
||||||
|
"""
|
||||||
|
return np.array(
|
||||||
|
[
|
||||||
|
world.COUPONS[number][0] if number >= 0 else ""
|
||||||
|
for number in draws.coupon.tolist()
|
||||||
|
],
|
||||||
|
dtype=object,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _revenue(
|
||||||
|
goods: catalog.Catalog, baskets: _Baskets, draws: _Draws
|
||||||
|
) -> NDArray[np.int64]:
|
||||||
|
"""Деньги клиента по корзинам, целыми копейками: сумма купленных позиций.
|
||||||
|
|
||||||
|
Сумма считается один раз на день и уезжает в оба источника: в событие —
|
||||||
|
как `purchaseRevenue`, в заказ — как `items_total`. Разойтись им негде,
|
||||||
|
и это не совпадение, а построение.
|
||||||
|
"""
|
||||||
|
value = goods.price[baskets.product] * draws.quantity
|
||||||
|
kopecks = np.zeros(len(baskets), dtype=np.int64)
|
||||||
|
np.add.at(kopecks, baskets.basket[draws.kept], value[draws.kept])
|
||||||
|
return kopecks
|
||||||
|
|
||||||
|
|
||||||
|
def _cart_events(
|
||||||
|
columns: dict[str, NDArray[Any]],
|
||||||
|
baskets: _Baskets,
|
||||||
|
goods: catalog.Catalog,
|
||||||
|
draws: _Draws,
|
||||||
|
) -> _Events:
|
||||||
|
"""Строки `add_to_cart`: по одной на каждый положенный товар."""
|
||||||
|
positions = baskets.product.size
|
||||||
|
alone = [np.array([position]) for position in range(positions)]
|
||||||
|
|
||||||
|
rows = _on_page(columns, baskets.anchor, ADD_TO_CART, draws.cart_delay)
|
||||||
|
rows["GoalsReached"] = _same(
|
||||||
|
np.array([world.GOAL_CART_ID], dtype=np.uint32), positions
|
||||||
|
)
|
||||||
|
side, blocks = _product_side(goods, baskets, alone, draws, ADD_ACTION)
|
||||||
|
rows.update(side)
|
||||||
|
|
||||||
|
return _Events(
|
||||||
|
columns=rows,
|
||||||
|
page=np.full(positions, Page.PRODUCT, dtype=np.uint8),
|
||||||
|
product=baskets.product.copy(),
|
||||||
|
raw=[{"currencyCode": world.CURRENCY, ADD_ACTION: block} for block in blocks],
|
||||||
|
basket=baskets.basket,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _order_events(
|
||||||
|
columns: dict[str, NDArray[Any]],
|
||||||
|
baskets: _Baskets,
|
||||||
|
goods: catalog.Catalog,
|
||||||
|
draws: _Draws,
|
||||||
|
revenue: NDArray[np.int64],
|
||||||
|
coupons: NDArray[np.object_],
|
||||||
|
) -> _Events:
|
||||||
|
"""Строки `purchase`: по одной на корзину, дошедшую до подтверждения."""
|
||||||
|
ordered = np.flatnonzero(baskets.confirmation >= 0)
|
||||||
|
bought = [baskets.positions_of(basket, draws.kept) for basket in ordered]
|
||||||
|
|
||||||
|
rows = _on_page(
|
||||||
|
columns, baskets.confirmation[ordered], PURCHASE, draws.order_delay[ordered]
|
||||||
|
)
|
||||||
|
rows["GoalsReached"] = _same(
|
||||||
|
np.array([world.GOAL_PURCHASE_ID], dtype=np.uint32), ordered.size
|
||||||
|
)
|
||||||
|
side, blocks = _product_side(goods, baskets, bought, draws, PURCHASE_ACTION)
|
||||||
|
rows.update(side)
|
||||||
|
|
||||||
|
# Выручка клиента — сумма позиций без скидки и доставки, целыми копейками.
|
||||||
|
# Считана она один раз на день: тем же числом её возьмёт заказ бэкенда.
|
||||||
|
kopecks = revenue[ordered].tolist()
|
||||||
|
codes = coupons[ordered].tolist()
|
||||||
|
rows["purchaseRevenue"] = _cells(
|
||||||
|
[np.array([money / KOPECKS], dtype=np.float64) for money in kopecks]
|
||||||
|
)
|
||||||
|
rows["purchaseCurrency"] = _same(
|
||||||
|
np.array([world.CURRENCY], dtype=object), ordered.size
|
||||||
|
)
|
||||||
|
rows["purchaseCoupon"] = _cells([np.array([code], dtype=object) for code in codes])
|
||||||
|
|
||||||
|
raw = [
|
||||||
|
{
|
||||||
|
"currencyCode": world.CURRENCY,
|
||||||
|
PURCHASE_ACTION: {"actionField": _action_field(money, code), **block},
|
||||||
|
}
|
||||||
|
for money, code, block in zip(kopecks, codes, blocks, strict=True)
|
||||||
|
]
|
||||||
|
return _Events(
|
||||||
|
columns=rows,
|
||||||
|
page=np.full(ordered.size, Page.CONFIRMATION, dtype=np.uint8),
|
||||||
|
product=np.full(ordered.size, -1, dtype=np.int64),
|
||||||
|
raw=raw,
|
||||||
|
basket=ordered,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _action_field(kopecks: int, code: str) -> dict[str, Any]:
|
||||||
|
"""Блок `actionField` заказа: номер, выручка и купон, если он был.
|
||||||
|
|
||||||
|
Номер пустой до сборки потока — его присваивает `_seal`, когда порядок
|
||||||
|
событий дня уже известен.
|
||||||
|
"""
|
||||||
|
field: dict[str, Any] = {"id": "", "revenue": kopecks / KOPECKS}
|
||||||
|
if code:
|
||||||
|
field["coupon"] = code
|
||||||
|
return field
|
||||||
|
|
||||||
|
|
||||||
|
def _product_side(
|
||||||
|
goods: catalog.Catalog,
|
||||||
|
baskets: _Baskets,
|
||||||
|
groups: list[NDArray[np.int64]],
|
||||||
|
draws: _Draws,
|
||||||
|
action: str,
|
||||||
|
) -> tuple[dict[str, NDArray[Any]], list[dict[str, Any]]]:
|
||||||
|
"""Массивы `product*` и товарная часть сырого JSON — одним проходом.
|
||||||
|
|
||||||
|
Массивы группы `product*` одной длины между собой: по элементу на товар.
|
||||||
|
С группой `purchase*` они не совпадают и не должны — там по элементу на
|
||||||
|
заказ (мастер-спека, раздел 1).
|
||||||
|
|
||||||
|
Сырой JSON несёт больше, чем колонки: бренд, вариант товара и точную
|
||||||
|
цену с копейками. На этом и стоит лаба «сырое против разобранного» —
|
||||||
|
иначе в сыром лежало бы ровно то же самое.
|
||||||
|
"""
|
||||||
|
columns: dict[str, list[NDArray[Any]]] = {name: [] for name in _PRODUCT_COLUMNS}
|
||||||
|
blocks: list[dict[str, Any]] = []
|
||||||
|
for group in groups:
|
||||||
|
numbers = baskets.product[group]
|
||||||
|
pieces = draws.quantity[group]
|
||||||
|
columns["productID"].append(np.array(goods.sku[numbers], dtype=object))
|
||||||
|
columns["productName"].append(np.array(goods.name[numbers], dtype=object))
|
||||||
|
columns["productCategory"].append(
|
||||||
|
np.array([_category(goods, number) for number in numbers], dtype=object)
|
||||||
|
)
|
||||||
|
# Цена в колонке — целые рубли, как у Метрики: это округление и есть
|
||||||
|
# тот разрыв, из-за которого выручку по массивам не пересобрать.
|
||||||
|
columns["productPrice"].append((goods.price[numbers] + KOPECKS // 2) // KOPECKS)
|
||||||
|
columns["productQuantity"].append(pieces.astype(np.uint64))
|
||||||
|
columns["productEventType"].append(np.full(group.size, action, dtype=object))
|
||||||
|
blocks.append(
|
||||||
|
{
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"id": goods.sku[number],
|
||||||
|
"name": goods.name[number],
|
||||||
|
"category": _category(goods, number),
|
||||||
|
"brand": goods.brand[number],
|
||||||
|
"variant": reference.PRODUCT_VARIANTS[draws.variant[position]],
|
||||||
|
"price": int(goods.price[number]) / KOPECKS,
|
||||||
|
"quantity": int(pieces[place]),
|
||||||
|
}
|
||||||
|
for place, (position, number) in enumerate(
|
||||||
|
zip(group.tolist(), numbers.tolist(), strict=True)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {name: _cells(values) for name, values in columns.items()}, blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _category(goods: catalog.Catalog, number: int) -> str:
|
||||||
|
"""Имя категории товара — то же, что в файле каталога и в словаре."""
|
||||||
|
return catalog.CATEGORIES[goods.category[number]].name
|
||||||
|
|
||||||
|
|
||||||
|
def _on_page(
|
||||||
|
columns: dict[str, NDArray[Any]],
|
||||||
|
anchor: NDArray[np.int64],
|
||||||
|
event_type: str,
|
||||||
|
delay: NDArray[np.int64],
|
||||||
|
) -> dict[str, NDArray[Any]]:
|
||||||
|
"""Событие на странице: её колонки целиком, свой тип и своё время.
|
||||||
|
|
||||||
|
Страница у торгового события та же, что у просмотра, на который оно
|
||||||
|
село: тот же адрес и реферер, та же кука, тот же визит, устройство и
|
||||||
|
гео. Различаются тип, время и торговые колонки — их кладёт вызывающий.
|
||||||
|
"""
|
||||||
|
rows = {name: value[anchor] for name, value in columns.items()}
|
||||||
|
rows["EventType"] = np.full(anchor.size, event_type, dtype=object)
|
||||||
|
rows["UTCEventTime"] = columns["UTCEventTime"][anchor] + delay.astype(
|
||||||
|
"timedelta64[s]"
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _stream(
|
||||||
|
rng: np.random.Generator,
|
||||||
|
columns: dict[str, NDArray[Any]],
|
||||||
|
page: NDArray[np.uint8],
|
||||||
|
product: NDArray[np.int64],
|
||||||
|
events: tuple[_Events, ...],
|
||||||
|
) -> tuple[
|
||||||
|
dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], NDArray[np.int64]
|
||||||
|
]:
|
||||||
|
"""Собирает поток дня целиком: сутки режут хвост, время задаёт порядок.
|
||||||
|
|
||||||
|
Четвёртым рядом уходит корзина каждой строки — у просмотра страницы её
|
||||||
|
нет: по ней покупка находит свою корзину, когда поток уже упорядочен и
|
||||||
|
полночь свой хвост отрезала.
|
||||||
|
"""
|
||||||
|
traffic = page.size
|
||||||
|
trade = {
|
||||||
|
name: np.concatenate([part.columns[name] for part in events])
|
||||||
|
for name in columns
|
||||||
|
}
|
||||||
|
raw = [block for part in events for block in part.raw]
|
||||||
|
|
||||||
|
alive = trade["UTCEventTime"] < _midnight(columns) + np.timedelta64(1, "D")
|
||||||
|
trade = {name: value[alive] for name, value in trade.items()}
|
||||||
|
# Номера торговых строк — из торгового подпотока: возьми их день у
|
||||||
|
# трафика, и правка торгового поведения сдвинула бы трафиковые `WatchID`.
|
||||||
|
trade["WatchID"] = ids.unique_apart_from(rng, int(alive.sum()), columns["WatchID"])
|
||||||
|
raw = [block for block, here in zip(raw, alive.tolist(), strict=True) if here]
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
name: np.concatenate((value, trade[name])) for name, value in columns.items()
|
||||||
|
}
|
||||||
|
page = np.concatenate((page, np.concatenate([part.page for part in events])[alive]))
|
||||||
|
product = np.concatenate(
|
||||||
|
(product, np.concatenate([part.product for part in events])[alive])
|
||||||
|
)
|
||||||
|
# Чей сырой блок в какой строке: у просмотра страницы блока нет.
|
||||||
|
place = np.full(traffic + len(raw), -1, dtype=np.int64)
|
||||||
|
place[traffic:] = np.arange(len(raw))
|
||||||
|
|
||||||
|
basket = np.concatenate(
|
||||||
|
(
|
||||||
|
np.full(traffic, -1, dtype=np.int64),
|
||||||
|
np.concatenate([part.basket for part in events])[alive],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
|
||||||
|
rows = {name: value[order] for name, value in rows.items()}
|
||||||
|
_seal(rows, raw, place[order], _order_prefix(columns))
|
||||||
|
return rows, page[order], product[order], basket[order]
|
||||||
|
|
||||||
|
|
||||||
|
def _seal(
|
||||||
|
rows: dict[str, NDArray[Any]],
|
||||||
|
raw: list[dict[str, Any]],
|
||||||
|
place: NDArray[np.int64],
|
||||||
|
prefix: str,
|
||||||
|
) -> None:
|
||||||
|
"""Раздаёт номера заказов и собирает сырой `ecommerce`.
|
||||||
|
|
||||||
|
Номер получают все покупки, дошедшие до потока, в порядке событий — до
|
||||||
|
всяких потерь: этап 6 выбрасывает событие, когда номер уже присвоен,
|
||||||
|
иначе одна потеря перенумеровала бы чужие заказы и мост к бэкенду
|
||||||
|
разъехался бы. Строку собирает канонический сериализатор: руками это был
|
||||||
|
бы второй сериализатор со своим экранированием.
|
||||||
|
"""
|
||||||
|
number = 0
|
||||||
|
for row in np.flatnonzero(place >= 0).tolist():
|
||||||
|
block = raw[place[row]]
|
||||||
|
if rows["EventType"][row] == PURCHASE:
|
||||||
|
number += 1
|
||||||
|
code = f"{prefix}-{number:04d}"
|
||||||
|
block[PURCHASE_ACTION]["actionField"]["id"] = code
|
||||||
|
rows["purchaseID"][row] = np.array([code], dtype=object)
|
||||||
|
rows["ecommerce"][row] = orjson.dumps(block).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _purchases(
|
||||||
|
rows: dict[str, NDArray[Any]],
|
||||||
|
basket: NDArray[np.int64],
|
||||||
|
baskets: _Baskets,
|
||||||
|
draws: _Draws,
|
||||||
|
revenue: NDArray[np.int64],
|
||||||
|
coupons: NDArray[np.object_],
|
||||||
|
person: NDArray[np.uint64],
|
||||||
|
assigned_order: NDArray[np.bool_],
|
||||||
|
) -> tuple[Purchases, NDArray[np.bool_]]:
|
||||||
|
"""Покупки дня — то же, чем они уехали в события, только структурой.
|
||||||
|
|
||||||
|
Порядок здесь — порядок строк потока, он же порядок номеров заказа:
|
||||||
|
поток уже упорядочен и пронумерован, полночь свой хвост уже отрезала.
|
||||||
|
Человек берётся у строки подтверждения — визит принадлежит одной куке,
|
||||||
|
а кука одному человеку.
|
||||||
|
"""
|
||||||
|
here = np.flatnonzero(rows["EventType"] == PURCHASE)
|
||||||
|
mine = basket[here]
|
||||||
|
bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()]
|
||||||
|
purchases = Purchases(
|
||||||
|
order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()),
|
||||||
|
person_id=person[baskets.confirmation[mine]],
|
||||||
|
product=tuple(baskets.product[group] for group in bought),
|
||||||
|
quantity=tuple(draws.quantity[group] for group in bought),
|
||||||
|
coupon=tuple(coupons[mine].tolist()),
|
||||||
|
revenue=revenue[mine],
|
||||||
|
moment=rows["UTCEventTime"][here],
|
||||||
|
)
|
||||||
|
return purchases, assigned_order[baskets.confirmation[mine]]
|
||||||
|
|
||||||
|
|
||||||
|
def _corrupt_events(
|
||||||
|
seed: int,
|
||||||
|
day: int,
|
||||||
|
rows: dict[str, NDArray[Any]],
|
||||||
|
page: NDArray[np.uint8],
|
||||||
|
product: NDArray[np.int64],
|
||||||
|
purchases: Purchases,
|
||||||
|
assigned: NDArray[np.bool_],
|
||||||
|
) -> tuple[
|
||||||
|
dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], NDArray[np.int8]
|
||||||
|
]:
|
||||||
|
"""Теряет purchase или повторяет пару подтверждения.
|
||||||
|
|
||||||
|
Заказы уже собраны, а номера покупок уже вписаны в строки. Поэтому
|
||||||
|
порча меняет только событийную сторону. Все броски делаются на полную
|
||||||
|
длину покупок дня, чтобы отбор одного класса не сдвигал другой.
|
||||||
|
"""
|
||||||
|
count = len(purchases)
|
||||||
|
if not count:
|
||||||
|
return rows, page, product, _NO_EVENT_OUTCOMES
|
||||||
|
|
||||||
|
rng = day_stream(seed, day, Component.BEACONS)
|
||||||
|
lost = (rng.integers(0, 100, count) < world.LOST_PURCHASE_PERCENT) & ~assigned
|
||||||
|
duplicate_draw = rng.integers(0, 100, count) < world.DUPLICATE_PURCHASE_PERCENT
|
||||||
|
delay = rng.integers(*world.DUPLICATE_DELAY_SECONDS, count)
|
||||||
|
watch = ids.unique_apart_from(rng, 2 * count, rows["WatchID"]).reshape(count, 2)
|
||||||
|
|
||||||
|
purchase_rows = np.flatnonzero(rows["EventType"] == PURCHASE)
|
||||||
|
confirmation_rows = _confirmation_rows(rows, page, purchase_rows)
|
||||||
|
eligible = _duplicate_eligibility(rows, purchase_rows, delay)
|
||||||
|
duplicated = ~lost & duplicate_draw & eligible
|
||||||
|
|
||||||
|
outcome = np.full(count, EventOutcome.DELIVERED, dtype=np.int8)
|
||||||
|
outcome[lost] = EventOutcome.LOST
|
||||||
|
outcome[duplicated] = EventOutcome.DUPLICATED
|
||||||
|
|
||||||
|
keep = np.ones(page.size, dtype=bool)
|
||||||
|
keep[purchase_rows[lost]] = False
|
||||||
|
sources = np.column_stack(
|
||||||
|
(confirmation_rows[duplicated], purchase_rows[duplicated])
|
||||||
|
)
|
||||||
|
selected = np.flatnonzero(duplicated)
|
||||||
|
if selected.size:
|
||||||
|
copies = sources.ravel()
|
||||||
|
shifted = np.repeat(delay[selected], 2).astype("timedelta64[s]")
|
||||||
|
additions = {name: values[copies] for name, values in rows.items()}
|
||||||
|
additions["UTCEventTime"] = additions["UTCEventTime"] + shifted
|
||||||
|
additions["WatchID"] = watch[selected].ravel()
|
||||||
|
# Первая строка пары — обновление confirmation: ее предыдущая
|
||||||
|
# страница — она же, а не checkout из первого просмотра.
|
||||||
|
additions["Referer"] = additions["URL"].copy()
|
||||||
|
rows = {
|
||||||
|
name: np.concatenate((values[keep], additions[name]))
|
||||||
|
for name, values in rows.items()
|
||||||
|
}
|
||||||
|
page = np.concatenate((page[keep], page[copies]))
|
||||||
|
product = np.concatenate((product[keep], product[copies]))
|
||||||
|
else:
|
||||||
|
rows = {name: values[keep] for name, values in rows.items()}
|
||||||
|
page = page[keep]
|
||||||
|
product = product[keep]
|
||||||
|
|
||||||
|
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
|
||||||
|
return (
|
||||||
|
{name: values[order] for name, values in rows.items()},
|
||||||
|
page[order],
|
||||||
|
product[order],
|
||||||
|
outcome,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _confirmation_rows(
|
||||||
|
rows: dict[str, NDArray[Any]],
|
||||||
|
page: NDArray[np.uint8],
|
||||||
|
purchase_rows: NDArray[np.int64],
|
||||||
|
) -> NDArray[np.int64]:
|
||||||
|
"""Находит просмотр, с которым purchase образует пару."""
|
||||||
|
confirmation = (rows["EventType"] == "pageview") & (page == Page.CONFIRMATION)
|
||||||
|
by_visit = {
|
||||||
|
int(rows["VisitID"][row]): row for row in np.flatnonzero(confirmation).tolist()
|
||||||
|
}
|
||||||
|
return np.array(
|
||||||
|
[by_visit[int(rows["VisitID"][row])] for row in purchase_rows.tolist()],
|
||||||
|
dtype=np.int64,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_eligibility(
|
||||||
|
rows: dict[str, NDArray[Any]],
|
||||||
|
purchase_rows: NDArray[np.int64],
|
||||||
|
delay: NDArray[np.int64],
|
||||||
|
) -> NDArray[np.bool_]:
|
||||||
|
"""Проверяет полночь и запас до следующего визита куки."""
|
||||||
|
moment = rows["UTCEventTime"][purchase_rows]
|
||||||
|
shifted = moment + delay.astype("timedelta64[s]")
|
||||||
|
before_midnight = shifted < _midnight(rows) + np.timedelta64(1, "D")
|
||||||
|
eligible = before_midnight.copy()
|
||||||
|
for number, row in enumerate(purchase_rows.tolist()):
|
||||||
|
later_visit = (
|
||||||
|
(rows["ClientID"] == rows["ClientID"][row])
|
||||||
|
& (rows["VisitID"] != rows["VisitID"][row])
|
||||||
|
& (rows["UTCEventTime"] > moment[number])
|
||||||
|
)
|
||||||
|
if np.any(later_visit):
|
||||||
|
next_start = rows["UTCEventTime"][later_visit].min()
|
||||||
|
eligible[number] &= next_start - moment[number] > np.timedelta64(
|
||||||
|
world.VISIT_TIMEOUT_SECONDS + int(delay[number]), "s"
|
||||||
|
)
|
||||||
|
return eligible
|
||||||
|
|
||||||
|
|
||||||
|
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
|
||||||
|
"""Начало модельных суток абсолютной меткой: полночь в поясе счётчика."""
|
||||||
|
date: np.datetime64 = columns["EventDate"][0]
|
||||||
|
return date.astype("datetime64[s]") - np.timedelta64(
|
||||||
|
world.COUNTER_TIMEZONE_MINUTES, "m"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _order_prefix(columns: dict[str, NDArray[Any]]) -> str:
|
||||||
|
"""Первая половина номера заказа: день модельного времени, `20260603`."""
|
||||||
|
return str(columns["EventDate"][0]).replace("-", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _cells(values: list[NDArray[Any]]) -> NDArray[np.object_]:
|
||||||
|
"""Колонка-массив: в каждой ячейке свой массив своего типа."""
|
||||||
|
column = np.empty(len(values), dtype=object)
|
||||||
|
for row, value in enumerate(values):
|
||||||
|
column[row] = value
|
||||||
|
return column
|
||||||
|
|
||||||
|
|
||||||
|
def _same(value: NDArray[Any], size: int) -> NDArray[np.object_]:
|
||||||
|
"""Колонка-массив, у которой во всех ячейках один и тот же массив."""
|
||||||
|
value.flags.writeable = False
|
||||||
|
column = np.empty(size, dtype=object)
|
||||||
|
column.fill(value)
|
||||||
|
return column
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
"""День-функция: (зерно, D) → упорядоченный поток событий модельных суток.
|
||||||
|
|
||||||
|
Здесь трафиковая половина мира: визиты, страницы, атрибуция, устройство и
|
||||||
|
гео. Торговые события садятся на этот же поток и добавляют к нему свои
|
||||||
|
строки — их собирает `commerce` и им же поток заканчивается. У просмотра
|
||||||
|
страницы торговые колонки присутствуют, но пусты по смыслу: «пусто» — всегда
|
||||||
|
пустой массив, пустая строка или 0, а не отсутствие ключа.
|
||||||
|
|
||||||
|
День — чистая функция зерна и номера дня: одна и та же пара даёт те же
|
||||||
|
события, а день N+1 не трогает дни 1…N. Держится это на подпотоке
|
||||||
|
`Component.TRAFFIC` (спека генератора, раздел 2) и на плане состава, который
|
||||||
|
про горизонт ничего не знает. Случайность целочисленная и векторная: считать
|
||||||
|
посточно приходится ровно одно — цепочку страниц визита, где следующий шаг
|
||||||
|
зависит от предыдущего. Посточные проходы есть и кроме неё (адреса,
|
||||||
|
заголовки, IP), но там ничего не решается: numpy не умеет собирать строки, а
|
||||||
|
броски к тому времени уже сделаны — векторно и все разом.
|
||||||
|
|
||||||
|
Модельные сутки считаются в поясе счётчика, как в выгрузке Метрики:
|
||||||
|
`EventDate` — дата в поясе счётчика, `UTCEventTime` — абсолютная метка. У
|
||||||
|
ночных событий гостей из других поясов `toDate(UTCEventTime)` ≠ `EventDate`;
|
||||||
|
сторона хранилища должна знать это заранее, иначе выведет дату сама и
|
||||||
|
разойдётся на несколько часов данных.
|
||||||
|
|
||||||
|
**Правила резки визитов** — те же, по которым лаба сессий собирает визиты
|
||||||
|
сама и сверяет сборку с `VisitID`:
|
||||||
|
|
||||||
|
1. Визит принадлежит одной куке: склейка `ClientID` визитом не считается.
|
||||||
|
2. Пауза дольше 30 минут рвёт визит надвое, поэтому паузы внутри визита
|
||||||
|
всегда короче таймаута, а соседние визиты куки разведены дальше него —
|
||||||
|
считая от последнего события визита, которым бывает покупка, а не от
|
||||||
|
последней его страницы.
|
||||||
|
3. Граница модельных суток режет визит: события за полночь в дне не живут.
|
||||||
|
Исключение одно — визит с заказом, обещанным планом двухкуковых пар: его
|
||||||
|
старт сдвигается назад, чтобы воронка уместилась в сутки вместе с
|
||||||
|
торговым хвостом. Это принятое ограничение модели: обещание плана —
|
||||||
|
гарантия, ради неё мы сужаем свободу старта. Цена названа — около 24
|
||||||
|
визитов в день из ~9,5 тыс. не начинаются в последние минуты суток.
|
||||||
|
|
||||||
|
**Шов с торговыми событиями.** Поток несёт, кроме колонок, три выровненных
|
||||||
|
по строкам ряда: `page` — какая это страница магазина, `product` — какой
|
||||||
|
товар показывала карточка (−1 у прочих страниц), и `person` — человек за
|
||||||
|
кукой этой строки. По первым двум `commerce` знает и то, куда сажать событие
|
||||||
|
(карточка, подтверждение), и то, что посетитель на самом деле смотрел: товар
|
||||||
|
в корзине, которого никто не открывал, — видимая глупость в воронке. Третий
|
||||||
|
в события не попадает вовсе: личность нужна заказу, а кликстрим анонимен.
|
||||||
|
Визит с назначенным заказом всегда доходит до `/confirmation`, а перед
|
||||||
|
корзиной у него всегда есть карточка товара. Случайность у половин разная:
|
||||||
|
трафик берёт подпоток `TRAFFIC`, торговля — `COMMERCE`, заказы — свой, и
|
||||||
|
правка одной не сдвигает другие.
|
||||||
|
|
||||||
|
**Заказы бэкенда** день отдаёт второй половиной: покупки, которые собрала
|
||||||
|
торговая половина, заказная превращает в заказы дня — с личностью
|
||||||
|
покупателя и деньгами магазина (`orders`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from clickstream_generator import catalog, commerce, ids, orders, plan, reference, world
|
||||||
|
from clickstream_generator.reference import Page
|
||||||
|
from clickstream_generator.seeds import Component, day_stream
|
||||||
|
from clickstream_generator.weights import pick, pick_row
|
||||||
|
|
||||||
|
DAY_SECONDS = 24 * 60 * 60
|
||||||
|
|
||||||
|
# Карточка перед корзиной обязательна: положить в корзину то, чего не
|
||||||
|
# открывал, посетитель не может.
|
||||||
|
MIN_PAGES_BEFORE_CART = 2
|
||||||
|
|
||||||
|
# На столько секунд визит длиннее своих страниц: торговое событие встаёт
|
||||||
|
# позже страницы, на которую село, и последним событием визита бывает
|
||||||
|
# покупка, а не просмотр подтверждения. Величина нужна здесь дважды — визит
|
||||||
|
# с обещанным заказом обязан уместиться в сутки вместе с хвостом, а соседние
|
||||||
|
# визиты куки разводятся дальше таймаута тоже от хвоста, а не от страницы.
|
||||||
|
TRADE_TAIL_SECONDS = world.TRADE_DELAY_SECONDS[1]
|
||||||
|
|
||||||
|
_VISIT_COUNT_CUMULATIVE = np.cumsum(world.VISITS_PER_ACTIVE_DAY_WEIGHTS)
|
||||||
|
_VISIT_PAGES_CUMULATIVE = np.cumsum(world.VISIT_PAGES_WEIGHTS)
|
||||||
|
_SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SOURCES])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Day:
|
||||||
|
"""Прожитый день: поток событий трекера и заказы бэкенда.
|
||||||
|
|
||||||
|
Строки упорядочены по времени — так их и проиграет проигрыватель.
|
||||||
|
`columns` — колонки контракта схемы по его порядку, все до одной;
|
||||||
|
`page` и `product` выровнены по тем же строкам (см. шов в докстринге
|
||||||
|
модуля). `orders` — вторая половина дня: заказы, рождённые в нём.
|
||||||
|
|
||||||
|
Половины уезжают разными командами и в разные топики, но считаются
|
||||||
|
вместе: заказ — проекция покупки, и порознь им разойтись негде.
|
||||||
|
"""
|
||||||
|
|
||||||
|
day: int
|
||||||
|
columns: dict[str, NDArray[Any]]
|
||||||
|
page: NDArray[np.uint8]
|
||||||
|
product: NDArray[np.int64]
|
||||||
|
orders: orders.Orders
|
||||||
|
# Событийный исход каждого заказа, в том же порядке.
|
||||||
|
purchase_outcome: NDArray[np.int8]
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return self.page.size
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _Visits:
|
||||||
|
"""Визиты дня рядами: строка — визит, а его страницы лежат подряд.
|
||||||
|
|
||||||
|
Где именно лежат, говорят `first` и `pages`: с какой строки начинается
|
||||||
|
визит и сколько их у него. Всё остальное решено до того, как страницы
|
||||||
|
сложились в цепочку.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cookie: NDArray[np.int64] # номер куки в дневной аудитории
|
||||||
|
ordinal: NDArray[np.int64] # какой это визит куки за день
|
||||||
|
ordering: NDArray[np.bool_] # визит с заказом, обещанным планом
|
||||||
|
source: NDArray[np.int64]
|
||||||
|
category: NDArray[np.int64]
|
||||||
|
stage: NDArray[np.int64] # докуда дошла воронка
|
||||||
|
browse: NDArray[np.int64] # страниц до воронки
|
||||||
|
pages: NDArray[np.int64]
|
||||||
|
first: NDArray[np.int64]
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return self.cookie.size
|
||||||
|
|
||||||
|
|
||||||
|
def stream(seed: int, day: int) -> Day:
|
||||||
|
"""События дня `day` мира `seed`, упорядоченные по времени."""
|
||||||
|
audience = plan.audience(seed, day)
|
||||||
|
rng = day_stream(seed, day, Component.TRAFFIC)
|
||||||
|
|
||||||
|
visits = _visits(rng, audience)
|
||||||
|
page, product = _walk(rng, visits)
|
||||||
|
elapsed, duration = _elapsed(rng, visits)
|
||||||
|
start = _starts(rng, day, audience, visits, duration)
|
||||||
|
|
||||||
|
second = np.repeat(start, visits.pages) + elapsed
|
||||||
|
# Граница суток режет визит: хвост за полночью в этот день не попадает.
|
||||||
|
# Странице подтверждения нужно место и под её покупку: подтверждение без
|
||||||
|
# покупки было бы потерей на клиентской стороне, а она здесь честная —
|
||||||
|
# потери и дубли стенд заводит намеренно и позже (этапы 4 и 6). Поэтому
|
||||||
|
# полночь забирает подтверждение вместе с торговым хвостом или не
|
||||||
|
# забирает ни того, ни другого.
|
||||||
|
room = np.where(page == Page.CONFIRMATION, TRADE_TAIL_SECONDS, 0)
|
||||||
|
alive = second + room < DAY_SECONDS
|
||||||
|
visit_id = np.repeat(ids.unique(rng, len(visits)), visits.pages)
|
||||||
|
watch_id = ids.unique(rng, int(alive.sum()))
|
||||||
|
|
||||||
|
rest = _columns(rng, day, audience, visits, page, product, second)
|
||||||
|
columns = {
|
||||||
|
"WatchID": watch_id,
|
||||||
|
"VisitID": visit_id[alive],
|
||||||
|
**{name: value[alive] for name, value in rest.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
order = np.lexsort((columns["WatchID"], columns["UTCEventTime"]))
|
||||||
|
# Торговые события садятся на готовый трафиковый поток и отдают его
|
||||||
|
# целиком: в нём же они и упорядочиваются. Вторым выходом приходят
|
||||||
|
# покупки дня — из них заказная сторона собирает заказы.
|
||||||
|
person = np.repeat(audience.person_id[visits.cookie], visits.pages)
|
||||||
|
assigned_order = np.repeat(visits.ordering, visits.pages)
|
||||||
|
woven = commerce.weave(
|
||||||
|
seed,
|
||||||
|
day,
|
||||||
|
{name: value[order] for name, value in columns.items()},
|
||||||
|
page[alive][order],
|
||||||
|
product[alive][order],
|
||||||
|
person[alive][order],
|
||||||
|
assigned_order[alive][order],
|
||||||
|
)
|
||||||
|
return Day(
|
||||||
|
day=day,
|
||||||
|
columns=woven.columns,
|
||||||
|
page=woven.page,
|
||||||
|
product=woven.product,
|
||||||
|
orders=orders.of_day(seed, day, woven.purchases, woven.assigned_order),
|
||||||
|
purchase_outcome=woven.event_outcome,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
|
||||||
|
"""Визиты дня: сколько их у каждой куки и что в каждом.
|
||||||
|
|
||||||
|
Всё, кроме цепочки страниц: откуда пришли, за какой категорией, докуда
|
||||||
|
дойдут по воронке и сколько страниц на это уйдёт.
|
||||||
|
"""
|
||||||
|
counts = 1 + pick(rng, _VISIT_COUNT_CUMULATIVE, audience.client_id.size)
|
||||||
|
cookie = np.repeat(np.arange(counts.size, dtype=np.int64), counts)
|
||||||
|
ordinal = np.arange(cookie.size) - np.repeat(np.cumsum(counts) - counts, counts)
|
||||||
|
visits = cookie.size
|
||||||
|
|
||||||
|
source = pick(rng, _SOURCE_CUMULATIVE, visits)
|
||||||
|
category = rng.integers(0, len(catalog.CATEGORIES), visits)
|
||||||
|
length = 1 + pick(rng, _VISIT_PAGES_CUMULATIVE, visits)
|
||||||
|
# Обещанный планом заказ достаётся первому визиту дня: слева от него
|
||||||
|
# соседей нет, поэтому двигать его внутри суток можно свободно.
|
||||||
|
ordering = audience.assigned_order[cookie] & (ordinal == 0)
|
||||||
|
stage = _funnel(rng, audience.buyer[cookie], ordering)
|
||||||
|
# Воронка удлиняет визит, а не съедает его: до корзины надо ещё дойти.
|
||||||
|
browse = np.where(
|
||||||
|
stage > 0, np.maximum(length - stage, MIN_PAGES_BEFORE_CART), length
|
||||||
|
)
|
||||||
|
pages = browse + stage
|
||||||
|
return _Visits(
|
||||||
|
cookie=cookie,
|
||||||
|
ordinal=ordinal,
|
||||||
|
ordering=ordering,
|
||||||
|
source=source,
|
||||||
|
category=category,
|
||||||
|
stage=stage,
|
||||||
|
browse=browse,
|
||||||
|
pages=pages,
|
||||||
|
first=np.cumsum(pages) - pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _funnel(
|
||||||
|
rng: np.random.Generator,
|
||||||
|
buyer: NDArray[np.bool_],
|
||||||
|
ordering: NDArray[np.bool_],
|
||||||
|
) -> NDArray[np.int64]:
|
||||||
|
"""Докуда дошёл визит: 0 — до корзины не дошёл, 3 — до подтверждения.
|
||||||
|
|
||||||
|
Помеченный планом покупатель отличается на обоих шагах: и до корзины
|
||||||
|
доходит чаще, и бросает её реже. Склонность покупать — свойство
|
||||||
|
человека, а не визита, поэтому одинаковый для всех бросок оставил бы
|
||||||
|
метку плана словом без следа в данных (спека генератора, раздел 9).
|
||||||
|
"""
|
||||||
|
draw = rng.integers(0, 100, (3, buyer.size))
|
||||||
|
cart = draw[0] < np.where(buyer, world.BUYER_CART_PERCENT, world.CART_PERCENT)
|
||||||
|
checkout = cart & (
|
||||||
|
draw[1]
|
||||||
|
< np.where(
|
||||||
|
buyer,
|
||||||
|
world.BUYER_CHECKOUT_OF_CART_PERCENT,
|
||||||
|
world.CHECKOUT_OF_CART_PERCENT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
confirmation = checkout & (draw[2] < world.CONFIRMATION_OF_CHECKOUT_PERCENT)
|
||||||
|
|
||||||
|
stage = cart.astype(np.int64) + checkout + confirmation
|
||||||
|
# Заказ, обещанный планом, воронку проходит целиком: гарантия пар стоит
|
||||||
|
# на том, что событие покупки в этот день случится: его повесит `commerce`.
|
||||||
|
stage[ordering] = len(reference.FUNNEL_PAGES)
|
||||||
|
return stage
|
||||||
|
|
||||||
|
|
||||||
|
def _walk(
|
||||||
|
rng: np.random.Generator, visits: _Visits
|
||||||
|
) -> tuple[NDArray[np.uint8], NDArray[np.int64]]:
|
||||||
|
"""Страницы каждого визита и товар их карточек.
|
||||||
|
|
||||||
|
Единственное место дня, где посточен сам расчёт: следующая страница
|
||||||
|
зависит от предыдущей, векторно такую цепочку не сложить. Броски
|
||||||
|
заготовлены заранее и целиком — в цикле остаётся ходить по таблицам.
|
||||||
|
"""
|
||||||
|
total = int(visits.pages.sum())
|
||||||
|
page = np.empty(total, dtype=np.uint8)
|
||||||
|
page[visits.first] = _ENTRY_PAGE[visits.source, rng.integers(0, 100, len(visits))]
|
||||||
|
step = rng.integers(0, 100, total)
|
||||||
|
funnel = np.array(reference.FUNNEL_PAGES, dtype=np.uint8)
|
||||||
|
|
||||||
|
for visit in range(len(visits)):
|
||||||
|
begin, browsed = int(visits.first[visit]), int(visits.browse[visit])
|
||||||
|
for row in range(begin + 1, begin + browsed):
|
||||||
|
previous = page[row - 1]
|
||||||
|
table = _NEXT_FROM_PRODUCT if previous == Page.PRODUCT else _NEXT_FROM_LIST
|
||||||
|
page[row] = table[step[row]]
|
||||||
|
stage = int(visits.stage[visit])
|
||||||
|
if stage:
|
||||||
|
# В корзину — только с карточки: иначе в ней окажется товар,
|
||||||
|
# которого посетитель не открывал.
|
||||||
|
page[begin + browsed - 1] = Page.PRODUCT
|
||||||
|
page[begin + browsed : begin + browsed + stage] = funnel[:stage]
|
||||||
|
|
||||||
|
goods = catalog.catalog()
|
||||||
|
shown = page == Page.PRODUCT
|
||||||
|
row_category = np.repeat(visits.category, visits.pages)[shown]
|
||||||
|
# Товар — равномерно внутри категории визита: карточки всех товаров
|
||||||
|
# открывают одинаково часто, а различает их уровень спроса — уже в
|
||||||
|
# корзине, а не в показе (см. `catalog`).
|
||||||
|
inside = rng.integers(0, goods.count[row_category])
|
||||||
|
product = np.full(total, -1, dtype=np.int64)
|
||||||
|
product[shown] = goods.grouped[goods.first[row_category] + inside]
|
||||||
|
return page, product
|
||||||
|
|
||||||
|
|
||||||
|
def _elapsed(
|
||||||
|
rng: np.random.Generator, visits: _Visits
|
||||||
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
||||||
|
"""Секунды каждой страницы от начала своего визита и длина визитов."""
|
||||||
|
total = int(visits.pages.sum())
|
||||||
|
short = rng.integers(*world.PAGE_PAUSE_SECONDS, total)
|
||||||
|
long = rng.integers(*world.LONG_PAUSE_SECONDS, total)
|
||||||
|
thinking = rng.integers(0, 100, total) < world.LONG_PAUSE_PERCENT
|
||||||
|
pause = np.where(thinking, long, short)
|
||||||
|
|
||||||
|
pause[visits.first] = 0
|
||||||
|
elapsed = np.cumsum(pause)
|
||||||
|
elapsed -= np.repeat(elapsed[visits.first], visits.pages)
|
||||||
|
return elapsed, elapsed[visits.first + visits.pages - 1]
|
||||||
|
|
||||||
|
|
||||||
|
def _starts(
|
||||||
|
rng: np.random.Generator,
|
||||||
|
day: int,
|
||||||
|
audience: plan.DayAudience,
|
||||||
|
visits: _Visits,
|
||||||
|
duration: NDArray[np.int64],
|
||||||
|
) -> NDArray[np.int64]:
|
||||||
|
"""Секунда начала каждого визита внутри модельных суток.
|
||||||
|
|
||||||
|
Волна задана в местном времени посетителя, а сутки считаются в поясе
|
||||||
|
счётчика: гостю из другого пояса профиль поворачивается на разницу.
|
||||||
|
Пик от этого слегка размазывается — как в жизни.
|
||||||
|
"""
|
||||||
|
hour = pick_row(rng, _hour_cumulative(day), audience.city[visits.cookie])
|
||||||
|
start = hour * 3600 + rng.integers(0, 3600, hour.size)
|
||||||
|
# Визит с обещанным заказом обязан уместиться в сутки целиком — вместе с
|
||||||
|
# торговым хвостом: событие покупки встаёт на секунды позже страницы
|
||||||
|
# подтверждения, и зажимать его к последней секунде значило бы ломать
|
||||||
|
# правило ровно там, ради чего оно написано.
|
||||||
|
fits = np.minimum(start, DAY_SECONDS - duration - TRADE_TAIL_SECONDS - 1)
|
||||||
|
start = np.where(visits.ordering, fits, start)
|
||||||
|
|
||||||
|
# Визиты куки идут по возрастанию времени и разведены дальше таймаута —
|
||||||
|
# иначе лаба склеила бы два визита в один и разошлась бы с `VisitID`.
|
||||||
|
# Разводятся они от последнего события визита, а им бывает покупка:
|
||||||
|
# считать от последней страницы значило бы отдать таймауту торговый хвост.
|
||||||
|
start = start[np.lexsort((start, visits.cookie))]
|
||||||
|
for repeat in range(1, len(world.VISITS_PER_ACTIVE_DAY_WEIGHTS)):
|
||||||
|
later = np.flatnonzero(visits.ordinal == repeat)
|
||||||
|
earlier = later - 1
|
||||||
|
start[later] = np.maximum(
|
||||||
|
start[later],
|
||||||
|
start[earlier]
|
||||||
|
+ duration[earlier]
|
||||||
|
+ TRADE_TAIL_SECONDS
|
||||||
|
+ world.VISIT_TIMEOUT_SECONDS
|
||||||
|
+ 1,
|
||||||
|
)
|
||||||
|
return start
|
||||||
|
|
||||||
|
|
||||||
|
def _columns(
|
||||||
|
rng: np.random.Generator,
|
||||||
|
day: int,
|
||||||
|
audience: plan.DayAudience,
|
||||||
|
visits: _Visits,
|
||||||
|
page: NDArray[np.uint8],
|
||||||
|
product: NDArray[np.int64],
|
||||||
|
second: NDArray[np.int64],
|
||||||
|
) -> dict[str, NDArray[Any]]:
|
||||||
|
"""Колонки выгрузки по строкам-страницам — все, что решил контракт схемы.
|
||||||
|
|
||||||
|
Торговые колонки заполнены пустотой своего типа: пустым массивом и
|
||||||
|
пустой строкой. Ключ есть всегда — потребитель не должен гадать, была
|
||||||
|
колонка или её забыли.
|
||||||
|
"""
|
||||||
|
total = page.size
|
||||||
|
sources = reference.TRAFFIC_SOURCES
|
||||||
|
source, pages, first = visits.source, visits.pages, visits.first
|
||||||
|
device = audience.device[visits.cookie]
|
||||||
|
city = audience.city[visits.cookie]
|
||||||
|
|
||||||
|
url, title = _addresses(rng, visits, page, product)
|
||||||
|
# Метки перехода живут и в адресе входа, как в жизни: разбор такого
|
||||||
|
# адреса — материал лабы, а колонки UTM рядом дают ей эталон.
|
||||||
|
url[first] = [
|
||||||
|
f"{address}{'&' if '?' in address else '?'}{query}" if query else address
|
||||||
|
for address, query in zip(url[first], _UTM_QUERY[source], strict=True)
|
||||||
|
]
|
||||||
|
referer = np.empty(total, dtype=object)
|
||||||
|
referer[1:], referer[0] = url[:-1], ""
|
||||||
|
referer[first] = _of(sources, "referer")[source]
|
||||||
|
|
||||||
|
def by_visit(values: NDArray[Any]) -> NDArray[Any]:
|
||||||
|
return np.repeat(values, pages)
|
||||||
|
|
||||||
|
def by_source(field: str, dtype: Any = object) -> NDArray[Any]:
|
||||||
|
return by_visit(_of(sources, field, dtype)[source])
|
||||||
|
|
||||||
|
def by_device(field: str, dtype: Any = object) -> NDArray[Any]:
|
||||||
|
return by_visit(_of(reference.DEVICE_PROFILES, field, dtype)[device])
|
||||||
|
|
||||||
|
def by_city(field: str, dtype: Any = object) -> NDArray[Any]:
|
||||||
|
return by_visit(_of(reference.CITIES, field, dtype)[city])
|
||||||
|
|
||||||
|
# Дата дня — в поясе счётчика, а полночь того же дня — метка UTC, от
|
||||||
|
# которой отсчитываются секунды: между ними ровно смещение пояса.
|
||||||
|
date = np.datetime64(world.ORIGIN, "D") + np.timedelta64(day, "D")
|
||||||
|
midnight = np.datetime64(world.ORIGIN, "s") + np.timedelta64(day, "D")
|
||||||
|
away = np.timedelta64(world.COUNTER_TIMEZONE_MINUTES, "m")
|
||||||
|
yclid = np.where(
|
||||||
|
_of(sources, "has_yclid", bool)[source],
|
||||||
|
rng.integers(1, YCLID_LIMIT, source.size, dtype=np.uint64),
|
||||||
|
0,
|
||||||
|
).astype(np.uint64)
|
||||||
|
return {
|
||||||
|
"ClientID": by_visit(audience.client_id[visits.cookie]),
|
||||||
|
"CounterID": np.full(total, world.COUNTER_ID, dtype=np.uint32),
|
||||||
|
"EventDate": np.full(total, date, dtype="datetime64[D]"),
|
||||||
|
"UTCEventTime": midnight - away + second.astype("timedelta64[s]"),
|
||||||
|
"ClientTimeZone": by_city("timezone_minutes", np.int16),
|
||||||
|
"EventType": np.full(total, "pageview", dtype=object),
|
||||||
|
"Sign": np.ones(total, dtype=np.int8),
|
||||||
|
"URL": url,
|
||||||
|
"Referer": referer,
|
||||||
|
"Title": title,
|
||||||
|
"UTMSource": by_source("utm_source"),
|
||||||
|
"UTMMedium": by_source("utm_medium"),
|
||||||
|
"UTMCampaign": by_source("utm_campaign"),
|
||||||
|
"UTMContent": by_source("utm_content"),
|
||||||
|
"UTMTerm": by_source("utm_term"),
|
||||||
|
"LastTrafficSource": by_source("last_traffic_source"),
|
||||||
|
"HasGCLID": by_source("has_gclid", np.uint8),
|
||||||
|
"YCLID": by_visit(yclid),
|
||||||
|
"Browser": by_device("browser"),
|
||||||
|
"BrowserMajorVersion": by_device("browser_major_version", np.uint16),
|
||||||
|
"BrowserLanguage": by_device("language"),
|
||||||
|
"OperatingSystem": by_device("operating_system"),
|
||||||
|
"OperatingSystemRoot": by_device("operating_system_root"),
|
||||||
|
"DeviceCategory": by_device("category", np.uint8),
|
||||||
|
"MobilePhoneModel": by_device("phone_model"),
|
||||||
|
"ScreenWidth": by_device("screen_width", np.uint16),
|
||||||
|
"ScreenHeight": by_device("screen_height", np.uint16),
|
||||||
|
"IPAddress": by_visit(_ip_addresses(rng, city, device)),
|
||||||
|
"RegionCountry": np.full(total, reference.COUNTRY_NAME, dtype=object),
|
||||||
|
"RegionCity": by_city("name"),
|
||||||
|
"RegionCountryID": np.full(total, reference.COUNTRY_REGION_ID, dtype=np.uint32),
|
||||||
|
"RegionCityID": by_city("region_id", np.uint32),
|
||||||
|
# Цели дублируют торговые события, поэтому их ставит `commerce`:
|
||||||
|
# у просмотра страницы достигнутых целей нет.
|
||||||
|
"GoalsReached": _blank(total, "uint32"),
|
||||||
|
# Своих параметров сайт стенда пока не шлёт: вариант A/B-теста был бы
|
||||||
|
# постоянной куки, а не поведением дня. Решение отложено, не забыто:
|
||||||
|
# колонку заполнит #47.
|
||||||
|
"ParsedParamsKey1": _blank(total, object),
|
||||||
|
"purchaseID": _blank(total, object),
|
||||||
|
"purchaseRevenue": _blank(total, "float64"),
|
||||||
|
"purchaseCurrency": _blank(total, object),
|
||||||
|
"purchaseCoupon": _blank(total, object),
|
||||||
|
"productID": _blank(total, object),
|
||||||
|
"productName": _blank(total, object),
|
||||||
|
"productCategory": _blank(total, object),
|
||||||
|
"productPrice": _blank(total, "int64"),
|
||||||
|
"productQuantity": _blank(total, "uint64"),
|
||||||
|
"productEventType": _blank(total, object),
|
||||||
|
"ecommerce": np.full(total, "", dtype=object),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _addresses(
|
||||||
|
rng: np.random.Generator,
|
||||||
|
visits: _Visits,
|
||||||
|
page: NDArray[np.uint8],
|
||||||
|
product: NDArray[np.int64],
|
||||||
|
) -> tuple[NDArray[np.object_], NDArray[np.object_]]:
|
||||||
|
"""Адрес и заголовок каждой страницы; у входа в адресе живут метки UTM."""
|
||||||
|
total = page.size
|
||||||
|
goods = catalog.catalog()
|
||||||
|
row_category = np.repeat(visits.category, visits.pages)
|
||||||
|
query = rng.integers(0, len(reference.SEARCH_QUERIES), total)
|
||||||
|
static = rng.integers(0, len(reference.STATIC_PAGES), total)
|
||||||
|
|
||||||
|
url: list[str] = []
|
||||||
|
title: list[str] = []
|
||||||
|
for row in range(total):
|
||||||
|
kind = page[row]
|
||||||
|
if kind == Page.PRODUCT:
|
||||||
|
number = product[row]
|
||||||
|
path, heading = f"/product/{goods.sku[number]}", goods.name[number]
|
||||||
|
elif kind == Page.CATALOG:
|
||||||
|
group = catalog.CATEGORIES[row_category[row]]
|
||||||
|
path, heading = f"/catalog/{group.slug}", group.name
|
||||||
|
elif kind == Page.SEARCH:
|
||||||
|
text = reference.SEARCH_QUERIES[query[row]]
|
||||||
|
path, heading = f"/search?text={text}", "Поиск по магазину"
|
||||||
|
elif kind == Page.STATIC:
|
||||||
|
path, heading = reference.STATIC_PAGES[static[row]]
|
||||||
|
else:
|
||||||
|
path, heading = _FIXED_PAGES[kind]
|
||||||
|
url.append(reference.SITE_URL + path)
|
||||||
|
title.append(f"{heading} — {reference.SITE_NAME}")
|
||||||
|
return np.array(url, dtype=object), np.array(title, dtype=object)
|
||||||
|
|
||||||
|
|
||||||
|
def _ip_addresses(
|
||||||
|
rng: np.random.Generator, city: NDArray[np.int64], device: NDArray[np.int64]
|
||||||
|
) -> NDArray[np.object_]:
|
||||||
|
"""Адрес визита: городской ломоть, а у телефонов — CGNAT оператора."""
|
||||||
|
count = city.size
|
||||||
|
prefix = _of(reference.CITIES, "ip_prefix")[city]
|
||||||
|
category = _of(reference.DEVICE_PROFILES, "category", np.int64)[device]
|
||||||
|
phone = category == reference.PHONE_CATEGORY
|
||||||
|
operator = rng.integers(*reference.MOBILE_IP_SECOND_BYTE, count)
|
||||||
|
block = rng.integers(0, 256, count)
|
||||||
|
host = rng.integers(1, 255, count)
|
||||||
|
|
||||||
|
first_byte = reference.MOBILE_IP_FIRST_BYTE
|
||||||
|
return np.array(
|
||||||
|
[
|
||||||
|
f"{first_byte}.{operator[visit]}.{block[visit]}.{host[visit]}"
|
||||||
|
if phone[visit]
|
||||||
|
else f"{prefix[visit]}{host[visit]}"
|
||||||
|
for visit in range(count)
|
||||||
|
],
|
||||||
|
dtype=object,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hour_cumulative(day: int) -> NDArray[np.int64]:
|
||||||
|
"""Веса часов суток по городам, накопленные: строка города — его волна."""
|
||||||
|
# Суббота и воскресенье — последние два дня недели, а D0 — понедельник.
|
||||||
|
weekend = day % 7 >= 5
|
||||||
|
profile = np.array(
|
||||||
|
world.WEEKEND_HOURS_PERCENT if weekend else world.WEEKDAY_HOURS_PERCENT
|
||||||
|
)
|
||||||
|
shifts = [
|
||||||
|
(city.timezone_minutes - world.COUNTER_TIMEZONE_MINUTES) // 60
|
||||||
|
for city in reference.CITIES
|
||||||
|
]
|
||||||
|
return np.cumsum([np.roll(profile, -shift) for shift in shifts], axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _of(table: tuple[Any, ...], field: str, dtype: Any = object) -> NDArray[Any]:
|
||||||
|
"""Колонка справочника массивом: строка выбирается своим номером."""
|
||||||
|
return np.array([getattr(row, field) for row in table], dtype=dtype)
|
||||||
|
|
||||||
|
|
||||||
|
def _blank(size: int, dtype: Any) -> NDArray[np.object_]:
|
||||||
|
"""Колонка-массив, пустая по смыслу: у каждой строки пустой массив."""
|
||||||
|
empty = np.array([], dtype=dtype)
|
||||||
|
empty.flags.writeable = False
|
||||||
|
column = np.empty(size, dtype=object)
|
||||||
|
column.fill(empty)
|
||||||
|
return column
|
||||||
|
|
||||||
|
|
||||||
|
def _hundred(percent: tuple[int, ...]) -> NDArray[np.uint8]:
|
||||||
|
"""Сотня ячеек «бросок 0…99 → страница»: выбор одним обращением."""
|
||||||
|
return np.repeat(np.array(reference.BROWSE_PAGES, dtype=np.uint8), percent)
|
||||||
|
|
||||||
|
|
||||||
|
def _utm_query(source: reference.TrafficSource) -> str:
|
||||||
|
"""Метки перехода строкой запроса; у источника без меток — пустая."""
|
||||||
|
marks = ((field, getattr(source, f"utm_{field}")) for field in _UTM_FIELDS)
|
||||||
|
return "&".join(f"utm_{field}={value}" for field, value in marks if value)
|
||||||
|
|
||||||
|
|
||||||
|
_UTM_FIELDS = ("source", "medium", "campaign", "content", "term")
|
||||||
|
_UTM_QUERY = np.array(
|
||||||
|
[_utm_query(source) for source in reference.TRAFFIC_SOURCES], dtype=object
|
||||||
|
)
|
||||||
|
|
||||||
|
_FIXED_PAGES = {
|
||||||
|
Page.HOME: ("/", "Интернет-магазин товаров для дома"),
|
||||||
|
Page.CART: ("/cart", "Корзина"),
|
||||||
|
Page.CHECKOUT: ("/checkout", "Оформление заказа"),
|
||||||
|
Page.CONFIRMATION: ("/confirmation", "Заказ оформлен"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_ENTRY_PAGE = np.array(
|
||||||
|
[_hundred(source.entry_percent) for source in reference.TRAFFIC_SOURCES]
|
||||||
|
)
|
||||||
|
_NEXT_FROM_LIST = _hundred(reference.FROM_LISTING_PERCENT)
|
||||||
|
_NEXT_FROM_PRODUCT = _hundred(reference.FROM_PRODUCT_PERCENT)
|
||||||
|
|
||||||
|
# Потолок id клика Директа: тринадцать цифр, как у настоящих меток.
|
||||||
|
YCLID_LIMIT = 10**13
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Короткий проход по цепочке генератора на каноническом дне D0."""
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from clickstream_generator import day, plan, seeds, world
|
||||||
|
|
||||||
|
DAY = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: int) -> str:
|
||||||
|
"""Разделяет разряды числа для чтения с экрана."""
|
||||||
|
return f"{value:,}".replace(",", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def render() -> str:
|
||||||
|
"""Собирает D0 и показывает его стадии без числового эталона."""
|
||||||
|
seed = seeds.CANONICAL_SEED
|
||||||
|
cohort = plan.cohort(seed, DAY)
|
||||||
|
audience = plan.audience(seed, DAY)
|
||||||
|
events = day.stream(seed, DAY)
|
||||||
|
|
||||||
|
_, rows_per_visit = np.unique(events.columns["VisitID"], return_counts=True)
|
||||||
|
event_types = Counter(str(value) for value in events.columns["EventType"])
|
||||||
|
smallest_visit = int(rows_per_visit.min())
|
||||||
|
largest_visit = int(rows_per_visit.max())
|
||||||
|
|
||||||
|
return "\n".join(
|
||||||
|
(
|
||||||
|
f"Зерно: {seed}",
|
||||||
|
"Подпотоки D0: состав мира → когорта; день → трафик, торговля, заказы",
|
||||||
|
(
|
||||||
|
f"Когорта D0: людей — {_number(cohort.people)}; "
|
||||||
|
f"кук — {_number(len(cohort.client_id))}"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"Аудитория D0: кук — {_number(len(audience.client_id))}; "
|
||||||
|
f"когорты D-{world.RETURN_TAIL_DAYS}...D0"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"Визиты по VisitID: групп — {_number(len(rows_per_visit))}; "
|
||||||
|
f"строк в группе — {smallest_visit}–{largest_visit}"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"Строки событий: {_number(len(events))} "
|
||||||
|
f"(pageview — {_number(event_types['pageview'])}; "
|
||||||
|
f"add_to_cart — {_number(event_types['add_to_cart'])}; "
|
||||||
|
f"purchase — {_number(event_types['purchase'])}); "
|
||||||
|
f"заказов — {_number(len(events.orders))}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Печатает проход по D0."""
|
||||||
|
print(render())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Идентификаторы событий: неповторяющиеся числа, которые переживут JSON.
|
||||||
|
|
||||||
|
Уникальность обещана не для красоты: `WatchID` — ключ дедупликации при
|
||||||
|
переигровке дня (спека генератора, раздел 4), и два одинаковых номера
|
||||||
|
склеили бы разные события. Строк дня две породы — трафиковые и торговые, —
|
||||||
|
и рисует их разная случайность, поэтому обещание держится здесь, в одном
|
||||||
|
месте на весь генератор: у приёма одно определение, иначе дисциплина живёт
|
||||||
|
копиями и расходится с ними.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
# Потолок идентификаторов: выше 2^53 числа в JSON (jq, консоль браузера)
|
||||||
|
# округляются при разборе, и id перестаёт быть собой. Настоящая Метрика так
|
||||||
|
# не делает — её id длиннее (мастер-спека, раздел 1.1).
|
||||||
|
LIMIT = 2**53
|
||||||
|
|
||||||
|
|
||||||
|
def unique(rng: np.random.Generator, size: int) -> NDArray[np.uint64]:
|
||||||
|
"""Неповторяющиеся id ниже 2^53, разбросанные по диапазону.
|
||||||
|
|
||||||
|
Не броски наугад, а шаги случайной длины — они не повторяются по
|
||||||
|
построению, — и потом перемешивание, чтобы номер не выдавал порядок
|
||||||
|
строк. Средний шаг — весь диапазон, делённый на число событий, поэтому в
|
||||||
|
среднем ряд занимает его половину.
|
||||||
|
"""
|
||||||
|
step = LIMIT // (size + 1)
|
||||||
|
numbers = np.cumsum(rng.integers(1, step + 1, size, dtype=np.uint64))
|
||||||
|
return numbers[np.argsort(rng.integers(0, size * size + 1, size), kind="stable")]
|
||||||
|
|
||||||
|
|
||||||
|
def unique_apart_from(
|
||||||
|
rng: np.random.Generator, size: int, taken: NDArray[np.uint64]
|
||||||
|
) -> NDArray[np.uint64]:
|
||||||
|
"""То же, но и с занятыми номерами ряд не пересекается.
|
||||||
|
|
||||||
|
Торговые события берут случайность из своего подпотока — иначе правка
|
||||||
|
торгового поведения сдвинула бы трафик, — а значит, про уже розданные
|
||||||
|
трафиковые номера их ряд ничего не знает. Совпадение двух рядов на
|
||||||
|
диапазоне в 2^53 невероятно, но обещание уникальности держит дедуп, и
|
||||||
|
проверить его дешевле, чем предположить.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
numbers = unique(rng, size)
|
||||||
|
if not np.intersect1d(numbers, taken).size:
|
||||||
|
return numbers
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Опись мира: чем стенд наполняется при подъёме и каким это обязано выйти.
|
||||||
|
|
||||||
|
Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не
|
||||||
|
он сам, а опись: паспорт мира, число событий и хеш байтов каждого дня, счётчики
|
||||||
|
классов расхождений по заказам. Сам мир пересчитывается когда угодно, а опись
|
||||||
|
отвечает на единственный вопрос — **тот ли это мир, что был вчера**. Разошлись
|
||||||
|
хеши — мир уехал, и дальше уже неважно, чего от него ждали проверки.
|
||||||
|
|
||||||
|
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
|
||||||
|
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
|
||||||
|
Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 —
|
||||||
|
у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот
|
||||||
|
же файл, а не заведет второй.
|
||||||
|
|
||||||
|
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
|
||||||
|
это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир
|
||||||
|
сдвинется, — версия останется прежней, а хеши покраснеют; наоборот не бывает.
|
||||||
|
|
||||||
|
Хеш каталога стоит здесь по третьему основанию — ни сторожить, ни описывать, а
|
||||||
|
**объяснять**. Правка цены в `data/catalog/products.csv` меняет мир так же
|
||||||
|
молча, как правка кода, и по одним хешам эти два случая неразличимы. С хешем
|
||||||
|
каталога различимы: разошлись хеши дней и каталога — правили CSV; разошлись
|
||||||
|
только дни — правили код.
|
||||||
|
|
||||||
|
Хеш дня — sha256 тех самых байтов, что уезжают в Kafka, с переводом строки
|
||||||
|
после каждого события. Это ровно то, что пишет файловый приёмник, поэтому
|
||||||
|
пересчитывается он и обычным `sha256sum` по сыгранному в файл дню (как
|
||||||
|
именно — в README репозитория).
|
||||||
|
|
||||||
|
Слепок заказов — второй артефакт мира, и хешами дней он не покрыт ни при каком
|
||||||
|
раскладе подпотоков, поэтому у каждого отправленного слепка своя строка. Их на
|
||||||
|
один меньше, чем дней: прогон дня 0 отправлять ещё нечего. Счёта строк у
|
||||||
|
слепка нет — опись описывает мир, а не доставку.
|
||||||
|
|
||||||
|
Собирается опись из каталога генератора целью `make inventory`, а свежесть её
|
||||||
|
сторожит тест — как и у «описания выгрузки».
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
from importlib.metadata import version
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from clickstream_generator import commerce, serialize, world
|
||||||
|
from clickstream_generator import day as day_module
|
||||||
|
from clickstream_generator import orders as orders_module
|
||||||
|
from clickstream_generator.catalog import CATALOG_PATH
|
||||||
|
from clickstream_generator.seeds import CANONICAL_SEED
|
||||||
|
|
||||||
|
# Сколько дней оси описывает стартовый мир. То же число Airflow получает из
|
||||||
|
# compose.yaml: YAML не читает Python, и одно из двух мест лишнее по построению.
|
||||||
|
# Расхождение поймают счётчики make check-clickhouse.
|
||||||
|
STARTING_DAYS = 8
|
||||||
|
|
||||||
|
|
||||||
|
def build() -> dict[str, Any]:
|
||||||
|
"""Опись целиком: паспорт мира, строка на день и строка на слепок.
|
||||||
|
|
||||||
|
Дни играются по одному и отпускаются: заказы дня остаются, потому что из
|
||||||
|
них собираются слепки, а полсотни тысяч событий восьми дней сразу в память
|
||||||
|
не нужны.
|
||||||
|
"""
|
||||||
|
days = []
|
||||||
|
orders = []
|
||||||
|
for number in range(STARTING_DAYS):
|
||||||
|
today = day_module.stream(CANONICAL_SEED, number)
|
||||||
|
row = _day(number, today)
|
||||||
|
if number < STARTING_DAYS - world.ORDER_WINDOW_DAYS:
|
||||||
|
row["orders"] = _order_class_counts(today)
|
||||||
|
days.append(row)
|
||||||
|
orders.append(today.orders)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"seed": CANONICAL_SEED,
|
||||||
|
"generator_version": version("clickstream-generator"),
|
||||||
|
"catalog_sha256": _digest(CATALOG_PATH.read_bytes()),
|
||||||
|
"days": days,
|
||||||
|
"snapshots": [_snapshot(number, orders) for number in range(STARTING_DAYS - 1)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render() -> str:
|
||||||
|
"""Опись текстом файла: отступы в два пробела, кириллица как есть."""
|
||||||
|
return json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _day(number: int, today: day_module.Day) -> dict[str, Any]:
|
||||||
|
"""Строка описи: номер дня, его дата, число событий и хеш байтов.
|
||||||
|
|
||||||
|
Дата считается от D0 арифметикой, а не берётся из событий: ось модельного
|
||||||
|
времени так и определена (`world.ORIGIN`), и по этой же дате счётчики
|
||||||
|
стенда обрамляют счёт в `ods.event`. Соври она — подневная сверка это и
|
||||||
|
покажет, каждый день сразу.
|
||||||
|
"""
|
||||||
|
payloads = serialize.events(today)
|
||||||
|
return {
|
||||||
|
"day": number,
|
||||||
|
"date": _date(number),
|
||||||
|
"events": len(payloads),
|
||||||
|
"sha256": _digest(b"".join(payload + b"\n" for payload in payloads)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]:
|
||||||
|
"""Строка описи слепка: какой день снят, его дата и хеш отправленных байтов.
|
||||||
|
|
||||||
|
Байты те же, что уезжают в топик `orders`, с переводом строки после
|
||||||
|
каждого заказа: пересъёмка слепка обязана дать их снова.
|
||||||
|
"""
|
||||||
|
window = [orders[born] for born in orders_module.window(number)]
|
||||||
|
payloads = serialize.orders(window, number)
|
||||||
|
return {
|
||||||
|
"day": number,
|
||||||
|
"date": _date(number),
|
||||||
|
"sha256": _digest(b"".join(payload + b"\n" for payload in payloads)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _order_class_counts(today: day_module.Day) -> dict[str, int]:
|
||||||
|
"""Заказы дня по итоговому классу по приоритету спеки."""
|
||||||
|
purchase = today.columns["EventType"] == commerce.PURCHASE
|
||||||
|
declared = {
|
||||||
|
purchase_id[0]: round(revenue[0] * commerce.KOPECKS)
|
||||||
|
for purchase_id, revenue in zip(
|
||||||
|
today.columns["purchaseID"][purchase],
|
||||||
|
today.columns["purchaseRevenue"][purchase],
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
counts = {
|
||||||
|
"match": 0,
|
||||||
|
"cancelled": 0,
|
||||||
|
"lost_event": 0,
|
||||||
|
"duplicate_event": 0,
|
||||||
|
"amount_delta": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for order_id, order_outcome, event_outcome, items_total in zip(
|
||||||
|
today.orders.order_id,
|
||||||
|
today.orders.outcome,
|
||||||
|
today.purchase_outcome,
|
||||||
|
today.orders.items_total,
|
||||||
|
strict=True,
|
||||||
|
):
|
||||||
|
if order_outcome != orders_module.OrderOutcome.PAID:
|
||||||
|
counts["cancelled"] += 1
|
||||||
|
elif event_outcome == commerce.EventOutcome.LOST:
|
||||||
|
counts["lost_event"] += 1
|
||||||
|
elif event_outcome == commerce.EventOutcome.DUPLICATED:
|
||||||
|
counts["duplicate_event"] += 1
|
||||||
|
elif items_total != declared[order_id]:
|
||||||
|
counts["amount_delta"] += 1
|
||||||
|
else:
|
||||||
|
counts["match"] += 1
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _date(number: int) -> str:
|
||||||
|
return (world.ORIGIN + timedelta(days=number)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(payload: bytes) -> str:
|
||||||
|
return hashlib.sha256(payload).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Собирает опись мира: паспорт, счётчики и хеши дней."
|
||||||
|
)
|
||||||
|
parser.add_argument("output", type=Path, help="путь к файлу описи")
|
||||||
|
output = parser.parse_args().output
|
||||||
|
output.write_text(render(), encoding="utf-8")
|
||||||
|
print(f"Опись мира собрана: {output}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""Заказы бэкенда: вторая проекция покупки и деньги магазина.
|
||||||
|
|
||||||
|
Заказ — не второе порождение, а вторая проекция того же факта мира. Корзину,
|
||||||
|
цены, промокод и номер посчитала торговая половина дня-функции; заказная
|
||||||
|
половина берёт её покупки готовой структурой и добавляет то, чего у клиента
|
||||||
|
нет: личность покупателя под родным именем `user_id` и деньги магазина —
|
||||||
|
скидку по коду, доставку и итог. Новых бросков в торговый подпоток заказная
|
||||||
|
половина не делает, поэтому два источника согласованы по построению, а не
|
||||||
|
сверкой (docs/architecture/orders/snapshot.md).
|
||||||
|
|
||||||
|
**Деньги — целыми копейками**, как и везде в генераторе: `items_total` —
|
||||||
|
сумма позиций, посчитанная торговой половиной (у клиента то же число зовётся
|
||||||
|
выручкой), за вычетом строки, которую унесла дельта; `discount` — скидка по
|
||||||
|
промокоду события от оставшегося `items_total`, по таблице «код → скидка» из
|
||||||
|
чисел мира; `delivery` — единственные деньги заказа, которых нет ни в одном
|
||||||
|
событии; `total` — `items_total` − `discount` + `delivery`. Отсюда правило
|
||||||
|
витрин «деньги считаем по бэкенду»: про скидку и доставку клиент не знает
|
||||||
|
вовсе.
|
||||||
|
|
||||||
|
**Случайность — подпоток заказной стороны**, ветвящийся по дню рождения
|
||||||
|
заказа: слепок несёт семь дней рождения сразу и судьбу каждого заказа обязан
|
||||||
|
читать из его собственного дня. Броски делаются на полную длину дня, а не на
|
||||||
|
отобранных заказах, — иначе длина броска стала бы функцией доли, и правка
|
||||||
|
одной доли перебрасывала бы весь подпоток после себя
|
||||||
|
(docs/architecture/orders/fate.md).
|
||||||
|
|
||||||
|
**Судьба заказа — исход, его моменты и задержка выгрузки**. Из окна
|
||||||
|
изменяемости заказ выходит оплаченным или отменённым, а когда именно это
|
||||||
|
случилось, сказано смещением в секундах от рождения заказа. Момента, которого
|
||||||
|
у исхода нет, нет и в данных: его место занимает −1, а не ноль, — иначе
|
||||||
|
«оплатили в секунду рождения» было бы не отличить от «не оплатили вовсе».
|
||||||
|
Задержка говорит, сколько ранних слепков заказ пропустит; назначенный планом
|
||||||
|
приезжает сразу.
|
||||||
|
|
||||||
|
**Состояние на границе суток** — чтение готовой судьбы, а не накопление:
|
||||||
|
слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет
|
||||||
|
статус. Отсюда «дыхание» окна — заказ, оплаченный назавтра, стоит в сегодняшнем
|
||||||
|
слепке как `created`, а завтра как `paid`.
|
||||||
|
|
||||||
|
**Дельта суммы — вычеркнутая позиция**: товара не оказалось в наличии, и
|
||||||
|
заказ приезжает на строку короче клиентской корзины, а `items_total` меньше
|
||||||
|
ровно на её полную стоимость. Момента у дельты нет — склад собрал заказ до
|
||||||
|
первого слепка, поэтому урезан он во всех своих слепках. Заказ из одной
|
||||||
|
позиции дельты не получает: пустых заказов не бывает.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from clickstream_generator import catalog, world
|
||||||
|
from clickstream_generator.commerce import Purchases
|
||||||
|
from clickstream_generator.seeds import Component, day_stream
|
||||||
|
from clickstream_generator.weights import pick
|
||||||
|
|
||||||
|
_DISCOUNT_PERCENT = dict(world.COUPONS)
|
||||||
|
_DELIVERY_PRICE = np.array(
|
||||||
|
[price for price, _ in world.DELIVERY_KOPECKS_WEIGHTS], dtype=np.int64
|
||||||
|
)
|
||||||
|
_DELIVERY_CUMULATIVE = np.cumsum(
|
||||||
|
[weight for _, weight in world.DELIVERY_KOPECKS_WEIGHTS]
|
||||||
|
)
|
||||||
|
_OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS)
|
||||||
|
_MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS)
|
||||||
|
_SNAPSHOT_DELAY_CUMULATIVE = np.cumsum(world.ORDER_SNAPSHOT_DELAY_WEIGHTS)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderOutcome(IntEnum):
|
||||||
|
"""Чем кончилось окно изменяемости заказа. Порядок — порядок весов мира."""
|
||||||
|
|
||||||
|
PAID = 0
|
||||||
|
PAID_THEN_CANCELLED = 1
|
||||||
|
UNPAID_THEN_CANCELLED = 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Orders:
|
||||||
|
"""Заказы одного модельного дня: ряды одной длины, по элементу на заказ.
|
||||||
|
|
||||||
|
Порядок — порядок рождения заказов, он же возрастание `order_id`. День
|
||||||
|
рождения заказа — `day`: строка в базе источника создаётся синхронно с
|
||||||
|
покупкой, поэтому день создания строки и день покупки совпадают.
|
||||||
|
"""
|
||||||
|
|
||||||
|
day: int
|
||||||
|
order_id: tuple[str, ...]
|
||||||
|
# Пользователь магазина: тот же человек, что стоит за купившей кукой.
|
||||||
|
user_id: NDArray[np.uint64]
|
||||||
|
# Когда строка заказа создана в базе источника: секунда той самой покупки
|
||||||
|
# — в модели строка создаётся синхронно с ней — и миллисекунда часов базы.
|
||||||
|
# От этого момента отсчитываются и моменты судьбы.
|
||||||
|
created_at: NDArray[np.datetime64]
|
||||||
|
# Позиции заказа: номера товаров каталога и штуки, ячейка на заказ. У
|
||||||
|
# заказа с дельтой позиций на одну меньше, чем в корзине клиента.
|
||||||
|
product: tuple[NDArray[np.int64], ...]
|
||||||
|
quantity: tuple[NDArray[np.int64], ...]
|
||||||
|
# Деньги заказа, целые копейки.
|
||||||
|
items_total: NDArray[np.int64]
|
||||||
|
discount: NDArray[np.int64]
|
||||||
|
delivery: NDArray[np.int64]
|
||||||
|
total: NDArray[np.int64]
|
||||||
|
# Судьба заказа: исход (`OrderOutcome`) и его моменты — смещения в
|
||||||
|
# секундах от рождения заказа, −1 у момента, которого у исхода нет.
|
||||||
|
outcome: NDArray[np.int64]
|
||||||
|
paid_after: NDArray[np.int64]
|
||||||
|
cancelled_after: NDArray[np.int64]
|
||||||
|
snapshot_delay: NDArray[np.int64]
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return self.user_id.size
|
||||||
|
|
||||||
|
|
||||||
|
def of_day(
|
||||||
|
seed: int,
|
||||||
|
day: int,
|
||||||
|
purchases: Purchases,
|
||||||
|
assigned_order: NDArray[np.bool_],
|
||||||
|
) -> Orders:
|
||||||
|
"""Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги."""
|
||||||
|
rng = day_stream(seed, day, Component.ORDERS)
|
||||||
|
delivery = _delivery(rng, len(purchases))
|
||||||
|
outcome, paid_after, cancelled_after = _fate(rng, len(purchases))
|
||||||
|
product, quantity, items_total = _delta(rng, purchases)
|
||||||
|
created_at = _created_at(rng, purchases)
|
||||||
|
discount = _discount(purchases, items_total)
|
||||||
|
snapshot_delay = _snapshot_delay(rng, assigned_order)
|
||||||
|
return Orders(
|
||||||
|
day=day,
|
||||||
|
order_id=purchases.order_id,
|
||||||
|
user_id=purchases.person_id,
|
||||||
|
created_at=created_at,
|
||||||
|
product=product,
|
||||||
|
quantity=quantity,
|
||||||
|
items_total=items_total,
|
||||||
|
discount=discount,
|
||||||
|
delivery=delivery,
|
||||||
|
total=items_total - discount + delivery,
|
||||||
|
outcome=outcome,
|
||||||
|
paid_after=paid_after,
|
||||||
|
cancelled_after=cancelled_after,
|
||||||
|
snapshot_delay=snapshot_delay,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def window(day: int) -> range:
|
||||||
|
"""Дни рождения, чьи заказы несёт слепок дня `day`.
|
||||||
|
|
||||||
|
Окно изменяемости — константа мира; у начала оси оно усекается само, а не
|
||||||
|
сторожем: дней до D0 попросту нет.
|
||||||
|
"""
|
||||||
|
return range(max(0, day - world.ORDER_WINDOW_DAYS + 1), day + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def at_boundary(rows: Orders, day: int) -> tuple[list[str], NDArray[np.datetime64]]:
|
||||||
|
"""Статус заказов и момент их последнего изменения на границе `day`|`day+1`.
|
||||||
|
|
||||||
|
Судьба решена при рождении, поэтому слепок её только читает: момент позже
|
||||||
|
границы для него ещё не случился. Отмена перевешивает оплату — у дороги
|
||||||
|
«оплачен и отменён» она поздняя, и заказ на границе уже отменён.
|
||||||
|
`updated_at` — поздний учтённый момент, а без единого заказ показывает своё
|
||||||
|
рождение: строку с тех пор никто не трогал.
|
||||||
|
"""
|
||||||
|
edge = _boundary(day)
|
||||||
|
paid = rows.created_at + rows.paid_after.astype("timedelta64[s]")
|
||||||
|
cancelled = rows.created_at + rows.cancelled_after.astype("timedelta64[s]")
|
||||||
|
# Момента, которого у исхода нет, в данных нет вовсе: там −1, и без маски
|
||||||
|
# он прикинулся бы моментом за секунду до рождения.
|
||||||
|
got_paid = (rows.paid_after >= 0) & (paid <= edge)
|
||||||
|
got_cancelled = (rows.cancelled_after >= 0) & (cancelled <= edge)
|
||||||
|
|
||||||
|
status = np.where(got_cancelled, "cancelled", np.where(got_paid, "paid", "created"))
|
||||||
|
updated = np.where(
|
||||||
|
got_cancelled, cancelled, np.where(got_paid, paid, rows.created_at)
|
||||||
|
)
|
||||||
|
return status.tolist(), updated
|
||||||
|
|
||||||
|
|
||||||
|
def arrived(rows: Orders, day: int) -> NDArray[np.bool_]:
|
||||||
|
"""Какие заказы уже попали в выгрузку к слепку дня `day`."""
|
||||||
|
return rows.day + rows.snapshot_delay <= day
|
||||||
|
|
||||||
|
|
||||||
|
def _boundary(day: int) -> np.datetime64:
|
||||||
|
"""Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика.
|
||||||
|
|
||||||
|
Модельные сутки считаются в поясе счётчика, а моменты заказа — метки UTC:
|
||||||
|
между ними ровно смещение пояса.
|
||||||
|
"""
|
||||||
|
midnight = np.datetime64(world.ORIGIN, "s") + np.timedelta64(day + 1, "D")
|
||||||
|
return midnight - np.timedelta64(world.COUNTER_TIMEZONE_MINUTES, "m")
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
|
||||||
|
"""Стоимость доставки каждого заказа дня — броском по таблице весов."""
|
||||||
|
return _DELIVERY_PRICE[pick(rng, _DELIVERY_CUMULATIVE, orders)]
|
||||||
|
|
||||||
|
|
||||||
|
def _fate(
|
||||||
|
rng: np.random.Generator, orders: int
|
||||||
|
) -> tuple[NDArray[np.int64], NDArray[np.int64], NDArray[np.int64]]:
|
||||||
|
"""Исход каждого заказа и моменты, которые этот исход себе оставил.
|
||||||
|
|
||||||
|
Моментов бросается два, и всегда два — обоим исходам с одним моментом
|
||||||
|
второй достаётся лишним и выбрасывается. У двух дорог заказа порядок
|
||||||
|
выходит сортировкой: ранний момент — оплата, поздний — отмена, — а не
|
||||||
|
условной точкой отсчёта, от которой отмеряется вторая.
|
||||||
|
"""
|
||||||
|
outcome = pick(rng, _OUTCOME_CUMULATIVE, orders)
|
||||||
|
first, second = _moment(rng, orders), _moment(rng, orders)
|
||||||
|
both = outcome == OrderOutcome.PAID_THEN_CANCELLED
|
||||||
|
|
||||||
|
paid = np.where(outcome == OrderOutcome.UNPAID_THEN_CANCELLED, -1, first)
|
||||||
|
paid = np.where(both, np.minimum(first, second), paid)
|
||||||
|
cancelled = np.where(outcome == OrderOutcome.PAID, -1, first)
|
||||||
|
cancelled = np.where(both, np.maximum(first, second), cancelled)
|
||||||
|
return outcome, paid, cancelled
|
||||||
|
|
||||||
|
|
||||||
|
def _created_at(
|
||||||
|
rng: np.random.Generator, purchases: Purchases
|
||||||
|
) -> NDArray[np.datetime64]:
|
||||||
|
"""Момент создания строки заказа: секунда покупки и миллисекунда часов базы.
|
||||||
|
|
||||||
|
Секунда приходит из события — строка создаётся синхронно с покупкой, и
|
||||||
|
сдвигать её значило бы подделывать аудит источника. Миллисекунду трекер не
|
||||||
|
видит вовсе: у него своё разрешение, у базы своё, и три дописанных нуля
|
||||||
|
выдали бы секундную модель за миллисекундную (исследование формата слепка).
|
||||||
|
"""
|
||||||
|
millisecond = rng.integers(0, 1000, len(purchases))
|
||||||
|
return purchases.moment.astype("datetime64[ms]") + millisecond.astype(
|
||||||
|
"timedelta64[ms]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_delay(
|
||||||
|
rng: np.random.Generator,
|
||||||
|
assigned_order: NDArray[np.bool_],
|
||||||
|
) -> NDArray[np.int64]:
|
||||||
|
"""Сколько ранних слепков пропустит заказ; назначенный приезжает сразу."""
|
||||||
|
delay = pick(rng, _SNAPSHOT_DELAY_CUMULATIVE, assigned_order.size)
|
||||||
|
return np.where(assigned_order, 0, delay)
|
||||||
|
|
||||||
|
|
||||||
|
def _moment(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
|
||||||
|
"""Момент внутри окна: час по таблице весов и равномерная секунда в нём.
|
||||||
|
|
||||||
|
Без секунды моменты ложились бы на круглые часы, и разности времён в
|
||||||
|
аудите давали бы точные равенства там, где их в жизни не бывает.
|
||||||
|
"""
|
||||||
|
hour = pick(rng, _MOMENT_HOUR_CUMULATIVE, orders)
|
||||||
|
return hour * 3600 + rng.integers(0, 3600, orders)
|
||||||
|
|
||||||
|
|
||||||
|
def _delta(
|
||||||
|
rng: np.random.Generator, purchases: Purchases
|
||||||
|
) -> tuple[
|
||||||
|
tuple[NDArray[np.int64], ...], tuple[NDArray[np.int64], ...], NDArray[np.int64]
|
||||||
|
]:
|
||||||
|
"""Позиции заказа и сумма позиций: у выбранных заказов строкой меньше.
|
||||||
|
|
||||||
|
Бросков два, и оба на полную длину дня: признак дельты и номер позиции,
|
||||||
|
которую вычеркнул склад. Позиция выбирается равновероятно — корреляцию со
|
||||||
|
спросом на такой доле не увидеть ничем, а стоила бы она таблицей чисел
|
||||||
|
мира. Верхняя граница у каждого заказа своя, число его позиций:
|
||||||
|
`Generator.integers` транслирует массивы границ (стабильная документация
|
||||||
|
NumPy, проверено 2026-08-18), поэтому хватает одного векторного броска.
|
||||||
|
|
||||||
|
Однопозиционные заказы запрещаются маской **после** обоих бросков, а не
|
||||||
|
отбором до них.
|
||||||
|
"""
|
||||||
|
goods = catalog.catalog()
|
||||||
|
count = np.array([line.size for line in purchases.product], dtype=np.int64)
|
||||||
|
marked = rng.integers(0, 100, count.size) < world.ORDER_DELTA_PERCENT
|
||||||
|
gone = rng.integers(0, count)
|
||||||
|
dropped = marked & (count > 1)
|
||||||
|
|
||||||
|
product = list(purchases.product)
|
||||||
|
quantity = list(purchases.quantity)
|
||||||
|
items_total = purchases.revenue.copy()
|
||||||
|
for order in np.flatnonzero(dropped).tolist():
|
||||||
|
line = gone[order]
|
||||||
|
# Строка уходит целиком, вместе со своей полной стоимостью.
|
||||||
|
items_total[order] -= goods.price[product[order][line]] * quantity[order][line]
|
||||||
|
product[order] = np.delete(product[order], line)
|
||||||
|
quantity[order] = np.delete(quantity[order], line)
|
||||||
|
return tuple(product), tuple(quantity), items_total
|
||||||
|
|
||||||
|
|
||||||
|
def _discount(
|
||||||
|
purchases: Purchases, items_total: NDArray[np.int64]
|
||||||
|
) -> NDArray[np.int64]:
|
||||||
|
"""Скидка каждого заказа: процент промокода от оставшейся корзины, вниз.
|
||||||
|
|
||||||
|
Броска здесь нет: код выбрал посетитель, и он уже уехал в событие —
|
||||||
|
бэкенду остаётся прочитать таблицу. Заказ без кода скидки не получает,
|
||||||
|
а спорную копейку округление оставляет магазину. Склад сначала вычёркивает
|
||||||
|
отсутствующую позицию, поэтому скидка считается уже от `items_total`.
|
||||||
|
"""
|
||||||
|
percent = np.array(
|
||||||
|
[_DISCOUNT_PERCENT[code] if code else 0 for code in purchases.coupon],
|
||||||
|
dtype=np.int64,
|
||||||
|
)
|
||||||
|
return items_total * percent // 100
|
||||||
@@ -9,11 +9,22 @@
|
|||||||
|
|
||||||
Что план решает до генерации событий и чем связывает дни между собой:
|
Что план решает до генерации событий и чем связывает дни между собой:
|
||||||
|
|
||||||
- приток — кто и когда впервые появился, и сколько раз вернётся;
|
- приток — кто и когда впервые появился, и сколько раз вернётся; кука
|
||||||
|
помеченного покупателя живёт дольше прочих — это один из двух рычагов
|
||||||
|
метки, второй лежит в воронке дня;
|
||||||
- двухкуковые пары — какой человек завёл вторую куку и в какие дни
|
- двухкуковые пары — какой человек завёл вторую куку и в какие дни
|
||||||
каждая из двух кук обязана оформить заказ;
|
каждая из двух кук обязана оформить заказ;
|
||||||
|
- паспорт куки — устройство и город: они у куки одни и те же во всех её
|
||||||
|
днях, а знает об этом только план (у пары один город на двоих);
|
||||||
|
- личность — `person_id` человека за кукой: у двух кук пары он один, и
|
||||||
|
заказ бэкенда показывает его как `user_id`
|
||||||
|
(docs/architecture/orders/identity.md);
|
||||||
- счётчики — приток по дням, дневная и накопленная аудитория, пары.
|
- счётчики — приток по дням, дневная и накопленная аудитория, пары.
|
||||||
|
|
||||||
|
Единица дня здесь — день активности куки, а не визит: слово «визит»
|
||||||
|
закреплено за сессией и полем `VisitID`, и визитов у куки за день бывает
|
||||||
|
несколько. Сколько именно — решает день-функция, плану это безразлично.
|
||||||
|
|
||||||
Случайность тянется целыми числами: диапазоны и выбор по целым весам.
|
Случайность тянется целыми числами: диапазоны и выбор по целым весам.
|
||||||
Плавающие распределения системной математики не зовутся — они расходятся
|
Плавающие распределения системной математики не зовутся — они расходятся
|
||||||
между версиями numpy и архитектурами CPU, а обещано побайтовое совпадение
|
между версиями numpy и архитектурами CPU, а обещано побайтовое совпадение
|
||||||
@@ -26,29 +37,48 @@ from functools import lru_cache
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
from clickstream_generator import world
|
from clickstream_generator import ids, reference, world
|
||||||
from clickstream_generator.seeds import cohort_stream
|
from clickstream_generator.seeds import cohort_stream
|
||||||
|
from clickstream_generator.weights import pick
|
||||||
# Куки живут числами ниже 2^53: выше JSON округляет — тот же довод, что у
|
|
||||||
# `WatchID` в контракте схемы. Граница не достигается: 2^53 сам уже за ней.
|
|
||||||
CLIENT_ID_LIMIT = 2**53
|
|
||||||
|
|
||||||
# Кумулятивные веса: выбор по ним — целочисленный, бросок попадает в чью-то
|
# Кумулятивные веса: выбор по ним — целочисленный, бросок попадает в чью-то
|
||||||
# долю общего веса.
|
# долю общего веса.
|
||||||
_RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS)
|
_RETURN_COUNT_CUMULATIVE = np.cumsum(world.RETURN_COUNT_WEIGHTS)
|
||||||
|
_BUYER_RETURN_COUNT_CUMULATIVE = np.cumsum(world.BUYER_RETURN_COUNT_WEIGHTS)
|
||||||
_RETURN_DELAY_CUMULATIVE = np.cumsum(world.RETURN_DELAY_WEIGHTS)
|
_RETURN_DELAY_CUMULATIVE = np.cumsum(world.RETURN_DELAY_WEIGHTS)
|
||||||
|
_CITY_CUMULATIVE = np.cumsum([city.weight for city in reference.CITIES])
|
||||||
|
_DEVICE_CUMULATIVE = np.cumsum(
|
||||||
|
[profile.weight for profile in reference.DEVICE_PROFILES]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Профили, разложенные надвое — телефоны и всё остальное. Вторая кука пары
|
||||||
|
# берётся из другой половины: у человека это второе устройство, а не копия
|
||||||
|
# первого. Мастер-спека (раздел 5) называет пару «телефон и ноутбук»; у нас
|
||||||
|
# к телефону встаёт десктоп или планшет — вторым устройством бывает и он, а
|
||||||
|
# запрет на планшет не дал бы ничего, кроме зауженного справочника.
|
||||||
|
_IS_PHONE = np.array(
|
||||||
|
[
|
||||||
|
profile.category == reference.PHONE_CATEGORY
|
||||||
|
for profile in reference.DEVICE_PROFILES
|
||||||
|
]
|
||||||
|
)
|
||||||
|
_DEVICE_HALVES = (np.flatnonzero(~_IS_PHONE), np.flatnonzero(_IS_PHONE))
|
||||||
|
_DEVICE_HALF_CUMULATIVE = tuple(
|
||||||
|
np.cumsum([reference.DEVICE_PROFILES[number].weight for number in half])
|
||||||
|
for half in _DEVICE_HALVES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Cohort:
|
class Cohort:
|
||||||
"""Люди, впервые пришедшие в мир в день `day`, с их куками и визитами.
|
"""Люди, впервые пришедшие в мир в день `day`, с их куками и днями.
|
||||||
|
|
||||||
Куки лежат одним рядом: сначала первые куки людей — по одной на человека,
|
Куки лежат одним рядом: сначала первые куки людей — по одной на человека,
|
||||||
индексы 0…`people`−1, — затем вторые куки двухкуковых пар. Кука без пары
|
индексы 0…`people`−1, — затем вторые куки двухкуковых пар. Кука без пары
|
||||||
и есть человек целиком.
|
и есть человек целиком.
|
||||||
|
|
||||||
Визиты — плоская таблица «кука — день», отсортированная и без повторов:
|
Дни активности — плоская таблица «кука — день», отсортированная и без
|
||||||
на день у куки приходится не больше одного визита. Все дни лежат в окне
|
повторов: дважды за день кука не появляется. Все дни лежат в окне
|
||||||
активности человека: от `day` до `day` + хвост возвратов.
|
активности человека: от `day` до `day` + хвост возвратов.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -57,11 +87,16 @@ class Cohort:
|
|||||||
client_id: NDArray[np.uint64]
|
client_id: NDArray[np.uint64]
|
||||||
birth_day: NDArray[np.int64]
|
birth_day: NDArray[np.int64]
|
||||||
buyer: NDArray[np.bool_]
|
buyer: NDArray[np.bool_]
|
||||||
visit_cookie: NDArray[np.int64]
|
active_cookie: NDArray[np.int64]
|
||||||
visit_day: NDArray[np.int64]
|
active_day: NDArray[np.int64]
|
||||||
# Пары и назначенные им заказы — по строке на пару, по колонке на куку.
|
# Пары и назначенные им заказы — по строке на пару, по колонке на куку.
|
||||||
pair_cookies: NDArray[np.int64]
|
pair_cookies: NDArray[np.int64]
|
||||||
pair_order_days: NDArray[np.int64]
|
pair_order_days: NDArray[np.int64]
|
||||||
|
# Паспорт куки: номер профиля устройства и номер города в справочниках.
|
||||||
|
device: NDArray[np.int64]
|
||||||
|
city: NDArray[np.int64]
|
||||||
|
# Человек за кукой: непрозрачный ID, одинаковый у двух кук пары.
|
||||||
|
person_id: NDArray[np.uint64]
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
"""Когорта запоминается, поэтому массивы отдаются только на чтение.
|
"""Когорта запоминается, поэтому массивы отдаются только на чтение.
|
||||||
@@ -87,28 +122,39 @@ class Cohort:
|
|||||||
"""Сколько пар получили назначенные заказы."""
|
"""Сколько пар получили назначенные заказы."""
|
||||||
return len(self.pair_cookies)
|
return len(self.pair_cookies)
|
||||||
|
|
||||||
def visitors_on(
|
def visitors_on(self, day: int) -> DayAudience:
|
||||||
self, day: int
|
"""Кто из когорты пришёл в день `day` — её доля дневной аудитории.
|
||||||
) -> tuple[NDArray[np.uint64], NDArray[np.bool_], NDArray[np.bool_]]:
|
|
||||||
"""Кто из когорты пришёл в день `day`: куки, покупатели, заказы пар.
|
|
||||||
|
|
||||||
Как визиты и пары уложены в массивы, знает только когорта: снаружи
|
Как дни активности и пары уложены в массивы, знает только когорта:
|
||||||
спрашивают день и получают три ряда одной длины.
|
снаружи спрашивают день и получают готовые ряды одной длины.
|
||||||
"""
|
"""
|
||||||
here = self.visit_cookie[self.visit_day == day]
|
here = self.active_cookie[self.active_day == day]
|
||||||
ordering = self.pair_cookies[self.pair_order_days == day]
|
ordering = self.pair_cookies[self.pair_order_days == day]
|
||||||
return self.client_id[here], self.buyer[here], np.isin(here, ordering)
|
return DayAudience(
|
||||||
|
day=day,
|
||||||
|
client_id=self.client_id[here],
|
||||||
|
buyer=self.buyer[here],
|
||||||
|
assigned_order=np.isin(here, ordering),
|
||||||
|
device=self.device[here],
|
||||||
|
city=self.city[here],
|
||||||
|
person_id=self.person_id[here],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DayAudience:
|
class DayAudience:
|
||||||
"""Куки, пришедшие в день `day`, — вход для будущей дня-функции."""
|
"""Куки, пришедшие в день `day`, — вход дня-функции."""
|
||||||
|
|
||||||
day: int
|
day: int
|
||||||
client_id: NDArray[np.uint64]
|
client_id: NDArray[np.uint64]
|
||||||
buyer: NDArray[np.bool_]
|
buyer: NDArray[np.bool_]
|
||||||
# Куки, которым план назначил на этот день гарантированный заказ пары.
|
# Куки, которым план назначил на этот день гарантированный заказ пары.
|
||||||
assigned_order: NDArray[np.bool_]
|
assigned_order: NDArray[np.bool_]
|
||||||
|
# Паспорт куки: номера строк в справочниках устройств и городов.
|
||||||
|
device: NDArray[np.int64]
|
||||||
|
city: NDArray[np.int64]
|
||||||
|
# Человек за кукой: его заказ покажет этот ID как `user_id`.
|
||||||
|
person_id: NDArray[np.uint64]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -143,32 +189,43 @@ def cohort(seed: int, day: int) -> Cohort:
|
|||||||
]
|
]
|
||||||
|
|
||||||
cookies = people + paired.size
|
cookies = people + paired.size
|
||||||
client_id = rng.integers(1, CLIENT_ID_LIMIT, cookies, dtype=np.uint64)
|
# Кука живёт числом ниже 2^53 — тот же потолок, что у номера события:
|
||||||
|
# выше JSON округляет при разборе. Граница не достигается.
|
||||||
|
client_id = rng.integers(1, ids.LIMIT, cookies, dtype=np.uint64)
|
||||||
birth_day = np.full(cookies, day, dtype=np.int64)
|
birth_day = np.full(cookies, day, dtype=np.int64)
|
||||||
# Вторая кука рождается, пока человек ещё ходит: тем же затухающим
|
# Вторая кука рождается, пока человек ещё ходит: тем же затухающим
|
||||||
# профилем, что и возвраты, — обычно через дни, изредка через месяцы.
|
# профилем, что и возвраты, — обычно через дни, изредка через месяцы.
|
||||||
# Фиксированного зазора нет, иначе пары в данных узнавались бы по нему.
|
# Фиксированного зазора нет, иначе пары в данных узнавались бы по нему.
|
||||||
birth_day[people:] += 1 + _pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size)
|
birth_day[people:] += 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, paired.size)
|
||||||
|
|
||||||
visit_cookie, visit_day = _visits(rng, birth_day, day + world.RETURN_TAIL_DAYS)
|
# Вторая кука принадлежит покупателю — как и первая кука его пары.
|
||||||
pair_cookies, pair_order_days = _assign_orders(
|
buyer_cookie = np.concatenate((buyer, np.ones(paired.size, dtype=bool)))
|
||||||
rng,
|
active_cookie, active_day = _active_days(
|
||||||
np.column_stack((paired, np.arange(people, cookies, dtype=np.int64))),
|
rng, birth_day, buyer_cookie, day + world.RETURN_TAIL_DAYS
|
||||||
visit_cookie,
|
|
||||||
visit_day,
|
|
||||||
cookies,
|
|
||||||
)
|
)
|
||||||
|
twins = np.column_stack((paired, np.arange(people, cookies, dtype=np.int64)))
|
||||||
|
pair_cookies, pair_order_days = _assign_orders(
|
||||||
|
rng, twins, active_cookie, active_day, cookies
|
||||||
|
)
|
||||||
|
# Паспорт бросается последним — после всего, что уже измерено: тогда
|
||||||
|
# счётчики канонического мира от этой добавки не двигаются. По тому же
|
||||||
|
# правилу за ним приписана личность: этап 3 дописал её в конец, и куки,
|
||||||
|
# пары и паспорта остались теми же до байта.
|
||||||
|
device, city = _passports(rng, twins, cookies)
|
||||||
|
person_id = _persons(rng, twins, people, cookies)
|
||||||
return Cohort(
|
return Cohort(
|
||||||
day=day,
|
day=day,
|
||||||
people=people,
|
people=people,
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
birth_day=birth_day,
|
birth_day=birth_day,
|
||||||
# Вторая кука принадлежит покупателю — как и первая кука его пары.
|
buyer=buyer_cookie,
|
||||||
buyer=np.concatenate((buyer, np.ones(paired.size, dtype=bool))),
|
active_cookie=active_cookie,
|
||||||
visit_cookie=visit_cookie,
|
active_day=active_day,
|
||||||
visit_day=visit_day,
|
|
||||||
pair_cookies=pair_cookies,
|
pair_cookies=pair_cookies,
|
||||||
pair_order_days=pair_order_days,
|
pair_order_days=pair_order_days,
|
||||||
|
device=device,
|
||||||
|
city=city,
|
||||||
|
person_id=person_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -177,18 +234,19 @@ def audience(seed: int, day: int) -> DayAudience:
|
|||||||
if day < 0:
|
if day < 0:
|
||||||
raise ValueError(f"события начинаются в D0: дня {day} на оси нет")
|
raise ValueError(f"события начинаются в D0: дня {day} на оси нет")
|
||||||
|
|
||||||
client_id, buyer, assigned = [], [], []
|
|
||||||
# Предыстория ровно такой глубины, чтобы окна хватило и первому дню оси.
|
# Предыстория ровно такой глубины, чтобы окна хватило и первому дню оси.
|
||||||
for born in range(day - world.RETURN_TAIL_DAYS, day + 1):
|
parts = [
|
||||||
came, bought, ordered = cohort(seed, born).visitors_on(day)
|
cohort(seed, born).visitors_on(day)
|
||||||
client_id.append(came)
|
for born in range(day - world.RETURN_TAIL_DAYS, day + 1)
|
||||||
buyer.append(bought)
|
]
|
||||||
assigned.append(ordered)
|
|
||||||
return DayAudience(
|
return DayAudience(
|
||||||
day=day,
|
day=day,
|
||||||
client_id=np.concatenate(client_id),
|
client_id=np.concatenate([part.client_id for part in parts]),
|
||||||
buyer=np.concatenate(buyer),
|
buyer=np.concatenate([part.buyer for part in parts]),
|
||||||
assigned_order=np.concatenate(assigned),
|
assigned_order=np.concatenate([part.assigned_order for part in parts]),
|
||||||
|
device=np.concatenate([part.device for part in parts]),
|
||||||
|
city=np.concatenate([part.city for part in parts]),
|
||||||
|
person_id=np.concatenate([part.person_id for part in parts]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -202,9 +260,9 @@ def counters(seed: int, days: int) -> PlanCounters:
|
|||||||
# Ниже — вся предыстория: её когорты ещё возвращаются в горизонт.
|
# Ниже — вся предыстория: её когорты ещё возвращаются в горизонт.
|
||||||
for born in range(-world.RETURN_TAIL_DAYS, days):
|
for born in range(-world.RETURN_TAIL_DAYS, days):
|
||||||
born_cohort = cohort(seed, born)
|
born_cohort = cohort(seed, born)
|
||||||
inside = (born_cohort.visit_day >= 0) & (born_cohort.visit_day < days)
|
inside = (born_cohort.active_day >= 0) & (born_cohort.active_day < days)
|
||||||
daily += np.bincount(born_cohort.visit_day[inside], minlength=days)
|
daily += np.bincount(born_cohort.active_day[inside], minlength=days)
|
||||||
seen.append(born_cohort.client_id[np.unique(born_cohort.visit_cookie[inside])])
|
seen.append(born_cohort.client_id[np.unique(born_cohort.active_cookie[inside])])
|
||||||
appeared = born_cohort.birth_day[
|
appeared = born_cohort.birth_day[
|
||||||
(born_cohort.birth_day >= 0) & (born_cohort.birth_day < days)
|
(born_cohort.birth_day >= 0) & (born_cohort.birth_day < days)
|
||||||
]
|
]
|
||||||
@@ -227,17 +285,34 @@ def _influx(rng: np.random.Generator, day: int) -> int:
|
|||||||
return base + int(rng.integers(-spread, spread + 1))
|
return base + int(rng.integers(-spread, spread + 1))
|
||||||
|
|
||||||
|
|
||||||
def _visits(
|
def _active_days(
|
||||||
rng: np.random.Generator, birth_day: NDArray[np.int64], window_end: int
|
rng: np.random.Generator,
|
||||||
|
birth_day: NDArray[np.int64],
|
||||||
|
buyer: NDArray[np.bool_],
|
||||||
|
window_end: int,
|
||||||
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
||||||
"""Дни визитов каждой куки: день рождения и возвраты, пока окно открыто."""
|
"""Дни активности каждой куки: день рождения и возвраты, пока окно открыто.
|
||||||
|
|
||||||
|
Помеченный планом покупатель живёт дольше прочих: одноразовым бывает
|
||||||
|
много реже и возвращается чаще. Это первый из двух рычагов метки
|
||||||
|
(второй — воронка дня): метки в событии нет, поэтому «постоянный
|
||||||
|
покупатель» читается в данных только как кука, которая ходит неделями и
|
||||||
|
покупает не раз. Одним лифтом конверсии этого не добиться — кука живёт
|
||||||
|
меньше двух визитов за снимок, и второй покупке негде случиться (спека
|
||||||
|
генератора, раздел 9).
|
||||||
|
"""
|
||||||
cookies = birth_day.size
|
cookies = birth_day.size
|
||||||
returns = np.zeros(cookies, dtype=np.int64)
|
returns = np.zeros(cookies, dtype=np.int64)
|
||||||
returning = rng.integers(0, 100, cookies) >= world.ONE_SHOT_PERCENT
|
one_shot = np.where(buyer, world.BUYER_ONE_SHOT_PERCENT, world.ONE_SHOT_PERCENT)
|
||||||
returns[returning] = 1 + _pick(rng, _RETURN_COUNT_CUMULATIVE, int(returning.sum()))
|
returning = rng.integers(0, 100, cookies) >= one_shot
|
||||||
|
for here, weights in (
|
||||||
|
(returning & ~buyer, _RETURN_COUNT_CUMULATIVE),
|
||||||
|
(returning & buyer, _BUYER_RETURN_COUNT_CUMULATIVE),
|
||||||
|
):
|
||||||
|
returns[here] = 1 + pick(rng, weights, int(here.sum()))
|
||||||
|
|
||||||
owner = np.repeat(np.arange(cookies, dtype=np.int64), returns)
|
owner = np.repeat(np.arange(cookies, dtype=np.int64), returns)
|
||||||
delay = 1 + _pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size)
|
delay = 1 + pick(rng, _RETURN_DELAY_CUMULATIVE, owner.size)
|
||||||
cookie = np.concatenate((np.arange(cookies, dtype=np.int64), owner))
|
cookie = np.concatenate((np.arange(cookies, dtype=np.int64), owner))
|
||||||
when = np.concatenate((birth_day, birth_day[owner] + delay))
|
when = np.concatenate((birth_day, birth_day[owner] + delay))
|
||||||
|
|
||||||
@@ -247,7 +322,7 @@ def _visits(
|
|||||||
order = np.lexsort((when, cookie))
|
order = np.lexsort((when, cookie))
|
||||||
cookie, when = cookie[order], when[order]
|
cookie, when = cookie[order], when[order]
|
||||||
|
|
||||||
# Два возврата в один день — один визит: день у куки бывает только один.
|
# Два возврата в один день — один день активности: он у куки бывает один.
|
||||||
first_of_day = np.ones(cookie.size, dtype=bool)
|
first_of_day = np.ones(cookie.size, dtype=bool)
|
||||||
first_of_day[1:] = (cookie[1:] != cookie[:-1]) | (when[1:] != when[:-1])
|
first_of_day[1:] = (cookie[1:] != cookie[:-1]) | (when[1:] != when[:-1])
|
||||||
return cookie[first_of_day], when[first_of_day]
|
return cookie[first_of_day], when[first_of_day]
|
||||||
@@ -256,32 +331,68 @@ def _visits(
|
|||||||
def _assign_orders(
|
def _assign_orders(
|
||||||
rng: np.random.Generator,
|
rng: np.random.Generator,
|
||||||
pair_cookies: NDArray[np.int64],
|
pair_cookies: NDArray[np.int64],
|
||||||
visit_cookie: NDArray[np.int64],
|
active_cookie: NDArray[np.int64],
|
||||||
visit_day: NDArray[np.int64],
|
active_day: NDArray[np.int64],
|
||||||
cookies: int,
|
cookies: int,
|
||||||
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
||||||
"""Дни гарантированных заказов пары: по визиту каждой из двух кук, от D0.
|
"""Дни гарантированных заказов пары: по дню каждой из двух кук, от D0.
|
||||||
|
|
||||||
Человеку предыстории, у чьей куки визитов на оси не осталось, пара не
|
Человеку предыстории, у чьей куки дней на оси не осталось, пара не
|
||||||
назначается: обещать заказ, которого никто не увидит, нечестно. Дни
|
назначается: обещать заказ, которого никто не увидит, нечестно. Дни
|
||||||
берутся той же случайностью, что и всё остальное, — в данных условность
|
берутся той же случайностью, что и всё остальное, — в данных условность
|
||||||
не видна.
|
не видна.
|
||||||
"""
|
"""
|
||||||
on_axis = visit_day >= 0
|
on_axis = active_day >= 0
|
||||||
counts = np.bincount(visit_cookie[on_axis], minlength=cookies)
|
counts = np.bincount(active_cookie[on_axis], minlength=cookies)
|
||||||
# Визиты отсортированы по куке, а внутри куки — по дню, и дни от D0 идут
|
# Дни отсортированы по куке, а внутри куки — по возрастанию, и дни от D0
|
||||||
# последними. Значит, дни на оси у куки — хвост её блока: от конца блока
|
# идут последними. Значит, дни на оси у куки — хвост её блока: от конца
|
||||||
# назад ровно `counts` визитов.
|
# блока назад ровно `counts` дней.
|
||||||
first_on_axis = np.searchsorted(visit_cookie, np.arange(cookies), "right") - counts
|
first_on_axis = np.searchsorted(active_cookie, np.arange(cookies), "right") - counts
|
||||||
|
|
||||||
assigned = np.all(counts[pair_cookies] > 0, axis=1)
|
assigned = np.all(counts[pair_cookies] > 0, axis=1)
|
||||||
pairs = pair_cookies[assigned]
|
pairs = pair_cookies[assigned]
|
||||||
chosen = first_on_axis[pairs] + rng.integers(0, counts[pairs])
|
chosen = first_on_axis[pairs] + rng.integers(0, counts[pairs])
|
||||||
return pairs, visit_day[chosen]
|
return pairs, active_day[chosen]
|
||||||
|
|
||||||
|
|
||||||
def _pick(
|
def _passports(
|
||||||
rng: np.random.Generator, cumulative: NDArray[np.int64], size: int
|
rng: np.random.Generator, twins: NDArray[np.int64], cookies: int
|
||||||
) -> NDArray[np.int64]:
|
) -> tuple[NDArray[np.int64], NDArray[np.int64]]:
|
||||||
"""Выбор по целым весам: куда попал бросок в общий вес, тот вариант и вышел."""
|
"""Устройство и город каждой куки; у пары город один, устройства разные.
|
||||||
return np.searchsorted(cumulative, rng.integers(0, cumulative[-1], size), "right")
|
|
||||||
|
Выводить паспорт арифметикой из `ClientID` было бы дешевле, но про
|
||||||
|
двухкуковые пары знает только план, а два города у одного человека —
|
||||||
|
ложь в данных (спека генератора, раздел 9).
|
||||||
|
"""
|
||||||
|
device = pick(rng, _DEVICE_CUMULATIVE, cookies)
|
||||||
|
city = pick(rng, _CITY_CUMULATIVE, cookies)
|
||||||
|
|
||||||
|
first, second = twins[:, 0], twins[:, 1]
|
||||||
|
city[second] = city[first]
|
||||||
|
# Половина справочника выбирается по первой куке, строка в ней — броском.
|
||||||
|
other_half = np.where(_IS_PHONE[device[first]], 0, 1)
|
||||||
|
for half in (0, 1):
|
||||||
|
here = second[other_half == half]
|
||||||
|
device[here] = _DEVICE_HALVES[half][
|
||||||
|
pick(rng, _DEVICE_HALF_CUMULATIVE[half], here.size)
|
||||||
|
]
|
||||||
|
return device, city
|
||||||
|
|
||||||
|
|
||||||
|
def _persons(
|
||||||
|
rng: np.random.Generator, twins: NDArray[np.int64], people: int, cookies: int
|
||||||
|
) -> NDArray[np.uint64]:
|
||||||
|
"""Человек за каждой кукой: у двух кук пары ID один и тот же.
|
||||||
|
|
||||||
|
Личность — минимальная форма отношения «кука принадлежит человеку», и
|
||||||
|
знает его только план: заказ спрашивает и показывает то же число как
|
||||||
|
`user_id`, а в кликстрим оно не попадает вовсе
|
||||||
|
(docs/architecture/orders/identity.md). Значение непрозрачное и живёт
|
||||||
|
ниже 2^53 — тот же потолок, что у куки: выше JSON округляет при разборе.
|
||||||
|
"""
|
||||||
|
person_id = np.empty(cookies, dtype=np.uint64)
|
||||||
|
person_id[:people] = rng.integers(1, ids.LIMIT, people, dtype=np.uint64)
|
||||||
|
# Вторая кука пары повторяет ID своего человека: заказы с двух кук —
|
||||||
|
# это и есть склейка, ради которой пары заведены.
|
||||||
|
person_id[twins[:, 1]] = person_id[twins[:, 0]]
|
||||||
|
return person_id
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Проигрыватель: гонит дни мира в приёмник — пачкой или с темпом живого дня.
|
||||||
|
|
||||||
|
Состояния у него нет (спека генератора, раздел 9). Зерно и номер дня приходят
|
||||||
|
параметрами, позицию на оси он не хранит и из данных не выводит: её ведёт тот,
|
||||||
|
кто зовёт, — на этапе 5 это переменная Airflow у дага `next_day`. Отсюда и
|
||||||
|
переигровка обрыва: позвали тот же день заново — получили те же `WatchID`, и
|
||||||
|
дедупликация склеила повтор.
|
||||||
|
|
||||||
|
Два режима отличаются только темпом. Пакетный шлёт события подряд, без пауз, —
|
||||||
|
это заливка снимка и переигровка дня. Живой держит модельное время: событие
|
||||||
|
уезжает тогда, когда до него дошли модельные часы, поделённые на ускорение.
|
||||||
|
По умолчанию ускорение ×60 — модельные сутки за 24 реальные минуты (спека,
|
||||||
|
раздел 5): суточная волна разворачивается на глазах.
|
||||||
|
|
||||||
|
Тайминги печатаются раздельно — генерация и доставка, как требует спека
|
||||||
|
(раздел 5): это разные машины разной природы, и сложенные в одно число они
|
||||||
|
перестают что-либо говорить. Сериализация считается частью генерации: она
|
||||||
|
рождает те самые байты, которые сторожит опись.
|
||||||
|
|
||||||
|
**Слепки заказов идут третьим ходом того же проигрывателя** и живут по правилу
|
||||||
|
сдвига: прогон дня D отправляет слепок дня D−1 — ночная выгрузка бэкенда за
|
||||||
|
вчера. Правило записано здесь одно и целиком, потому что оно и есть разница
|
||||||
|
между двумя источниками: трекер шлёт сегодняшний день, бэкенд — вчерашний.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from clickstream_generator import day as day_module
|
||||||
|
from clickstream_generator import orders as orders_module
|
||||||
|
from clickstream_generator import serialize
|
||||||
|
from clickstream_generator.sinks import Sink
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Как часто живой режим отчитывается о ходе дня. Минута реального времени — это
|
||||||
|
# час модельного при ×60: отчёт на каждый модельный час.
|
||||||
|
REPORT_SECONDS = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Played:
|
||||||
|
"""Итог прогона: сколько уехало и за сколько."""
|
||||||
|
|
||||||
|
events: int
|
||||||
|
generated_seconds: float
|
||||||
|
delivered_seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
def play(
|
||||||
|
sink: Sink,
|
||||||
|
seed: int,
|
||||||
|
first_day: int,
|
||||||
|
days: int = 1,
|
||||||
|
limit: int | None = None,
|
||||||
|
speed: float | None = None,
|
||||||
|
) -> Played:
|
||||||
|
"""Проиграть `days` дней подряд начиная с `first_day` в приёмник `sink`.
|
||||||
|
|
||||||
|
`limit` — потолок событий на весь прогон: срез для того, кто смотрит на
|
||||||
|
конвейер и не хочет ждать целый день. `speed` — ускорение живого режима;
|
||||||
|
`None` означает пакетный, то есть без пауз вовсе.
|
||||||
|
"""
|
||||||
|
events = 0
|
||||||
|
generated = 0.0
|
||||||
|
delivered = 0.0
|
||||||
|
|
||||||
|
for number in range(first_day, first_day + days):
|
||||||
|
left = None if limit is None else limit - events
|
||||||
|
if left is not None and left <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
clock = time.monotonic()
|
||||||
|
today = day_module.stream(seed, number)
|
||||||
|
payloads = serialize.events(today, limit=left)
|
||||||
|
seconds = _event_seconds(today, len(payloads))
|
||||||
|
spent = time.monotonic() - clock
|
||||||
|
generated += spent
|
||||||
|
log.info("день %d: событий %d, генерация %.1f с", number, len(payloads), spent)
|
||||||
|
|
||||||
|
clock = time.monotonic()
|
||||||
|
if speed is None:
|
||||||
|
_send(sink, payloads)
|
||||||
|
else:
|
||||||
|
_send_paced(sink, payloads, seconds, speed)
|
||||||
|
# Рубеж дня: доставка асинхронна, и без него напечатанное время
|
||||||
|
# означало бы только «события легли в очередь отправителя».
|
||||||
|
sink.flush()
|
||||||
|
spent = time.monotonic() - clock
|
||||||
|
delivered += spent
|
||||||
|
|
||||||
|
events += len(payloads)
|
||||||
|
log.info(
|
||||||
|
"день %d: отправлено %d, доставка %.1f с", number, len(payloads), spent
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"итого отправлено %d событий: генерация %.1f с, доставка %.1f с",
|
||||||
|
events,
|
||||||
|
generated,
|
||||||
|
delivered,
|
||||||
|
)
|
||||||
|
return Played(
|
||||||
|
events=events, generated_seconds=generated, delivered_seconds=delivered
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def play_snapshots(sink: Sink, seed: int, first_day: int, days: int = 1) -> None:
|
||||||
|
"""Отправить слепки прогонов `days` дней подряд начиная с `first_day`.
|
||||||
|
|
||||||
|
Прогон дня D отправляет слепок дня D−1: содержимое слепка — чистая функция
|
||||||
|
зерна и дня, от момента отправки оно не зависит, а сдвиг делает живой день
|
||||||
|
обычным. На старте оси вчера нет, поэтому прогон дня 0 не отправляет
|
||||||
|
ничего.
|
||||||
|
|
||||||
|
Слепок собирается переигровкой дней своего окна: заказы дня — производная
|
||||||
|
всей воронки, дешёвого пути к ним нет. Окна соседних слепков перекрываются
|
||||||
|
почти целиком, и внутри одного прогона день играется один раз — иначе
|
||||||
|
стартовый диапазон стоил бы полусотни проигрышей вместо восьми. Между
|
||||||
|
прогонами не остаётся ничего: кэш на томе был бы состоянием, которого у
|
||||||
|
проигрывателя нет.
|
||||||
|
"""
|
||||||
|
played: dict[int, orders_module.Orders] = {}
|
||||||
|
sent = 0
|
||||||
|
generated = 0.0
|
||||||
|
delivered = 0.0
|
||||||
|
|
||||||
|
for number in range(first_day, first_day + days):
|
||||||
|
taken = number - 1
|
||||||
|
if taken < 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
clock = time.monotonic()
|
||||||
|
window = [
|
||||||
|
_orders_of(played, seed, born) for born in orders_module.window(taken)
|
||||||
|
]
|
||||||
|
payloads = serialize.orders(window, taken)
|
||||||
|
spent = time.monotonic() - clock
|
||||||
|
generated += spent
|
||||||
|
log.info(
|
||||||
|
"слепок дня %d: заказов %d, генерация %.1f с", taken, len(payloads), spent
|
||||||
|
)
|
||||||
|
|
||||||
|
clock = time.monotonic()
|
||||||
|
_send(sink, payloads)
|
||||||
|
sink.flush()
|
||||||
|
spent = time.monotonic() - clock
|
||||||
|
delivered += spent
|
||||||
|
|
||||||
|
sent += len(payloads)
|
||||||
|
log.info(
|
||||||
|
"слепок дня %d: отправлено %d, доставка %.1f с", taken, len(payloads), spent
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"итого отправлено %d заказов: генерация %.1f с, доставка %.1f с",
|
||||||
|
sent,
|
||||||
|
generated,
|
||||||
|
delivered,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _orders_of(
|
||||||
|
played: dict[int, orders_module.Orders], seed: int, number: int
|
||||||
|
) -> orders_module.Orders:
|
||||||
|
"""Заказы дня `number`, сыгранного один раз на весь прогон."""
|
||||||
|
if number not in played:
|
||||||
|
played[number] = day_module.stream(seed, number).orders
|
||||||
|
return played[number]
|
||||||
|
|
||||||
|
|
||||||
|
def _send(sink: Sink, payloads: list[bytes]) -> None:
|
||||||
|
"""Пакетно: подряд и без пауз."""
|
||||||
|
for payload in payloads:
|
||||||
|
sink.send(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_paced(
|
||||||
|
sink: Sink, payloads: list[bytes], seconds: NDArray[np.int64], speed: float
|
||||||
|
) -> None:
|
||||||
|
"""С темпом: событие уезжает, когда до него дошло модельное время.
|
||||||
|
|
||||||
|
Отставание не догоняется рывком и не прячется: спешить некуда — событие
|
||||||
|
всё равно уедет, — а вот увидеть отставание в логе нужно, иначе живой
|
||||||
|
режим врёт про темп. Обгонять модельное время нельзя, отставать можно, и
|
||||||
|
именно это печатает отчёт.
|
||||||
|
"""
|
||||||
|
started = time.monotonic()
|
||||||
|
origin = int(seconds[0]) if seconds.size else 0
|
||||||
|
reported = started
|
||||||
|
lag = 0.0
|
||||||
|
|
||||||
|
for number, payload in enumerate(payloads):
|
||||||
|
due = started + (int(seconds[number]) - origin) / speed
|
||||||
|
now = time.monotonic()
|
||||||
|
if now < due:
|
||||||
|
time.sleep(due - now)
|
||||||
|
else:
|
||||||
|
lag = max(lag, now - due)
|
||||||
|
sink.send(payload)
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - reported >= REPORT_SECONDS:
|
||||||
|
log.info(
|
||||||
|
"проиграно %d из %d, модельное время %s, лаг %.1f с",
|
||||||
|
number + 1,
|
||||||
|
len(payloads),
|
||||||
|
_model_time(int(seconds[number]) - origin),
|
||||||
|
lag,
|
||||||
|
)
|
||||||
|
reported = now
|
||||||
|
lag = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _event_seconds(today: day_module.Day, count: int) -> NDArray[np.int64]:
|
||||||
|
"""Секунды событий абсолютной меткой — по ним живой режим держит темп."""
|
||||||
|
times: NDArray[np.datetime64] = today.columns["UTCEventTime"][:count]
|
||||||
|
return times.astype("datetime64[s]").astype(np.int64)
|
||||||
|
|
||||||
|
|
||||||
|
def _model_time(elapsed: int) -> str:
|
||||||
|
"""Прожитое модельное время дня в виде `ЧЧ:ММ` — от первого события."""
|
||||||
|
return f"{elapsed // 3600:02d}:{elapsed % 3600 // 60:02d}"
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""Справочники мира: устройства, города, источники трафика, карта сайта.
|
||||||
|
|
||||||
|
Таблицы-литералы, а не посточный фейкер (спека генератора, разделы 6 и 9):
|
||||||
|
нам нужны не случайные строки, а связки — телефон тянет за собой Safari, iOS
|
||||||
|
и размер экрана; город тянет id региона и часовой пояс. Фейкер связок не
|
||||||
|
даёт, таблицу пришлось бы написать всё равно, а новая зависимость молча
|
||||||
|
меняла бы мир при обновлении своих словарей.
|
||||||
|
|
||||||
|
Веса долей живут в самих строках: доля неотделима от строки, которой она
|
||||||
|
принадлежит. Числа, ни к какой строке не привязанные — суточная волна, длина
|
||||||
|
визита, воронка, — лежат в `world.py`.
|
||||||
|
|
||||||
|
Настоящее здесь одно — гео-идентификаторы (оговорка в `City`). Магазин,
|
||||||
|
сайты-рефереры и адреса вымышлены: домен `example.com` отведён под примеры
|
||||||
|
RFC 2606, адреса нероутируемы.
|
||||||
|
|
||||||
|
Таблицы записаны руками (`fmt: off`): строка справочника — строка таблицы,
|
||||||
|
а не десять строк исходника.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import IntEnum
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
# Магазин стенда: вымышленный, домен из зарезервированных под примеры.
|
||||||
|
SITE_NAME = "Дом и уют"
|
||||||
|
SITE_URL = "https://shop.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
class Page(IntEnum):
|
||||||
|
"""Страницы магазина: пять для блуждания, три для воронки заказа."""
|
||||||
|
|
||||||
|
HOME = 0
|
||||||
|
CATALOG = 1
|
||||||
|
SEARCH = 2
|
||||||
|
PRODUCT = 3
|
||||||
|
STATIC = 4
|
||||||
|
CART = 5
|
||||||
|
CHECKOUT = 6
|
||||||
|
CONFIRMATION = 7
|
||||||
|
|
||||||
|
|
||||||
|
# Страницы блуждания: их порядок — порядок колонок в таблицах переходов ниже.
|
||||||
|
BROWSE_PAGES = (Page.HOME, Page.CATALOG, Page.SEARCH, Page.PRODUCT, Page.STATIC)
|
||||||
|
|
||||||
|
# Хвост воронки по порядку прохождения: сколько шагов пройдено — то и есть
|
||||||
|
# «докуда дошёл визит».
|
||||||
|
FUNNEL_PAGES = (Page.CART, Page.CHECKOUT, Page.CONFIRMATION)
|
||||||
|
|
||||||
|
# Куда посетитель уходит со страницы, проценты по колонкам `BROWSE_PAGES`.
|
||||||
|
# Правил всего два: с карточки товара идут смотреть соседние карточки и
|
||||||
|
# каталог, с любой листающей страницы — в карточку.
|
||||||
|
FROM_PRODUCT_PERCENT = (9, 30, 8, 45, 8)
|
||||||
|
FROM_LISTING_PERCENT = (6, 12, 6, 70, 6)
|
||||||
|
|
||||||
|
# Статические страницы: адрес и заголовок.
|
||||||
|
STATIC_PAGES = (
|
||||||
|
("/delivery", "Доставка и оплата"),
|
||||||
|
("/about", "О магазине"),
|
||||||
|
("/contacts", "Контакты"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Запросы к поиску по сайту. В адресе они лежат процентными кодами — так их
|
||||||
|
# отдаёт и выгрузка, и разбор такого адреса сам по себе материал лабы.
|
||||||
|
SEARCH_QUERIES = tuple(
|
||||||
|
quote(text)
|
||||||
|
for text in (
|
||||||
|
"полотенца",
|
||||||
|
"сковорода",
|
||||||
|
"постельное бельё",
|
||||||
|
"детский стульчик",
|
||||||
|
"электрочайник",
|
||||||
|
"шторы",
|
||||||
|
"набор кастрюль",
|
||||||
|
"тапочки",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Вариант товара — цвет или исполнение карточки. Живёт только в сыром
|
||||||
|
# `ecommerce`: в плоских массивах события такой колонки нет, и это одна из
|
||||||
|
# причин, по которым лаба «сырое против разобранного» вообще имеет смысл.
|
||||||
|
PRODUCT_VARIANTS = (
|
||||||
|
"стандарт",
|
||||||
|
"белый",
|
||||||
|
"серый",
|
||||||
|
"бежевый",
|
||||||
|
"синий",
|
||||||
|
"зелёный",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Коды типа устройства у Метрики: 1 — десктоп, 2 — телефон, 3 — планшет,
|
||||||
|
# 4 — телевизор. Телефон назван отдельно: по нему различаются и мобильный
|
||||||
|
# адрес, и вторая кука пары — «телефон и ноутбук» (мастер-спека, раздел 5).
|
||||||
|
PHONE_CATEGORY = 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DeviceProfile:
|
||||||
|
"""Устройство посетителя целиком: браузер, ОС, экран.
|
||||||
|
|
||||||
|
Это паспорт куки: кука — браузер на устройстве, поэтому во всех её
|
||||||
|
визитах профиль один и тот же (спека генератора, раздел 9).
|
||||||
|
"""
|
||||||
|
|
||||||
|
weight: int
|
||||||
|
category: int # коды Метрики, см. `PHONE_CATEGORY`
|
||||||
|
browser: str
|
||||||
|
browser_major_version: int
|
||||||
|
language: str
|
||||||
|
operating_system: str
|
||||||
|
operating_system_root: str
|
||||||
|
phone_model: str
|
||||||
|
screen_width: int
|
||||||
|
screen_height: int
|
||||||
|
|
||||||
|
|
||||||
|
# Доли устройств — правдоподобная российская розница: мобильных около двух
|
||||||
|
# третей, десктоп треть, планшеты тонкой полосой.
|
||||||
|
# fmt: off
|
||||||
|
DEVICE_PROFILES = (
|
||||||
|
DeviceProfile(14, 1, "Chrome", 131, "ru",
|
||||||
|
"Windows 10", "Windows", "", 1920, 1080),
|
||||||
|
DeviceProfile(6, 1, "Chrome", 131, "ru",
|
||||||
|
"Windows 11", "Windows", "", 1366, 768),
|
||||||
|
DeviceProfile(6, 1, "YandexBrowser", 24, "ru",
|
||||||
|
"Windows 10", "Windows", "", 1920, 1080),
|
||||||
|
DeviceProfile(3, 1, "Safari", 17, "ru",
|
||||||
|
"macOS 14", "macOS", "", 1440, 900),
|
||||||
|
DeviceProfile(2, 1, "Firefox", 133, "ru",
|
||||||
|
"Windows 10", "Windows", "", 1600, 900),
|
||||||
|
DeviceProfile(1, 1, "Chrome", 131, "en",
|
||||||
|
"Ubuntu 24.04", "Linux", "", 1920, 1080),
|
||||||
|
DeviceProfile(10, 2, "Safari", 17, "ru",
|
||||||
|
"iOS 17.4", "iOS", "iPhone 14", 390, 844),
|
||||||
|
DeviceProfile(6, 2, "Safari", 16, "ru",
|
||||||
|
"iOS 16.6", "iOS", "iPhone 12", 390, 844),
|
||||||
|
DeviceProfile(13, 2, "Chrome", 131, "ru",
|
||||||
|
"Android 14", "Android", "Galaxy A53", 412, 915),
|
||||||
|
DeviceProfile(12, 2, "Chrome", 130, "ru",
|
||||||
|
"Android 13", "Android", "Redmi Note 12", 393, 873),
|
||||||
|
DeviceProfile(9, 2, "YandexBrowser", 24, "ru",
|
||||||
|
"Android 13", "Android", "Honor X8", 360, 780),
|
||||||
|
DeviceProfile(6, 2, "Samsung Internet", 26, "ru",
|
||||||
|
"Android 14", "Android", "Galaxy S23", 360, 780),
|
||||||
|
DeviceProfile(5, 2, "Chrome", 131, "ru",
|
||||||
|
"Android 12", "Android", "Vivo Y21", 360, 800),
|
||||||
|
DeviceProfile(4, 3, "Safari", 17, "ru",
|
||||||
|
"iPadOS 17.4", "iOS", "", 810, 1080),
|
||||||
|
DeviceProfile(3, 3, "Chrome", 130, "ru",
|
||||||
|
"Android 13", "Android", "", 800, 1280),
|
||||||
|
)
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class City:
|
||||||
|
"""Город посетителя: id региона, часовой пояс, ломоть адресов.
|
||||||
|
|
||||||
|
Гео-идентификаторы — настоящие числа геобазы Яндекса, а не выдуманные:
|
||||||
|
каждый проверен 2026-08-02 обращением к живым сервисам Яндекса по этому
|
||||||
|
же номеру (`yandex.ru/pogoda/<id>`, `yandex.ru/maps/225/russia/`) —
|
||||||
|
страница открывает ожидаемое место. Оговорка честная: опубликованной
|
||||||
|
таблицы геобазы найти не удалось, а что `RegionCityID` Метрики нумерует
|
||||||
|
регионы той же геобазой — обоснованное допущение, а не подтверждённый
|
||||||
|
источником факт. Часовые пояса — из Википедии; часы в России не
|
||||||
|
переводят с 2014 года, поэтому смещение постоянное.
|
||||||
|
|
||||||
|
Адреса нероутируемые (спека генератора, раздел 9): городу отводится
|
||||||
|
ломоть документационных сетей RFC 5737 или benchmark-сети 198.18/15.
|
||||||
|
Правдоподобные публичные адреса принадлежат живым организациям, и в
|
||||||
|
учебных данных им не место.
|
||||||
|
"""
|
||||||
|
|
||||||
|
weight: int
|
||||||
|
name: str # по-английски, как в выгрузке Метрики
|
||||||
|
region_id: int
|
||||||
|
timezone_minutes: int
|
||||||
|
ip_prefix: str
|
||||||
|
|
||||||
|
|
||||||
|
COUNTRY_NAME = "RU"
|
||||||
|
COUNTRY_REGION_ID = 225
|
||||||
|
|
||||||
|
# Регион присутствия — Поволжье с центром в Самаре: свой миллионник, города
|
||||||
|
# своего региона и тонкий хвост остальной страны (спека генератора,
|
||||||
|
# раздел 9). «Топ городов России» дал бы магазину с одним складом карту,
|
||||||
|
# которой у него быть не может.
|
||||||
|
CITIES = (
|
||||||
|
City(34, "Samara", 51, 240, "192.0.2."),
|
||||||
|
City(12, "Tolyatti", 240, 240, "198.51.100."),
|
||||||
|
City(5, "Syzran", 11139, 240, "203.0.113."),
|
||||||
|
City(7, "Ulyanovsk", 195, 240, "198.18.0."),
|
||||||
|
City(7, "Saratov", 194, 240, "198.18.1."),
|
||||||
|
City(5, "Penza", 49, 180, "198.18.2."),
|
||||||
|
City(5, "Kazan", 43, 180, "198.18.3."),
|
||||||
|
City(4, "Ufa", 172, 300, "198.18.4."),
|
||||||
|
City(4, "Orenburg", 48, 300, "198.18.5."),
|
||||||
|
City(3, "Nizhny Novgorod", 47, 180, "198.18.6."),
|
||||||
|
City(2, "Volgograd", 38, 180, "198.18.7."),
|
||||||
|
City(5, "Moscow", 213, 180, "198.18.8."),
|
||||||
|
City(3, "Saint Petersburg", 2, 180, "198.18.9."),
|
||||||
|
City(2, "Yekaterinburg", 54, 300, "198.18.10."),
|
||||||
|
City(1, "Novosibirsk", 65, 420, "198.18.11."),
|
||||||
|
City(1, "Krasnodar", 35, 180, "198.18.12."),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Мобильный интернет: операторы раздают телефонам адреса CGNAT-диапазона
|
||||||
|
# 100.64/10 — второй байт от 64 до 127. Город по такому адресу не читается,
|
||||||
|
# как и в жизни.
|
||||||
|
MOBILE_IP_FIRST_BYTE = 100
|
||||||
|
MOBILE_IP_SECOND_BYTE = (64, 128)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TrafficSource:
|
||||||
|
"""Откуда посетитель пришёл: реферер, метки перехода, вход на сайт.
|
||||||
|
|
||||||
|
Метки кликов отданы колонками `HasGCLID` и `YCLID`: их выгрузка
|
||||||
|
разбирает за нас. UTM остаются и в адресе входа — как в жизни.
|
||||||
|
"""
|
||||||
|
|
||||||
|
weight: int
|
||||||
|
last_traffic_source: str
|
||||||
|
referer: str
|
||||||
|
utm_source: str
|
||||||
|
utm_medium: str
|
||||||
|
utm_campaign: str
|
||||||
|
utm_content: str
|
||||||
|
utm_term: str
|
||||||
|
has_gclid: int
|
||||||
|
has_yclid: bool
|
||||||
|
entry_percent: tuple[int, ...] # вход на сайт по колонкам `BROWSE_PAGES`
|
||||||
|
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
TRAFFIC_SOURCES = (
|
||||||
|
TrafficSource(30, "organic", "https://yandex.ru/search/",
|
||||||
|
"", "", "", "", "", 0, False, (10, 30, 5, 50, 5)),
|
||||||
|
TrafficSource(9, "organic", "https://www.google.com/",
|
||||||
|
"", "", "", "", "", 0, False, (10, 30, 5, 50, 5)),
|
||||||
|
TrafficSource(22, "direct", "",
|
||||||
|
"", "", "", "", "", 0, False, (55, 15, 5, 15, 10)),
|
||||||
|
TrafficSource(8, "ad", "https://yandex.ru/",
|
||||||
|
"yandex", "cpc", "posuda-poisk", "text-1", "kupit-skovorodu",
|
||||||
|
0, True, (5, 45, 0, 50, 0)),
|
||||||
|
TrafficSource(6, "ad", "https://an.yandex.ru/",
|
||||||
|
"yandex", "cpc", "tekstil-rsya", "banner-2", "",
|
||||||
|
0, True, (5, 45, 0, 50, 0)),
|
||||||
|
TrafficSource(5, "ad", "https://www.google.com/",
|
||||||
|
"google", "cpc", "home-shopping", "ad-1", "postelnoe-belyo",
|
||||||
|
1, False, (5, 45, 0, 50, 0)),
|
||||||
|
TrafficSource(7, "social", "https://vk.com/",
|
||||||
|
"vk", "social", "vk-lenta", "post-3", "",
|
||||||
|
0, False, (15, 25, 0, 55, 5)),
|
||||||
|
TrafficSource(5, "email", "",
|
||||||
|
"email", "email", "nedelnaya-rassylka", "blok-1", "",
|
||||||
|
0, False, (10, 40, 0, 45, 5)),
|
||||||
|
TrafficSource(5, "referral", "https://market.example.com/",
|
||||||
|
"", "", "", "", "", 0, False, (5, 25, 0, 65, 5)),
|
||||||
|
TrafficSource(3, "recommend", "https://dzen.ru/",
|
||||||
|
"", "", "", "", "", 0, False, (10, 30, 0, 55, 5)),
|
||||||
|
)
|
||||||
|
# fmt: on
|
||||||
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user