Compare commits
212
Commits
664abf0160
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
849630f153 | ||
|
|
453a1d6ddf | ||
|
|
9394b789e5 | ||
|
|
4693bc5af9 | ||
|
|
3c3eb825e1 | ||
|
|
d6b172e511 | ||
|
|
b839a671a1 | ||
|
|
c1d3a6db9b | ||
|
|
bb8605eed7 | ||
|
|
3e9249933b | ||
|
|
bb087f4477 | ||
|
|
515f386e5f | ||
|
|
2502277af1 | ||
|
|
ddb186f841 | ||
|
|
090df8a715 | ||
|
|
7a002f4d5a | ||
|
|
d15f4fe4d8 | ||
|
|
431fed72ad | ||
|
|
75f28fe99f | ||
|
|
83434102d5 | ||
|
|
086007fbd4 | ||
|
|
5f317ba8d2 | ||
|
|
84dc718f62 | ||
|
|
5a3e3f92d9 | ||
|
|
f3373f1e8f | ||
|
|
915e976fc2 | ||
|
|
ce15836093 | ||
|
|
8c9c10a875 | ||
|
|
1b95ce456c | ||
|
|
3bfe9ca4ee | ||
|
|
4058e18864 | ||
|
|
a03838b953 | ||
|
|
3ac3911e95 | ||
|
|
b6c10ecf21 | ||
|
|
3ad377e02e | ||
|
|
10c94b206a | ||
|
|
f438e91d80 | ||
|
|
7b4842d684 | ||
|
|
8771e2d675 | ||
|
|
30ddcf470e | ||
|
|
af3f23f9ea | ||
|
|
f0c7fe0c74 | ||
|
|
1671e67813 | ||
|
|
ea56314c25 | ||
|
|
3ec4d79576 | ||
|
|
9734449eae | ||
|
|
6e4680d12d | ||
|
|
7918eeed25 | ||
|
|
7ca39edb4c | ||
|
|
596bcba01e | ||
|
|
d686de516b | ||
|
|
d7e8a6f1a1 | ||
|
|
05f4e775e2 | ||
|
|
49a156537b | ||
|
|
2a06582062 | ||
|
|
e07484e9c2 | ||
|
|
4c95dc3a5b | ||
|
|
c4f8051c70 | ||
|
|
6804929fb0 | ||
|
|
d955f7d694 | ||
|
|
8b9258ed41 | ||
|
|
65575346b6 | ||
|
|
29a01f0016 | ||
|
|
dc2cbc99dc | ||
|
|
cffe1aeb73 | ||
|
|
830efa7ef4 | ||
|
|
92948986a0 | ||
|
|
3fa669a920 | ||
|
|
fddb35c8c4 | ||
|
|
466acafce6 | ||
|
|
4995628f2a | ||
|
|
043d1c1382 | ||
|
|
ac21399fe4 | ||
|
|
b15d5253ee | ||
|
|
46e82b5240 | ||
|
|
354b8abe56 | ||
|
|
7108cd4af3 | ||
|
|
e56b23816e | ||
|
|
59d8c83b17 | ||
|
|
86ad8afaf2 | ||
|
|
7a3542d933 | ||
|
|
bad5981904 | ||
|
|
2588515b9a | ||
|
|
ab35560ece | ||
|
|
d1dc0dd7bf | ||
|
|
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 |
No files matched your search
@@ -7,8 +7,11 @@ CLICKHOUSE_02_TCP_PORT=29001
|
|||||||
CLICKHOUSE_CLUSTER_SECRET=cluster-26-secret
|
CLICKHOUSE_CLUSTER_SECRET=cluster-26-secret
|
||||||
CLICKHOUSE_DEFAULT_PASSWORD=default-26-local
|
CLICKHOUSE_DEFAULT_PASSWORD=default-26-local
|
||||||
CLICKHOUSE_ETL_PASSWORD=etl-26-writer
|
CLICKHOUSE_ETL_PASSWORD=etl-26-writer
|
||||||
|
CLICKHOUSE_LIFECYCLE_PASSWORD=lifecycle-26-owner
|
||||||
CLICKHOUSE_BI_PASSWORD=bi-26-reader
|
CLICKHOUSE_BI_PASSWORD=bi-26-reader
|
||||||
CLICKHOUSE_ANALYST_PASSWORD=analyst-26-reader
|
CLICKHOUSE_ANALYST_PASSWORD=analyst-26-reader
|
||||||
|
CLICKHOUSE_STUDENT_PASSWORD=student-26-lab
|
||||||
|
CLICKHOUSE_GRAFANA_PASSWORD=grafana-26-reader
|
||||||
KAFKA_EXTERNAL_PORT=29092
|
KAFKA_EXTERNAL_PORT=29092
|
||||||
PROMETHEUS_PORT=29090
|
PROMETHEUS_PORT=29090
|
||||||
GRAFANA_PORT=23000
|
GRAFANA_PORT=23000
|
||||||
|
|||||||
@@ -8,15 +8,26 @@
|
|||||||
Стенд строится по спеке
|
Стенд строится по спеке
|
||||||
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
|
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
|
||||||
|
|
||||||
Отсюда вопрос к любой доработке — коду, проверке, документу: **чему на ней
|
Стенд рассчитан на начинающих инженеров данных. Они недавно изучили ClickHouse,
|
||||||
научится менти?** Ответ называется одной фразой до того, как писать. Не
|
но почти не работали с ним руками. Здесь они разбирают и достраивают хранилище
|
||||||
|
небольшого интернет-магазина: проходят путь от загрузки данных до витрин и BI.
|
||||||
|
Программа дает практику общепринятых инженерных приемов. После нее участники
|
||||||
|
должны уметь построить похожее хранилище без готового образца. Состав и порядок
|
||||||
|
работы задает [программа учебных заданий](docs/specs/2026-09-01-labs-program.md).
|
||||||
|
|
||||||
|
Стенд существует только ради обучения. Его поддержка имеет смысл, когда
|
||||||
|
сохраняет возможность пройти программу или сама становится учебной задачей.
|
||||||
|
Отдельной эксплуатационной ценности у стенда нет.
|
||||||
|
|
||||||
|
Отсюда вопрос к любой доработке — коду, проверке, документу: **чему она
|
||||||
|
учит?** Ответ называется одной фразой до того, как писать. Не
|
||||||
складывается фраза — это вопрос владельцу, а не строчка кода: проверки в этом
|
складывается фраза — это вопрос владельцу, а не строчка кода: проверки в этом
|
||||||
репозитории однажды уже переросли продукт.
|
репозитории однажды уже переросли продукт.
|
||||||
|
|
||||||
**Усложнение не бесплатно, и платит за него учебная ценность.** Чем сложнее
|
**Усложнение не бесплатно, и платит за него учебная ценность.** Чем сложнее
|
||||||
стенд, тем хуже он как учебный материал: внимание менти конечно, и каждая
|
стенд, тем хуже он как учебный материал: внимание читателя конечно, и каждая
|
||||||
конструкция, которую он обязан расшифровать по дороге к уроку, списывается с
|
конструкция, которую он обязан расшифровать по дороге к уроку, списывается с
|
||||||
этого счёта. Поэтому вопрос выше имеет вторую половину: **что менти платит,
|
этого счёта. Поэтому вопрос выше имеет вторую половину: **что читатель платит,
|
||||||
чтобы это прочесть, и что получает взамен?** Ответ «вопрос — и ничего»
|
чтобы это прочесть, и что получает взамен?** Ответ «вопрос — и ничего»
|
||||||
означает, что писать не нужно.
|
означает, что писать не нужно.
|
||||||
|
|
||||||
@@ -35,15 +46,10 @@
|
|||||||
|
|
||||||
## Кому что поручать
|
## Кому что поручать
|
||||||
|
|
||||||
Читатель здесь не побочный потребитель, а тот, ради кого стенд существует:
|
Автор задачи сам пишет и редактирует код, комментарии и документы, если
|
||||||
код, комментарии и документы и есть продукт. Поэтому всё, что человек будет
|
владелец не задал другое распределение работы.
|
||||||
читать и разбирать, пишет модель с чувством меры — сейчас это Opus. Codex
|
|
||||||
берёт техническую работу без вкусовых решений: миграции, разбор журналов и
|
|
||||||
объёмного вывода, обход репозитория, повторяющиеся правки. Там его запас
|
|
||||||
лимитов работает на нас, а экономить их на читаемом коде — экономия ложная:
|
|
||||||
переписывать выйдет дороже, чем сразу написать хорошо.
|
|
||||||
|
|
||||||
Делить работу между ними стоит не целыми задачами, а подзадачами, и решать
|
Делить работу стоит не целыми задачами, а подзадачами, и решать
|
||||||
это при планировании. Вкусовая часть идёт первой: она задаёт форму, имена и
|
это при планировании. Вкусовая часть идёт первой: она задаёт форму, имена и
|
||||||
границы, под которые механическая потом подстраивается.
|
границы, под которые механическая потом подстраивается.
|
||||||
|
|
||||||
@@ -70,6 +76,9 @@ Airflow) и названия из кода. Если для понятия ес
|
|||||||
писал: простые слова, короткие фразы, сложную мысль поясняй при первом
|
писал: простые слова, короткие фразы, сложную мысль поясняй при первом
|
||||||
упоминании. Если понятность и буквальная точность спорят — выбирай понятность.
|
упоминании. Если понятность и буквальная точность спорят — выбирай понятность.
|
||||||
|
|
||||||
|
При создании или редактировании лабораторных следуй
|
||||||
|
[правилам учебного текста](docs/specs/2026-09-01-labs-program.md#как-писать-задания).
|
||||||
|
|
||||||
Внутренние рассуждения и промежуточные пометки по ходу работы веди на
|
Внутренние рассуждения и промежуточные пометки по ходу работы веди на
|
||||||
английском — он экономнее по токенам. На русском остаётся всё, что видит и
|
английском — он экономнее по токенам. На русском остаётся всё, что видит и
|
||||||
хранит проект: итоговые ответы, документы, комментарии в коде и SQL, сообщения
|
хранит проект: итоговые ответы, документы, комментарии в коде и SQL, сообщения
|
||||||
@@ -82,6 +91,11 @@ Airflow) и названия из кода. Если для понятия ес
|
|||||||
`generator/` — код генератора. Правишь даги или Superset — зови корневую,
|
`generator/` — код генератора. Правишь даги или Superset — зови корневую,
|
||||||
правишь генератор — зови из `generator/`.
|
правишь генератор — зови из `generator/`.
|
||||||
- Изменения держать минимальными и в границах задания.
|
- Изменения держать минимальными и в границах задания.
|
||||||
|
- `make clean` полностью удаляет постоянное состояние стенда. Перед этой
|
||||||
|
командой назови состояние вне прикладного мира, которое требуется удалить,
|
||||||
|
и объясни, почему `make rebuild-storage` его не удаляет. При изменении DDL
|
||||||
|
или проверке на чистых данных по умолчанию используй адресную очистку либо
|
||||||
|
`make rebuild-storage`.
|
||||||
- Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук
|
- Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук
|
||||||
обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md).
|
обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md).
|
||||||
- Секреты не коммитить: настройки — через `.env`, образец — `.env.example`.
|
- Секреты не коммитить: настройки — через `.env`, образец — `.env.example`.
|
||||||
@@ -152,6 +166,21 @@ Airflow) и названия из кода. Если для понятия ес
|
|||||||
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
|
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
|
||||||
истории, а текущее описание живого формата — и собирается из кода, а не
|
истории, а текущее описание живого формата — и собирается из кода, а не
|
||||||
пишется руками.
|
пишется руками.
|
||||||
|
- Имена файлов основной программы в `docs/labs/` — `S-N-slug.md`:
|
||||||
|
номер ступени, номер задания внутри ступени и слаг строчными латинскими
|
||||||
|
буквами через дефис (`1-2-top-pages-daily.md`). Здесь живут ТЗ
|
||||||
|
учебных заданий. Номера повторяют порядок программы из `README.md`, чтобы
|
||||||
|
папка читалась как оглавление; даты нет: задание — живой документ и
|
||||||
|
правится вместе с тем, о чем говорит. Слаг называет предмет задания.
|
||||||
|
Факультативы живут в `docs/labs/optional/`, без номеров, под одним слагом
|
||||||
|
(`superset-dashboard.md`). Общий индекс обеих частей — `docs/labs/README.md`;
|
||||||
|
отдельного индекса в `optional/` нет.
|
||||||
|
- Имена файлов в `docs/handbook/` — слаг строчными латинскими буквами через
|
||||||
|
дефис, индекс папки — `README.md`. Здесь живет путеводитель по стенду:
|
||||||
|
учебные главы для читателя. Ни дат, ни номеров: глава — живой документ и
|
||||||
|
правится вместе с тем, что описывает. Исходники интерактивной карты лежат
|
||||||
|
подпапкой `architecture-map/`, а ее рендер — файлом `architecture-map.html`
|
||||||
|
рядом с главами.
|
||||||
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
|
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
|
||||||
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
|
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
|
||||||
в имени не нужна.
|
в имени не нужна.
|
||||||
|
|||||||
@@ -1 +1,7 @@
|
|||||||
@AGENTS.md
|
@AGENTS.md
|
||||||
|
|
||||||
|
# Читабельность языка
|
||||||
|
|
||||||
|
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
|
||||||
|
|
||||||
|
Please remove all mannered prose.
|
||||||
+237
-173
@@ -1,211 +1,275 @@
|
|||||||
# Кликстрим-платформа (стенд v2)
|
# Кликстрим-платформа (стенд v2)
|
||||||
|
|
||||||
Словарь понятий проекта: одни и те же слова для одних и тех же вещей —
|
Словарь понятий, которые встречаются в учебных заданиях, документации и коде
|
||||||
у владельца, кода, документов и агентов. Только язык, никаких решений.
|
стенда.
|
||||||
|
|
||||||
## Язык
|
## Язык
|
||||||
|
|
||||||
**Хранилище**:
|
- [Люди и события](#люди-и-события)
|
||||||
Аналитическая база стенда — кластер ClickHouse со слоями STG/ODS/DDS/DM.
|
- [Хранилище и форматы данных](#хранилище-и-форматы-данных)
|
||||||
Принимающая сторона границы «трекер | хранилище»: нормализует имена и стили
|
- [Заказы](#заказы)
|
||||||
источников, строится по их документации.
|
- [Модельное время и запуск стенда](#модельное-время-и-запуск-стенда)
|
||||||
_Избегать_: склад, склад данных
|
- [Устройство генератора](#устройство-генератора) — для чтения его кода
|
||||||
|
и технических справочников.
|
||||||
|
|
||||||
**Состав мира**:
|
### Люди и события
|
||||||
Постоянная часть мира генератора — популяция посетителей, их привычки,
|
|
||||||
двухкуковые пары. По дням его выдаёт план состава.
|
|
||||||
_Избегать_: состояние мира
|
|
||||||
|
|
||||||
**План состава**:
|
|
||||||
Способ спросить состав мира: функция зерна, выдающая его по дням —
|
|
||||||
когорту новых кук, их возвраты, назначенные заказы двухкуковых пар.
|
|
||||||
|
|
||||||
**Подпоток**:
|
|
||||||
Ветвь дерева случайности генератора: своё зерно у состава мира, у каждого
|
|
||||||
дня и у каждого компонента дня. Подпоток задан позицией в дереве, а не
|
|
||||||
порядком вычислений.
|
|
||||||
|
|
||||||
**Конфигурация мира**:
|
|
||||||
Модуль чистых данных со всеми числами мира: приток, профили возвратов, доли
|
|
||||||
покупателей, D0. Правка модуля — смена мира. Модуль-близнец контракта схемы:
|
|
||||||
там колонки, здесь числа.
|
|
||||||
|
|
||||||
**Когорта дня**:
|
|
||||||
Люди, впервые пришедшие в мир в один день, со всеми их куками и днями
|
|
||||||
активности. Единица плана состава: когорта — функция зерна и номера дня.
|
|
||||||
|
|
||||||
**Человек**:
|
**Человек**:
|
||||||
Устойчивая личность в составе мира, которой принадлежат одна или две куки.
|
Участник модели магазина, который может заходить с одного или двух
|
||||||
Не поле источника: кликстрим видит куку, а заказ — пользователя магазина.
|
устройств. Генератор знает, какие идентификаторы посетителя ему принадлежат,
|
||||||
|
но в событиях сайта этой связи нет.
|
||||||
|
|
||||||
**Посетитель**:
|
**Посетитель**:
|
||||||
Кука, наблюдаемая в кликстриме и обозначенная `ClientID`; единица
|
Браузер на устройстве, который счетчик сайта узнает по куке — сохраненному
|
||||||
`uniq(ClientID)`. Один человек может быть представлен несколькими посетителями.
|
в браузере идентификатору. В кликстриме он обозначается `ClientID`:
|
||||||
_Избегать_: «посетитель» про человека после склейки
|
`uniq(ClientID)` считает посетителей, и у одного человека их может быть несколько.
|
||||||
|
_Избегать_: «посетитель» про человека после объединения его идентификаторов.
|
||||||
|
|
||||||
**Пользователь магазина**:
|
**Пользователь магазина**:
|
||||||
Представление человека со стороны заказов; в записи заказа обозначается
|
Человек, известный серверной части магазина под идентификатором `user_id`.
|
||||||
`user_id`. Через заказы связывает разные куки человека, в кликстрим напрямую
|
Он указан в заказах и позволяет связать разные `ClientID` одного человека;
|
||||||
не попадает.
|
в кликстриме поля `user_id` нет. Например, покупки с телефона и ноутбука
|
||||||
|
могут иметь разные `ClientID`, но один `user_id` в заказах.
|
||||||
**Приток**:
|
|
||||||
Появление новых кук на всём протяжении оси модельного времени; единица —
|
|
||||||
кука (`ClientID`). Из-за притока накопленная аудитория растёт с
|
|
||||||
горизонтом и не совпадает с дневной.
|
|
||||||
|
|
||||||
**Хвост возвратов**:
|
|
||||||
Окно активности, отсчитанное от первого дня человека и общее на обе
|
|
||||||
его куки; дольше окна кука не возвращается.
|
|
||||||
|
|
||||||
**Предыстория**:
|
|
||||||
Когорты плана с первым днём активности до D0; событий не порождают.
|
|
||||||
|
|
||||||
**День активности**:
|
|
||||||
День, в который кука хоть раз появилась в мире: день её рождения или день
|
|
||||||
возврата. Единица плана состава: за день он у куки один, а визитов внутри
|
|
||||||
него бывает несколько.
|
|
||||||
_Избегать_: визит (визит — про сессию)
|
|
||||||
|
|
||||||
**Визит**:
|
**Визит**:
|
||||||
Подряд идущие события одной куки без пауз длиннее 30 минут, не пересекающие
|
Последовательность событий одного посетителя без пауз длиннее 30 минут
|
||||||
границу суток. Поле `VisitID` — эталон для лабы сессий. «Сессия» — то же
|
и без перехода через полночь по часовому поясу счетчика. Сессия означает
|
||||||
понятие словами аналитики: в данных и в коде оно зовётся визитом, но имена
|
то же самое; поле `VisitID` служит эталоном в задании по нарезке сессий.
|
||||||
вроде «лаба сессий» и «сборка сессий» остаются.
|
|
||||||
|
**День активности**:
|
||||||
|
День, в который посетитель появился на сайте хотя бы раз: впервые или
|
||||||
|
вернувшись. За один день активности у него может быть несколько визитов.
|
||||||
|
_Избегать_: визит в значении целого дня активности.
|
||||||
|
|
||||||
**Паспорт куки**:
|
**Паспорт куки**:
|
||||||
Устройство и город, приписанные куке на всю жизнь: кука — это браузер на
|
Устройство и город, закрепленные за посетителем во все дни его активности.
|
||||||
устройстве. Держит их план состава, разворачивает в поля события
|
Генератор записывает их в поля событий. У двух посетителей одного человека
|
||||||
день-функция; у двухкуковой пары город один на две куки, устройства разные.
|
город совпадает, а устройства различаются.
|
||||||
|
|
||||||
**Покупатель**:
|
|
||||||
Человек, которого план состава пометил склонным покупать. Метка значима:
|
|
||||||
помеченный доходит до заказа заметно чаще прочих, но и непомеченный иногда
|
|
||||||
покупает. Из покупателей отбираются двухкуковые пары.
|
|
||||||
_Избегать_: «покупатель» про того, кто купил в конкретный день — это визит
|
|
||||||
с заказом.
|
|
||||||
|
|
||||||
**День-функция**:
|
|
||||||
Функция (зерно, номер дня), выдающая упорядоченный поток событий этих
|
|
||||||
модельных суток. Состояния между днями нет: день D не зависит от того,
|
|
||||||
прожиты ли дни до него.
|
|
||||||
|
|
||||||
**Торговое событие**:
|
**Торговое событие**:
|
||||||
Событие корзины или покупки — отдельная строка потока, а не просмотр
|
Запись о добавлении товара в корзину или о покупке: `add_to_cart` или
|
||||||
страницы. Садится на ту страницу, где случилось: корзина — на карточку
|
`purchase`. Это отдельная строка кликстрима со страницей, где произошло
|
||||||
товара, покупка — на страницу подтверждения заказа.
|
действие: карточкой товара для корзины или страницей подтверждения для покупки.
|
||||||
|
|
||||||
|
### Хранилище и форматы данных
|
||||||
|
|
||||||
|
**Хранилище**:
|
||||||
|
Аналитическая база стенда в ClickHouse. Данные проходят слои STG, ODS, DDS
|
||||||
|
и DM: от исходных сообщений до витрин для отчетов. Хранилище принимает
|
||||||
|
данные по документации источников и приводит их к своей модели и именам.
|
||||||
|
_Избегать_: склад, склад данных.
|
||||||
|
|
||||||
|
**Сырые данные**:
|
||||||
|
Исходные сообщения Kafka, сохраненные в слое STG без разбора: одно сообщение
|
||||||
|
занимает одну строку таблицы. Слой STG также называют сырым слоем.
|
||||||
|
_Избегать_: сырьё.
|
||||||
|
|
||||||
**Справочник**:
|
**Справочник**:
|
||||||
Данные, которые хранилище не производит, а получает готовыми со стороны:
|
Готовые сведения из внешнего источника, которыми хранилище дополняет
|
||||||
содержимым владеет источник вне хранилища, слои только читают. Живут в базе
|
свои данные. На стенде они находятся в базе `dic`; пока это только каталог
|
||||||
`dic`. Первый и пока единственный — каталог товаров.
|
товаров. Объект `DICTIONARY` ClickHouse — одна из форм доступа к справочнику.
|
||||||
_Избегать_: словарь — этим словом в проекте зовут и глоссарий понятий, и
|
_Избегать_: словарь в значении справочных данных: это слово уже обозначает
|
||||||
объект `DICTIONARY` ClickHouse, одну из форм, в которой справочник доступен.
|
`DICTIONARY` и словарь понятий.
|
||||||
|
|
||||||
**Каталог товаров**:
|
**Каталог товаров**:
|
||||||
`data/catalog/products.csv` — общий файл генератора и словаря
|
Список товаров с артикулами, названиями, категориями, брендами, ценами
|
||||||
ClickHouse. Форма файла решена, длина — нет: строки дописываются.
|
и уровнями спроса. Файл `data/catalog/products.csv` общий для генератора
|
||||||
|
и справочника ClickHouse; товары можно добавлять строками в этом формате.
|
||||||
**Уровень спроса**:
|
|
||||||
Свойство товара в каталоге: как часто открытую карточку кладут в корзину.
|
|
||||||
Три значения — магнит, обычный, залёживается. Постоянная часть мира, а не
|
|
||||||
поведение дня: он и делает конверсию «просмотр → корзина» по товарам и
|
|
||||||
брендам различимой.
|
|
||||||
|
|
||||||
**Ось модельного времени**:
|
|
||||||
Собственный календарь мира генератора. Дни считаются от фиксированного
|
|
||||||
первого дня D0; реальный календарь в модели не участвует. Между прогонами
|
|
||||||
живут только зерно и позиция на оси.
|
|
||||||
|
|
||||||
**Стартовый мир**:
|
|
||||||
Первые восемь дней оси (понедельник по понедельник), которыми стенд
|
|
||||||
наполняется при каждом `make up`. Маленький кусок эталонного мира, всегда
|
|
||||||
один и тот же: на нём принимаются следующие этапы.
|
|
||||||
_Избегать_: зерновой мир
|
|
||||||
|
|
||||||
**Опись мира**:
|
|
||||||
`data/world-inventory.json` — единственное, что о мире хранится в git: паспорт
|
|
||||||
мира (зерно, версия генератора, хеш каталога), строка на каждый день событий
|
|
||||||
(число и хеш байтов) и строка на каждый отправленный слепок (хеш байтов).
|
|
||||||
Счётчик сверх этого заводится только с названным читателем. Сам мир не
|
|
||||||
хранится, а пересчитывается; опись отвечает на один вопрос: тот ли это мир.
|
|
||||||
_Избегать_: манифест, мини-манифест
|
|
||||||
|
|
||||||
**Пошаговый режим**:
|
|
||||||
Базовый способ движения по оси модельного времени: «прожить следующий
|
|
||||||
день» — явное действие.
|
|
||||||
|
|
||||||
**Живой день**:
|
|
||||||
Проигрывание текущего модельного дня в реальном времени с ускорением;
|
|
||||||
включается по требованию — постоянным фоном идёт, только пока включён
|
|
||||||
выключатель пульта.
|
|
||||||
|
|
||||||
**Пакетный режим**:
|
|
||||||
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
|
|
||||||
стенда, пересборки и проверки.
|
|
||||||
|
|
||||||
**Пульт мира**:
|
|
||||||
Даги Airflow, которыми двигают ось модельного времени: работники играют день —
|
|
||||||
пачкой или в темпе, — а выключатель держит стенд живущим, пока включён.
|
|
||||||
_Избегать_: управляющие даги
|
|
||||||
|
|
||||||
**Граница суток**:
|
|
||||||
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
|
|
||||||
снимается на ней, день проживается только целиком.
|
|
||||||
|
|
||||||
**Контракт схемы**:
|
|
||||||
Python-модуль с описателями колонок события — собственность генератора.
|
|
||||||
Из него выводятся генератор, валидация и документация формата; хранилище
|
|
||||||
строится по документации, не по модулю.
|
|
||||||
|
|
||||||
**Нормализованное имя**:
|
**Нормализованное имя**:
|
||||||
Имя колонки источника, приведённое к нашему стилю (snake_case). Живёт в
|
Имя поля источника, записанное в стиле `snake_case`: например,
|
||||||
контракте схемы и в описании выгрузки. Не то же, что имя атрибута в модели
|
`ClientID` становится `client_id`. Оно указано в описании выгрузки и
|
||||||
данных: слой DDS складывает модель и называет атрибуты по ней.
|
контракте схемы. Слой DDS выбирает имена по своей модели данных, поэтому
|
||||||
|
они не обязаны повторять нормализованные имена источника.
|
||||||
|
|
||||||
**Описание выгрузки**:
|
**Описание выгрузки**:
|
||||||
Публичная документация формата события: таблица колонок, собранная из
|
Документ, который объясняет поля и формат событий источника:
|
||||||
контракта схемы. По ней пишется сторона хранилища — как в бою по документации
|
[`docs/formats/clickstream-event.md`](docs/formats/clickstream-event.md).
|
||||||
источника. Правится только контракт, документ пересобирается.
|
По нему строится прием данных в хранилище. Документ собирается из
|
||||||
_Избегать_: описание схемы, документация контракта
|
описаний колонок генератора: правят их, затем пересобирают документ.
|
||||||
|
_Избегать_: описание схемы, документация контракта.
|
||||||
|
|
||||||
**Канонический сериализатор**:
|
**Техническая проверка**:
|
||||||
Единственная граница, где запись генератора превращается в байты. Фиксированный
|
Проверка по внутреннему эталону стенда, например сравнение числа событий
|
||||||
порядок ключей и строк — основа побайтовой воспроизводимости.
|
с паспортом мира. Проверка качества данных (DQ) опирается на данные
|
||||||
|
магазина и связи между ними; ей не нужен эталон генератора, которого
|
||||||
|
у настоящего магазина нет.
|
||||||
|
_Избегать_: DQ-проверка в значении проверки по паспорту мира.
|
||||||
|
|
||||||
**Проигрыватель**:
|
### Заказы
|
||||||
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
|
|
||||||
(пачкой, без темпа) и живой день.
|
|
||||||
|
|
||||||
**Слепок**:
|
**Слепок**:
|
||||||
Полная выгрузка заказов окна изменяемости, снятая бэкендом на границе суток:
|
Дневная выгрузка состояния заказов из серверной части магазина.
|
||||||
состояние заказов на этот момент, а не поток их изменений. Опоздавший заказ
|
Она содержит заказы, оформленные за последние семь модельных дней,
|
||||||
может отсутствовать в ранних слепках; после первого появления ездит до конца
|
с учетом изменений к концу дня. Опоздавший заказ может отсутствовать
|
||||||
своего окна.
|
в первых слепках.
|
||||||
|
|
||||||
**Дата слепка**:
|
**Дата слепка**:
|
||||||
Дата завершившегося модельного дня, состояние которого снято на исходящей
|
Дата модельного дня, на конец которого снято состояние заказов;
|
||||||
границе суток. Одна для всех записей выгрузки; не дата заказа и не время
|
поле `snapshot_date` одинаково во всех строках выгрузки. Даты оформления
|
||||||
отправки сообщения.
|
заказов могут различаться; выгрузка отправляется на следующий модельный день.
|
||||||
|
|
||||||
**Время аудита источника**:
|
**Время аудита источника**:
|
||||||
Время создания или последнего изменения строки заказа по часам базы источника.
|
Время создания и последнего изменения записи заказа по часам базы
|
||||||
В записи слепка это `created_at` и `updated_at`; бизнес-время покупки живёт в
|
магазина: поля `created_at` и `updated_at`. Время покупки в событии
|
||||||
событии `purchase` отдельно.
|
`purchase` записывает счетчик сайта, и оно может отличаться от этих полей.
|
||||||
|
|
||||||
**Судьба заказа**:
|
|
||||||
Исход, моменты изменений, вычеркнутая позиция и опоздание заказа — всё
|
|
||||||
решается бросками при его рождении и укладывается в окно изменяемости целиком.
|
|
||||||
Слепок любого дня — чтение готовой судьбы, а не накопление состояния.
|
|
||||||
|
|
||||||
**Окно изменяемости**:
|
**Окно изменяемости**:
|
||||||
Сколько модельных дней заказ ещё может измениться и потому продолжает ездить
|
Семь модельных дней, включая день оформления, в течение которых заказ
|
||||||
в слепках. Константа мира: семь дней. За окном заказ замёрз, выручка дня
|
может измениться и входит в слепки. После этого его состояние окончательно.
|
||||||
перестала «дышать».
|
Например, заказ от 1 июня входит в окно слепков за 1–7 июня; опоздание
|
||||||
|
сокращает число его появлений, но не сдвигает конец окна.
|
||||||
|
|
||||||
**Пакетный забор**:
|
**Пакетный забор**:
|
||||||
Способ приёма топика, при котором данные тянет запрос по команде, а не
|
Чтение сообщений Kafka отдельным запросом по команде. Так хранилище
|
||||||
матвью непрерывно. Так стенд принимает заказы: у слепка есть начало и конец,
|
принимает заказы после отправки всего слепка. События сайта оно принимает
|
||||||
и забирать его уместно тогда, когда он собран целиком. Противоположность —
|
непрерывно через материализованное представление.
|
||||||
потоковый приём событий.
|
_Избегать_: смешивать с пакетным режимом генератора: забор — чтение
|
||||||
_Избегать_: путать с приёмником и пакетным режимом — те про сторону
|
на стороне хранилища, режим генератора — отправка.
|
||||||
генератора, про отправку; забор — про сторону хранилища, про чтение.
|
|
||||||
|
### Модельное время и запуск стенда
|
||||||
|
|
||||||
|
**Мир**:
|
||||||
|
Модель интернет-магазина: люди, их посещения сайта, товары и заказы,
|
||||||
|
которые создает генератор.
|
||||||
|
|
||||||
|
**Ось модельного времени**:
|
||||||
|
Календарь магазина в генераторе. Первый день, D0, — 1 июня 2026 года,
|
||||||
|
понедельник (`world.ORIGIN`); следующие дни отсчитываются от него.
|
||||||
|
Реальное время — часы компьютера, на котором работает стенд. Дата запуска
|
||||||
|
не меняет даты событий: D0 остается 1 июня, даже если стенд запущен осенью.
|
||||||
|
|
||||||
|
**Граница суток**:
|
||||||
|
Конец одного модельного дня и начало следующего, полночь по часовому
|
||||||
|
поясу счетчика. Здесь заканчиваются визиты и снимается состояние заказов
|
||||||
|
для слепка. Даги времени продвигают мир только на целые дни.
|
||||||
|
|
||||||
|
**Стартовый мир**:
|
||||||
|
Данные первых восьми модельных дней, с понедельника по следующий
|
||||||
|
понедельник, которыми стенд наполняется при первом `make up`.
|
||||||
|
Команда вызывает `world_initialize`: если мир уже готов и сохранен,
|
||||||
|
повторный запуск оставляет данные и позицию времени прежними.
|
||||||
|
`make rebuild-storage` пересоздает стартовый мир. При неизменных коде,
|
||||||
|
каталоге и настройках его данные повторяются и служат эталоном проверок.
|
||||||
|
_Избегать_: зерновой мир.
|
||||||
|
|
||||||
|
**Паспорт мира**:
|
||||||
|
Файл для сверки с эталонными данными: `data/world-inventory.json`.
|
||||||
|
В нем записаны зерно генерации, версия генератора, контрольная сумма
|
||||||
|
каталога, число событий и контрольная сумма каждого начального дня,
|
||||||
|
контрольные суммы слепков и счетчики расхождений по заказам.
|
||||||
|
В git хранится паспорт; сами события и слепки пересчитываются генератором.
|
||||||
|
_Избегать_: опись мира, манифест, мини-манифест.
|
||||||
|
|
||||||
|
**Пошаговый режим**:
|
||||||
|
Продвижение модельного времени по явной команде: запустить генерацию
|
||||||
|
следующего дня или нескольких дней.
|
||||||
|
|
||||||
|
**Пакетный режим**:
|
||||||
|
Отправка событий готового модельного дня без пауз между ними.
|
||||||
|
Используется для начального наполнения, пересборок и проверок.
|
||||||
|
|
||||||
|
**Живой день**:
|
||||||
|
Постепенная отправка событий по времени их появления в модельном дне,
|
||||||
|
с ускорением: по умолчанию сутки занимают около 24 реальных минут.
|
||||||
|
Чтобы следующие дни запускались автоматически, снимают с паузы `world_live`.
|
||||||
|
|
||||||
|
**Даги модельного времени**:
|
||||||
|
Наборы связанных задач Airflow для генерации следующих дней магазина:
|
||||||
|
`world_next_day`, `world_live_day` и `world_live`. Первые два выполняют
|
||||||
|
шаг по времени, третий автоматически запускает живые дни, пока снят с паузы.
|
||||||
|
Короткое название — даги времени.
|
||||||
|
_Избегать_: пульт мира, управляющие даги.
|
||||||
|
|
||||||
|
**Шаговый даг**:
|
||||||
|
Даг, который отправляет данные следующих модельных дней и после успеха
|
||||||
|
записывает новую позицию времени. `world_next_day` отправляет их пачкой,
|
||||||
|
`world_live_day` — один день с ускорением. У них нет расписания;
|
||||||
|
их запускают вручную или из другого дага.
|
||||||
|
_Избегать_: работник.
|
||||||
|
|
||||||
|
### Устройство генератора
|
||||||
|
|
||||||
|
**Зерно генерации**:
|
||||||
|
Число, которое задает повторяемую последовательность случайных выборов
|
||||||
|
генератора. Одинаковые зерно и день дают одинаковые данные при неизменных
|
||||||
|
коде, каталоге и настройках. Эталон использует `CANONICAL_SEED` из `seeds.py`.
|
||||||
|
|
||||||
|
**Состав мира**:
|
||||||
|
Люди в модели, их идентификаторы посетителей и заранее заданные привычки:
|
||||||
|
когда они приходят, как часто возвращаются, склонны ли покупать.
|
||||||
|
У части людей две куки — это двухкуковые пары.
|
||||||
|
_Избегать_: состояние мира.
|
||||||
|
|
||||||
|
**План состава**:
|
||||||
|
Расчет того, какие посетители придут в каждый день и какие свойства у них
|
||||||
|
будут. В `plan.py` из зерна и номера дня получаются новые посетители,
|
||||||
|
их возвраты и назначенные заказы людей с двумя куками.
|
||||||
|
|
||||||
|
**Когорта дня**:
|
||||||
|
Группа людей, впервые пришедших в один день, со всеми их куками и днями
|
||||||
|
активности. Генератор рассчитывает ее по зерну и номеру первого дня.
|
||||||
|
|
||||||
|
**Приток**:
|
||||||
|
Появление новых посетителей (`ClientID`) по мере продвижения модельного
|
||||||
|
времени. Поэтому число разных посетителей за весь период растет и
|
||||||
|
не совпадает с числом посетителей за отдельный день.
|
||||||
|
|
||||||
|
**Хвост возвратов**:
|
||||||
|
Срок от первого появления человека, в пределах которого его куки могут
|
||||||
|
возвращаться на сайт. У человека с двумя куками конец срока общий.
|
||||||
|
|
||||||
|
**Предыстория**:
|
||||||
|
Люди, впервые пришедшие до D0, и план их активности. До D0 события
|
||||||
|
не создаются; возвраты этих людей дают повторных посетителей уже в первый
|
||||||
|
день генерируемых данных.
|
||||||
|
|
||||||
|
**Покупатель**:
|
||||||
|
Человек, которому генератор заранее назначил повышенную склонность
|
||||||
|
покупать. Он чаще доходит до заказа, но иногда покупают и остальные.
|
||||||
|
Из таких людей выбираются те, у кого будут две куки.
|
||||||
|
_Избегать_: покупатель в значении любого посетителя с покупкой за день;
|
||||||
|
для этого случая — «визит с заказом».
|
||||||
|
|
||||||
|
**Уровень спроса**:
|
||||||
|
Свойство товара, от которого зависит, как часто после просмотра карточки
|
||||||
|
его добавляют в корзину. В колонке `demand` каталога три значения:
|
||||||
|
`магнит`, `обычный`, `залёживается`. Они постоянны по дням и задают
|
||||||
|
различия в конверсии товаров и брендов.
|
||||||
|
|
||||||
|
**Конфигурация мира**:
|
||||||
|
Числовые настройки модели магазина в `world.py`: приток, частота возвратов,
|
||||||
|
доли покупателей, начальная дата и другие параметры. Изменение этих
|
||||||
|
настроек меняет генерируемые данные.
|
||||||
|
|
||||||
|
**Подпоток**:
|
||||||
|
Отдельная последовательность случайных выборов для части генератора,
|
||||||
|
например для торговли в заданный день. В `seeds.py` она определяется
|
||||||
|
общим зерном, днем и компонентом; для состава мира есть отдельные
|
||||||
|
последовательности. Результат не зависит от порядка расчета частей.
|
||||||
|
|
||||||
|
**День-функция**:
|
||||||
|
Расчет событий одних модельных суток по зерну и номеру дня, без генерации
|
||||||
|
предыдущих дней. Функция `day.stream(seed, day)` возвращает события
|
||||||
|
в порядке времени.
|
||||||
|
|
||||||
|
**Судьба заказа**:
|
||||||
|
Заранее рассчитанные изменения заказа: итоговый статус, время оплаты
|
||||||
|
или отмены, удаленная позиция и задержка первого появления в слепке.
|
||||||
|
Они укладываются в семидневное окно заказа. Слепок показывает состояние
|
||||||
|
на выбранный день без проигрывания предыдущих изменений.
|
||||||
|
|
||||||
|
**Контракт схемы**:
|
||||||
|
Описания колонок события в модуле генератора `schema.py`: имена,
|
||||||
|
типы и ограничения. По ним генератор создает и проверяет события,
|
||||||
|
а `schema_doc.py` собирает описание выгрузки для хранилища.
|
||||||
|
|
||||||
|
**Канонический сериализатор**:
|
||||||
|
Код в `serialize.py`, который превращает события и записи заказов в JSON
|
||||||
|
для отправки. Порядок ключей и формат значений фиксированы, поэтому
|
||||||
|
одинаковые записи дают одинаковые байты.
|
||||||
|
|
||||||
|
**Проигрыватель**:
|
||||||
|
Код в `player.py`, который доставляет готовые события дня в приемник,
|
||||||
|
например в Kafka или файл. В пакетном режиме он отправляет их без пауз,
|
||||||
|
в режиме живого дня — с ускорением по времени событий.
|
||||||
@@ -4,22 +4,27 @@ GENERATOR_LIMIT ?=
|
|||||||
GENERATOR_SPEED ?=
|
GENERATOR_SPEED ?=
|
||||||
|
|
||||||
# Цели корня — про стенд; проверки генератора — в `generator/Makefile`.
|
# Цели корня — про стенд; проверки генератора — в `generator/Makefile`.
|
||||||
.PHONY: up down clean ps logs smoke check-clickhouse check-services config-test lint generate-batch generate-live
|
.PHONY: up down clean rebuild-storage ps logs smoke check-clickhouse check-services config-test lint generate-batch generate-live
|
||||||
|
|
||||||
# --- Жизнь стенда ---
|
# --- Жизнь стенда ---
|
||||||
|
|
||||||
# Второй шаг: `--wait` дожидается служб, а приём событий асинхронный — довод
|
# 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/wait-for-world.sh
|
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
|
||||||
|
|
||||||
@@ -46,9 +51,11 @@ config-test:
|
|||||||
# того своя дверь и свой конфиг. Версия закреплена, потому что лока в корне
|
# того своя дверь и свой конфиг. Версия закреплена, потому что лока в корне
|
||||||
# нет, а форматтер между версиями меняет вывод — иначе проверка однажды
|
# нет, а форматтер между версиями меняет вывод — иначе проверка однажды
|
||||||
# покраснела бы сама, без единой правки в репозитории.
|
# покраснела бы сама, без единой правки в репозитории.
|
||||||
|
# Проверка рендера быстрая, не требует стенда и ловит устаревший HTML.
|
||||||
lint:
|
lint:
|
||||||
uvx ruff@0.16.1 check dags infra/superset
|
uvx ruff@0.16.1 check dags infra/superset docs/handbook/architecture-map
|
||||||
uvx ruff@0.16.1 format --check dags infra/superset
|
uvx ruff@0.16.1 format --check dags infra/superset docs/handbook/architecture-map
|
||||||
|
uv run docs/handbook/architecture-map/render.py --check
|
||||||
|
|
||||||
# --- Наполнение миром ---
|
# --- Наполнение миром ---
|
||||||
|
|
||||||
|
|||||||
@@ -1,381 +1,160 @@
|
|||||||
# Учебная дата-платформа кликстрима
|
# Учебная дата-платформа кликстрима
|
||||||
|
|
||||||
Стенд для работы с кликстримом. Преемник учебного стенда
|
> Этим стендом можно пользоваться самостоятельно. Он также входит в
|
||||||
[clickstream-ch-kafka-superset-demo](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo).
|
> [план обучения Data Engineering Roadmap](https://de.dementev.space) как
|
||||||
|
> курсовая работа. Если вам нужна помощь, обратитесь к автору материалов —
|
||||||
|
> ментору [@dementev_dev](https://t.me/dementev_dev).
|
||||||
|
|
||||||
## Статус
|
Здесь вы будете работать с хранилищем данных небольшого интернет-магазина:
|
||||||
|
загружать и обрабатывать данные в ClickHouse, строить витрины, проверять
|
||||||
|
качество данных и создавать графики. Стенд рассчитан на тех, кто уже изучил
|
||||||
|
основы ClickHouse и хочет попрактиковаться.
|
||||||
|
|
||||||
Репозиторий строится по спеке
|
Генератор создает заказы и кликстрим — записи о действиях посетителей
|
||||||
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
|
магазина. Данные поступают через Kafka в ClickHouse и проходят четыре слоя
|
||||||
Сейчас работают кластер ClickHouse из двух шардов, отдельный
|
обработки: STG, ODS, DDS и DM. В кластере ClickHouse два шарда и отдельный
|
||||||
clickhouse-keeper, односерверная Kafka в режиме KRaft, Airflow 3.3, Superset,
|
ClickHouse Keeper. Airflow управляет загрузкой и обработкой, Superset
|
||||||
Prometheus, Grafana и общая база Postgres для метаданных. Генератор
|
показывает отчеты, а Prometheus и Grafana помогают следить за работой стенда.
|
||||||
кликстрима в [`generator/`](generator/) собран целиком со стороны клиента: у
|
|
||||||
него есть контракт схемы события, из которого собрано
|
При первом запуске стенд загрузит данные за неделю работы магазина.
|
||||||
[описание выгрузки](docs/formats/clickstream-event.md), модельный мир и
|
После запуска начните с [путеводителя](docs/handbook/README.md): он объясняет
|
||||||
проигрыватель, отправляющий дни в Kafka или в файл. События доезжают до
|
устройство хранилища. Затем переходите к [учебным заданиям](docs/labs/README.md).
|
||||||
типизированного `ods.event`, а `make up` наполняет стенд стартовым миром —
|
|
||||||
первой неделей модельного времени.
|
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
Нужны Docker с Compose и около 8 ГБ памяти, доступной Docker. Это не объём
|
Для запуска нужны Docker с Compose, `make`, `curl` и `jq`.
|
||||||
ноутбука, а то, что отдано самому Docker: в Docker Desktop и WSL2 он живёт
|
Выделите Docker около 8 ГБ оперативной памяти. В Docker Desktop и WSL2
|
||||||
внутри виртуальной машины и получает лишь часть памяти хозяина. Сколько выдано
|
проверьте лимит памяти виртуальной машины: он может быть меньше, чем общий
|
||||||
сейчас, в байтах, покажет `docker info --format '{{.MemTotal}}'`. В WSL2 это
|
объем памяти компьютера. Команда `docker info --format '{{.MemTotal}}'`
|
||||||
поднимается параметром `memory` в файле `.wslconfig` домашнего каталога
|
покажет доступный Docker объем в байтах. В WSL2 лимит задается параметром
|
||||||
пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не
|
`memory` в файле `.wslconfig` в домашнем каталоге пользователя Windows.
|
||||||
хватает, стенд одинаково хорошо живёт на недорогом VPS.
|
После изменения выполните `wsl --shutdown`. Если ресурсов компьютера
|
||||||
|
недостаточно, стенд можно запустить на VPS.
|
||||||
|
|
||||||
Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`,
|
По умолчанию должны быть свободны порты
|
||||||
`grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им
|
`23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
|
||||||
читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны
|
и `29092`. Для работы с командами `make` в каталоге `generator/` также
|
||||||
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
|
понадобится `uv`.
|
||||||
и `29092`. Проверкам без стенда — `make config-test` и `make lint` в корне,
|
|
||||||
всем целям генератора в `generator/` — нужен `uv`.
|
|
||||||
|
|
||||||
Сначала скопируйте настройки стенда:
|
В каталоге репозитория скопируйте образец настроек:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
В `.env` задаются имя экземпляра стенда, внешние порты, учебные логины,
|
||||||
|
пароли и ключи. Проверьте `DOCKER_GID`: это числовой идентификатор группы
|
||||||
|
`docker` на вашей машине. Узнать его можно командой `getent group docker`.
|
||||||
|
Если число отличается от образца, замените значение в `.env`. От этой
|
||||||
|
настройки зависит запуск генератора из Airflow.
|
||||||
|
|
||||||
|
Запустите стенд:
|
||||||
|
|
||||||
|
```bash
|
||||||
make up
|
make up
|
||||||
make smoke
|
|
||||||
make check-clickhouse
|
|
||||||
make check-services
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Первому знакомству нужны все три проверки на стенде: `make smoke` говорит, что
|
Команда запустит службы и дождется загрузки начальных данных. Порядок запуска
|
||||||
стенд собран, `make check-clickhouse` — что кластер работает кластером, а
|
и проверки результата описаны в [справочнике проверок](docs/architecture/testing.md).
|
||||||
`make check-services` — что Airflow запускает DAG, а Superset ходит в базу.
|
Если обязательной настройки в `.env` не хватает, Compose сообщит об ошибке.
|
||||||
Дальше, в рабочей петле, обычно хватает `make smoke`.
|
Внутренние адреса служб, имена топиков и версии образов заданы в `compose.yaml`
|
||||||
|
и Dockerfile.
|
||||||
|
|
||||||
`.env.example` — образец обязательной локальной настройки. В нём живут имя
|
Логины и пароли Postgres и Grafana сохраняются при первом запуске в томах
|
||||||
экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца
|
Docker. Простая правка `.env` их не изменит. Чтобы задать новые значения
|
||||||
верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и
|
через `.env`, потребуется `make clean` и повторный `make up`. При этом
|
||||||
свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь,
|
будут удалены все локальные данные стенда.
|
||||||
кроме пульта мира — о нём ниже. `compose.yaml` не
|
|
||||||
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
|
|
||||||
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
|
|
||||||
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
|
|
||||||
|
|
||||||
Учётные данные Postgres и Grafana применяются при создании их томов.
|
## Адреса и пароли
|
||||||
После первого запуска меняйте их только вместе с `make clean`: команда удалит
|
|
||||||
все локальные данные стенда, а следующий `make up` создаст их с новыми
|
|
||||||
значениями.
|
|
||||||
|
|
||||||
`make up` собирает локальные образы Airflow и Superset, поднимает весь стенд и
|
|
||||||
ждёт здорового состояния долгоживущих контейнеров. В образ Airflow добавлены
|
|
||||||
закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и
|
|
||||||
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
|
|
||||||
подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет
|
|
||||||
Superset, создаёт администратора и импортирует подключение к `clickhouse-02`.
|
|
||||||
С нуля подъём занимает около трёх минут, на живом стенде — около минуты.
|
|
||||||
|
|
||||||
`make smoke` за секунды спрашивает, собран ли стенд: зависимости машины,
|
|
||||||
здоровье контейнеров, устройство keeper, ответ Kafka с машины через отображённый
|
|
||||||
порт, три цели Prometheus, источник Grafana, компоненты Airflow и подготовленное
|
|
||||||
подключение к `clickhouse-01`. В конце проверка спрашивает у Docker, не убивало
|
|
||||||
ли ядро что-нибудь в долгоживущих контейнерах за нехватку памяти и не включалась
|
|
||||||
ли политика перезапуска: убитый контейнер Docker поднимает сам, и проверка
|
|
||||||
состояния об этом промолчит.
|
|
||||||
|
|
||||||
Одиннадцать проверок здоровья сразу после `make up --wait` повторяют то, чего
|
|
||||||
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.
|
|
||||||
|
|
||||||
`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse:
|
|
||||||
описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`,
|
|
||||||
`Distributed`, очередь распределённых DDL и очистку временных таблиц. Девятая
|
|
||||||
из десяти проверок спрашивает словарь товаров на обеих нодах. Последняя —
|
|
||||||
единственная на настоящих событиях: она подневно сверяет стартовый мир с описью
|
|
||||||
и при расхождении говорит, где искать — в событиях или в браке.
|
|
||||||
|
|
||||||
`make config-test` проверяет Compose, синтаксис файлов DAG и пробельные ошибки
|
|
||||||
в diff без запуска стенда.
|
|
||||||
|
|
||||||
Обычный рабочий цикл — `make config-test` и `make smoke`; остальные цели гоняют
|
|
||||||
тогда, когда правка их касается. Что утверждает каждая проверка, нужен ли ей
|
|
||||||
поднятый стенд, сколько стоит прогон и куда класть новую — в
|
|
||||||
[карте проверок](docs/architecture/testing.md).
|
|
||||||
|
|
||||||
Остановить контейнеры без удаления данных можно командой `make down`. Для
|
|
||||||
полного сброса с удалением всех именованных томов используйте `make clean`.
|
|
||||||
Повторный `make up` безопасен: одноразовая подготовка приложений идемпотентна.
|
|
||||||
|
|
||||||
## Стартовый мир
|
|
||||||
|
|
||||||
Стенд поднимается не пустым: разовая служба `world-init` играет в топик `hits`
|
|
||||||
первые восемь дней модельного времени — понедельник по понедельник, 401 185
|
|
||||||
событий. Дальше их обычным путём разбирает хранилище, и к концу `make up` они
|
|
||||||
лежат в `ods.event`. Так у всякой лабы есть данные, и всегда одни и те же.
|
|
||||||
|
|
||||||
Ждать приходится дольше, чем работает заливка: приём асинхронный, поэтому
|
|
||||||
вторым шагом `make up` зовёт `scripts/wait-for-world.sh` — тот опрашивает
|
|
||||||
ClickHouse, пока мир не доедет. Повторный `make up` заливает мир заново; это
|
|
||||||
не ошибка, а свойство: номера событий те же, и повтор схлопнет
|
|
||||||
`ReplacingMergeTree`.
|
|
||||||
|
|
||||||
Сам мир в 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
|
|
||||||
```
|
|
||||||
|
|
||||||
Тот же образ несёт заливка стартового мира, поэтому свежим его держит и
|
|
||||||
обычный `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_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
|
|
||||||
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
|
|
||||||
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём |
|
|
||||||
|
|
||||||
Работник не только играет день: следом он отправляет слепок заказов за
|
|
||||||
сыгранное и дёргает отдельный даг, `orders_ingest`, — тот забирает приехавший
|
|
||||||
слепок из топика `orders` в сырьё, — и дожидается его конца. Так две половины
|
|
||||||
стенда идут в ногу: упал приём — краснеет и работник, а позиция остаётся на
|
|
||||||
месте ([ADR 0008](docs/adr/0008-order-ingestion.md)).
|
|
||||||
|
|
||||||
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
|
|
||||||
дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон
|
|
||||||
позицию не двигает, и следующий запуск играет тот же день заново. Нет
|
|
||||||
переменной — мир в стартовом состоянии.
|
|
||||||
|
|
||||||
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
|
|
||||||
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
|
|
||||||
целиком — [ADR 0009](docs/adr/0009-world-control.md).
|
|
||||||
|
|
||||||
**Плата названа вслух: планировщику Airflow отдан сокет докера** — иначе
|
|
||||||
контейнер генератора ему не поднять. Доступ к сокету равен праву root на
|
|
||||||
машине; для локального учебного стенда размен принят, но знать о нём надо.
|
|
||||||
Открывает дверь не монтирование, а членство в группе: GID группы `docker` у
|
|
||||||
каждой машины свой, живёт в `.env` и подсматривается командой
|
|
||||||
`getent group docker`.
|
|
||||||
|
|
||||||
## Состав и доступ
|
|
||||||
|
|
||||||
- `clickhouse-01` — инициатор DDL и точка подключения Airflow;
|
|
||||||
- `clickhouse-02` — точка подключения Superset;
|
|
||||||
- `clickhouse-keeper` — координатор кластера;
|
|
||||||
- `kafka` — один брокер KRaft;
|
|
||||||
- `postgres-metadata` — один Postgres с отдельными базами и пользователями
|
|
||||||
`airflow` и `superset`;
|
|
||||||
- `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor` —
|
|
||||||
Airflow 3.3 с LocalExecutor, без triggerer;
|
|
||||||
- `superset` — интерфейс и подготовленное подключение ClickHouse;
|
|
||||||
- `prometheus` и `grafana` — сбор и просмотр встроенных метрик ClickHouse.
|
|
||||||
|
|
||||||
С настройками из `.env.example` порты доступны только с локальной машины:
|
С настройками из `.env.example` порты доступны только с локальной машины:
|
||||||
|
|
||||||
- нода 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
|
- узел ClickHouse 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
|
||||||
- нода 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
|
- узел ClickHouse 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
|
||||||
- Kafka — `127.0.0.1:29092`;
|
- Kafka — `127.0.0.1:29092`;
|
||||||
- Airflow — `http://127.0.0.1:28080`, пользователь `admin`, пароль `airflow`;
|
- Airflow — `http://127.0.0.1:28080`, пользователь `admin`, пароль `airflow`;
|
||||||
- Superset — `http://127.0.0.1:28088`, пользователь `admin`, пароль `superset`;
|
- Superset — `http://127.0.0.1:28088`, пользователь `admin`, пароль `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 пять пользователей:
|
Подробнее о службах, правах пользователей и изменении настроек —
|
||||||
|
в [справочнике стенда](docs/architecture/stand.md).
|
||||||
|
|
||||||
- `etl` применяет DDL и подключает Airflow; роль `etl_writer` читает и пишет
|
## Добавить данные за следующие дни
|
||||||
слои хранилища;
|
|
||||||
- `bi` подключает Superset; роль `bi_reader` читает справочники `dic` и будущие
|
|
||||||
слои DDS и DM;
|
|
||||||
- `analyst` предназначен для подключения человека и читает все слои и
|
|
||||||
справочники;
|
|
||||||
- `dict` читает только базу `dic` и пароля не имеет: им словарь товаров ходит
|
|
||||||
за своей подложкой;
|
|
||||||
- `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри
|
|
||||||
контейнеров, но не приложения.
|
|
||||||
|
|
||||||
Решение и его доводы — в [ADR 0007](docs/adr/0007-clickhouse-access.md).
|
Время в учебном магазине идет отдельно от календарного. В документации оно
|
||||||
Пользователи и роли объявлены в `infra/clickhouse/users.d/access.xml`. Пароли и
|
называется модельным временем, а сам магазин с его посетителями и заказами —
|
||||||
общий секрет нод живут в `.env` и передаются в конфигурацию через окружение;
|
миром.
|
||||||
Superset получает пароль `bi` тем же путём через штатную функцию настройки.
|
|
||||||
Значения для локального стенда есть в `.env.example`.
|
|
||||||
|
|
||||||
Пароли ClickHouse и интерфейсов, пароли Postgres, ключи Airflow и Superset,
|
В Airflow запустите `world_next_day`. Это даг — набор связанных задач,
|
||||||
отсутствие проверки доступа у Kafka и Prometheus — намеренно простые и явно
|
которые Airflow выполняет в заданном порядке. Он создаст и загрузит данные
|
||||||
ненастоящие настройки локального учебного стенда. Это не пример настройки
|
за следующий день работы магазина. В форме запуска можно задать число дней.
|
||||||
защиты: не копируйте значения из `.env.example` в рабочую среду. Все
|
|
||||||
опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не
|
|
||||||
опубликован.
|
|
||||||
|
|
||||||
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
|
Чтобы новые данные поступали непрерывно, снимите с паузы даг `world_live`.
|
||||||
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
|
Он будет запускать генерацию день за днем; один модельный день занимает
|
||||||
2×2 намеренно не входят в стенд.
|
около 24 минут. Подробности — в [справочнике генератора и модельного
|
||||||
|
времени](docs/architecture/world.md#даги-модельного-времени).
|
||||||
|
|
||||||
Конфигурация сверена 30 июля 2026 года с официальной документацией ClickHouse:
|
## Восстановить начальные данные
|
||||||
[настройками сервера](https://clickhouse.com/docs/operations/server-configuration-parameters/settings),
|
|
||||||
[Keeper](https://clickhouse.com/docs/guides/oss/deployment-and-scaling/keeper/),
|
|
||||||
[ReplicatedMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replication),
|
|
||||||
[ON CLUSTER](https://clickhouse.com/docs/sql-reference/distributed-ddl) и
|
|
||||||
[Distributed](https://clickhouse.com/docs/engines/table-engines/special/distributed).
|
|
||||||
Описание кластера задаётся через `remote_servers`, макросы — через `macros`,
|
|
||||||
подключение к keeper — через `zookeeper`; путь `ReplicatedMergeTree` содержит
|
|
||||||
`{shard}` и `{replica}`, а `Distributed` получает имя кластера, базу, локальную
|
|
||||||
таблицу и ключ шардирования. Макросы выбраны, чтобы один DDL через `ON CLUSTER`
|
|
||||||
создавал отдельный путь каждого шарда без вписанных вручную значений. Для
|
|
||||||
образа зафиксирован точный текущий
|
|
||||||
[LTS-выпуск 26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts);
|
|
||||||
серверы и 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).
|
|
||||||
Секция метрик взята из конфигурации закреплённого образа ClickHouse и проверена
|
|
||||||
на серверах и keeper. Подготовка источника Grafana сверена с
|
|
||||||
[официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/).
|
|
||||||
Prometheus собирает только встроенные метрики двух серверов и keeper; внешних
|
|
||||||
сборщиков, панелей и правил оповещения пока нет.
|
|
||||||
После изменения `infra/clickhouse/config.d/prometheus.xml` выполните
|
|
||||||
`docker compose restart clickhouse-01 clickhouse-02`: обычный `make up` не
|
|
||||||
перезапускает уже созданные серверы и они продолжают работать со старой
|
|
||||||
конфигурацией.
|
|
||||||
|
|
||||||
Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor,
|
Если хотите начать заново, поставьте `world_live` на паузу и дождитесь
|
||||||
публичный `airflow.sdk`, API здоровья и SimpleAuthManager сверены с
|
завершения уже запущенного дага генерации. Затем выполните
|
||||||
[архитектурой Airflow 3.3](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/overview.html),
|
`make rebuild-storage`.
|
||||||
[публичным интерфейсом](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).
|
|
||||||
Для пробников проверены публичный `Connection.get` из `airflow.sdk` и клиент
|
|
||||||
`clickhouse-connect`. Официальный провайдер Kafka сам использует
|
|
||||||
`confluent-kafka`; отдельное подключение и обёртки провайдера здесь не нужны,
|
|
||||||
поэтому прямой клиент оставляет образ и пример короче.
|
|
||||||
Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, форма
|
|
||||||
`clickhousedb://` и драйвер Postgres сверены с
|
|
||||||
[документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/)
|
|
||||||
и [настройкой базы метаданных](https://superset.apache.org/admin-docs/6.1.0/configuration/configuring-superset/).
|
|
||||||
|
|
||||||
## Что здесь будет
|
Команда удалит прикладные базы ClickHouse, топики и позиции потребителей
|
||||||
|
Kafka, затем заново создаст схему хранилища и загрузит начальные данные.
|
||||||
|
Службы и их тома останутся на месте.
|
||||||
|
|
||||||
- одно широкое событие кликстрима по образцу выгрузки Яндекс Метрики вместо
|
## Остановить или удалить стенд
|
||||||
четырёх топиков;
|
|
||||||
- второй источник — заказы бэкенда, ежедневным слепком в ту же Kafka;
|
|
||||||
- сверка клиентской покупки с заказом бэкенда: деньги считаем по бэкенду,
|
|
||||||
поведение и атрибуцию — по трекеру;
|
|
||||||
- анонимный кликстрим и склейка кука↔пользователь через покупки;
|
|
||||||
- ClickHouse кластером как единственным режимом.
|
|
||||||
|
|
||||||
## Чем отличается от предшественника
|
`make down` остановит стенд и сохранит данные. Для повторного запуска
|
||||||
|
выполните `make up`.
|
||||||
|
|
||||||
Предшественник остаётся стабильным учебным стендом и заморожен для новых фич:
|
`make clean` удалит контейнеры и все данные в томах Docker. Для обычного
|
||||||
там событие разрезано на четыре топика, есть только просмотры страниц,
|
завершения работы достаточно `make down`.
|
||||||
посетители опознаны по email, ClickHouse — одна нода. Развитие идёт здесь.
|
|
||||||
|
## Подключиться из DBeaver
|
||||||
|
|
||||||
|
Драйвер ClickHouse в DBeaver подключается по JDBC через HTTP-порт. Укажите хост
|
||||||
|
`127.0.0.1`, порт `28123`, пользователя `analyst` и пароль из переменной
|
||||||
|
`CLICKHOUSE_ANALYST_PASSWORD` в `.env`; в образце это `analyst-26-reader`.
|
||||||
|
Для подключения ко второму узлу укажите порт `28124`.
|
||||||
|
|
||||||
|
Пользователь `analyst` может читать данные во всех слоях и справочниках.
|
||||||
|
Для заданий с собственными таблицами наблюдений используйте `student` и пароль
|
||||||
|
из `CLICKHOUSE_STUDENT_PASSWORD` в `.env`. Он читает те же источники, а создавать,
|
||||||
|
заполнять и удалять таблицы может только в базе `default`.
|
||||||
|
Для создания учебных витрин используйте подключение, описанное
|
||||||
|
в [инструкции к заданиям](docs/labs/README.md#выполнение-sql-в-dbeaver).
|
||||||
|
Нативные порты `29000` и `29001` нужны `clickhouse-client`, но не DBeaver.
|
||||||
|
Остальные пользователи и роли описаны в
|
||||||
|
[справочнике стенда](docs/architecture/stand.md#пользователи-и-роли-clickhouse).
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
- [docs/specs/](docs/specs/) — спеки: источник истины о задуманном.
|
- [docs/handbook/](docs/handbook/) — путеводитель по стенду и порядок чтения.
|
||||||
- [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми
|
- [docs/labs/](docs/labs/) — учебные задания с порядком прохождения и
|
||||||
вариантами: почему сделано так, а не иначе.
|
самопроверкой.
|
||||||
- [docs/architecture/](docs/architecture/) — рабочие справочники по зонам
|
- [docs/specs/](docs/specs/) — требования к стенду и программа обучения.
|
||||||
ответственности: [хранилище](docs/architecture/storage.md) — конвенции имён,
|
- [docs/adr/](docs/adr/) — решения об устройстве стенда: что выбрали,
|
||||||
раскладка по шардам, приём событий, карта таблиц;
|
какие варианты отклонили и почему.
|
||||||
[проверки](docs/architecture/testing.md) — что утверждает каждая цель `make`
|
- [docs/architecture/](docs/architecture/) — технические справочники:
|
||||||
и куда класть новую проверку.
|
- [хранилище](docs/architecture/storage.md) — правила именования,
|
||||||
|
распределение данных по шардам, прием событий и список таблиц.
|
||||||
|
- [конвейер](docs/architecture/etl.md) — движение данных между слоями и
|
||||||
|
работа дагов.
|
||||||
|
- [мир и модельное время](docs/architecture/world.md) — начальные данные,
|
||||||
|
создание данных за следующие дни и управление генератором.
|
||||||
|
- [стенд](docs/architecture/stand.md) — службы, доступ и настройка.
|
||||||
|
- [проверки](docs/architecture/testing.md) — команды `make` для проверки
|
||||||
|
стенда и правила добавления новых проверок.
|
||||||
|
- [мониторинг](docs/architecture/monitoring/README.md) — сбор метрик и
|
||||||
|
устройство дашборда.
|
||||||
|
- [Superset](docs/architecture/superset.md) — подключения, наборы данных и
|
||||||
|
автоматическая настройка при запуске.
|
||||||
- [docs/research/](docs/research/) — исследования; сейчас это формат
|
- [docs/research/](docs/research/) — исследования; сейчас это формат
|
||||||
кликстрима Яндекса, по которому строится модель события.
|
кликстрима Яндекса, по которому строится модель события.
|
||||||
- [docs/formats/](docs/formats/) — описания форматов источников: по ним
|
- [docs/formats/](docs/formats/) — форматы исходных данных, необходимые
|
||||||
пишется сторона хранилища. Собираются из кода командой `make docs` в
|
для их загрузки и обработки. Эти документы собираются из кода командой
|
||||||
`generator/`, руками не правятся.
|
`make docs` в `generator/`; вручную их менять не нужно.
|
||||||
- [AGENTS.md](AGENTS.md) — контракт работы в репозитории.
|
- [AGENTS.md](AGENTS.md) — правила работы в репозитории для ИИ-агентов.
|
||||||
+50
-133
@@ -17,8 +17,11 @@ x-clickhouse-common: &clickhouse-common
|
|||||||
CLICKHOUSE_CLUSTER_SECRET: ${CLICKHOUSE_CLUSTER_SECRET:?Скопируйте .env.example в .env}
|
CLICKHOUSE_CLUSTER_SECRET: ${CLICKHOUSE_CLUSTER_SECRET:?Скопируйте .env.example в .env}
|
||||||
CLICKHOUSE_DEFAULT_PASSWORD: ${CLICKHOUSE_DEFAULT_PASSWORD:?Скопируйте .env.example в .env}
|
CLICKHOUSE_DEFAULT_PASSWORD: ${CLICKHOUSE_DEFAULT_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_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_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
CLICKHOUSE_ANALYST_PASSWORD: ${CLICKHOUSE_ANALYST_PASSWORD:?Скопируйте .env.example в .env}
|
CLICKHOUSE_ANALYST_PASSWORD: ${CLICKHOUSE_ANALYST_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
|
CLICKHOUSE_STUDENT_PASSWORD: ${CLICKHOUSE_STUDENT_PASSWORD:?Добавьте CLICKHOUSE_STUDENT_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
|
||||||
@@ -32,21 +35,24 @@ x-clickhouse-common: &clickhouse-common
|
|||||||
retries: 30
|
retries: 30
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
# Факты стенда, у которых стало по два потребителя: разовая служба генератора и
|
# Факты генератора и дагов модельного времени названы по одному разу — иначе они однажды
|
||||||
# пульт мира. Названы по одному разу — иначе однажды разойдутся, и заметит это
|
# разойдутся, и заметит это не проверка, а менти с пустым топиком.
|
||||||
# не проверка, а менти с пустым топиком.
|
|
||||||
x-generator-image: &generator-image clickstream-generator:local
|
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
|
x-kafka-target: &kafka-target
|
||||||
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
|
<<: *kafka-common
|
||||||
KAFKA_TOPIC: hits
|
KAFKA_TOPIC: hits
|
||||||
|
|
||||||
x-world-starting-days: &world-starting-days "8"
|
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
|
|
||||||
environment:
|
environment:
|
||||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||||
AIRFLOW__CORE__PARALLELISM: "4"
|
AIRFLOW__CORE__PARALLELISM: "4"
|
||||||
@@ -64,19 +70,24 @@ x-airflow-common: &airflow-common
|
|||||||
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}
|
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}
|
||||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
# Пульт мира поднимает генератор сам, отдельным контейнером, — значит те же
|
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
# факты стенда, что compose даёт разовой службе генератора, нужны и дагам.
|
# Даги времени поднимают генератор отдельным контейнером. Имя сети собирается
|
||||||
# Имя сети собирается из имени проекта: у второй копии стенда оно другое.
|
# из имени проекта: у второй копии стенда оно другое.
|
||||||
<<: *kafka-target
|
<<: *kafka-target
|
||||||
GENERATOR_IMAGE: *generator-image
|
GENERATOR_IMAGE: *generator-image
|
||||||
STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default
|
STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default
|
||||||
WORLD_STARTING_DAYS: *world-starting-days
|
WORLD_STARTING_DAYS: *world-starting-days
|
||||||
KAFKA_ORDERS_TOPIC: orders
|
KAFKA_ORDERS_TOPIC: *orders-topic
|
||||||
volumes:
|
volumes:
|
||||||
- ./dags:/opt/airflow/dags:ro
|
- ./dags:/opt/airflow/dags:ro
|
||||||
# Запросы дагов: даг называет файл, а текст читает Airflow при исполнении
|
# Запросы дагов: даг называет файл, а обработчик DAG при разборе загружает
|
||||||
# задачи. Каталог тот же, что применяет DDL, — вся SQL стенда живёт в sql/.
|
# его текст. Каталог тот же, что применяет DDL, — вся SQL стенда живёт в sql/.
|
||||||
- ./sql:/opt/airflow/sql:ro
|
- ./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
|
||||||
@@ -87,15 +98,6 @@ x-generator-common: &generator-common
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: generator/Dockerfile
|
dockerfile: generator/Dockerfile
|
||||||
restart: "no"
|
restart: "no"
|
||||||
depends_on:
|
|
||||||
kafka-init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
# Читать топик движок Kafka начинает не при своём создании, а когда над ним
|
|
||||||
# появляется матвью приёма, и она создаётся последней по номеру файла
|
|
||||||
# (sql/ddl/40-stg-views.sql). Отправь генератор события раньше — они лягут в
|
|
||||||
# топик и молча минуют хранилище.
|
|
||||||
clickhouse-init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
environment:
|
environment:
|
||||||
# Адрес брокера и имя топика — факты стенда, и называет их стенд.
|
# Адрес брокера и имя топика — факты стенда, и называет их стенд.
|
||||||
# Остальное (день, зерно, число дней, предел пачки) приходит аргументами
|
# Остальное (день, зерно, число дней, предел пачки) приходит аргументами
|
||||||
@@ -104,8 +106,6 @@ x-generator-common: &generator-common
|
|||||||
|
|
||||||
x-superset-common: &superset-common
|
x-superset-common: &superset-common
|
||||||
image: clickstream-superset:local
|
image: clickstream-superset:local
|
||||||
build:
|
|
||||||
context: ./infra/superset
|
|
||||||
environment:
|
environment:
|
||||||
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
|
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
|
||||||
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env}
|
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env}
|
||||||
@@ -205,107 +205,19 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|
||||||
kafka-init:
|
kafka-exporter:
|
||||||
image: apache/kafka:4.3.1
|
image: danielqsj/kafka-exporter:v1.9.0
|
||||||
restart: "no"
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
kafka:
|
kafka:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
entrypoint: ["/bin/bash", "-ec"]
|
command: ["--kafka.server=kafka:9092"]
|
||||||
command:
|
healthcheck:
|
||||||
- |
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9308/metrics >/dev/null 2>&1"]
|
||||||
topic_partition_count() {
|
interval: 10s
|
||||||
/opt/kafka/bin/kafka-topics.sh \
|
timeout: 5s
|
||||||
--bootstrap-server kafka:9092 \
|
retries: 15
|
||||||
--describe \
|
start_period: 10s
|
||||||
--topic hits | \
|
|
||||||
sed -n '1s/.*PartitionCount: \([0-9][0-9]*\).*/\1/p'
|
|
||||||
}
|
|
||||||
|
|
||||||
/opt/kafka/bin/kafka-topics.sh \
|
|
||||||
--bootstrap-server kafka:9092 \
|
|
||||||
--create \
|
|
||||||
--if-not-exists \
|
|
||||||
--topic hits \
|
|
||||||
--partitions 2 \
|
|
||||||
--replication-factor 1
|
|
||||||
|
|
||||||
partition_count="$$(topic_partition_count)"
|
|
||||||
# Брокер разрешает автосоздание, хотя потребитель ClickHouse его не запрашивает.
|
|
||||||
# На сохранённом томе другой клиент мог уже создать hits с одним разделом.
|
|
||||||
if [[ "$$partition_count" == "1" ]]; then
|
|
||||||
/opt/kafka/bin/kafka-topics.sh \
|
|
||||||
--bootstrap-server kafka:9092 \
|
|
||||||
--alter \
|
|
||||||
--topic hits \
|
|
||||||
--partitions 2
|
|
||||||
partition_count="$$(topic_partition_count)"
|
|
||||||
fi
|
|
||||||
if [[ "$$partition_count" != "2" ]]; then
|
|
||||||
printf 'ОШИБКА: у топика hits разделов: %s, ожидалось: 2.\n' \
|
|
||||||
"$${partition_count:-неизвестно}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Топик заказов: одна партиция, читает его один чтец на ноде 1 —
|
|
||||||
# довод в ADR 0008.
|
|
||||||
/opt/kafka/bin/kafka-topics.sh \
|
|
||||||
--bootstrap-server kafka:9092 \
|
|
||||||
--create \
|
|
||||||
--if-not-exists \
|
|
||||||
--topic orders \
|
|
||||||
--partitions 1 \
|
|
||||||
--replication-factor 1
|
|
||||||
|
|
||||||
# DDL применяется с ноды 1 по порядку имён файлов после готовности всего кластера.
|
|
||||||
clickhouse-init:
|
|
||||||
image: clickhouse/clickhouse-server:26.3.17.56
|
|
||||||
restart: "no"
|
|
||||||
depends_on:
|
|
||||||
kafka-init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
clickhouse-01:
|
|
||||||
condition: service_healthy
|
|
||||||
clickhouse-02:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
|
||||||
LC_ALL: C
|
|
||||||
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
|
||||||
entrypoint: ["/bin/bash", "-ec"]
|
|
||||||
command:
|
|
||||||
- |
|
|
||||||
set -- /ddl/*.sql
|
|
||||||
if [[ ! -e "$$1" ]]; then
|
|
||||||
printf 'ОШИБКА: в /ddl нет файлов SQL.\n' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
for ddl_file do
|
|
||||||
printf 'Применяем %s.\n' "$${ddl_file##*/}"
|
|
||||||
clickhouse-client \
|
|
||||||
--host clickhouse-01 \
|
|
||||||
--user etl \
|
|
||||||
--password "$$CLICKHOUSE_ETL_PASSWORD" \
|
|
||||||
--multiquery \
|
|
||||||
--queries-file "$$ddl_file"
|
|
||||||
done
|
|
||||||
volumes:
|
|
||||||
- ./sql/ddl:/ddl:ro
|
|
||||||
|
|
||||||
# Стартовый мир: восемь модельных дней уезжают в топик при каждом подъёме.
|
|
||||||
# Что это за дни и каким мир обязан выйти — data/world-inventory.json.
|
|
||||||
#
|
|
||||||
# Число дней стоит здесь числом: YAML не читает Python, и одно из двух мест
|
|
||||||
# (второе — `STARTING_DAYS` в inventory.py) лишнее по построению. Правя одно,
|
|
||||||
# правьте второе — на страже тут никто не стоит: залей эта служба лишний
|
|
||||||
# день, он лёг бы за рамкой дат описи и остался бы незамеченным. Внутри YAML
|
|
||||||
# число одно на всех: то же говорит дагам пульта, где кончается стартовый мир.
|
|
||||||
#
|
|
||||||
# Повторный `make up` заливает мир заново, и это не оплошность: `WatchID` у
|
|
||||||
# событий те же, ReplacingMergeTree схлопнет повтор в ODS. Сырьё в STG при
|
|
||||||
# этом честно удвоится — свойство слоя, описанное в storage.md.
|
|
||||||
world-init:
|
|
||||||
<<: *generator-common
|
|
||||||
command: ["batch", "--day", "0", "--days", *world-starting-days]
|
|
||||||
|
|
||||||
# Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`.
|
# Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`.
|
||||||
# Под профилем — чтобы обычный подъём стенда её не трогал.
|
# Под профилем — чтобы обычный подъём стенда её не трогал.
|
||||||
@@ -344,6 +256,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:
|
||||||
@@ -351,15 +265,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
clickhouse-01:
|
clickhouse-01:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
# Не убирать: без зависимого успешный clickhouse-init считается
|
|
||||||
# упавшим для --wait.
|
|
||||||
clickhouse-init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
# По той же причине. Заливка стартового мира идёт последней в цепи
|
|
||||||
# разовых служб, и зависимого ей взять больше негде: долгоживущие службы
|
|
||||||
# данных не ждут, а `airflow-init` эту цепь и так замыкает.
|
|
||||||
world-init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
entrypoint: ["/bin/bash"]
|
entrypoint: ["/bin/bash"]
|
||||||
command: ["/opt/airflow/init.sh"]
|
command: ["/opt/airflow/init.sh"]
|
||||||
|
|
||||||
@@ -387,7 +292,7 @@ services:
|
|||||||
airflow-init:
|
airflow-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
command: scheduler
|
command: scheduler
|
||||||
# Пульт мира зовёт генератор отдельным контейнером, а при LocalExecutor
|
# Даги времени зовут генератор отдельным контейнером, а при LocalExecutor
|
||||||
# задачи исполняет сам планировщик — значит сокет докера нужен ему одному.
|
# задачи исполняет сам планировщик — значит сокет докера нужен ему одному.
|
||||||
# Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root
|
# Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root
|
||||||
# на машине. Открывает дверь не монтирование, а группа: у сокета права 660
|
# на машине. Открывает дверь не монтирование, а группа: у сокета права 660
|
||||||
@@ -400,10 +305,16 @@ services:
|
|||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./dags:/opt/airflow/dags:ro
|
- ./dags:/opt/airflow/dags:ro
|
||||||
- ./sql:/opt/airflow/sql: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
|
- ./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
|
||||||
mem_limit: 640m
|
# 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
|
||||||
@@ -430,6 +341,8 @@ services:
|
|||||||
|
|
||||||
superset-init:
|
superset-init:
|
||||||
<<: *superset-common
|
<<: *superset-common
|
||||||
|
build:
|
||||||
|
context: ./infra/superset
|
||||||
restart: "no"
|
restart: "no"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: true
|
disable: true
|
||||||
@@ -486,7 +399,9 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
grafana:
|
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:
|
||||||
@@ -498,8 +413,10 @@ services:
|
|||||||
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:?Скопируйте .env.example в .env}:3000"
|
- "127.0.0.1:${GRAFANA_PORT:?Скопируйте .env.example в .env}:3000"
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"""Трансформации DDS: заказы, сессии и карта идентичностей.
|
||||||
|
|
||||||
|
Запросы рассчитывают заказы и сессии за выбранные дни и записывают результат
|
||||||
|
в промежуточные таблицы. Затем готовые дневные разделы этих таблиц заменяют
|
||||||
|
разделы основных таблиц. Пока идет расчет, читатели видят прежние данные.
|
||||||
|
|
||||||
|
Карта идентичностей пересчитывается целиком после заказов. Новые строки
|
||||||
|
добавляются с меткой времени сборки; представление с FINAL выбирает последнюю
|
||||||
|
версию каждой строки.
|
||||||
|
|
||||||
|
Даг запускается без расписания и параметров. Даты он выбирает по состоянию
|
||||||
|
таблиц и позиции мира. Новый запуск заново определяет диапазон: наличие даты
|
||||||
|
не подтверждает полноту дня и не гарантирует повтор его сборки после сбоя.
|
||||||
|
Подробности — docs/architecture/etl.md, «Объём прогона» и «Идемпотентность».
|
||||||
|
"""
|
||||||
|
|
||||||
|
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]]:
|
||||||
|
"""Подготовить параметры отдельной задачи replace для каждого дня.
|
||||||
|
|
||||||
|
expand_kwargs получает список словарей и создает по одному экземпляру
|
||||||
|
задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja:
|
||||||
|
параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
# Без заказов ODS расчет невозможен: задача сообщает об ошибке.
|
||||||
|
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,212 @@
|
|||||||
|
"""Трансформации слоя 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]]:
|
||||||
|
"""Подготовить параметры отдельной задачи replace для каждого дня.
|
||||||
|
|
||||||
|
expand_kwargs получает список словарей и создает по одному экземпляру
|
||||||
|
задачи на каждый словарь. Дата подставляется в SQL шаблоном Jinja:
|
||||||
|
параметр ClickHouse в ALTER ... PARTITION здесь не поддерживается.
|
||||||
|
"""
|
||||||
|
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,334 @@
|
|||||||
|
"""Проверки качества стендовых моделей и витрин.
|
||||||
|
|
||||||
|
Пять проверок сначала собирают общий донор. После его полной сборки дневные
|
||||||
|
партиции публикуются одной серией замен, и только затем именованные задачи
|
||||||
|
утверждают результат. Расхождение поэтому остаётся в ``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}: запрос не вернул ни одного дня для проверки; "
|
||||||
|
"проверьте наличие данных в источниках сверки. Состав проверок "
|
||||||
|
"описан в docs/architecture/dm.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
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: в data/world-inventory.json нет счетчиков "
|
||||||
|
"заказов для закрытых дней; сообщите наставнику"
|
||||||
|
)
|
||||||
|
|
||||||
|
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} mismatch_class={mismatch_class}: "
|
||||||
|
f"заказов в паспорте={reference_count}, "
|
||||||
|
f"в dm.purchase_vs_orders_v={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_stage_dist пуста: нет результатов для записи "
|
||||||
|
"в сводку DQ; проверьте журналы задач build_* этого запуска"
|
||||||
|
)
|
||||||
|
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}, "
|
||||||
|
f"отсутствуют или различаются {failed_rows}"
|
||||||
|
for data_date, reference_rows, actual_rows, failed_rows in failed_days
|
||||||
|
)
|
||||||
|
raise AirflowException(
|
||||||
|
f"{check_name}: {summary}. Примеры расхождений — в журнале этой задачи; "
|
||||||
|
"итоги по дням — в dm.dq_summary_v. Источники каждой сверки описаны "
|
||||||
|
"в docs/architecture/dm.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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,143 @@
|
|||||||
|
"""Ручная самопроверка учебных витрин 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} не найдена на узле подключения clickhouse_default; "
|
||||||
|
f"проверьте имя и место создания витрины по заданию {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}. Другие примеры — в журнале этой "
|
||||||
|
f"задачи. Проверьте расчет для указанных ключей по заданию {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/1-1-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/1-2-top-pages-daily.md",
|
||||||
|
"top_pages_daily_vs_dds.sql",
|
||||||
|
)
|
||||||
|
check_mart.override(task_id="session_overview")(
|
||||||
|
"dm.session_overview_v",
|
||||||
|
"docs/labs/1-3-session-overview.md",
|
||||||
|
"session_overview_vs_dds.sql",
|
||||||
|
)
|
||||||
|
check_mart.override(task_id="dq_errors_daily")(
|
||||||
|
"dm.dq_errors_daily_v",
|
||||||
|
"docs/labs/1-4-dq-errors-daily.md",
|
||||||
|
"dq_errors_daily_vs_ods.sql",
|
||||||
|
)
|
||||||
|
check_mart.override(task_id="utm_effectiveness")(
|
||||||
|
"dm.utm_effectiveness_v",
|
||||||
|
"docs/labs/1-5-utm-effectiveness.md",
|
||||||
|
"utm_effectiveness_vs_dds.sql",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
homework_check()
|
||||||
+31
-80
@@ -1,31 +1,25 @@
|
|||||||
"""Приём заказов: пакетный забор слепка из Kafka в хранилище.
|
"""Пакетный прием заказов: Kafka → STG → ODS.
|
||||||
|
|
||||||
Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор
|
Шаговый даг модельного времени отправляет слепок, запускает orders_ingest
|
||||||
порции в сырьё, затем разбор того же среза в типизированный ODS и в таблицу
|
и ждет его завершения. Собственного расписания у orders_ingest нет.
|
||||||
брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, —
|
Сначала pull_batch сохраняет порцию сообщений в STG, затем parse_batch
|
||||||
работник пульта мира после проигрыша дня, — и ждёт конца.
|
разбирает тот же срез по _load_id в заказы и ошибки.
|
||||||
|
|
||||||
Контраст с приёмом событий и есть урок. События тянет матвью, навсегда
|
События принимаются непрерывно через материализованные представления.
|
||||||
подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов
|
Заказы читаются одним запросом; полнота слепка этим не гарантируется.
|
||||||
приезжает раз в модельный день целой выгрузкой, у которой есть начало и конец,
|
Правила и ограничения — docs/architecture/orders/ingestion.md.
|
||||||
и забирает её запрос по команде. Push против pull — два режима на одном стенде,
|
|
||||||
каждый там, где ему место по природе источника.
|
|
||||||
|
|
||||||
Решения и доводы целиком — ADR 0008 (забор) и ADR 0010 (версии в ODS); форма
|
|
||||||
перехода — docs/architecture/orders/ingestion.md.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
|
||||||
|
|
||||||
from airflow.sdk import Connection, dag, get_current_context, task
|
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
|
||||||
|
from airflow.sdk import dag
|
||||||
|
|
||||||
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
|
|
||||||
# Запросы лежат файлами в sql/ репозитория, разложенные по слоям хранилища;
|
# Compose монтирует sql/ по этому пути во все службы Airflow.
|
||||||
# сюда их монтирует compose — всем службам Airflow сразу.
|
|
||||||
SQL_ROOT = "/opt/airflow/sql"
|
SQL_ROOT = "/opt/airflow/sql"
|
||||||
|
|
||||||
|
|
||||||
@@ -34,76 +28,33 @@ SQL_ROOT = "/opt/airflow/sql"
|
|||||||
schedule=None,
|
schedule=None,
|
||||||
start_date=START_DATE,
|
start_date=START_DATE,
|
||||||
is_paused_upon_creation=False,
|
is_paused_upon_creation=False,
|
||||||
# Чтец у топика один, и группа потребителей у него одна. Два прогона разом
|
# Один запуск за раз: параллельные чтения разделили бы порцию
|
||||||
# дрались бы за неё, а слепок разъехался бы по двум _load_id; второй
|
# между разными _load_id.
|
||||||
# прогон подождёт своей очереди.
|
|
||||||
max_active_runs=1,
|
max_active_runs=1,
|
||||||
template_searchpath=SQL_ROOT,
|
template_searchpath=SQL_ROOT,
|
||||||
tags=["заказы"],
|
tags=["заказы"],
|
||||||
)
|
)
|
||||||
def orders_ingest():
|
def orders_ingest():
|
||||||
"""Забрать приехавший слепок заказов и разложить его по слоям."""
|
"""Сохранить порцию заказов и разобрать ее в ODS."""
|
||||||
|
pull_batch = SQLExecuteQueryOperator(
|
||||||
def clickhouse_client():
|
task_id="pull_batch",
|
||||||
# Импорт при выполнении задачи, а не при разборе файла: обработчик DAG
|
conn_id="clickhouse_default",
|
||||||
# разбирает его снова и снова, и импорт наверху оплачивался бы каждым
|
sql="stg/orders_raw_load.sql",
|
||||||
# разбором.
|
parameters={"load_id": "{{ run_id }}"},
|
||||||
import clickhouse_connect
|
do_xcom_push=False,
|
||||||
|
)
|
||||||
connection = Connection.get("clickhouse_default")
|
parse_batch = SQLExecuteQueryOperator(
|
||||||
return clickhouse_connect.get_client(
|
task_id="parse_batch",
|
||||||
host=connection.host,
|
conn_id="clickhouse_default",
|
||||||
port=connection.port,
|
sql="ods/order_parse.sql",
|
||||||
username=connection.login,
|
parameters={"load_id": "{{ run_id }}"},
|
||||||
password=connection.password,
|
split_statements=True,
|
||||||
database=connection.schema or "default",
|
do_xcom_push=False,
|
||||||
connect_timeout=5,
|
|
||||||
send_receive_timeout=30,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Аргумент с расширением из templates_exts Airflow подменяет текстом файла:
|
# Две вставки parse_batch не образуют транзакцию. Повтор задачи
|
||||||
# берёт его из template_searchpath и прогоняет через Jinja при исполнении
|
# выполняет обе над тем же срезом STG по _load_id.
|
||||||
# задачи, а не при разборе дага. В задачу приезжает готовый запрос — вместе
|
pull_batch >> parse_batch
|
||||||
# с тем, что файл подключил через include.
|
|
||||||
@task(templates_exts=(".sql",))
|
|
||||||
def pull_batch(sql: str) -> None:
|
|
||||||
load_id = get_current_context()["run_id"]
|
|
||||||
client = clickhouse_client()
|
|
||||||
try:
|
|
||||||
summary = client.command(sql, parameters={"load_id": load_id})
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
# Размер порции — read_rows: written_rows у вставки в Distributed
|
|
||||||
# считает не приехавшее.
|
|
||||||
logging.info(
|
|
||||||
"порция принята: строк %s, _load_id %s",
|
|
||||||
summary.summary["read_rows"],
|
|
||||||
load_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
@task(templates_exts=(".sql",))
|
|
||||||
def parse_batch(good_rows_sql: str, bad_rows_sql: str) -> None:
|
|
||||||
"""Разобрать срез сырья в версии заказов и в брак.
|
|
||||||
|
|
||||||
Обе вставки в одном task_id: транзакции между ними ClickHouse не даёт,
|
|
||||||
а повтор задачи безопасен — срез читается по тому же неизменному
|
|
||||||
_load_id (docs/architecture/orders/ingestion.md, «Поток данных»).
|
|
||||||
"""
|
|
||||||
load_id = get_current_context()["run_id"]
|
|
||||||
client = clickhouse_client()
|
|
||||||
try:
|
|
||||||
client.command(good_rows_sql, parameters={"load_id": load_id})
|
|
||||||
client.command(bad_rows_sql, parameters={"load_id": load_id})
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
# Счётчиков строк нет: у запроса с WHERE read_rows считает прочитанное
|
|
||||||
# с диска, а не подошедшее (storage.md, «Что проверено»).
|
|
||||||
logging.info("срез разобран: _load_id %s", load_id)
|
|
||||||
|
|
||||||
pull_batch("stg/orders_raw_load.sql") >> parse_batch(
|
|
||||||
"ods/order_snapshot_load.sql",
|
|
||||||
"ods/order_snapshot_errors_load.sql",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
orders_ingest()
|
orders_ingest()
|
||||||
+36
-64
@@ -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"
|
||||||
@@ -54,27 +55,12 @@ def _remote_parameters() -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _clickhouse_client():
|
def _clickhouse_hook() -> ClickHouseHook:
|
||||||
# clickhouse_connect импортируется внутри функции, а не наверху файла:
|
return ClickHouseHook(clickhouse_conn_id="clickhouse_default")
|
||||||
# обработчик DAG разбирает этот файл снова и снова, и импорт наверху
|
|
||||||
# оплачивался бы каждым разбором. Тяжёлые импорты Airflow советует
|
|
||||||
# держать внутри задач.
|
|
||||||
import clickhouse_connect
|
|
||||||
|
|
||||||
connection = Connection.get("clickhouse_default")
|
|
||||||
return clickhouse_connect.get_client(
|
|
||||||
host=connection.host,
|
|
||||||
port=connection.port,
|
|
||||||
username=connection.login,
|
|
||||||
password=connection.password,
|
|
||||||
database=connection.schema or "default",
|
|
||||||
connect_timeout=5,
|
|
||||||
send_receive_timeout=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _table_engines(client, source: str) -> list[tuple[str, str]]:
|
def _table_engines(hook: ClickHouseHook, source: str) -> list[tuple[str, str]]:
|
||||||
result = client.query(
|
return hook.get_records(
|
||||||
f"""
|
f"""
|
||||||
SELECT name, engine
|
SELECT name, engine
|
||||||
FROM {source}
|
FROM {source}
|
||||||
@@ -84,23 +70,23 @@ def _table_engines(client, source: str) -> list[tuple[str, str]]:
|
|||||||
""",
|
""",
|
||||||
parameters=_remote_parameters(),
|
parameters=_remote_parameters(),
|
||||||
)
|
)
|
||||||
return result.result_rows
|
|
||||||
|
|
||||||
|
|
||||||
def _drop_tables(client) -> None:
|
def _drop_tables(hook: ClickHouseHook) -> None:
|
||||||
client.command(
|
hook.run(
|
||||||
f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} ON CLUSTER {CLUSTER} SYNC"
|
[
|
||||||
)
|
f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} "
|
||||||
client.command(
|
f"ON CLUSTER {CLUSTER} SYNC",
|
||||||
f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC"
|
f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Отсутствие таблиц проверяют обе задачи — перед созданием и после уборки,
|
# Отсутствие таблиц проверяют обе задачи — перед созданием и после уборки,
|
||||||
# — поэтому у этой проверки своё имя, а остальные живут прямо в теле задач.
|
# — поэтому у этой проверки своё имя, а остальные живут прямо в теле задач.
|
||||||
def _assert_tables_absent(client) -> None:
|
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}"
|
||||||
@@ -118,11 +104,11 @@ def _assert_tables_absent(client) -> None:
|
|||||||
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}
|
||||||
(
|
(
|
||||||
@@ -133,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}
|
||||||
@@ -145,46 +129,41 @@ def test_clickhouse():
|
|||||||
'{LOCAL_TABLE}',
|
'{LOCAL_TABLE}',
|
||||||
cityHash64(marker)
|
cityHash64(marker)
|
||||||
)
|
)
|
||||||
"""
|
""",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
for node_name, source in NODES:
|
for node_name, source in NODES:
|
||||||
actual_tables = _table_engines(client, source)
|
actual_tables = _table_engines(hook, source)
|
||||||
if actual_tables != EXPECTED_TABLES:
|
if actual_tables != EXPECTED_TABLES:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"неверный набор таблиц на {node_name}: {actual_tables}"
|
f"неверный набор таблиц на {node_name}: {actual_tables}"
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
@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()}"
|
||||||
client.insert(
|
hook.bulk_insert_rows(
|
||||||
f"default.{LOCAL_TABLE}",
|
f"default.{LOCAL_TABLE}",
|
||||||
[[marker]],
|
[[marker]],
|
||||||
column_names=["marker"],
|
column_names=["marker"],
|
||||||
)
|
)
|
||||||
local_rows = client.query(
|
local_rows = hook.get_records(
|
||||||
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(
|
||||||
@@ -193,10 +172,10 @@ def test_clickhouse():
|
|||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
parameters=_remote_parameters(),
|
parameters=_remote_parameters(),
|
||||||
).result_rows
|
)
|
||||||
if len(node_2_rows) != 1:
|
if len(node_2_rows) != 1:
|
||||||
raise RuntimeError(f"не удалось определить имя ноды 2: {node_2_rows}")
|
raise RuntimeError(f"не удалось определить имя ноды 2: {node_2_rows}")
|
||||||
distributed_rows = client.query(
|
distributed_rows = hook.get_records(
|
||||||
f"""
|
f"""
|
||||||
SELECT _shard_num, hostName(), marker
|
SELECT _shard_num, hostName(), marker
|
||||||
FROM remote(
|
FROM remote(
|
||||||
@@ -212,19 +191,15 @@ def test_clickhouse():
|
|||||||
"marker": written["marker"],
|
"marker": written["marker"],
|
||||||
**_remote_parameters(),
|
**_remote_parameters(),
|
||||||
},
|
},
|
||||||
).result_rows
|
|
||||||
if written["hostname"] == node_2_rows[0][0]:
|
|
||||||
raise RuntimeError(
|
|
||||||
"запись и чтение маркера должны выполняться с разных нод"
|
|
||||||
)
|
)
|
||||||
|
if written["hostname"] == node_2_rows[0][0]:
|
||||||
|
raise RuntimeError("запись и чтение маркера должны идти с разных нод")
|
||||||
expected_rows = [(1, written["hostname"], written["marker"])]
|
expected_rows = [(1, written["hostname"], written["marker"])]
|
||||||
if distributed_rows != expected_rows:
|
if distributed_rows != expected_rows:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"нода 2 не прочитала маркер первого шарда через Distributed: "
|
"нода 2 не прочитала маркер первого шарда через Distributed: "
|
||||||
f"{written['marker']}, получено {distributed_rows}"
|
f"{written['marker']}, получено {distributed_rows}"
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
# Уборка идёт только после успеха: упавший пробник оставляет кластер таким,
|
# Уборка идёт только после успеха: упавший пробник оставляет кластер таким,
|
||||||
# каким сломался, а остатки сносит начало следующего запуска. Правило
|
# каким сломался, а остатки сносит начало следующего запуска. Правило
|
||||||
@@ -233,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()
|
||||||
|
|||||||
+85
-33
@@ -1,22 +1,25 @@
|
|||||||
"""Пульт мира: даги, которыми двигают ось модельного времени.
|
"""Даги модельного времени: ими двигают ось мира.
|
||||||
|
|
||||||
Их три, и они двух родов. **Работники** играют день и запускаются руками:
|
Их три, и они двух родов. **Шаговые даги** играют день и запускаются руками:
|
||||||
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
|
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
|
||||||
один день в темпе модельного времени. **Выключатель** `world_live` своей работы
|
один день в темпе модельного времени. **Выключатель** `world_live` своей работы
|
||||||
не делает: он тикает по расписанию и дёргает работника живого дня, дожидаясь
|
не делает: он тикает по расписанию и дёргает шаговый даг живого дня, дожидаясь
|
||||||
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
|
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
|
||||||
границе модельных суток.
|
границе модельных суток.
|
||||||
|
|
||||||
Сыграть день — половина работы. Вторая половина: отправить слепок заказов и
|
Сыграть день — половина работы. Вторая половина: отправить слепок заказов и
|
||||||
дождаться, пока хранилище его заберёт. Поэтому у обоих работников за проигрышем
|
дождаться, пока хранилище его заберёт. Поэтому у обоих шаговых дагов за проигрышем
|
||||||
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
|
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
|
||||||
`orders_ingest`.
|
`orders_ingest`.
|
||||||
|
|
||||||
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку
|
Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка
|
||||||
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник
|
только разгоняет источники: слои после нее менти догоняет ручным запуском.
|
||||||
|
|
||||||
|
Разделение не косметическое. Расписание на самом шаговом даге заставило бы кнопку
|
||||||
|
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а шаговый даг
|
||||||
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
|
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
|
||||||
|
|
||||||
Календарь Airflow к оси мира отношения не имеет: какой день играть, работник
|
Календарь Airflow к оси мира отношения не имеет: какой день играть, шаговый даг
|
||||||
спрашивает у переменной, а не у логической даты прогона.
|
спрашивает у переменной, а не у логической даты прогона.
|
||||||
|
|
||||||
Решения и доводы целиком — ADR 0009.
|
Решения и доводы целиком — ADR 0009.
|
||||||
@@ -25,8 +28,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from airflow.exceptions import AirflowException
|
||||||
from airflow.providers.docker.operators.docker import DockerOperator
|
from airflow.providers.docker.operators.docker import DockerOperator
|
||||||
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
|
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
|
||||||
from airflow.sdk import Param, Variable, dag, get_current_context, task
|
from airflow.sdk import Param, Variable, dag, get_current_context, task
|
||||||
@@ -45,8 +50,8 @@ GENERATOR_ENVIRONMENT = {
|
|||||||
ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"]
|
ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"]
|
||||||
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
|
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
|
||||||
|
|
||||||
# Позиция на оси: номер первого несыгранного дня. Переменной нет — мир в
|
# Позиция на оси: номер первого несыгранного дня. До конца инициализации
|
||||||
# стартовом состоянии, и играть надо сразу за ним.
|
# шаговые даги не имеют права угадывать её — состояние объяснено в ADR 0013.
|
||||||
#
|
#
|
||||||
# Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее
|
# Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее
|
||||||
# при таком правиле — сыгранный дважды день, а повтор схлопнет
|
# при таком правиле — сыгранный дважды день, а повтор схлопнет
|
||||||
@@ -58,18 +63,35 @@ WORLD_POSITION = "world_position"
|
|||||||
# двадцати четырёх минут: тик только спрашивает «не пора ли снова».
|
# двадцати четырёх минут: тик только спрашивает «не пора ли снова».
|
||||||
LIVE_TICK = datetime.timedelta(minutes=25)
|
LIVE_TICK = datetime.timedelta(minutes=25)
|
||||||
|
|
||||||
# Какой день играть, работник узнаёт у первой задачи. Спрашивают её все
|
# Какой день играть, шаговый даг узнаёт у первой задачи. Спрашивают её все
|
||||||
# генераторные шаги: слепок снимается с той же позиции, что и проигрыш.
|
# генераторные шаги: слепок снимается с той же позиции, что и проигрыш.
|
||||||
PLAYED_DAY = "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}"
|
PLAYED_DAY = "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}"
|
||||||
|
|
||||||
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
TAGS = ["пульт мира"]
|
TAGS = ["модельное время"]
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def first_unplayed_day() -> int:
|
def first_unplayed_day() -> int:
|
||||||
"""Номер дня, с которого играть."""
|
"""Номер дня, с которого играть."""
|
||||||
return int(Variable.get(WORLD_POSITION, default=STARTING_DAYS))
|
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:
|
def _generator(task_id: str, command: list[str]) -> DockerOperator:
|
||||||
@@ -98,7 +120,7 @@ def _generator(task_id: str, command: list[str]) -> DockerOperator:
|
|||||||
def _ingest_orders() -> TriggerDagRunOperator:
|
def _ingest_orders() -> TriggerDagRunOperator:
|
||||||
"""Задача забора: дёрнуть даг приёма и дождаться, чем он кончился.
|
"""Задача забора: дёрнуть даг приёма и дождаться, чем он кончился.
|
||||||
|
|
||||||
Ожидание здесь несущее. Без него работник позеленел бы, не узнав, доехал
|
Ожидание здесь несущее. Без него шаговый даг позеленел бы, не узнав, доехал
|
||||||
ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа
|
ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа
|
||||||
не должен переживать отказ выше (ADR 0003).
|
не должен переживать отказ выше (ADR 0003).
|
||||||
"""
|
"""
|
||||||
@@ -106,7 +128,7 @@ def _ingest_orders() -> TriggerDagRunOperator:
|
|||||||
task_id="trigger_orders_ingest",
|
task_id="trigger_orders_ingest",
|
||||||
trigger_dag_id="orders_ingest",
|
trigger_dag_id="orders_ingest",
|
||||||
wait_for_completion=True,
|
wait_for_completion=True,
|
||||||
# Умолчание — минута опроса, а весь прогон работника пачкой короче.
|
# Умолчание — минута опроса, а весь прогон шагового дага пачкой короче.
|
||||||
poke_interval=10,
|
poke_interval=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,22 +142,43 @@ def _ingest_orders() -> TriggerDagRunOperator:
|
|||||||
tags=TAGS,
|
tags=TAGS,
|
||||||
params={
|
params={
|
||||||
"days": Param(
|
"days": Param(
|
||||||
1, type="integer", minimum=1, maximum=7, title="Сколько дней прожить"
|
1,
|
||||||
|
type="integer",
|
||||||
|
minimum=1,
|
||||||
|
maximum=7,
|
||||||
|
title="Число модельных дней для генерации",
|
||||||
|
# description_md рендерится подсказкой прямо в форме запуска
|
||||||
|
# (params.rst, Airflow 3.3), чтобы правило было видно сразу.
|
||||||
|
description_md=(
|
||||||
|
"Создать данные за следующие 1–7 модельных дней; по умолчанию "
|
||||||
|
"за один день. События отправляются без пауз. Номер первого дня "
|
||||||
|
"берется из `world_position` (Admin → Variables). Поля Logical "
|
||||||
|
"date и Run ID не меняют выбор дня. После завершения запустите "
|
||||||
|
"`etl_pipeline`, чтобы обновить DDS и витрины DM."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def world_next_day():
|
def world_next_day():
|
||||||
"""Прожить следующие дни пачкой, без пауз.
|
"""Создать данные за следующие модельные дни без пауз между событиями.
|
||||||
|
|
||||||
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
|
Параметр `days` задает число дней: от 1 до 7, по умолчанию 1. Первый день
|
||||||
нажимом, а не десятью: сколько дней играть — параметр запуска.
|
берется из `world_position`. После загрузки событий и слепков заказов
|
||||||
|
запустите `etl_pipeline` вручную, чтобы обновить DDS и витрины DM.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def remember_played(first_day: int) -> None:
|
def remember_played(first_day: int) -> None:
|
||||||
"""Позиция ставится по сыгранным дням и только по успеху."""
|
"""Позиция ставится по сыгранным дням и только по успеху."""
|
||||||
days = get_current_context()["params"]["days"]
|
days = get_current_context()["params"]["days"]
|
||||||
Variable.set(WORLD_POSITION, str(first_day + days))
|
position = first_day + days
|
||||||
|
Variable.set(WORLD_POSITION, str(position))
|
||||||
|
logging.info(
|
||||||
|
"данные за модельные дни %s–%s отправлены; world_position=%s",
|
||||||
|
first_day,
|
||||||
|
position - 1,
|
||||||
|
position,
|
||||||
|
)
|
||||||
|
|
||||||
first_day = first_unplayed_day()
|
first_day = first_unplayed_day()
|
||||||
played = _generator(
|
played = _generator(
|
||||||
@@ -170,17 +213,23 @@ def world_next_day():
|
|||||||
tags=TAGS,
|
tags=TAGS,
|
||||||
)
|
)
|
||||||
def world_live_day():
|
def world_live_day():
|
||||||
"""Прожить следующий день в темпе модельного времени.
|
"""Постепенно отправить события за следующий модельный день.
|
||||||
|
|
||||||
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
|
При ускорении ×60 отправка занимает около 24 реальных минут. Номер дня
|
||||||
реальные минуты, и суточная волна разворачивается на глазах. Запускается
|
берется из `world_position`; Logical date его не меняет. Затем даг
|
||||||
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
|
отправляет слепок заказов и ждет его загрузки и завершения `etl_pipeline`.
|
||||||
|
Для автоматического запуска следующих дней снимите с паузы `world_live`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def remember_played(first_day: int) -> None:
|
def remember_played(first_day: int) -> None:
|
||||||
"""Позиция ставится по сыгранному дню и только по успеху."""
|
"""Позиция ставится по сыгранному дню и только по успеху."""
|
||||||
Variable.set(WORLD_POSITION, str(first_day + 1))
|
Variable.set(WORLD_POSITION, str(first_day + 1))
|
||||||
|
logging.info(
|
||||||
|
"данные за модельный день %s отправлены; world_position=%s",
|
||||||
|
first_day,
|
||||||
|
first_day + 1,
|
||||||
|
)
|
||||||
|
|
||||||
first_day = first_unplayed_day()
|
first_day = first_unplayed_day()
|
||||||
played = _generator("play_day", ["live", "--day", PLAYED_DAY])
|
played = _generator("play_day", ["live", "--day", PLAYED_DAY])
|
||||||
@@ -191,7 +240,14 @@ def world_live_day():
|
|||||||
["snapshot", "--day", PLAYED_DAY, "--topic", ORDERS_TOPIC],
|
["snapshot", "--day", PLAYED_DAY, "--topic", ORDERS_TOPIC],
|
||||||
)
|
)
|
||||||
|
|
||||||
first_day >> played >> sent >> _ingest_orders() >> remember_played(first_day)
|
position = remember_played(first_day)
|
||||||
|
etl = TriggerDagRunOperator(
|
||||||
|
task_id="trigger_etl_pipeline",
|
||||||
|
trigger_dag_id="etl_pipeline",
|
||||||
|
wait_for_completion=True,
|
||||||
|
poke_interval=10,
|
||||||
|
)
|
||||||
|
first_day >> played >> sent >> _ingest_orders() >> position >> etl
|
||||||
|
|
||||||
|
|
||||||
@dag(
|
@dag(
|
||||||
@@ -204,16 +260,12 @@ def world_live_day():
|
|||||||
tags=TAGS,
|
tags=TAGS,
|
||||||
)
|
)
|
||||||
def world_live():
|
def world_live():
|
||||||
"""Выключатель: пока включён, мир живёт день за днём.
|
"""Автоматически запускать модельные дни, пока даг снят с паузы.
|
||||||
|
|
||||||
Своей работы у выключателя нет — он дёргает `world_live_day` и ждёт конца.
|
Запускает `world_live_day` и ждет его завершения. Пауза останавливает
|
||||||
Ожидание тут несущая конструкция, а не вежливость: без него тик шёл бы
|
запуск следующих дней; уже запущенный день завершится. Если он завершился
|
||||||
независимо от хода дня, лишние прогоны скопились бы очередью, и мир потом
|
с ошибкой, этот даг тоже завершится с ошибкой: откройте дочерний запуск
|
||||||
промчался бы по ней без всякого темпа.
|
и журнал неуспешной задачи.
|
||||||
|
|
||||||
Ждём триггером, а не сенсором: оператор опрашивает тот прогон, который сам
|
|
||||||
и создал, и ссылка на дочерний прогон видна прямо отсюда. Упал день —
|
|
||||||
краснеет и выключатель.
|
|
||||||
"""
|
"""
|
||||||
TriggerDagRunOperator(
|
TriggerDagRunOperator(
|
||||||
task_id="trigger_live_day",
|
task_id="trigger_live_day",
|
||||||
|
|||||||
@@ -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()
|
||||||
+29
-22
@@ -6,87 +6,94 @@
|
|||||||
{
|
{
|
||||||
"day": 0,
|
"day": 0,
|
||||||
"date": "2026-06-01",
|
"date": "2026-06-01",
|
||||||
"events": 50626,
|
"events": 50628,
|
||||||
"sha256": "81ad7f402bdd4c8b56425f6439a024b9996e5ca95dd207d444290d20a70ed407"
|
"sha256": "d09704ed99e45824e451e2f6bbbbd5c547df382f7a582699955010f3807eb0ee",
|
||||||
|
"orders": {
|
||||||
|
"match": 192,
|
||||||
|
"cancelled": 20,
|
||||||
|
"lost_event": 3,
|
||||||
|
"duplicate_event": 2,
|
||||||
|
"amount_delta": 6
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 1,
|
"day": 1,
|
||||||
"date": "2026-06-02",
|
"date": "2026-06-02",
|
||||||
"events": 49722,
|
"events": 49727,
|
||||||
"sha256": "5f7aa0301832008db612eff05899b1210af958c888ac2e9e89f4b7a552569eff"
|
"sha256": "8005d2934eb353478e35331d7ae7ae6876c7934b2d9032fde92cf6ce86d6460f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 2,
|
"day": 2,
|
||||||
"date": "2026-06-03",
|
"date": "2026-06-03",
|
||||||
"events": 52361,
|
"events": 52363,
|
||||||
"sha256": "10ac9ce65ca6dc171689edf78ef5cd785e8c8a80e54d236bf2b9d6dbf6997070"
|
"sha256": "e13f6f3f3977386a94d0e63b6849675cb5d2486a277d559fd415923cfc82e53a"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 3,
|
"day": 3,
|
||||||
"date": "2026-06-04",
|
"date": "2026-06-04",
|
||||||
"events": 53157,
|
"events": 53159,
|
||||||
"sha256": "7bf4deadd8f69a6c479fceabfe2d1e8ea8db76919b10a8b9e780006708da31c3"
|
"sha256": "91c713add62222adc3f89c04e6cc087d8c44e2385d14d0464e2c016b6952b9fd"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 4,
|
"day": 4,
|
||||||
"date": "2026-06-05",
|
"date": "2026-06-05",
|
||||||
"events": 50078,
|
"events": 50087,
|
||||||
"sha256": "b9e758fb72f937aa5ad1f2e90dee5ec147b16cfd89e49e51a00582f909458c29"
|
"sha256": "b7fc27d73fc157a48bece0189c7983f6bc672f052e0703cd799b9774da4b904e"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 5,
|
"day": 5,
|
||||||
"date": "2026-06-06",
|
"date": "2026-06-06",
|
||||||
"events": 47164,
|
"events": 47169,
|
||||||
"sha256": "f08a9f9a439f1da9e4ad8d94b9f65b7f3329256e41446d22d900cc4825f2480c"
|
"sha256": "82cb8017d53d6dee8438d3683b7a901f1ace139f3b8ae836215722bb580fe207"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 6,
|
"day": 6,
|
||||||
"date": "2026-06-07",
|
"date": "2026-06-07",
|
||||||
"events": 48683,
|
"events": 48686,
|
||||||
"sha256": "7e45d9dd59c902067fa6847d813669b1ab9a4ee88d2f192382577a5ed8d590a7"
|
"sha256": "b50ad0fcc7d1ea0c637cfcba98e5c2b55d65215cffa3693bc77edeaa83e6c783"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 7,
|
"day": 7,
|
||||||
"date": "2026-06-08",
|
"date": "2026-06-08",
|
||||||
"events": 49394,
|
"events": 49394,
|
||||||
"sha256": "286ccc2451847ac8de215b5e902c328371021ad5502bd4afca0494b6f313aef7"
|
"sha256": "358d51b36221d822960237702fa6327fb663a57cf49cc4415a859b16e5aa05a2"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"snapshots": [
|
"snapshots": [
|
||||||
{
|
{
|
||||||
"day": 0,
|
"day": 0,
|
||||||
"date": "2026-06-01",
|
"date": "2026-06-01",
|
||||||
"sha256": "6c3e3c1c8689ec23f53f0953db1b941e6f010e9e56a74d8d017f40195d3dec17"
|
"sha256": "93b39f261cbbe894402ebe0525460fed1809e5712639be4b73b79dac2b39f5bd"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 1,
|
"day": 1,
|
||||||
"date": "2026-06-02",
|
"date": "2026-06-02",
|
||||||
"sha256": "9acb523cfd47f3d97d55122025852982ba5a1d8362036308ce72a4d9fb767f43"
|
"sha256": "4fef382cd84868ccfb61a9d8610cdd26f9a749bbae8b20800c45e520e9ce414e"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 2,
|
"day": 2,
|
||||||
"date": "2026-06-03",
|
"date": "2026-06-03",
|
||||||
"sha256": "b81482491c4c0b874ea84d5f3f05144100c906dacc059c068b331df258b97172"
|
"sha256": "caf9ed2e77d2fcc99066a548dcba60f61f1ff0d4402de31457a06f23fd2d8306"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 3,
|
"day": 3,
|
||||||
"date": "2026-06-04",
|
"date": "2026-06-04",
|
||||||
"sha256": "9d1ab326c4bf8b78c9fcaf7015d8029f9ad4cff5558b89c730eb428d175cc9ee"
|
"sha256": "cdb9e4e00b57c04985866b5a851a8e2f23b3e9205ea131c7a5076672ee8b5669"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 4,
|
"day": 4,
|
||||||
"date": "2026-06-05",
|
"date": "2026-06-05",
|
||||||
"sha256": "0e30d0e192e121121003c0cc9aaa55e24924afb983caff15ab105705fbf2bd00"
|
"sha256": "c58d2c20fe3b4cee2973f34c322090dbb66d3793bfaab0337c22b53fb5a3ec7f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 5,
|
"day": 5,
|
||||||
"date": "2026-06-06",
|
"date": "2026-06-06",
|
||||||
"sha256": "9a2ad812fcff9a40a988f24f8aa4fc7a379873d173a95ddacd438592aed0bfb5"
|
"sha256": "bbf28b1d375e4815695c72a5915a22a3c9b36195b97ec4cc7c739466dbe70b53"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"day": 6,
|
"day": 6,
|
||||||
"date": "2026-06-07",
|
"date": "2026-06-07",
|
||||||
"sha256": "e1706a27ff55de489a505489148c7a959329d95ad6597d92d3f78505f67bb221"
|
"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 не резервирует разницу между
|
||||||
|
пиком и пределом, поэтому этот запас не увеличивает расход памяти в покое.
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Дата: 3 августа 2026 года. Статус: принято.
|
Дата: 3 августа 2026 года. Статус: принято.
|
||||||
|
|
||||||
|
Позднее тикет #214 сменил имя слоя: вместо «сырья» — сырые данные, сырой слой,
|
||||||
|
STG. Текст ниже оставлен в словах своего времени, действующее имя — в
|
||||||
|
`CONTEXT.md`.
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение
|
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
# ADR 0007. Доступ к ClickHouse: пользователи файлом, доверие нод секретом
|
# ADR 0007. Доступ к ClickHouse: пользователи файлом, доверие нод секретом
|
||||||
|
|
||||||
Дата: 9 августа 2026 года. Статус: принято. Реализация — отдельным тикетом.
|
Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader`
|
||||||
|
и пользователь `grafana` добавлены 21 августа 2026 года (#114), права
|
||||||
|
`bi_reader` расширены до ODS, DDS и DM 25 августа 2026 года (#150).
|
||||||
|
Пользователь `student` и роль `student_writer` добавлены 22 сентября 2026 года.
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
У кластера пять пользователей и четыре роли.
|
У кластера восемь пользователей и семь ролей.
|
||||||
|
|
||||||
- `default` — с паролем, только служебный: проверки здоровья нод и работа
|
- `default` — с паролем, только служебный: проверки здоровья нод и работа
|
||||||
изнутри контейнеров. Приложения им не ходят.
|
изнутри контейнеров. Приложения им не ходят.
|
||||||
- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходит
|
- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходят рабочие
|
||||||
Airflow и применяется DDL при подъёме стенда.
|
даги Airflow.
|
||||||
- `bi` с ролью `bi_reader` — чтение витрин DM, слоя DDS и справочников `dic`.
|
- `lifecycle` с ролью `lifecycle_owner` — создание и удаление только
|
||||||
Им ходит Superset.
|
прикладных баз и объектов. Им даги жизненного цикла применяют DDL и
|
||||||
|
пересоздают мир ([ADR 0013](0013-world-lifecycle.md)).
|
||||||
|
- `bi` с ролью `bi_reader` — чтение слоёв ODS, DDS и DM, а также справочников
|
||||||
|
`dic`. Им ходит Superset; его запросы обращаются к публичным `_v`, хотя права
|
||||||
|
шире.
|
||||||
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
|
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
|
||||||
человек подключается снаружи, из своего клиента.
|
человек подключается снаружи, из своего клиента.
|
||||||
|
- `student` с ролями `analyst_reader` и `student_writer` — то же чтение и
|
||||||
|
работа со своими таблицами в `default`: создание, вставка и удаление.
|
||||||
|
Сохранение учебных наблюдений не требует прав технической учётной записи
|
||||||
|
`etl` на изменение слоёв. Таблицы локальные; работа ведётся на одной ноде.
|
||||||
|
- `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и
|
||||||
|
системных таблиц, `CLUSTER` и `REMOTE`. Им ходит Grafana, и больше никто.
|
||||||
- `dict` с ролью `dict_reader` — чтение одной базы `dic`, пароля нет. Им
|
- `dict` с ролью `dict_reader` — чтение одной базы `dic`, пароля нет. Им
|
||||||
словарь читает свою подложку, и больше он никем не используется
|
словарь читает свою подложку, и больше он никем не используется
|
||||||
([ADR 0012](0012-dictionary-home.md)).
|
([ADR 0012](0012-dictionary-home.md)).
|
||||||
@@ -21,8 +34,7 @@
|
|||||||
Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения
|
Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения
|
||||||
на запросы `ON CLUSTER`, на чтение системных таблиц и на каждый движок
|
на запросы `ON CLUSTER`, на чтение системных таблиц и на каждый движок
|
||||||
внешнего источника. Без права на системные таблицы клиент вовсе не открывает
|
внешнего источника. Без права на системные таблицы клиент вовсе не открывает
|
||||||
соединение. Состав прав каждой роли — работа тикета
|
соединение.
|
||||||
реализации.
|
|
||||||
|
|
||||||
Пользователи, роли и права объявлены файлами настройки сервера, а не
|
Пользователи, роли и права объявлены файлами настройки сервера, а не
|
||||||
запросами SQL. Файл монтируется на обе ноды и лежит в git. Пароли живут
|
запросами SQL. Файл монтируется на обе ноды и лежит в git. Пароли живут
|
||||||
@@ -36,29 +48,47 @@
|
|||||||
Сетевых ограничений на `default` нет: порты стенда и так открыты только на
|
Сетевых ограничений на `default` нет: порты стенда и так открыты только на
|
||||||
`127.0.0.1`, а внутри сети Compose дверь заперта паролем.
|
`127.0.0.1`, а внутри сети Compose дверь заперта паролем.
|
||||||
|
|
||||||
Устройство модели — кто каким пользователем ходит и где живут пароли —
|
Раздел README «Адреса и пароли» описывает, кто каким пользователем ходит и где
|
||||||
опишет раздел README «Состав и доступ»; здесь только решение и доводы.
|
живут пароли; здесь остаются решение и доводы. Отдельного справочника по
|
||||||
Отдельного справочника по доступу в `docs/architecture/` не заводим: своего
|
доступу в `docs/architecture/` нет: точные права видны в `access.xml`, конвенция
|
||||||
содержания сверх этих двух документов у него сегодня нет, а дом ему
|
чтения через `_v` описана в доке хранилища.
|
||||||
понадобится, когда появятся слои DDS и DM и гранты размножатся. Абзац README
|
|
||||||
«У локального учебного кластера нет пароля» этим решением отменяется.
|
|
||||||
|
|
||||||
## Почему
|
## Почему
|
||||||
|
|
||||||
Стенд учит не администрированию доступа, а чтению отказа в правах: человек
|
Стенд учит не администрированию доступа, а чтению отказа в правах: человек
|
||||||
должен узнать в бою картину, которую видел здесь, и понимать, чего просить.
|
должен узнать в бою картину, которую видел здесь, и понимать, чего просить.
|
||||||
Отсюда критерий отбора — модель должна читаться из текста ошибки, а не из
|
Отсюда критерий отбора — модель должна читаться из текста ошибки, а не из
|
||||||
описания. Поэтому пользователей мало, имена у них говорящие, а границы
|
описания. Поэтому пользователей мало, имена у них говорящие. В бою BI обычно
|
||||||
проходят там, где их обычно проводят в компаниях: `bi` видит витрины и слой
|
читает витрины и модельный слой. ODS ему могут открыть для расследования или
|
||||||
DDS под ними, потому что так чаще всего и бывает; заодно расхождение витрины
|
сверки. Стенд оставляет этот доступ постоянным, но сохраняет две крупные
|
||||||
со слоем ниже остаётся проверяемым одним и тем же пользователем. Справочники
|
границы: `bi` отделён от STG и от записи. Один пользователь может сверить всю
|
||||||
видны всем читателям и никому на запись: зона входит в хранилище сбоку, и её
|
цепочку DM → DDS → ODS, не получая доступа к сырью и изменению данных.
|
||||||
содержимым владеет файл репозитория, а не слой.
|
Справочники видны всем читателям и никому на запись: зона входит в хранилище
|
||||||
|
сбоку, и её содержимым владеет файл репозитория, а не слой.
|
||||||
|
|
||||||
|
Ограничить `bi` одними `_v` поверх `Distributed` можно только отдельным
|
||||||
|
маршрутом, учётной записью или копией данных. Эта механика не даёт нового урока.
|
||||||
|
Публичное представление остаётся договором чтения; границу прав оно не задаёт
|
||||||
|
([исследование](../research/2026-08-25-clickhouse-distributed-view-security.md)).
|
||||||
|
|
||||||
**Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то
|
**Беспарольный `dict`.** Пароль пришлось бы вписать в текст DDL словаря, то
|
||||||
есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно —
|
есть положить секрет в git. Ходить этой учёткой некуда: право у неё одно —
|
||||||
`SELECT` на `dic`, и хост источника локальный.
|
`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`. Подключение к соседней ноде идёт от неё, а не от
|
||||||
@@ -79,14 +109,16 @@ DDS под ними, потому что так чаще всего и быва
|
|||||||
в разделе «Что проверено».
|
в разделе «Что проверено».
|
||||||
|
|
||||||
**Файлы, а не SQL.** Объявленное файлом не оставляет состояния в томах:
|
**Файлы, а не SQL.** Объявленное файлом не оставляет состояния в томах:
|
||||||
повторный `make up` на живом стенде ничего не сдвигает, правка пароля
|
источник истины — файл в git. Стенд считает `access.xml` и переданные через
|
||||||
применяется перезапуском, источник истины — файл в git. Оба варианта с SQL —
|
`.env` значения настройкой при создании контейнера; отдельного способа
|
||||||
локальное хранилище доступа с `ON CLUSTER` и реплицированное в keeper —
|
применять их на живом стенде нет, потому что уроки не меняют базовую модель
|
||||||
делают пользователей состоянием тома: `.env` на них больше не влияет, и
|
доступа. Порядок применения изменений — в разделе README «Адреса и пароли».
|
||||||
появляется расхождение того же сорта, которое README уже описывает для
|
Оба варианта с SQL — локальное хранилище доступа с `ON CLUSTER` и
|
||||||
Postgres и Grafana. Цена выбора известна: объявленных файлом пользователей
|
реплицированное в keeper — делают пользователей состоянием тома: `.env` на
|
||||||
нельзя менять запросами. Она принята как упрощение — сломать модель случайно
|
них больше не влияет, и появляется расхождение того же сорта, которое README
|
||||||
труднее.
|
уже описывает для Postgres и Grafana. Цена выбора известна: объявленных файлом
|
||||||
|
пользователей нельзя менять запросами. Она принята как упрощение — сломать
|
||||||
|
модель случайно труднее.
|
||||||
|
|
||||||
**Что ещё отвергнуто.** Оставить всё на беспарольном `default`
|
**Что ещё отвергнуто.** Оставить всё на беспарольном `default`
|
||||||
— цена нулевая, но и рассказать по итогам нечего. Раздать всем пароль без
|
— цена нулевая, но и рассказать по итогам нечего. Раздать всем пароль без
|
||||||
@@ -103,16 +135,15 @@ Postgres и Grafana. Цена выбора известна: объявленн
|
|||||||
|
|
||||||
## Следствия
|
## Следствия
|
||||||
|
|
||||||
До появления слоёв DDS и DM читать `bi` почти нечего, и это намеренно.
|
Новые таблицы ODS, DDS и DM не требуют отдельного гранта для `bi_reader`.
|
||||||
Дашборды рисуются поверх модели данных, а не поверх типизированных событий,
|
Явный договор `_v` защищает потребителя от устройства хранения и случайных
|
||||||
поэтому доступ Superset к ODS не планировался и правами не выдаётся.
|
новых колонок.
|
||||||
Единственное, что ему доступно сегодня, — справочники: с ними зона `dic`
|
|
||||||
пришла раньше своих потребителей.
|
|
||||||
|
|
||||||
Владелец матвью приёма определяется тем, кто применил DDL. Весь DDL стенда
|
DDL для матвью приёма выполняется от имени `lifecycle`. Это видно в журнале
|
||||||
идёт через `IF NOT EXISTS`, поэтому на существующих томах три матвью
|
запросов, а при создании ClickHouse проверяет у этой учётки чтение источника и
|
||||||
сохранят нынешнего владельца, а на собранном заново стенде им станет `etl`.
|
вставку в цель. Так отдельная учётка показывает границу между редким
|
||||||
Работают оба — права есть у обоих, — но два стенда в этом расходятся.
|
управлением структурой и повседневной записью `etl`, не выдавая рабочим дагам
|
||||||
|
право удалять базы.
|
||||||
|
|
||||||
Лаба «сломать права» вариантом с файлами не закрывается: создать через SQL
|
Лаба «сломать права» вариантом с файлами не закрывается: создать через SQL
|
||||||
новую роль и нового пользователя ничто не мешает, и новую роль разрешено
|
новую роль и нового пользователя ничто не мешает, и новую роль разрешено
|
||||||
@@ -122,6 +153,11 @@ Postgres и Grafana. Цена выбора известна: объявленн
|
|||||||
|
|
||||||
## Что проверено
|
## Что проверено
|
||||||
|
|
||||||
|
Права `student` сверены 22 сентября 2026 года через MCP Context7 и на стенде
|
||||||
|
26.3.17.56: создание локальных `MergeTree`, `INSERT SELECT` из DDS и DM,
|
||||||
|
чтение и удаление своих таблиц работают. На обеих нодах вставка в STG, ODS,
|
||||||
|
DDS, DM и `dic` завершается `ACCESS_DENIED`.
|
||||||
|
|
||||||
Сверено 9 августа 2026 года по документации ClickHouse через MCP Context7 и
|
Сверено 9 августа 2026 года по документации ClickHouse через MCP Context7 и
|
||||||
на живом стенде 26.3.17.56.
|
на живом стенде 26.3.17.56.
|
||||||
|
|
||||||
@@ -136,22 +172,48 @@ Postgres и Grafana. Цена выбора известна: объявленн
|
|||||||
такого пользователя запросом даёт `ACCESS_STORAGE_READONLY`.
|
такого пользователя запросом даёт `ACCESS_STORAGE_READONLY`.
|
||||||
- Роль, созданную через SQL, разрешено выдавать пользователю из файла —
|
- Роль, созданную через SQL, разрешено выдавать пользователю из файла —
|
||||||
отдельное улучшение ClickHouse 2025 года, в 26.3 оно есть.
|
отдельное улучшение ClickHouse 2025 года, в 26.3 оно есть.
|
||||||
- Настройку пользователей можно перечитать на живом стенде, в том числе на
|
|
||||||
всём кластере сразу.
|
|
||||||
- Подстановка значений из переменных окружения — общий механизм файлов
|
- Подстановка значений из переменных окружения — общий механизм файлов
|
||||||
настройки ClickHouse.
|
настройки ClickHouse.
|
||||||
- Весь DDL репозитория идёт через `IF NOT EXISTS` (файлы `sql/ddl/`).
|
- Весь 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.
|
Проверено на живом стенде 13 августа 2026 года, ClickHouse 26.3.17.56.
|
||||||
|
|
||||||
- Секрет кластера принимается внутри `remote_servers` в виде
|
- Секрет кластера принимается внутри `remote_servers` в виде
|
||||||
`<secret from_env="CLICKHOUSE_CLUSTER_SECRET"/>`. Запрос от `etl` через обе
|
`<secret from_env="CLICKHOUSE_CLUSTER_SECRET"/>`. Запрос от `etl` через обе
|
||||||
ноды выполнился на каждой под пользователем `etl`.
|
ноды выполнился на каждой под пользователем `etl`.
|
||||||
- `from_env` работает у элемента `password`: `etl`, `bi` и `analyst` вошли с
|
- `from_env` работает у элемента `password`: `etl`, `lifecycle`, `bi` и
|
||||||
паролями из окружения и получили свои роли.
|
`analyst` вошли с паролями из окружения и получили свои роли.
|
||||||
- При запросе от `bi` через Distributed на второй ноде `system.query_log`
|
- При запросе от `bi` через Distributed на второй ноде `system.query_log`
|
||||||
показал `user = bi` и `initial_user = bi`. Значит, на соседнем шарде
|
показал `user = bi` и `initial_user = bi`. Значит, на соседнем шарде
|
||||||
применяются права инициатора, а не общей учётной записи.
|
применяются права инициатора, а не общей учётной записи.
|
||||||
- Права на отсутствующие базы `dds` и `dm` приняты. До создания баз они видны
|
- Права на отсутствующие базы `dds` и `dm` приняты. До создания баз они видны
|
||||||
в `SHOW GRANTS FOR etl_writer`; временная таблица в `dm` подтвердила право
|
в `SHOW GRANTS FOR etl_writer`; временная таблица в `dm` подтвердила право
|
||||||
`bi` на чтение после появления базы.
|
`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).
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
Дата: 12 августа 2026 года. Статус: частично заменено
|
Дата: 12 августа 2026 года. Статус: частично заменено
|
||||||
[ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93.
|
[ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93.
|
||||||
|
|
||||||
|
Позднее тикет #214 сменил имена: «сырьё» стало сырыми данными, «работник» —
|
||||||
|
шаговым дагом. Текст ниже оставлен в словах своего времени, действующие
|
||||||
|
имена — в `CONTEXT.md`.
|
||||||
|
|
||||||
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
|
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
|
||||||
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
|
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
|
||||||
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
|
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
|
||||||
@@ -141,7 +145,10 @@
|
|||||||
|
|
||||||
Этап 3 забирает у этапа 5 первый настоящий даг. Раздел 9 мастер-спеки отдавал
|
Этап 3 забирает у этапа 5 первый настоящий даг. Раздел 9 мастер-спеки отдавал
|
||||||
даги этапу 5 целиком; приём заказов без дага не существует, поэтому порядок
|
даги этапу 5 целиком; приём заказов без дага не существует, поэтому порядок
|
||||||
меняется. `etl_pipeline` остаётся за этапом 5.
|
меняется. `etl_pipeline` остаётся за этапом 5. Позднее сдвинулся и он:
|
||||||
|
резолюция #134 родила его этапом 4 — [дневным конвейером
|
||||||
|
трансформаций](../architecture/etl.md), — а этапу 5 оставила партиционную
|
||||||
|
переобработку.
|
||||||
|
|
||||||
Расхождения с мастер-спекой, внесённые тем же коммитом: раздел 6 (чтец заказов
|
Расхождения с мастер-спекой, внесённые тем же коммитом: раздел 6 (чтец заказов
|
||||||
живёт на одной ноде и без матвью), раздел 7 (развилка закрыта, у `orders` одна
|
живёт на одной ноде и без матвью), раздел 7 (развилка закрыта, у `orders` одна
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
|
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
|
||||||
|
|
||||||
|
Позднее тикет #214 сменил имена: «пульт мира» стал дагами модельного времени,
|
||||||
|
«работник» — шаговым дагом. Текст ниже оставлен в словах своего времени,
|
||||||
|
действующие имена — в `CONTEXT.md`.
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
Ось модельного времени двигают даги пульта, и они двух родов.
|
Ось модельного времени двигают даги пульта, и они двух родов.
|
||||||
@@ -30,6 +34,11 @@
|
|||||||
работник берёт из переменной, а не из логической даты прогона: календарь Airflow
|
работник берёт из переменной, а не из логической даты прогона: календарь Airflow
|
||||||
к оси мира отношения не имеет.
|
к оси мира отношения не имеет.
|
||||||
|
|
||||||
|
Позднее [ADR 0013](0013-world-lifecycle.md) отдал Airflow начальную позицию и
|
||||||
|
состояния обслуживания: переменной нет до первого создания мира, `-1` означает
|
||||||
|
незавершённую очистку, `0` — чистый мир до генерации, а работники принимают
|
||||||
|
только позицию `8` и больше.
|
||||||
|
|
||||||
Пакетная автоматика — второй выключатель, дёргающий `world_next_day`, —
|
Пакетная автоматика — второй выключатель, дёргающий `world_next_day`, —
|
||||||
**отложена, а не отвергнута**.
|
**отложена, а не отвергнута**.
|
||||||
|
|
||||||
@@ -130,7 +139,10 @@
|
|||||||
Этапы 3 и 5 зовут генератор тем же приёмом: пакетный забор слепка заказов
|
Этапы 3 и 5 зовут генератор тем же приёмом: пакетный забор слепка заказов
|
||||||
([ADR 0008](0008-order-ingestion.md)) прирастает к тому же работнику, а
|
([ADR 0008](0008-order-ingestion.md)) прирастает к тому же работнику, а
|
||||||
`etl_pipeline` этапа 5 получает готовую обвязку `DockerOperator` и готовое
|
`etl_pipeline` этапа 5 получает готовую обвязку `DockerOperator` и готовое
|
||||||
правило позиции.
|
правило позиции. Позднее резолюция #134 родила `etl_pipeline` этапом 4 —
|
||||||
|
[дневным конвейером трансформаций](../architecture/etl.md) без генераторных
|
||||||
|
шагов: обвязка и правило позиции остаются работникам, а живой день дёргает
|
||||||
|
конвейер после установки позиции.
|
||||||
|
|
||||||
Расхождения, внесённые тем же коммитом: раздел 8 спеки генератора (позицию
|
Расхождения, внесённые тем же коммитом: раздел 8 спеки генератора (позицию
|
||||||
двигал «только даг `next_day`»; теперь — всякий, кто сыграл день, значением
|
двигал «только даг `next_day`»; теперь — всякий, кто сыграл день, значением
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# ADR 0010. Заказы в ODS: версии сущности вместо подмены слепка
|
# ADR 0010. Заказы в ODS: версии сущности вместо подмены слепка
|
||||||
|
|
||||||
Дата: 16 августа 2026 года. Статус: принято. Частично заменяет
|
Дата: 16 августа 2026 года. Статус: принято. Частично заменяет
|
||||||
[ADR 0008](0008-order-ingestion.md).
|
[ADR 0008](0008-order-ingestion.md). Колонка версии пересмотрена
|
||||||
|
[ADR 0014](0014-order-version-snapshot-date.md): вместо `updated_at` —
|
||||||
|
`snapshot_date`.
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
@@ -9,7 +11,7 @@
|
|||||||
команде Airflow, но порция чтения больше не считается полным слепком и не
|
команде Airflow, но порция чтения больше не считается полным слепком и не
|
||||||
публикуется заменой партиции `snapshot_date`. Прочитанные строки получают
|
публикуется заменой партиции `snapshot_date`. Прочитанные строки получают
|
||||||
`_load_id` запуска, разбираются из одного среза STG в годные строки и ошибки,
|
`_load_id` запуска, разбираются из одного среза STG в годные строки и ошибки,
|
||||||
а `ods.order_snapshot` хранит принятые версии заказа в
|
а `ods.order` хранит принятые версии заказа в
|
||||||
`ReplacingMergeTree(updated_at)`. Ключ сущности — `order_id`, партиция строится
|
`ReplacingMergeTree(updated_at)`. Ключ сущности — `order_id`, партиция строится
|
||||||
от неизменного `created_at`, все версии ключа направляются на один шард.
|
от неизменного `created_at`, все версии ключа направляются на один шард.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 из STG. Это содержимое источника; отдельным
|
||||||
|
атрибутом модели оно не становится. Разобранные массивы `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` нет. Запланированные витрины его не читают, а два возможных
|
||||||
|
смысла — «первая покупка вообще» и «момент, с которого связь была известна
|
||||||
|
хранилищу» — требуют разных исторических моделей. Этот вопрос оставлен
|
||||||
|
[самостоятельной работе](../../labs/optional/identity-over-time.md),
|
||||||
|
а текущий стенд не выдаёт один смысл за другой.
|
||||||
|
|
||||||
|
Статус заказа связь не меняет: любой заказ, сопоставленный с событием покупки,
|
||||||
|
показывает владельца куки. Отмена меняет состояние заказа, но не личность
|
||||||
|
покупателя.
|
||||||
|
|
||||||
|
## Физическая модель и публичный интерфейс
|
||||||
|
|
||||||
|
- `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,113 @@
|
|||||||
|
# Модель заказа в DDS
|
||||||
|
|
||||||
|
DDS готовит заказ для анализа: выбирает текущее состояние из ODS,
|
||||||
|
разбирает позиции и переводит время в часовой пояс счетчика. Этот слой
|
||||||
|
показывает, как собрать модель данных для витрин и повторить загрузку
|
||||||
|
без удвоения заказов.
|
||||||
|
|
||||||
|
## Зерно
|
||||||
|
|
||||||
|
Одна строка — один заказ в последнем известном состоянии, ключ — `order_id`.
|
||||||
|
История изменений здесь не хранится. ODS тоже не служит ее архивом:
|
||||||
|
промежуточные версии удаляются при фоновых слияниях.
|
||||||
|
|
||||||
|
## Состав строки
|
||||||
|
|
||||||
|
Поля заказа приходят из `ods.order_v` с именами источника: `order_id`,
|
||||||
|
`user_id`, `status`, `items_total`, `discount`, `delivery`, `total`.
|
||||||
|
Денежные колонки хранят рубли с копейками в `Decimal(18, 2)`.
|
||||||
|
|
||||||
|
`created_at` и `updated_at` переводятся из UTC в `Europe/Samara`, пояс
|
||||||
|
счетчика сайта. Из `created_at` в этом поясе вычисляется `order_date` —
|
||||||
|
день оформления заказа. Витрины группируют по готовой дате;
|
||||||
|
правило времени общее для [хранилища](../storage.md#часовые-пояса).
|
||||||
|
|
||||||
|
Строка `items` из ODS разбирается в три массива одной длины:
|
||||||
|
|
||||||
|
| Колонка | Что хранит |
|
||||||
|
|---|---|
|
||||||
|
| `item_sku` | Артикулы товаров |
|
||||||
|
| `item_quantity` | Количество каждого товара, `UInt32` |
|
||||||
|
| `item_price` | Цены за единицу в рублях, `Decimal(18, 2)` |
|
||||||
|
|
||||||
|
Элементы с одинаковым номером описывают одну позицию заказа. Витрина
|
||||||
|
выручки разворачивает их через `ARRAY JOIN` и получает категорию товара
|
||||||
|
из справочника через `dictGet`. Разбор JSON выполняется один раз
|
||||||
|
при загрузке DDS.
|
||||||
|
|
||||||
|
ODS проверяет только то, что `items` — массив. Разбор в DDS не заменяет
|
||||||
|
проверку его содержимого: например, некорректное количество может стать
|
||||||
|
нулем. Проверку суммы позиций участник добавляет в
|
||||||
|
[первом задании ступени 5](../../labs/5-1-order-checks.md).
|
||||||
|
|
||||||
|
## Движок и наполнение
|
||||||
|
|
||||||
|
`dds.order_rep` — таблица на `ReplicatedMergeTree`, без колонки версии.
|
||||||
|
Партиция хранит один `order_date`, ключ сортировки — `order_id`.
|
||||||
|
Запись через `dds.order_dist` распределяет заказы по `cityHash64(order_id)`,
|
||||||
|
как в ODS. Для чтения служит `dds.order_v` с явным списком колонок
|
||||||
|
над `dds.order_dist`; `FINAL` в этом слое не нужен.
|
||||||
|
|
||||||
|
Даг `dds_transform` пересобирает выбранные дни из `ods.order_v`:
|
||||||
|
|
||||||
|
1. [`order_scope.sql`](../../../sql/dds/order_scope.sql) определяет
|
||||||
|
непрерывный отрезок `first_day`…`last_day`. Он включает дни заказов
|
||||||
|
из последнего слепка и начинается раньше, если в DDS отсутствует
|
||||||
|
день, который есть в ODS.
|
||||||
|
2. [`order_rebuild.sql`](../../../sql/dds/order_rebuild.sql) очищает
|
||||||
|
промежуточную таблицу `dds.order_stage_rep` и наполняет ее через
|
||||||
|
`dds.order_stage_dist`. Это донор: в нем готовится результат,
|
||||||
|
пока основная таблица остается доступной для чтения.
|
||||||
|
3. [`order_replace.sql`](../../../sql/dds/order_replace.sql) заменяет
|
||||||
|
каждую дневную партицию основной таблицы готовой партицией донора.
|
||||||
|
|
||||||
|
Повтор при неизменном ODS дает те же деловые данные без дублей —
|
||||||
|
это идемпотентность загрузки. Пока заказы дня входят в семидневное окно
|
||||||
|
источника, их состояние может меняться, и день пересобирается.
|
||||||
|
За пределами окна новые состояния не приходят, но пропущенный день
|
||||||
|
можно восстановить из ODS. Границы пересборки берутся из данных;
|
||||||
|
число семь в SQL хранилища не повторяется.
|
||||||
|
|
||||||
|
Замена атомарна внутри одного шарда: читатель видит прежнюю или новую
|
||||||
|
партицию целиком. Замены на двух шардах независимы, поэтому между ними
|
||||||
|
запрос через Distributed может увидеть смешанный результат.
|
||||||
|
Донор остается до следующей сборки и доступен для диагностики.
|
||||||
|
Порядок задач и повторы — в [описании дневного конвейера](../etl.md).
|
||||||
|
|
||||||
|
## Служебные колонки
|
||||||
|
|
||||||
|
`_load_id` и `_load_ts` показывают, какой запуск DDS собрал строку
|
||||||
|
и когда он выполнил вставку. Они одинаковы у строк одной сборки.
|
||||||
|
При следующей пересборке эти метки обновятся, даже если заказ не изменился.
|
||||||
|
|
||||||
|
`snapshot_date` и метки приема ODS в DDS не переносятся: они описывают
|
||||||
|
получение сообщения источника. Их можно найти в ODS по `order_id`.
|
||||||
|
В каждом слое служебные колонки относятся к его собственной загрузке.
|
||||||
|
|
||||||
|
При поиске ошибки начните с `_load_id` в DDS: он указывает на запуск
|
||||||
|
сборки. По `order_id` найдите версии заказа в ODS, а по их `_load_id` —
|
||||||
|
соответствующие сырые сообщения в STG и ошибки того же приема.
|
||||||
|
В таблице ошибок `order_id` может быть не разобран, поэтому нужное
|
||||||
|
сообщение ищут по `raw` и координатам Kafka.
|
||||||
|
Сырые сообщения хранятся около трех реальных суток, ошибки — месяц;
|
||||||
|
уточнения к срокам — в [справочнике хранилища](../storage.md#срок-жизни-сырых-данных).
|
||||||
|
|
||||||
|
## Читатели
|
||||||
|
|
||||||
|
- `dm.revenue_daily_v` считает выручку по заказам со статусом `paid`,
|
||||||
|
разворачивает позиции и дополняет их категорией товара.
|
||||||
|
- `dm.purchase_vs_orders_v` сопоставляет заказ с покупкой на сайте
|
||||||
|
по `order_id`, используя `order_date`, `items_total` и `status`.
|
||||||
|
- `dds.identity_map` связывает идентификаторы посетителей с `user_id`
|
||||||
|
через покупки и заказы.
|
||||||
|
|
||||||
|
## Связанные решения
|
||||||
|
|
||||||
|
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) — версии заказов в ODS
|
||||||
|
и чтение через `ods.order_v`.
|
||||||
|
- [Прием заказов](../orders/ingestion.md) — проверки формата и выбор
|
||||||
|
актуальной версии.
|
||||||
|
- [Исследование соединений](../../research/2026-08-22-distributed-join.md) —
|
||||||
|
почему сверка распределенных источников использует `GLOBAL JOIN`.
|
||||||
|
- [Проталкивание условий сквозь представления](../../research/2026-08-22-view-predicate-pushdown.md) —
|
||||||
|
как фильтр запроса влияет на объем чтения ODS.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# Модель сессии в DDS
|
||||||
|
|
||||||
|
Сессия объединяет события одного визита. На стенде вы учитесь определять
|
||||||
|
ее границы по событиям, вычислять устойчивый идентификатор и сверять
|
||||||
|
результат с эталоном. Поэтому сборка читает `dds.event_v`, где нет готового
|
||||||
|
`VisitID`. Этот идентификатор источника остается в ODS для проверки.
|
||||||
|
|
||||||
|
## Зерно
|
||||||
|
|
||||||
|
Зерно описывает, чему соответствует одна строка: здесь это один
|
||||||
|
[визит](../../../CONTEXT.md#люди-и-события). В него входят подряд идущие
|
||||||
|
события одного `client_id` без пауз длиннее 30 минут и без перехода через
|
||||||
|
модельную полночь. Пауза ровно в 30 минут новую сессию не открывает.
|
||||||
|
Учитываются все три типа событий: `pageview`, `add_to_cart`, `purchase`.
|
||||||
|
|
||||||
|
День сессии `session_date` берется из `event_date` событий DDS — это
|
||||||
|
`EventDate` источника, дата по часовому поясу счетчика. У всех событий
|
||||||
|
одной сессии она одинакова. Пересчитывать ее из времени UTC не нужно.
|
||||||
|
|
||||||
|
SQL рассчитывает границы визитов двумя оконными функциями: первая отмечает
|
||||||
|
начало нового визита, вторая по этим отметкам присваивает событиям номер
|
||||||
|
визита. Обе используют порядок `(event_time, event_id)`: при равном времени
|
||||||
|
события идут по идентификатору. Иначе два расчета могли бы по-разному
|
||||||
|
упорядочить события и отнести отметку начала к разным строкам.
|
||||||
|
|
||||||
|
## Идентификатор
|
||||||
|
|
||||||
|
`session_id` вычисляется как `cityHash64(client_id, min(event_time))`.
|
||||||
|
При повторной сборке тех же событий посетитель и начало визита остаются
|
||||||
|
прежними, поэтому идентификатор не меняется. Счетчик или случайный UUID
|
||||||
|
выдали бы другие значения.
|
||||||
|
|
||||||
|
По `session_id` можно сопоставить сессии до и после пересборки и назвать
|
||||||
|
конкретную строку при разборе расхождения. Он не используется для сортировки
|
||||||
|
или шардирования. У событий этого поля нет; связь с заказами строится
|
||||||
|
по событиям покупки.
|
||||||
|
|
||||||
|
## Состав строки
|
||||||
|
|
||||||
|
- `client_id`, `session_id`, `session_date` — посетитель, сессия и ее день.
|
||||||
|
- `started_at`, `finished_at`, `duration_seconds` — время первого и последнего
|
||||||
|
события и разница между ними в секундах. Времена хранятся в часовом поясе
|
||||||
|
счетчика.
|
||||||
|
- `events_total`, `pageviews`, `cart_adds`, `purchases` — число всех событий
|
||||||
|
и счетчики трех типов.
|
||||||
|
- `entry_url`, `exit_url` — страницы первого и последнего события.
|
||||||
|
- `utm_*` — метки первого события: по ним визит относят к рекламной кампании.
|
||||||
|
- `device_category`, `region_city` — устройство и город посетителя.
|
||||||
|
На стенде они постоянны для одной куки.
|
||||||
|
- `_load_id`, `_load_ts` — идентификатор запуска и время сборки строки.
|
||||||
|
|
||||||
|
В готовом SQL страницы выбираются через `argMin` и `argMax`, а UTM,
|
||||||
|
устройство и город — через `argMin`, только по `event_time`. При одинаковом
|
||||||
|
времени выбор между событиями не определен. Полный порядок окон нарезки
|
||||||
|
сам по себе эту неоднозначность не устраняет. Это ограничение учитывается
|
||||||
|
в [задании ступени 4](../../labs/4-1-session-pipeline.md).
|
||||||
|
|
||||||
|
В сессии нет суммы выручки: ее считают по заказам магазина. Клиентская
|
||||||
|
сумма покупки используется в сверке с заказами и в учебной UTM-витрине.
|
||||||
|
|
||||||
|
## Движок и наполнение
|
||||||
|
|
||||||
|
`dds.session_rep` хранит готовые сессии в `ReplicatedMergeTree` без колонки
|
||||||
|
версии. При чтении не нужно заново выполнять оконный расчет. Партиция —
|
||||||
|
раздел таблицы за один `session_date`; ключ сортировки — посетитель
|
||||||
|
и начало сессии. Читайте результат через `dds.session_v`: представление
|
||||||
|
над `dds.session_dist` явно перечисляет деловые и служебные колонки.
|
||||||
|
|
||||||
|
Источник сборки — `dds.event_v`. События, сессии и карта идентичностей
|
||||||
|
распределены по одному ключу `cityHash64(client_id)`: данные посетителя
|
||||||
|
находятся на одном шарде. Роли объектов и правило записи через `_dist`
|
||||||
|
описаны в [справочнике хранилища](../storage.md#раскладка-по-шардам).
|
||||||
|
|
||||||
|
Три SQL-файла выполняются последовательно в группе `session` дага
|
||||||
|
`dds_transform`:
|
||||||
|
|
||||||
|
1. `session_scope.sql` находит первую дату событий, за которую еще нет
|
||||||
|
сессий. Последний день для расчета — первый день событий ODS
|
||||||
|
плюс `world_position` минус один; `world_position` — номер следующего
|
||||||
|
дня для генерации. Так в расчет попадают только полностью
|
||||||
|
прожитые дни. При пустом ODS запрос не возвращает строк и задача падает;
|
||||||
|
если пропущенных прожитых дней нет, `first_day` равен `NULL` и группа
|
||||||
|
пропускается.
|
||||||
|
2. `session_rebuild.sql` очищает промежуточную таблицу
|
||||||
|
`dds.session_stage_rep`. Затем рассчитывает сессии выбранных дней
|
||||||
|
и записывает строки через `dds.session_stage_dist`. Пока идет расчет,
|
||||||
|
в основной таблице остаются прежние данные. Вставка ждет доставки
|
||||||
|
строк на шарды благодаря `distributed_foreground_insert = 1`.
|
||||||
|
3. `session_replace.sql` заменяет в `dds.session_rep` раздел за каждый
|
||||||
|
выбранный день готовым разделом `dds.session_stage_rep`. Для каждого
|
||||||
|
дня Airflow создает отдельную задачу `replace`.
|
||||||
|
|
||||||
|
Когда все события завершенного дня приняты в ODS, их набор на стенде
|
||||||
|
больше не меняется. Поэтому готовые сессии обычно не пересчитываются. Если пропущен ранний
|
||||||
|
день, диапазон включает и все последующие прожитые дни, даже уже собранные.
|
||||||
|
Если за день сохранилась хотя бы одна сессия, поиск пропущенных дней
|
||||||
|
не обнаружит потерю остальных. Условия повторного запуска и замены
|
||||||
|
разделов описаны в [справочнике конвейера](../etl.md#идемпотентность).
|
||||||
|
|
||||||
|
Сессий еще идущего дня в модели нет; его события доступны через
|
||||||
|
`dds.event_v`. Перед выбором дней даг ждет завершения доставки событий
|
||||||
|
в ODS — см. [готовность источника](../etl.md#готовность-источника).
|
||||||
|
|
||||||
|
## Сверка с эталоном
|
||||||
|
|
||||||
|
Проверка `sessions_vs_reference` в `dq_check` сравнивает для каждого
|
||||||
|
посетителя и дня число строк `dds.session_v` с `uniqExact(VisitID)`
|
||||||
|
из `ods.event_v`. Эталон читается из ODS, потому что `dds.event_v`
|
||||||
|
скрывает `VisitID`.
|
||||||
|
|
||||||
|
Нулевое число расхождений подтверждает только число сессий каждого
|
||||||
|
посетителя за день. Например, ошибочное объединение двух визитов
|
||||||
|
и разделение другого на два сохранят их общее число. Поэтому в заданиях
|
||||||
|
вы дополнительно сопоставляете границы сессий по исходным событиям и проверяете страницы,
|
||||||
|
UTM, устройство и город.
|
||||||
|
|
||||||
|
## Читатели
|
||||||
|
|
||||||
|
- Витрина обзора сессий `session_overview_v`: дневные итоги визитов в
|
||||||
|
срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод
|
||||||
|
в ее [ТЗ](../../labs/1-3-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,300 @@
|
|||||||
|
# Слой DM: витрины
|
||||||
|
|
||||||
|
Витрины готовят данные DDS для отчетов и дашбордов. Здесь описаны форма
|
||||||
|
хранения, правила обновления и проверки. Состав колонок задан в
|
||||||
|
[спецификации](../specs/2026-07-30-stand-v2-realism.md#витрины-dm)
|
||||||
|
и [учебных заданиях](../labs/README.md).
|
||||||
|
|
||||||
|
## Таблица или представление
|
||||||
|
|
||||||
|
Зерно — то, чему соответствует одна строка. Например, в заказах это заказ,
|
||||||
|
а в дневной выручке — день и категория товара. На стенде результат
|
||||||
|
с новым зерном хранится в таблице: отчет читает готовые итоги без
|
||||||
|
повторного расчета. Таблицу наполняет `dm_transform`.
|
||||||
|
|
||||||
|
У такой витрины есть локальная `_rep`, распределенная `_dist` и публичное
|
||||||
|
представление `_v` с явным списком колонок. Для обновления по дням
|
||||||
|
нужна промежуточная таблица `_stage_rep`, в которую пишут через `_stage_dist`.
|
||||||
|
В нее записывают рассчитанные строки, пока основная таблица хранит прежние.
|
||||||
|
Затем дневные разделы основной таблицы заменяют готовыми разделами
|
||||||
|
промежуточной. Такие разделы называются партициями. Подробности — в
|
||||||
|
[справочнике хранилища](storage.md#имена-объектов).
|
||||||
|
|
||||||
|
Если запрос только переименовывает или дополняет поля и сохраняет одну
|
||||||
|
строку на событие, достаточно обычного представления. Оно выполняет
|
||||||
|
запрос при чтении, не хранит вторую копию событий и не требует пересборки.
|
||||||
|
|
||||||
|
На небольших данных стенда и агрегаты могли бы вычисляться при чтении
|
||||||
|
([исследование](../research/2026-08-22-view-predicate-pushdown.md)).
|
||||||
|
Готовые таблицы нужны в программе для практики загрузки, обновления
|
||||||
|
и включения витрин в конвейер.
|
||||||
|
|
||||||
|
## Что готово в стенде, а что остаётся заданием
|
||||||
|
|
||||||
|
Готовы три витрины дашборда и сводка качества:
|
||||||
|
|
||||||
|
- `revenue_daily` — одна строка на день заказа и категорию товара;
|
||||||
|
образец дневной пересборки для учебных заданий.
|
||||||
|
- `purchase_vs_orders` — одна строка на заказ: сопоставление покупки
|
||||||
|
из кликстрима с заказом магазина и класс расхождения.
|
||||||
|
- `daily_traffic` — одна строка на день: число посетителей и известных
|
||||||
|
пользователей с учетом [карты идентичностей](dds/identity.md).
|
||||||
|
- `dq_summary` — одна строка на день данных и проверку качества.
|
||||||
|
|
||||||
|
В [заданиях ступени 1](../labs/README.md) вы строите `events_enriched_v`,
|
||||||
|
`top_pages_daily`, `session_overview`, `dq_errors_daily` и `utm_effectiveness`.
|
||||||
|
Для каждой физической витрины нужно добавить сборку в `dm_transform`.
|
||||||
|
Представление `events_enriched_v` отдельной задачи не требует.
|
||||||
|
|
||||||
|
Стендовый дашборд читает готовые витрины и работает до выполнения заданий.
|
||||||
|
Дашборд по своим витринам можно собрать в
|
||||||
|
[факультативе](../labs/optional/superset-dashboard.md) после ступени 2.
|
||||||
|
Устройство стендового дашборда — в [справочнике Superset](superset.md).
|
||||||
|
|
||||||
|
## Ритм пересборки
|
||||||
|
|
||||||
|
Дни для пересчета выбирают по тому, как меняется источник:
|
||||||
|
|
||||||
|
- События и сессии прожитого дня на стенде больше не меняются. Витрины
|
||||||
|
по ним рассчитывают показатели от первого пропущенного дня до последнего
|
||||||
|
прожитого и заменяют дневные партиции.
|
||||||
|
- Заказы могут меняться в течение семи модельных дней. Последний слепок ODS
|
||||||
|
задает диапазон дат для пересборки; пропущенные дни могут расширить его.
|
||||||
|
- Новая связь в карте идентичностей позволяет определить пользователя
|
||||||
|
по его прежним визитам: один `client_id` мог встречаться и до покупки,
|
||||||
|
которая связала его с `user_id`.
|
||||||
|
Поэтому `daily_traffic`, как и карта, пересчитывается целиком.
|
||||||
|
|
||||||
|
`daily_traffic` каждый раз добавляет всю историю новой версией
|
||||||
|
в `ReplacingMergeTree(_load_ts)`. Дневных партиций у нее нет, а текущие
|
||||||
|
строки выбирает представление через `FINAL`.
|
||||||
|
|
||||||
|
У `purchase_vs_orders` два источника. Начало диапазона — самая ранняя
|
||||||
|
из трех дат: первая дата оформления заказа в последнем слепке, первый
|
||||||
|
пропущенный день и первый день с `awaiting_order` — покупкой, для которой
|
||||||
|
заказ пока не поступил. День ожидания проверяется отдельно: строки за него
|
||||||
|
уже есть в витрине, поэтому он не считается пропущенным.
|
||||||
|
Конец диапазона — более поздняя из последней даты заказов в слепке и последнего
|
||||||
|
прожитого дня с покупкой. Один запуск пересобирает весь отрезок,
|
||||||
|
даже если с предыдущего запуска прошло больше семи модельных дней.
|
||||||
|
|
||||||
|
Для учебной сводки брака действует отдельное правило: дни выбираются
|
||||||
|
по времени загрузки, а прежние итоги сохраняются после удаления
|
||||||
|
источника по TTL. Оно разобрано в
|
||||||
|
[задании `dq_errors_daily`](../labs/1-4-dq-errors-daily.md).
|
||||||
|
|
||||||
|
## Витрины стенда
|
||||||
|
|
||||||
|
Представления `_v` явно перечисляют поля для отчетов, а также `_load_id`
|
||||||
|
и `_load_ts` — идентификатор запуска и время записи. Новая колонка
|
||||||
|
физической таблицы появится в представлении только после изменения его SQL.
|
||||||
|
|
||||||
|
### `dm.revenue_daily_v`
|
||||||
|
|
||||||
|
Одна строка содержит итоги оплаченных заказов за день по категории товара.
|
||||||
|
Запрос читает `dds.order_v` и разворачивает массивы артикулов, количеств
|
||||||
|
и цен в строки позиций заказа. Артикул, количество и цена с одним индексом
|
||||||
|
становятся одной строкой. Категория артикула берется из `dic.products`.
|
||||||
|
|
||||||
|
`orders` считает разные заказы: несколько позиций одной категории
|
||||||
|
в заказе не увеличивают этот счетчик. `units` — сумма количества товаров,
|
||||||
|
`revenue` — сумма произведений цены на количество. Скидка и доставка
|
||||||
|
в нее не входят. `aov` равен `revenue / orders` для того же дня и категории
|
||||||
|
с округлением до двух знаков после запятой.
|
||||||
|
|
||||||
|
Первый запуск рассчитывает выручку за всю доступную историю. Следующие
|
||||||
|
берут диапазон дат оформления заказов из последнего слепка ODS.
|
||||||
|
Если в DDS есть оплаченные заказы за день, отсутствующий в витрине,
|
||||||
|
левая граница расширяется до него. Агрегат читает DDS; ODS отвечает
|
||||||
|
только на вопрос об объеме.
|
||||||
|
|
||||||
|
В группе `revenue_daily` дага `dm_transform` три шага:
|
||||||
|
|
||||||
|
1. [`revenue_daily_scope.sql`](../../sql/dm/revenue_daily_scope.sql)
|
||||||
|
возвращает `first_day` и `last_day`. Без заказов в ODS или DDS
|
||||||
|
запрос не возвращает строк, и задача завершается ошибкой.
|
||||||
|
2. [`revenue_daily_rebuild.sql`](../../sql/dm/revenue_daily_rebuild.sql)
|
||||||
|
очищает `dm.revenue_daily_stage_rep`, рассчитывает показатели за выбранные
|
||||||
|
дни и записывает строки через `dm.revenue_daily_stage_dist`.
|
||||||
|
Вставка ждет доставки строк на шарды.
|
||||||
|
3. [`revenue_daily_replace.sql`](../../sql/dm/revenue_daily_replace.sql)
|
||||||
|
заменяет в `dm.revenue_daily_rep` каждую дневную партицию `report_date`
|
||||||
|
готовой партицией `dm.revenue_daily_stage_rep`.
|
||||||
|
|
||||||
|
Повтор замены не добавляет копий строк. Ограничения при частичном отказе
|
||||||
|
описаны в [конвейере](etl.md#идемпотентность).
|
||||||
|
|
||||||
|
Основная и промежуточная таблицы распределяют строки по
|
||||||
|
`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` они заполнились бы
|
||||||
|
нулями или пустыми строками, которые сложнее отличить от данных источника.
|
||||||
|
Клиентская `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_users` дают одного пользователя.
|
||||||
|
Поэтому `known_users <= visitors`.
|
||||||
|
|
||||||
|
Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3
|
||||||
|
чтение через публичные `_v` с `distributed_product_mode = 'local'`
|
||||||
|
не дало локального соединения. Поэтому в запросе явно указан
|
||||||
|
`GLOBAL LEFT JOIN`. Он сохраняет сессии без известного пользователя;
|
||||||
|
в `known_users` учитываются только ненулевые `user_id`.
|
||||||
|
Обновление всей истории новой версией описано в разделе
|
||||||
|
[«Ритм пересборки»](#ритм-пересборки).
|
||||||
|
|
||||||
|
## `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` записаны идентификатор запуска и время расчета
|
||||||
|
сводки. Даже одно расхождение дает `fail`.
|
||||||
|
|
||||||
|
## Звено 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`.
|
||||||
|
|
||||||
|
Каждая проверка берет дни и ключи из обоих сравниваемых наборов данных.
|
||||||
|
Поэтому потерянная строка остается видна на другой стороне сравнения.
|
||||||
|
Расхождения считаются по ключам: завышение одного показателя не скроет
|
||||||
|
занижение другого в общей сумме за день.
|
||||||
|
|
||||||
|
`classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая
|
||||||
|
проверка доставки и обработки эталонных данных стенда. Она не проверяет
|
||||||
|
сам генератор; у настоящего магазина такого эталона нет.
|
||||||
|
|
||||||
|
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
|
||||||
|
сводку и несколько примеров расхождений. Техническая сверка с паспортом читает небольшой
|
||||||
|
файл JSON в Python. Результаты всех пяти проверок записываются в общую
|
||||||
|
промежуточную таблицу. Когда расчеты завершились, ее дневные разделы
|
||||||
|
заменяют разделы `dm.dq_summary_rep`. Затем отдельные задачи читают
|
||||||
|
сохраненную сводку и завершаются ошибкой при `failed_rows > 0`.
|
||||||
|
Так расхождение сохраняется в таблице до отказа задачи. Если сам расчет
|
||||||
|
не выполнен, прежняя сводка остается на месте. Задача с расхождениями
|
||||||
|
выводит в журнал не больше двадцати ключей со значениями обеих сторон. Для полного списка итоговую агрегацию SQL-сверки
|
||||||
|
заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date,
|
||||||
|
business_key`.
|
||||||
|
|
||||||
|
Если день исчез из обоих сравниваемых наборов, новый расчет не создаст
|
||||||
|
для него строк. Даг отдельно удаляет прежний результат за этот день
|
||||||
|
из сводки.
|
||||||
|
|
||||||
|
## Даг самопроверки
|
||||||
|
|
||||||
|
`homework_check` запускается вручную и содержит задачу для каждого из
|
||||||
|
[пяти заданий по витринам](../labs/README.md). Если витрины нет или значения
|
||||||
|
расходятся с источником, задача завершается ошибкой. Журнал показывает
|
||||||
|
причину и примеры несовпадений. Успех означает, что проверяемые значения
|
||||||
|
совпали; остальные требования задания вы проверяете сами.
|
||||||
|
|
||||||
|
Ожидаемые значения рассчитываются по исходным данным текущего стенда.
|
||||||
|
Для четырех заданий это события или сессии DDS, для сводки брака —
|
||||||
|
таблицы ошибок ODS. Сравнение по деловому ключу учитывает отсутствующие
|
||||||
|
строки с обеих сторон. Результаты не пишутся в `dq_summary`:
|
||||||
|
самопроверка учебных витрин отделена от качества готового конвейера.
|
||||||
|
|
||||||
|
Готовые SQL-проверки открывайте после собственного решения: они содержат
|
||||||
|
расчет существенной части задания.
|
||||||
|
|
||||||
|
## Отклонённые варианты
|
||||||
|
|
||||||
|
- **Весь слой представлениями.** Дёшево и всегда свежо, но главный
|
||||||
|
производственный паттерн слоя — пересборка агрегатов — остался бы
|
||||||
|
невидим, а `dm_transform` — без работы.
|
||||||
|
- **Материализовать всё.** Копия событий ради переименования колонок —
|
||||||
|
лишняя; такой пример приучал бы без нужды дублировать исходные данные.
|
||||||
|
- **Гибрид по цене запроса.** Выбор формы по стоимости, которой на объёмах
|
||||||
|
стенда нет: сторож без настоящей опасности.
|
||||||
|
- **Проверки домашек в конвейерном звене DQ.** Конвейер стоял бы красным до
|
||||||
|
выполнения лаб, а проверка, которая всегда красная, приучает себя
|
||||||
|
игнорировать.
|
||||||
|
- **«Жёлтый» статус недостроенной витрины.** У задачи Airflow нет такого
|
||||||
|
состояния; изображать его логами — семантика, которую пришлось бы
|
||||||
|
расшифровывать. Красное «ещё не построено» честнее.
|
||||||
|
- **Самопроверка пишет в `dq_summary`.** Результаты учебных попыток смешались бы
|
||||||
|
с историей качества конвейера.
|
||||||
|
- **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили
|
||||||
|
бы число известных на момент закрытия — витрина тихо расходится с картой
|
||||||
|
и протаскивает обратно «знание на дату», отвергнутое развилкой карты
|
||||||
|
(#133).
|
||||||
|
- **Инкрементальный пересчёт затронутых дней по `first_matched_at`.**
|
||||||
|
Производственный ход, но на объёмах стенда его экономия неизмерима, а
|
||||||
|
след новых связок требует вернуть колонку, выкинутую в #133. Стал лабой
|
||||||
|
повышенной сложности —
|
||||||
|
[«Как обновлять витрину без полной пересборки»](../labs/optional/incremental-daily-traffic.md).
|
||||||
|
В ней новые связи находятся сравнением наблюдений: время покупки
|
||||||
|
не заменяет момент обнаружения связи.
|
||||||
|
- **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,198 @@
|
|||||||
|
# Дневной конвейер трансформаций
|
||||||
|
|
||||||
|
Даг `etl_pipeline` собирает модель DDS, обновляет витрины DM и проверяет
|
||||||
|
результат. Здесь описаны запуск, выбор дней и запись результата в таблицы.
|
||||||
|
Состав моделей — в [справочниках DDS](dds/README.md) и [DM](dm.md).
|
||||||
|
|
||||||
|
## Форма
|
||||||
|
|
||||||
|
`etl_pipeline` последовательно запускает `dds_transform`, `dm_transform`
|
||||||
|
и `dq_check`. Каждый следующий даг начинается после успеха предыдущего.
|
||||||
|
Для этого `TriggerDagRunOperator` ждет завершения запущенного им дага.
|
||||||
|
Если тот завершился ошибкой, задача ожидания в конвейере тоже завершится
|
||||||
|
ошибкой.
|
||||||
|
Поведение проверено через Context7 по документации Airflow 3.x (3.1.6).
|
||||||
|
|
||||||
|
У этих дагов нет расписания. Их вызывает конвейер; для отдельного разбора
|
||||||
|
можно запустить даг слоя вручную. У каждого разрешен один активный запуск:
|
||||||
|
два запуска одного дага используют одни промежуточные таблицы и могли бы
|
||||||
|
стереть результаты друг друга. Расчет каждой модели выполняется отдельной
|
||||||
|
задачей или группой задач.
|
||||||
|
|
||||||
|
## Три пути запуска
|
||||||
|
|
||||||
|
```text
|
||||||
|
world_initialize ─┐
|
||||||
|
world_live_day ───┼─> etl_pipeline ─> dds_transform ─> dm_transform ─> dq_check
|
||||||
|
запуск рукой ─────┘
|
||||||
|
|
||||||
|
world_next_day ─────> источники без ETL
|
||||||
|
```
|
||||||
|
|
||||||
|
- `world_live_day` запускает конвейер после успешной генерации, приема
|
||||||
|
заказов и обновления `world_position`. Если трансформация упала, день
|
||||||
|
уже прожит и позиция не откатывается. После исправления причины
|
||||||
|
запустите `etl_pipeline` вручную.
|
||||||
|
- `world_next_day` отправляет дни пачкой и ждет приема заказов, но конвейер
|
||||||
|
не запускает. После него запустите `etl_pipeline` сами.
|
||||||
|
- `world_initialize` запускает конвейер последним шагом: при успешном
|
||||||
|
первом `make up` начальные данные уже собраны в DDS и DM.
|
||||||
|
|
||||||
|
## Объём прогона
|
||||||
|
|
||||||
|
Объем прогона — дни, которые обрабатывает один запуск. Даты передавать
|
||||||
|
не нужно: даги определяют их по состоянию таблиц и `world_position`.
|
||||||
|
Например, после генерации семи дней без ETL один запуск рассчитает сессии
|
||||||
|
за все семь дней.
|
||||||
|
|
||||||
|
Для расчета сессий выбираются дни от первого, за который в DDS еще нет
|
||||||
|
сессий, до последнего прожитого.
|
||||||
|
`world_position` хранит номер следующего дня для генерации, поэтому
|
||||||
|
последний прожитый день равен `min(EventDate) + world_position - 1`.
|
||||||
|
Начало календаря берется из ODS; события ранних дней там не удаляются по TTL.
|
||||||
|
Один `max(EventDate)` не годится: события за день есть и во время его
|
||||||
|
генерации, когда последние визиты еще не закончились.
|
||||||
|
Точный запрос — [`session_scope.sql`](../../sql/dds/session_scope.sql).
|
||||||
|
|
||||||
|
Для заказов диапазон задает последний принятый слепок ODS. Среди строк
|
||||||
|
с максимальной `snapshot_date` выбираются самая ранняя и самая поздняя
|
||||||
|
даты оформления заказа. Состояние заказов за эти дни еще может измениться.
|
||||||
|
Если в ODS есть заказы за более ранний день, а в DDS за него нет строк,
|
||||||
|
начало диапазона сдвигается до этого дня. Запрос заново рассчитывает
|
||||||
|
заказы за все дни выбранного отрезка.
|
||||||
|
|
||||||
|
Позиция мира для выбора дней заказов не подходит: с событиями дня D
|
||||||
|
приходит слепок за D−1. Объем рассчитывается по принятым строкам ODS;
|
||||||
|
их наличие еще не доказывает полноту слепка.
|
||||||
|
|
||||||
|
Карта идентичностей пересчитывается целиком. Витрины выбирают дни
|
||||||
|
по своим источникам — см. [ритм пересборки DM](dm.md#ритм-пересборки).
|
||||||
|
Если за день сохранилась хотя бы одна строка, запрос не считает его
|
||||||
|
пропущенным. Полноту данных проверяют отдельно.
|
||||||
|
|
||||||
|
## Готовность источника
|
||||||
|
|
||||||
|
Перед сборкой DDS задача `wait_for_source` последовательно ждет двух
|
||||||
|
условий:
|
||||||
|
|
||||||
|
1. Группа `clickstream_hits` дочитала оба раздела топика `hits`.
|
||||||
|
Подтвержденные смещения сравниваются с верхними смещениями Kafka.
|
||||||
|
Группа и смещения должны существовать, суммарное отставание — равняться нулю.
|
||||||
|
2. В `system.distribution_queue` у `ods.event_dist` не осталось файлов
|
||||||
|
доставки на шарды. Запрос через `clusterAllReplicas` читает обе ноды.
|
||||||
|
|
||||||
|
Опросы идут с паузой в три секунды; на обе стадии задан общий срок
|
||||||
|
300 секунд. Журнал показывает смещения и отставание Kafka, затем число
|
||||||
|
файлов и `last_exception` очереди на каждой ноде. Если готовность не
|
||||||
|
подтверждена за срок ожидания, задача завершается ошибкой.
|
||||||
|
|
||||||
|
Одного нулевого отставания Kafka мало: сообщение может быть подтверждено,
|
||||||
|
пока Distributed-таблица еще доставляет его на шард. Успех `orders_ingest`
|
||||||
|
тоже не заменяет эти проверки: у заказов другой топик и потребитель.
|
||||||
|
|
||||||
|
Эти условия проверяют доставку на момент опроса. Они не останавливают
|
||||||
|
поступление новых событий и не сверяют содержимое таблиц. Поэтому
|
||||||
|
`world_position` отдельно ограничивает сборку сессий прожитыми днями,
|
||||||
|
а качество результата проверяет `dq_check`.
|
||||||
|
|
||||||
|
## Идемпотентность
|
||||||
|
|
||||||
|
Идемпотентность означает, что повторная обработка тех же исходных данных
|
||||||
|
не добавляет копии деловых строк. Служебные `_load_id` и `_load_ts`
|
||||||
|
при новой сборке меняются.
|
||||||
|
|
||||||
|
Результат дневного расчета сначала записывается в промежуточную таблицу.
|
||||||
|
Она нужна, чтобы читатели продолжали видеть прежние данные, пока новые
|
||||||
|
еще рассчитываются. Например, для сессий это `dds.session_stage_rep`,
|
||||||
|
а основная таблица — `dds.session_rep`.
|
||||||
|
|
||||||
|
Задачи выполняются в таком порядке:
|
||||||
|
|
||||||
|
1. `scope` выбирает даты для расчета.
|
||||||
|
2. `rebuild` очищает промежуточную таблицу, рассчитывает строки за выбранные
|
||||||
|
дни и записывает их через `_stage_dist`. Настройка
|
||||||
|
`distributed_foreground_insert = 1` заставляет вставку дождаться
|
||||||
|
доставки строк на шарды.
|
||||||
|
3. `replace` заменяет в основной таблице раздел за каждый выбранный день
|
||||||
|
готовым разделом промежуточной таблицы. Раздел таблицы называется
|
||||||
|
партицией; для замены используется `REPLACE PARTITION`.
|
||||||
|
|
||||||
|
Структура и распределение строк по шардам у двух таблиц должны совпадать.
|
||||||
|
Замена выполняется на локальных `_rep` с `ON CLUSTER`. На каждом шарде
|
||||||
|
читатель видит либо старый, либо новый раздел целиком — это атомарность
|
||||||
|
замены. Общей транзакции для двух шардов и всех дней нет: при сбое один
|
||||||
|
шард может уже содержать новый результат, а другой — прежний. Повторить
|
||||||
|
`replace` можно, пока промежуточная таблица хранит тот же результат;
|
||||||
|
копии строк в основной таблице от этого не появятся.
|
||||||
|
|
||||||
|
Новый запуск всего конвейера заново выбирает дни. Он не обязан повторить
|
||||||
|
все дни неудачного запуска: если дата уже есть в целевой таблице, поиск
|
||||||
|
пропусков ее не выберет. В частности, изменение SQL сессий само по себе
|
||||||
|
не заставляет пересчитать заполненные дни. Повторная сборка выбранного
|
||||||
|
дня разобрана в [задании ступени 4](../labs/4-1-session-pipeline.md).
|
||||||
|
|
||||||
|
Карта идентичностей и `daily_traffic` обновляются иначе: каждый запуск
|
||||||
|
добавляет полный результат новой версией в `ReplacingMergeTree(_load_ts)`.
|
||||||
|
Публичные представления читают текущие строки через `FINAL`. Старые версии
|
||||||
|
могут физически оставаться до фонового слияния; повторяемость относится
|
||||||
|
к результату чтения, а не к числу строк на диске.
|
||||||
|
|
||||||
|
## Чего конвейер о себе не знает
|
||||||
|
|
||||||
|
Трансформация собирает слой по правилу, а не сверяет его с источником. Отсюда
|
||||||
|
три вещи, которых она не заметит, и у всех трёх один адрес — контур 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. Это слой, который читают, и у заказов дорога до него
|
||||||
|
длиннее: в STG данные привозит забор, а типизированную строку кладёт разбор.
|
||||||
|
Встань разбор — свежесть ODS это покажет, а свежесть STG — нет.
|
||||||
|
|
||||||
|
Читает свежесть физическую `_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()` строк STG по тому же интервалу.
|
||||||
|
|
||||||
|
Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase`
|
||||||
|
растягивает приращение на всё окно и на минутном интервале промахивается на
|
||||||
|
проценты: здоровый стенд выглядел бы расходящимся. Накопленный итог от начала
|
||||||
|
окна точнее обоих приростов, но отсчитывать его надо от края окна — а на
|
||||||
|
свежем стенде окно начинается раньше, чем появился экспортёр, и ряд пропадает
|
||||||
|
целиком.
|
||||||
|
|
||||||
|
Столбцы ClickHouse подписаны правым краем, а не левым. Точка Prometheus — это
|
||||||
|
прирост к моменту времени, а `count()` по интервалу — сумма, которая набирается
|
||||||
|
к его концу; подпиши мы её началом, здоровые ряды разъехались бы ровно на один
|
||||||
|
столбец. Шаг панели при этом не мельче минуты: экспортёр опрашивают раз в
|
||||||
|
пятнадцать секунд, и на более мелком шаге ряд Kafka пошёл бы ступеньками там,
|
||||||
|
где ряд ClickHouse идёт гладко.
|
||||||
|
|
||||||
|
Точка в точку ряды всё равно не совпадут, и ждать этого не надо: опрос
|
||||||
|
экспортёра не попадает на границы столбцов, поэтому на разгоне потока линия
|
||||||
|
Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в
|
||||||
|
один столбец не значит ничего.
|
||||||
|
|
||||||
|
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден сразу.
|
||||||
|
Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему другой — не
|
||||||
|
«сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него отвечает своя
|
||||||
|
панель, и модельный день она берёт из самого сообщения: в STG он приезжает полем
|
||||||
|
`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 млн строк за час, своими же панелями.
|
||||||
@@ -27,19 +27,21 @@
|
|||||||
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
|
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
|
||||||
[судьба заказа](fate.md).
|
[судьба заказа](fate.md).
|
||||||
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
|
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
|
||||||
заказная и событийная стороны; броски независимы, пересечения выходят
|
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
|
||||||
арифметикой, приоритет классов работает по-настоящему —
|
пересечения выходят арифметикой, приоритет классов работает по-настоящему —
|
||||||
[классы расхождений и опоздание](fate.md).
|
[классы расхождений и опоздание](fate.md).
|
||||||
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
|
- **Паспорт хранит только то, чего движение мира не меняет**: хеш байтов
|
||||||
каждого слепка и счётчики наблюдаемых классов —
|
каждого слепка и счётчики наблюдаемых классов — [что хранит
|
||||||
[что хранит опись](inventory.md).
|
паспорт](inventory.md).
|
||||||
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
|
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
|
||||||
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
|
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
|
||||||
[мост к склейке](identity.md).
|
[мост к склейке](identity.md).
|
||||||
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
|
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
|
||||||
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order_snapshot`
|
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order`
|
||||||
принимает версии заказа на `ReplacingMergeTree(updated_at)` —
|
принимает версии заказа на `ReplacingMergeTree(snapshot_date)` —
|
||||||
[приём из Kafka в ODS](ingestion.md).
|
[приём из Kafka в ODS](ingestion.md).
|
||||||
|
- **Дальше заказ живёт моделью DDS** — уже по хранилищную сторону границы,
|
||||||
|
в наборе слоя: [модель заказа](../dds/order.md).
|
||||||
- **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого
|
- **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого
|
||||||
дня клики есть, а заказов нет, и график выручки дозаполняется по ходу
|
дня клики есть, а заказов нет, и график выручки дозаполняется по ходу
|
||||||
мира — [заказы в стартовом мире](start-world.md).
|
мира — [заказы в стартовом мире](start-world.md).
|
||||||
@@ -49,15 +51,6 @@
|
|||||||
|
|
||||||
## Открытые решения
|
## Открытые решения
|
||||||
|
|
||||||
- **Имя подпотока событийной стороны** — вместе с её содержимым на этапе 6;
|
|
||||||
из мёртвых имён никто не бросает, переименование ничего не сдвигает (#72).
|
|
||||||
Заказная сторона уже названа — подпоток `ORDERS` ([судьба
|
|
||||||
заказа](fate.md)).
|
|
||||||
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
|
|
||||||
доли классов, стоимость доставки — калибровка при реализации; финальная
|
|
||||||
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
|
|
||||||
потребуют только числа, не устройство.
|
|
||||||
- **`_load_id` выше ODS** — вместе с устройством `dds.order` (#85).
|
|
||||||
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
|
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
|
||||||
остаётся в тумане карты #69; естественное место разговора — этап 4.
|
остаётся в тумане карты #69; естественное место разговора — этап 4.
|
||||||
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
|
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
|
||||||
|
|||||||
@@ -8,19 +8,18 @@
|
|||||||
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
|
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
|
||||||
решённая при его рождении и уложенная в окно K целиком.
|
решённая при его рождении и уложенная в окно K целиком.
|
||||||
|
|
||||||
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты,
|
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, дельта,
|
||||||
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и
|
опоздание. Событийная сторона — порча событийного потока: потеря и дубль. Довод
|
||||||
дубль. Довод за разделение — различимость по описи: правка заказной механики
|
за разделение — различимость по паспорту: правка заказной механики не двигает
|
||||||
не двигает хеши событий, правка событийной не двигает байты слепка, и по
|
хеши событий, правка событийной не двигает байты слепка, и по покрасневшим хешам
|
||||||
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
|
видно, какую сторону трогали. Прежние имена `DISCREPANCIES` и `LATECOMERS`
|
||||||
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
решения не переживают — они названы по классам витрины, а компонент называет
|
||||||
компонент называет часть мира; новые стороны занимают те же позиции.
|
часть мира; новые стороны занимают те же позиции. Заказная сторона названа при
|
||||||
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
|
реализации — подпоток `ORDERS` на позиции 2; событийная — `BEACONS` на позиции
|
||||||
событийная получит имя вместе с содержимым на этапе 6, до тех пор позиция 3
|
3: маячок счетчика либо не долетает, либо отправляется повторно. Отклонено:
|
||||||
занята мёртвым именем прежней нарезки. Отклонено: *один компонент на всю
|
*один компонент на всю судьбу* — правка событийной механики молча меняла бы
|
||||||
судьбу* — правка событийной механики молча меняла бы байты слепка;
|
байты слепка; *компонент на класс* — пять имён под ручки калибровки, которые
|
||||||
*компонент на класс* — пять имён под ручки калибровки, которые крутятся
|
крутятся разом.
|
||||||
разом.
|
|
||||||
|
|
||||||
**Правило формы, без которого разделение не работает: броски заказной стороны
|
**Правило формы, без которого разделение не работает: броски заказной стороны
|
||||||
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
||||||
@@ -56,19 +55,21 @@
|
|||||||
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
|
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
|
||||||
|
|
||||||
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
|
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
|
||||||
она делится между двумя дорогами — строки таблицы исходов. Точные числа —
|
она делится между двумя дорогами — строки таблицы исходов. Замер на
|
||||||
калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим.
|
каноническом мире (28 дней, 2026-08-30, docs/architecture/testing.md, «Что
|
||||||
|
проверено»): отменено 5,15% заказов — 3,03% оплачен-потом-отменен, 2,12%
|
||||||
|
отменен неоплаченным; в пределах ориентира. Проверок вида «отмен от 4 до 6
|
||||||
|
процентов» не заводим.
|
||||||
|
|
||||||
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
||||||
версия; различает их история версий: сырьё STG и физические версии
|
версия; различает их история версий: строки STG и физические версии `ods.order`
|
||||||
`ods.order_snapshot` до фоновых слияний, а в витринах — выручка дня, которая
|
до фоновых слияний, а в витринах — выручка дня, которая сначала выросла, потом
|
||||||
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
|
убыла. Полное различение не обещано: слепок — состояние на границе суток, и
|
||||||
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
|
оплата с отменой в один день в сырых данных неразличимы; то же у сильно
|
||||||
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
|
опоздавших, приехавших уже терминальными. Точную форму этого урока решает этап
|
||||||
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда
|
4. Отклонено: *отмена только после оплаты* — неоплаченному некуда деться, кроме
|
||||||
деться, кроме как остаться брошенным; *мгновенная отмена при рождении* —
|
как остаться брошенным; *мгновенная отмена при рождении* — «дыхание» окна на
|
||||||
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1,
|
отменах исчезает; *отмен нет вовсе* — страховочный срез 1, он в резерве.
|
||||||
он в резерве.
|
|
||||||
|
|
||||||
## Классы расхождений и опоздание
|
## Классы расхождений и опоздание
|
||||||
|
|
||||||
@@ -108,12 +109,13 @@
|
|||||||
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
|
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
|
||||||
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
|
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
|
||||||
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
|
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
|
||||||
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где
|
один в один — посчитал наивно, удвоил выручку. Дубль случается только там,
|
||||||
до следующего визита куки остаётся запас сверх таймаута: иначе сборка сессий у
|
где пауза до следующего визита куки длиннее таймаута и задержки дубля вместе:
|
||||||
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID`
|
иначе сборка сессий у менти разошлась бы с `VisitID` — сломался бы эталон,
|
||||||
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита,
|
ради которого `VisitID` в потоке лежит. Задержка дубля —
|
||||||
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с
|
секунды-минуты, короче таймаута визита, поэтому `VisitID` тот же. Полночь
|
||||||
покупкой, по тому же правилу, что у подтверждения с торговым хвостом.
|
режет дубль парой — просмотр вместе с покупкой, по тому же правилу, что у
|
||||||
|
подтверждения с торговым хвостом.
|
||||||
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
|
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
|
||||||
потеря перестаёт быть видна со стороны трекера.
|
потеря перестаёт быть видна со стороны трекера.
|
||||||
|
|
||||||
@@ -127,18 +129,20 @@
|
|||||||
подделывается — строка создана, когда заказ родился, — но в слепках дней
|
подделывается — строка создана, когда заказ родился, — но в слепках дней
|
||||||
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
|
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
|
||||||
дожил: отменённый на второй день и опоздавший на третий приедет в первом же
|
дожил: отменённый на второй день и опоздавший на третий приедет в первом же
|
||||||
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Задержка — таблица
|
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Таблица задержки
|
||||||
весов из трёх строк: 0 на подавляющем весе, 1 и 2 — это и есть «D+1/D+2»
|
состоит из трёх строк: δ = 0/1/2 с весами 90/5/5; 1 и 2 — это D+1 и D+2
|
||||||
мастер-спеки. Меряется она в днях снятия слепка, а не отправки: иначе сдвиг
|
мастер-спеки. Бросок идёт последним в подпотоке `ORDERS`; затем δ назначенного
|
||||||
отправки удвоился бы, и обещанные D+1/D+2 стали бы D+2/D+3. Дальше таблица не
|
планом заказа обнуляется. Меряется задержка в днях снятия слепка, а не
|
||||||
идёт: заказ с δ = 6 приехал бы ровно в одном слепке, и обещание «пропущенный
|
отправки: иначе сдвиг отправки удвоился бы, и обещанные D+1/D+2 стали бы
|
||||||
день ничего не ломает» на нём перестало бы быть верным; при δ ≤ 2 у всякого
|
D+2/D+3. Дальше таблица не идёт: заказ с δ = 6 приехал бы ровно в одном
|
||||||
заказа слепков не меньше пяти. Легенда: заказ ушёл в ручную обработку и попал
|
слепке, и обещание «пропущенный день ничего не ломает» на нём перестало бы
|
||||||
в выгрузку позже. Отклонено: *сдвиг `created_at`* — подделка аудита источника:
|
быть верным; при δ ≤ 2 у всякого заказа слепков не меньше пяти.
|
||||||
день создания строки разошёлся бы с днём покупки, чьё равенство держит
|
Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Отклонено:
|
||||||
синхронная модель ([откуда берётся заказ](snapshot.md)), заказ уехал бы в
|
*сдвиг `created_at`* — подделка аудита источника: день создания строки
|
||||||
чужую партицию, и «выручка дня D» перестала бы отвечать покупкам дня D —
|
разошёлся бы с днём покупки, чьё равенство держит синхронная модель
|
||||||
сломалась бы та самая сверка, ради которой всё строится.
|
([откуда берётся заказ](snapshot.md)), заказ уехал бы в чужую партицию,
|
||||||
|
и «выручка дня D» перестала бы отвечать покупкам дня D — сломалась бы та
|
||||||
|
самая сверка, ради которой всё строится.
|
||||||
|
|
||||||
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
|
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
|
||||||
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
|
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
|
||||||
|
|||||||
@@ -24,10 +24,10 @@
|
|||||||
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
|
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
|
||||||
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
|
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
|
||||||
отдельные прогоны одного мира дают то же соответствие; принятый мир
|
отдельные прогоны одного мира дают то же соответствие; принятый мир
|
||||||
воспроизводит эффект склейки без случайных ложных объединений — конкретные
|
воспроизводит эффект склейки без случайных ложных объединений. Паспорт не
|
||||||
числа пар контрактом описи не являются. Межкогортные столкновения принимаются
|
закрепляет конкретное число пар. Межкогортные столкновения принимаются по той же
|
||||||
по той же дисциплине, что у случайных `ClientID`: принимается конкретный
|
дисциплине, что у случайных `ClientID`: принимается конкретный канонический мир
|
||||||
канонический мир по внешнему результату.
|
по внешнему результату.
|
||||||
|
|
||||||
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
|
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
|
||||||
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
|
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
|
||||||
|
|||||||
@@ -1,197 +1,172 @@
|
|||||||
# Приём заказов из Kafka в ODS
|
# Прием заказов из Kafka в ODS
|
||||||
|
|
||||||
Учебный результат: менти различает версию бизнес-сущности, наблюдение источника
|
Здесь разобран путь одного заказа: сообщение Kafka сохраняется в STG,
|
||||||
и запуск загрузки, а затем читает физические версии через явную поверхность
|
проверяется и попадает либо в ODS, либо в таблицу ошибок. Следующие слои
|
||||||
текущего состояния.
|
читают из ODS последнюю принятую версию каждого заказа.
|
||||||
|
|
||||||
## Проблема
|
|
||||||
|
|
||||||
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
|
|
||||||
отдельными сообщениями и не сообщает потребителю, где слепок закончился. Прямое
|
|
||||||
чтение Kafka Engine возвращает одну порцию. Поэтому прежняя публикация через
|
|
||||||
`REPLACE PARTITION snapshot_date` приравнивала дату наблюдения к отсутствующей
|
|
||||||
транспортной границе и могла заменить день неполным набором строк.
|
|
||||||
|
|
||||||
Одновременно типизированный ODS не должен принимать правдоподобные значения по
|
|
||||||
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
|
|
||||||
|
|
||||||
## Цели
|
|
||||||
|
|
||||||
- сохранить пакетный забор как контраст потоковому приёму событий;
|
|
||||||
- один раз принять байты в STG и независимо разложить строки на годные и брак;
|
|
||||||
- хранить в ODS типизированные версии заказов, не привязывая идемпотентность к
|
|
||||||
`snapshot_date`;
|
|
||||||
- дать следующим слоям один корректный способ прочитать текущее состояние;
|
|
||||||
- оставить код проверки коротким и ограничить его контрактом провода.
|
|
||||||
|
|
||||||
## Не входит
|
|
||||||
|
|
||||||
- модель заказа в DDS: её зерно, связи, материализация и способ наполнения;
|
|
||||||
- проверка полей внутри `items`, переходов статуса и равенств денежных сумм;
|
|
||||||
- удаление заказа по отсутствию в следующем слепке;
|
|
||||||
- маркер конца слепка, опись ожидаемых строк и транзакция между целями ODS.
|
|
||||||
|
|
||||||
## Поток данных
|
## Поток данных
|
||||||
|
|
||||||
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
|
Источник отправляет в топик `orders` дневной слепок: по одному сообщению
|
||||||
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
|
на заказ. Слепок дня D содержит состояния заказов, оформленных в дни
|
||||||
дёргает его тот, кто положил слепок в топик, — работник пульта мира, — и ждёт
|
D−6…D, с учетом [задержек первого появления](fate.md).
|
||||||
конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
|
В начале модельного времени окно короче. Выгрузку за D отправляет
|
||||||
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта.
|
генератор при запуске дня D+1; подробности — в [описании слепка](snapshot.md).
|
||||||
Запрос забора лежит в [`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
|
|
||||||
даг задаёт порядок и параметры, а преобразование остаётся в SQL своего слоя.
|
|
||||||
|
|
||||||
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
|
После отправки шаговый даг модельного времени запускает
|
||||||
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
|
[`orders_ingest`](../../../dags/orders_ingest.py) и ждет его завершения.
|
||||||
пишет годные строки в `ods.order_snapshot`, второй — брак в
|
У `orders_ingest` нет расписания; его два шага выполняются по порядку:
|
||||||
`ods.order_snapshot_errors`. Транзакции между запросами нет. При частичном сбое
|
|
||||||
Airflow повторяет весь `task_id`; одинаковые исходные строки и служебные метки
|
|
||||||
не вычисляются заново. Запросы лежат рядом с целями:
|
|
||||||
[`order_snapshot_load.sql`](../../../sql/ods/order_snapshot_load.sql) и
|
|
||||||
[`order_snapshot_errors_load.sql`](../../../sql/ods/order_snapshot_errors_load.sql).
|
|
||||||
|
|
||||||
Условия запросов взаимодополняющие: один общий предикат определяет брак, а
|
1. `pull_batch` читает порцию из `stg.orders_raw_kafka` и записывает ее
|
||||||
годная ветвь использует его буквальное отрицание. Все функции предиката
|
через `stg.orders_raw_dist`. Содержимое сообщения остается строкой `raw`,
|
||||||
возвращают результат без исключения, а сам предикат всегда заканчивается в
|
рядом сохраняются координаты Kafka, `_load_id` и `_load_ts`.
|
||||||
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для
|
2. `parse_batch` читает сохраненные строки с этим `_load_id`. Первый
|
||||||
этого не нужен. Обе ветви включают один файл
|
`INSERT SELECT` пишет годные записи в `ods.order_dist`, второй —
|
||||||
[`_order_wire_contract.sql`](../../../sql/ods/_order_wire_contract.sql), поэтому
|
остальные в `ods.order_errors_dist`.
|
||||||
предикат нельзя случайно исправить только в одной из них.
|
|
||||||
|
`_load_id` равен `run_id` Airflow и связывает строки одного запуска.
|
||||||
|
`_load_ts` — реальное время записи в STG; при разборе оно переносится
|
||||||
|
без пересчета. Запросы лежат в
|
||||||
|
[`orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql) и
|
||||||
|
[`order_parse.sql`](../../../sql/ods/order_parse.sql).
|
||||||
|
`SQLExecuteQueryOperator` передает `run_id` параметром ClickHouse `load_id`.
|
||||||
|
|
||||||
|
Две вставки разбора используют одно условие годности, записанное макросом
|
||||||
|
Jinja внутри `order_parse.sql`. Перед выполнением макрос подставляется
|
||||||
|
в оба запроса; ветвь брака берет отрицание этого условия. Оно возвращает
|
||||||
|
только истину или ложь и не вызывает исключений на некорректном `raw`.
|
||||||
|
Так каждая строка сохраненной порции получает ровно один результат.
|
||||||
|
|
||||||
|
Транзакции между вставками нет: первая может завершиться, а вторая упасть.
|
||||||
|
Повтор `parse_batch` выполняет обе вставки над тем же срезом STG.
|
||||||
|
Текущее состояние заказа останется прежним, но таблица ошибок может
|
||||||
|
получить повторные строки с теми же `_load_id` и координатами Kafka.
|
||||||
|
Обе вставки ждут доставки строк на шарды и используют ключ целевой таблицы;
|
||||||
|
настройки объяснены в [справочнике хранилища](../storage.md#раскладка-по-шардам).
|
||||||
|
|
||||||
## Граница строгого приёма
|
## Граница строгого приёма
|
||||||
|
|
||||||
Единица решения — одна строка `stg.orders_raw`. Корень должен быть
|
Проверяется одна строка `stg.orders_raw_dist`. Корень JSON должен быть
|
||||||
JSON-объектом с точным набором ключей: `order_id`, `user_id`, `status`,
|
объектом с точным набором из одиннадцати ключей:
|
||||||
`created_at`, `updated_at`, `items_total`, `discount`, `delivery`, `total`,
|
`order_id`, `user_id`, `status`, `created_at`, `updated_at`,
|
||||||
`items`, `snapshot_date`.
|
`items_total`, `discount`, `delivery`, `total`, `items`, `snapshot_date`.
|
||||||
|
|
||||||
Скалярные поля проверяются по типу JSON. Деньги дополнительно обязаны быть
|
| Поля | Условия приема |
|
||||||
строками неотрицательной суммы с ровно двумя знаками после точки — минус в эту
|
|---|---|
|
||||||
форму не входит; времена — строками RFC 3339 в UTC с обязательными
|
| `order_id`, `status` | Строки JSON |
|
||||||
миллисекундами; дата слепка — строкой `YYYY-MM-DD`. `items` проверяется только
|
| `user_id` | Целое число JSON, которое разбирается в `UInt64` |
|
||||||
как JSON-массив. Каноническая форма и основания выбора зафиксированы в
|
| `created_at`, `updated_at` | Строки UTC с миллисекундами, например `2026-06-01T12:34:56.000Z`; проверяются форма и разбор времени |
|
||||||
[исследовании формата](../../research/2026-08-16-order-snapshot-wire-format.md).
|
| `items_total`, `discount`, `delivery`, `total` | Неотрицательные суммы в рублях, записанные строками с двумя знаками после точки; проверяется разбор в `Decimal(18, 2)` |
|
||||||
|
| `snapshot_date` | Строка даты `YYYY-MM-DD`, которая проходит разбор |
|
||||||
|
| `items` | Массив JSON; сохраняется целиком в строковой колонке |
|
||||||
|
|
||||||
Проверять все верхнеуровневые поля здесь уместно: их одиннадцать, и десять
|
ODS проверяет формат верхнего уровня. Здесь нет проверки допустимых
|
||||||
скалярных значений непосредственно образуют типизированную строку заказа. У
|
статусов, их переходов, полей внутри `items` или равенства денежных сумм.
|
||||||
события из 47 полей проверяются только пять опорных; переносить то сокращение на
|
Позиции разбираются в [DDS](../dds/order.md); проверку их суммы участник
|
||||||
малый контракт заказа нет причины. Граница строгости заканчивается на форме
|
добавляет в [задании ступени 5](../../labs/5-1-order-checks.md).
|
||||||
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
|
Основания выбора формата — в [исследовании](../../research/2026-08-16-order-snapshot-wire-format.md).
|
||||||
|
|
||||||
Класс брака выбирается первым совпадением:
|
Класс ошибки записывается в `error_class` по первому нарушенному условию:
|
||||||
|
|
||||||
1. `not_an_object`;
|
1. `not_an_object` — корень JSON не объект;
|
||||||
2. `keyset_mismatch`;
|
2. `keyset_mismatch` — набор ключей отличается от ожидаемого;
|
||||||
3. `field_invalid`.
|
3. `field_invalid` — тип, форма или разбор значения не прошли проверку.
|
||||||
|
|
||||||
Имя отдельного поля в класс не включается. В таблице ошибок остаются сырой
|
Имя поля в класс не входит. Для разбора конкретной ошибки в
|
||||||
текст, метаданные доставки и `_load_id`, поэтому единичный случай можно разобрать
|
`ods.order_errors_dist` есть исходный `raw`, координаты Kafka и метки загрузки.
|
||||||
без постоянной детализации предиката.
|
|
||||||
|
|
||||||
## Роль ODS
|
## Роль ODS
|
||||||
|
|
||||||
`ods.order_snapshot_rep` хранит физически принятые версии в
|
`ods.order_rep` использует `ReplicatedReplacingMergeTree(snapshot_date)`.
|
||||||
`ReplacingMergeTree(updated_at)`. Ключ сортировки — `order_id`, партиция — день
|
Строки с одним ключом сортировки `order_id` считаются версиями одного заказа;
|
||||||
неизменного `created_at`. `ods.order_snapshot_dist` шардирует по
|
остается строка с максимальной `snapshot_date`. Партиция —
|
||||||
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
|
`toDate(created_at)` в UTC, ключ шардирования — `cityHash64(order_id)`.
|
||||||
`FINAL` даёт корректный результат через распределённую таблицу.
|
Неизменный `created_at` и общий ключ направляют все версии заказа
|
||||||
|
в одну партицию и на один шард.
|
||||||
|
|
||||||
Четыре координаты отвечают на разные вопросы:
|
| Колонка | На какой вопрос отвечает |
|
||||||
|
|---|---|
|
||||||
|
| `snapshot_date` | На конец какого модельного дня источник показал это состояние? |
|
||||||
|
| `updated_at` | Когда запись заказа последний раз меняли в магазине? |
|
||||||
|
| `_load_id` | Какой запуск Airflow принял сообщение? |
|
||||||
|
| `_load_ts` | Когда сообщение записали в STG по реальным часам стенда? |
|
||||||
|
|
||||||
- `updated_at` — какая бизнес-версия заказа новее;
|
Например, заказ не менялся с 1 июня, но входит в слепки за 1 и 2 июня.
|
||||||
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
|
`updated_at` у строк одинаков, а `snapshot_date` различается. ODS оставит
|
||||||
- `_load_id` — какой запуск Airflow принял строку;
|
строку за 2 июня вместе с метками ее доставки. Новая дата наблюдения
|
||||||
- `_load_ts` — когда строка приехала в хранилище.
|
нужна DDS для определения окна пересборки, даже если состояние не изменилось.
|
||||||
|
|
||||||
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
|
Источник выпускает **один слепок на дату**. Повторная отправка дает то же
|
||||||
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
|
содержимое, а более поздний слепок не возвращает устаревшее состояние.
|
||||||
`ods.order_v` сохраняет те же источник-ориентированные поля без обогащения
|
Эти условия не проверяются на одной строке при приеме. Если источник
|
||||||
данными модели, но возвращает одну актуальную версию на `order_id`. Сначала оно
|
пришлет два разных состояния с одной `snapshot_date`, эта колонка
|
||||||
может быть простым представлением над `_dist FINAL`; способ выбора можно
|
не позволит определить, какое из них актуально.
|
||||||
заменить, не меняя потребителей.
|
Обоснование версии — [ADR 0014](../../adr/0014-order-version-snapshot-date.md).
|
||||||
|
|
||||||
Это представление остаётся ответственностью ODS: оно скрывает механику чтения
|
`ods.order_v` читает `ods.order_dist FINAL` и возвращает одну текущую строку
|
||||||
версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает,
|
на `order_id`, даже если фоновые слияния еще не убрали старые версии.
|
||||||
какие сущности, связи и производные признаки ему нужны. Нужен ли `_load_id`
|
Без `FINAL` физическая таблица может показать несколько строк заказа.
|
||||||
выше ODS, решается вместе с DDS, а не здесь.
|
Историю ODS не гарантирует: старые версии постепенно удаляются при слияниях.
|
||||||
|
Отсутствие заказа в новом слепке не удаляет его из ODS.
|
||||||
|
|
||||||
|
DDS читает `ods.order_v`, переводит времена в пояс счетчика и разбирает
|
||||||
|
позиции. Для своей загрузки DDS записывает собственные служебные метки —
|
||||||
|
[модель заказа](../dds/order.md#служебные-колонки).
|
||||||
|
|
||||||
## Одно чтение Kafka
|
## Одно чтение Kafka
|
||||||
|
|
||||||
Стандартный слепок содержит около полутора тысяч строк, тогда как предел одной
|
Один запуск делает один прямой `SELECT`, без чтения в цикле до пустоты.
|
||||||
порции на стенде — десятки тысяч сообщений. Поэтому один запуск Airflow делает
|
Обычный слепок содержит около полутора тысяч строк, а предел порции
|
||||||
один прямой `SELECT`, без цикла до пустоты и без фиксации конечных офсетов.
|
на стенде — десятки тысяч сообщений. Это допущение о размере данных:
|
||||||
|
Kafka не сообщает запросу, что прочитан весь слепок.
|
||||||
|
|
||||||
Это допущение о размере стенда, а не доказательство полноты слепка. Если Kafka
|
Короткая порция оставляет непрочитанные сообщения для следующих запусков.
|
||||||
вернёт короткую порцию, непрочитанный хвост останется в топике и приедет в один
|
Поэтому ODS добавляет версии заказов по мере поступления. Замена партиции
|
||||||
из следующих запусков. После отказа от замены партиции это задержка, а не потеря
|
по `snapshot_date` могла бы опубликовать неполный набор заказов
|
||||||
или публикация неполного дня.
|
([ADR 0010](../../adr/0010-order-versions-in-ods.md)).
|
||||||
|
|
||||||
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
|
Сбой после чтения опаснее короткой порции. Чтец подтверждает позиции
|
||||||
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
|
сообщений в Kafka — офсеты — при прямом чтении. Если последующая вставка
|
||||||
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
|
в STG упадет, эти сообщения уже не будут прочитаны той же группой повторно;
|
||||||
Позиция мира при этом не двигается, и тот же день уедет заново — в сырье он
|
часть из них может успеть записаться на шарды. Позиция модельного времени
|
||||||
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
|
при неуспехе не продвигается. Повтор шагового дага снова отправит слепок
|
||||||
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»).
|
того же дня, но не восстановит старые сообщения других слепков,
|
||||||
|
если они попали в неудачную порцию.
|
||||||
## Отклонённые варианты
|
Граница восстановления описана в [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), «Что проверено».
|
|
||||||
|
|
||||||
После исправления
|
- 18 августа 2026 года (#93, #94) прямое чтение приняло слепок,
|
||||||
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
|
а разбор разделил строки на годные и ошибки без потерь и пересечений.
|
||||||
честный прогон дня снят на живом стенде 20 августа 2026 года: все 1694 строки
|
Две версии заказа попали на один шард в одну партицию; `ods.order_v`
|
||||||
слепка дня 7 приняты, `ods.order_snapshot_errors` осталась пустой. У заказа
|
вернул позднюю. Повтор разбора с тем же `_load_id` сохранил текущую строку
|
||||||
`20260608-0007`, который прежде приходил с отрицательным итогом, теперь
|
и ее `_load_ts`, но повторил ошибки. Подробности —
|
||||||
`5490.00 − 823.50 + 299.00 = 4965.50`.
|
[справочник хранилища](../storage.md#что-проверено) и
|
||||||
|
[ADR 0008](../../adr/0008-order-ingestion.md#что-проверено).
|
||||||
|
- 20 августа после исправления
|
||||||
|
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
|
||||||
|
приняты все 1694 строки слепка дня 7 без ошибок. Итог заказа
|
||||||
|
`20260608-0007`: `5490.00 − 823.50 + 299.00 = 4965.50`.
|
||||||
|
- 21 августа (#124) пересборка через `SQLExecuteQueryOperator` завершила
|
||||||
|
`pull_batch` и `parse_batch`; в `system.query_log` осталось по одному
|
||||||
|
завершенному запросу `etl` на забор, годные строки и ошибки.
|
||||||
|
- 22 августа (#137) во всех 3034 парах с одинаковым `updated_at`
|
||||||
|
деловые поля совпали побайтово; по слепкам время изменения не убывало.
|
||||||
|
- 26 августа (#165) после двух слепков физическая таблица содержала
|
||||||
|
3356 версий 1917 заказов. В `ods.order_v` у всех заказов была дата
|
||||||
|
последнего слепка, а деловые поля совпали с `argMax` по `updated_at`
|
||||||
|
из сырых данных.
|
||||||
|
|
||||||
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
|
На ClickHouse `26.3.17.56` отдельно проверены одна порция при прямом
|
||||||
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
`SELECT` Kafka и выполнение `FINAL` на шардах через Distributed.
|
||||||
офсеты коммитятся, а сбой забора не двигает позицию мира. Числа — [ADR
|
При проектировании Context7 был недоступен; чтение сверяли с исходниками
|
||||||
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) и
|
[`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).
|
[`KafkaSource.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/KafkaSource.cpp).
|
||||||
|
Повторная сверка правил версий с документацией — в
|
||||||
|
[справочнике хранилища](../storage.md#актуальные-события).
|
||||||
|
|
||||||
## Связанные решения
|
## Связанные решения
|
||||||
|
|
||||||
@@ -199,4 +174,3 @@ MCP Context7 в сессии проектирования был недосту
|
|||||||
забора, `RawBLOB`, одного чтеца и одной партиции топика.
|
забора, `RawBLOB`, одного чтеца и одной партиции топика.
|
||||||
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) заменяет публикацию
|
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) заменяет публикацию
|
||||||
слепка версионным ODS.
|
слепка версионным ODS.
|
||||||
- Вопрос `_load_id` выше ODS оставлен проектированию DDS в тикете #85.
|
|
||||||
@@ -1,30 +1,30 @@
|
|||||||
# Что хранит опись
|
# Что хранит паспорт мира
|
||||||
|
|
||||||
Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что
|
Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что
|
||||||
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72)
|
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72)
|
||||||
и [«Места заказов в стартовом
|
и [«Места заказов в стартовом
|
||||||
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
||||||
Словарь описи — растяжка-хеш против опоры-счётчика — задан «Чем меряется
|
Термины паспорта — «растяжка-хеш» и «опора-счётчик» — заданы разделом «Чем
|
||||||
генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
|
меряется генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
|
||||||
термином «Опись мира» в [CONTEXT.md](../../../CONTEXT.md).
|
статьёй «Паспорт мира» в [CONTEXT.md](../../../CONTEXT.md).
|
||||||
|
|
||||||
> Опись хранит только то, чего движение мира не меняет.
|
> Паспорт хранит только то, чего движение мира не меняет. Хеш кладём всегда,
|
||||||
> Хеш кладём всегда, счётчик — только когда назван его читатель.
|
> счётчик — только когда назван его читатель.
|
||||||
|
|
||||||
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
|
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
|
||||||
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
|
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
|
||||||
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же
|
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же байты»)
|
||||||
байты») опись начинает сторожить.
|
паспорт начинает сторожить.
|
||||||
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
|
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
|
||||||
итоговой судьбе заказов дня; единица счёта — заказ, и опись называет её
|
итоговой судьбе заказов дня. В строке закрытого дня поле `orders` хранит
|
||||||
словом. Сойтись с запросом менти они могут только на днях с закрытым окном:
|
число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных
|
||||||
при N сыгранных днях таких N − 7 (день d закрывается слепком d + 6, а
|
днях закрыты N − 7 (день d закрывается слепком d + 6, а последний
|
||||||
последний отправленный слепок несёт день N − 2). Читатель у них придёт
|
отправленный слепок несёт день N − 2). Их читатель — техническая
|
||||||
этапом 4 — проверка сверки; не окажется читателя — та же бритва режет и их.
|
проверка `classes_vs_inventory` из DQ-звена этапа 4.
|
||||||
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
|
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
|
||||||
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
|
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
|
||||||
не выходит; генератор сторожит хеш, транспорт — счёт событий.
|
не выходит; генератор сторожит хеш, транспорт — счёт событий.
|
||||||
- **Опись описывает мир, а не доставку.** Работа генератора кончается на
|
- **Паспорт описывает мир, а не доставку.** Работа генератора кончается на
|
||||||
Kafka: дошли ли байты до `ods.order_snapshot` — вопрос стенда и его
|
Kafka: дошли ли байты до `ods.order` — вопрос стенда и его проверок. Поэтому
|
||||||
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка
|
счёта строк у слепка в паспорте нет; понадобится проверка приёма заказов —
|
||||||
приёма заказов — число заведётся вместе с ней.
|
число заведётся вместе с ней.
|
||||||
@@ -18,9 +18,10 @@
|
|||||||
в трекере — не отдельная порода, а класс B, и делает его событийная сторона
|
в трекере — не отдельная порода, а класс B, и делает его событийная сторона
|
||||||
выбрасыванием события после присвоения номера
|
выбрасыванием события после присвоения номера
|
||||||
([классы расхождений](fate.md));
|
([классы расхождений](fate.md));
|
||||||
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`,
|
- обе проекции используют один номер заказа: `order_id` = клиентский
|
||||||
читаемый номер «день и порядковый номер покупки» ([спека
|
`purchaseID`;
|
||||||
генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все
|
читаемый номер «день и порядковый номер покупки» описан в
|
||||||
|
[ADR 0017](../../adr/0017-order-numbering.md); нумеруются все
|
||||||
покупки, дошедшие до потока дня, — до всяких потерь;
|
покупки, дошедшие до потока дня, — до всяких потерь;
|
||||||
- скидка заказа выводится из промокода события по таблице «код → скидка» —
|
- скидка заказа выводится из промокода события по таблице «код → скидка» —
|
||||||
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
|
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
|
||||||
@@ -59,12 +60,14 @@
|
|||||||
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
|
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
|
||||||
топиком `hits`* — убивает два режима приёма.
|
топиком `hits`* — убивает два режима приёма.
|
||||||
|
|
||||||
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и
|
**Сборка окна.** Слепок дня D берет заказы, рожденные в дни D−6…D, а несет из
|
||||||
собирается переигровкой этих семи дней: заказы дня — производная всей воронки
|
них те, что уже попали в выгрузку: механика опоздания живет в
|
||||||
дня, дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; у
|
[судьбе заказа](fate.md). Окно собирается переигровкой этих семи дней: заказы
|
||||||
начала оси окно усекается само. Отклонено: *кэш заказов на томе* — состояние
|
дня — производная всей воронки дня, дешёвого пути к ним нет. Цена — семь
|
||||||
между прогонами; *окно держит хранилище* — топик перестаёт нести слепок;
|
проигрышей дня (~14 с) на слепок; у начала оси окно усекается само. Отклонено:
|
||||||
*K = 1* — это страховочный срез 1 мастер-спеки, он в резерве.
|
*кэш заказов на томе* — состояние между прогонами; *окно держит хранилище* —
|
||||||
|
топик перестаёт нести слепок; *K = 1* — это страховочный срез 1 мастер-спеки,
|
||||||
|
он в резерве.
|
||||||
|
|
||||||
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
|
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
|
||||||
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
|
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
|
||||||
@@ -109,5 +112,5 @@
|
|||||||
такого значения означает ошибку арифметики источника.
|
такого значения означает ошибку арифметики источника.
|
||||||
|
|
||||||
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
|
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
|
||||||
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
|
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт его
|
||||||
его даром, а хешу слепка в описи нужен именно названный порядок.
|
даром, а хешу слепка в паспорте нужен именно названный порядок.
|
||||||
@@ -6,13 +6,14 @@
|
|||||||
Генератор играет два источника с разными темпами — поток Метрики и ночную
|
Генератор играет два источника с разными темпами — поток Метрики и ночную
|
||||||
выгрузку магазина; разные темпы дают всё остальное.
|
выгрузку магазина; разные темпы дают всё остальное.
|
||||||
|
|
||||||
`world-init` играет восемь дней одним прогоном `batch --day 0 --days 8`;
|
Даг `world_initialize` играет восемь дней одним прогоном
|
||||||
слепки отправляет второй запуск — команда `snapshot` того же диапазона (два
|
`batch --day 0 --days 8`; слепки отправляет второй запуск — команда `snapshot`
|
||||||
источника — два запуска, [слепок и его доставка](snapshot.md)), и со сдвигом
|
того же диапазона (два источника — два запуска,
|
||||||
отправки уезжают **слепки дней 0…6**. Слепок дня 7 снят на границе суток и
|
[слепок и его доставка](snapshot.md)). Со сдвигом отправки уезжают **слепки
|
||||||
уедет первым же ходом мира. Особого режима у стартового мира нет: правило
|
дней 0…6**, затем даг `orders_ingest` принимает их в хранилище. Слепок дня 7
|
||||||
отправки живёт в одном месте — в проигрывателе; генератору это решение не
|
снят на границе суток и уедет первым же ходом мира. Особого режима у стартового
|
||||||
стоит ничего.
|
мира нет: правило отправки живёт в одном месте — в проигрывателе; генератору
|
||||||
|
это решение не стоит ничего.
|
||||||
|
|
||||||
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
|
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
|
||||||
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
|
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
|
||||||
@@ -24,11 +25,11 @@
|
|||||||
|
|
||||||
Цена принята с открытыми глазами: у части пар стартового мира поздний
|
Цена принята с открытыми глазами: у части пар стартового мира поздний
|
||||||
назначенный заказ падает на день 7, и до первого хода мира этих пар в
|
назначенный заказ падает на день 7, и до первого хода мира этих пар в
|
||||||
`dds.identity_map` нет. Опись пар не считает
|
`dds.identity_map` нет. Паспорт пар не считает ([что хранит
|
||||||
([что хранит опись](inventory.md)), поэтому красной проверки из этого не
|
паспорт](inventory.md)), поэтому красной проверки из этого не выходит.
|
||||||
выходит. Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на
|
Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на свежем
|
||||||
свежем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем
|
стенде, в CLI заводится рычаг, стартовый мир становится особым случаем ровно
|
||||||
ровно там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* —
|
там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* — число
|
||||||
число верно ровно до первого хода мира; *отменить сдвиг отправки* —
|
верно ровно до первого хода мира; *отменить сдвиг отправки* — `awaiting_order`
|
||||||
`awaiting_order` пропал бы навсегда; *подогнать план под горизонт* — мир
|
пропал бы навсегда; *подогнать план под горизонт* — мир перестал бы быть чистой
|
||||||
перестал бы быть чистой функцией зерна.
|
функцией зерна.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Стенд: службы, доступ, настройка
|
||||||
|
|
||||||
|
Здесь описано, из каких служб собран стенд, кто к кому подключается и как
|
||||||
|
настройки доходят до контейнеров. Состав каркаса обоснован в
|
||||||
|
[ADR 0001](../adr/0001-stand-services.md), доступ к ClickHouse — в
|
||||||
|
[ADR 0007](../adr/0007-clickhouse-access.md). Адреса и пароли интерфейсов
|
||||||
|
остаются в [корневом README](../../README.md#адреса-и-пароли).
|
||||||
|
|
||||||
|
## Службы
|
||||||
|
|
||||||
|
- `clickhouse-01` — инициатор DDL и точка подключения Airflow;
|
||||||
|
- `clickhouse-02` — точка подключения Superset;
|
||||||
|
- `clickhouse-keeper` — координатор кластера;
|
||||||
|
- `kafka` — один брокер KRaft;
|
||||||
|
- `kafka-exporter` — экспортёр метрик Kafka для Prometheus;
|
||||||
|
- `postgres-metadata` — один Postgres с отдельными базами и пользователями
|
||||||
|
`airflow` и `superset`;
|
||||||
|
- `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor` —
|
||||||
|
Airflow 3.3 с LocalExecutor, без triggerer;
|
||||||
|
- `superset` — интерфейс с заранее настроенным подключением к ClickHouse;
|
||||||
|
- `prometheus` и `grafana` — сбор и просмотр метрик ClickHouse и Kafka.
|
||||||
|
|
||||||
|
## Пользователи и роли ClickHouse
|
||||||
|
|
||||||
|
В ClickHouse восемь пользователей и семь ролей:
|
||||||
|
|
||||||
|
- `etl` — учётная запись рабочих дагов Airflow; роль `etl_writer` читает и
|
||||||
|
пишет слои хранилища;
|
||||||
|
- `lifecycle` — учётная запись дагов жизненного цикла; роль `lifecycle_owner`
|
||||||
|
разрешает им применять канонический DDL и пересоздавать прикладные базы. У
|
||||||
|
рабочих дагов этих административных прав нет;
|
||||||
|
- `bi` — учётная запись Superset; роль `bi_reader` читает ODS, DDS, DM и
|
||||||
|
справочники `dic`, а запросы Superset обращаются к публичным `_v`;
|
||||||
|
- `analyst` — учётная запись аналитика с правом чтения всех слоёв и
|
||||||
|
справочников;
|
||||||
|
- `student` — учебная учётная запись: роль `analyst_reader` дает чтение
|
||||||
|
источников, а `student_writer` — создание, заполнение и удаление своих
|
||||||
|
таблиц в `default`. Записи в слои хранилища у нее нет;
|
||||||
|
- `grafana` — учётная запись Grafana; роль `monitoring_reader` читает все слои,
|
||||||
|
системные таблицы и данные со всех реплик кластера;
|
||||||
|
- `dict` — беспарольная учётная запись словаря товаров с правом чтения базы
|
||||||
|
`dic`; словарь читает через неё свою подложку;
|
||||||
|
- `default` — служебная учётная запись для проверок состояния и скриптов внутри
|
||||||
|
контейнеров; приложения с ней не подключаются.
|
||||||
|
|
||||||
|
Почему пользователи объявлены файлом, а ноды доверяют общему секрету, — в
|
||||||
|
[ADR 0007](../adr/0007-clickhouse-access.md).
|
||||||
|
Пользователи и роли объявлены в `infra/clickhouse/users.d/access.xml`. Пароли и
|
||||||
|
общий секрет нод живут в `.env` и передаются в конфигурацию через окружение;
|
||||||
|
Superset получает пароль `bi`, а Grafana — пароль `grafana` тем же путём.
|
||||||
|
Значения для локального стенда есть в `.env.example`.
|
||||||
|
|
||||||
|
## Применение настроек
|
||||||
|
|
||||||
|
Для существующего стенда сначала добавьте `CLICKHOUSE_STUDENT_PASSWORD`
|
||||||
|
из `.env.example` в свой `.env`.
|
||||||
|
|
||||||
|
Чтобы применить новые значения ClickHouse из `.env` или изменения файлов в
|
||||||
|
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/`, пересоздайте ноды:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --force-recreate --wait clickhouse-01 clickhouse-02
|
||||||
|
```
|
||||||
|
|
||||||
|
Команда возвращает управление, когда обе ноды снова здоровы; именованные тома
|
||||||
|
при этом сохраняются. `docker compose restart` оставит прежнее окружение и
|
||||||
|
может оставить старую версию отдельно смонтированного файла.
|
||||||
|
|
||||||
|
## Секреты и порты
|
||||||
|
|
||||||
|
В локальном учебном стенде намеренно используются простые ненастоящие пароли и
|
||||||
|
ключи. Kafka и Prometheus работают без проверки доступа. Это не пример настройки
|
||||||
|
защиты: не копируйте значения из `.env.example` в рабочую среду. Все
|
||||||
|
опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не
|
||||||
|
опубликован.
|
||||||
|
|
||||||
|
## Одна реплика на шард
|
||||||
|
|
||||||
|
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
|
||||||
|
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
|
||||||
|
2×2 намеренно не входят в стенд.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
**Конфигурация сверена 30 июля 2026 года** с официальной документацией
|
||||||
|
ClickHouse:
|
||||||
|
[настройками сервера](https://clickhouse.com/docs/operations/server-configuration-parameters/settings),
|
||||||
|
[Keeper](https://clickhouse.com/docs/guides/oss/deployment-and-scaling/keeper/),
|
||||||
|
[ReplicatedMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replication),
|
||||||
|
[ON CLUSTER](https://clickhouse.com/docs/sql-reference/distributed-ddl) и
|
||||||
|
[Distributed](https://clickhouse.com/docs/engines/table-engines/special/distributed).
|
||||||
|
Кластер описан в `remote_servers`, макросы — в `macros`, а подключение к
|
||||||
|
keeper — в `zookeeper`. Путь `ReplicatedMergeTree` содержит `{shard}` и
|
||||||
|
`{replica}`.
|
||||||
|
Для `Distributed` задаются имя кластера, база, локальная таблица и ключ
|
||||||
|
шардирования. Макросы выбраны, чтобы один DDL через `ON CLUSTER` создавал
|
||||||
|
отдельный путь для каждого шарда без вписанных вручную значений. В образе
|
||||||
|
закреплена точная версия текущего LTS-выпуска —
|
||||||
|
[26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts);
|
||||||
|
серверы и 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).
|
||||||
|
|
||||||
|
Раздел метрик взят из конфигурации закреплённого образа ClickHouse и проверен
|
||||||
|
на серверах и keeper. Автоматическая настройка источников данных Grafana
|
||||||
|
сверена с
|
||||||
|
[официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/)
|
||||||
|
и [документацией плагина ClickHouse](https://grafana.com/docs/plugins/grafana-clickhouse-datasource/latest/configure/).
|
||||||
|
Prometheus собирает встроенные метрики двух серверов и keeper, а через
|
||||||
|
`kafka-exporter` — состояние Kafka. Поверх этого в Grafana поднимается дашборд
|
||||||
|
«Данные»: свежесть, поток, брак, сходимость Kafka с ClickHouse, отставание
|
||||||
|
чтения и дневной слепок заказов. Устройство зоны — в [справочнике
|
||||||
|
мониторинга](monitoring/README.md), разбор панелей — в
|
||||||
|
[описании дашборда](monitoring/data.md); правил оповещения
|
||||||
|
пока нет.
|
||||||
|
|
||||||
|
Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor,
|
||||||
|
публичный `airflow.sdk`, API проверки состояния и SimpleAuthManager сверены с
|
||||||
|
[архитектурой 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/administration-and-deployment/logging-monitoring/check-health.html).
|
||||||
|
Для ClickHouse установлен официальный провайдер Airflow: SQL-файлы исполняет
|
||||||
|
общий `SQLExecuteQueryOperator`, программные запросы идут через
|
||||||
|
`ClickHouseHook`. Провайдер работает поверх закреплённого
|
||||||
|
`clickhouse-connect`. Официальный провайдер Kafka уже использует
|
||||||
|
`confluent-kafka`. В стенде клиент добавлен в образ напрямую, без отдельного
|
||||||
|
подключения Airflow и обёрток провайдера.
|
||||||
|
|
||||||
|
Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, схема адреса
|
||||||
|
подключения `clickhousedb://` и драйвер Postgres сверены с
|
||||||
|
[документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/)
|
||||||
|
и [настройкой базы метаданных](https://superset.apache.org/admin-docs/6.1.0/configuration/configuring-superset/).
|
||||||
+470
-396
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,254 @@
|
|||||||
|
# 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`–`109` у графиков, `201` у дашборда
|
||||||
|
(`…-000000000101`). Экспорт из интерфейса приносит свои, сгенерированные;
|
||||||
|
их заменяют на эти, иначе повторный импорт заведет вторую копию объекта вместо
|
||||||
|
обновления первой.
|
||||||
|
|
||||||
|
Датасет несет русские имена колонок, формат чисел и метрики — а график только
|
||||||
|
ссылается на них по имени. Значит, подпись или формат правятся в одном месте, а
|
||||||
|
не в каждом графике. Два исключения проверены на живом стенде, Superset 6.1.
|
||||||
|
Подписи оси график берет не из датасета, а из своего `y_axis_format`: без него
|
||||||
|
ось выручки уходит в «1.2M». И разрез сводной таблицы подписан именем колонки
|
||||||
|
(`mismatch_class`), хотя колонке даты в той же таблице русское имя достается.
|
||||||
|
|
||||||
|
Датасеты объявлены физическими (`schema: dm`, `sql: null`), хотя за именем стоит
|
||||||
|
представление ClickHouse. Импорт в источник не ходит и разницы между таблицей и
|
||||||
|
представлением не знает, а виртуальный датасет со своим SQL просто повторил бы
|
||||||
|
запрос, который уже живет в `sql/ddl/60-dm-views.sql`.
|
||||||
|
|
||||||
|
## Из чего собран экран
|
||||||
|
|
||||||
|
Четыре строки панелей:
|
||||||
|
|
||||||
|
- ряд крупных чисел — выручка, заказы, средний чек, посетители;
|
||||||
|
- выручка по дням и категориям, рядом доля категорий кольцом;
|
||||||
|
- сводная сверки purchase и заказов;
|
||||||
|
- посетители и известные пользователи, рядом доля известных.
|
||||||
|
|
||||||
|
Правая панель пары отвечает на вопрос «сколько из целого» и берет цвета левой:
|
||||||
|
категория окрашена одинаково на столбиках и в кольце, доля известных — как
|
||||||
|
линия известных пользователей рядом. Цвет закрепляет `label_colors` в
|
||||||
|
`metadata` дашборда. Шесть категорий выписаны в него руками по каталогу
|
||||||
|
генератора (`generator/src/clickstream_generator/catalog.py`): седьмая
|
||||||
|
останется без закрепленного цвета, пока файл не поправят.
|
||||||
|
|
||||||
|
Средний чек и доля известных считаются метриками датасета —
|
||||||
|
`SUM(revenue) / nullIf(SUM(orders), 0)` и то же для доли известных. Знаменатель
|
||||||
|
обернут не зря: на периоде без заказов деление Decimal на ноль в ClickHouse —
|
||||||
|
не `nan`, а исключение, и панель показала бы ошибку вместо пустоты. У витрины
|
||||||
|
выручки есть готовая колонка `aov`, но среднее по ней дало бы другое число:
|
||||||
|
день с тремя заказами получил бы тот же вес, что день с тремя сотнями.
|
||||||
|
|
||||||
|
«Период» — единственный фильтр дашборда, тип `filter_time` с пустой целью
|
||||||
|
(`targets: [{}]`). Такой фильтр колонку не называет: он подставляет выбранный
|
||||||
|
период в то временное условие, которое уже стоит у каждого графика. Поэтому
|
||||||
|
сверка сужается по `order_day`, а остальные панели — по `report_date`.
|
||||||
|
|
||||||
|
## Импорт при каждой инициализации
|
||||||
|
|
||||||
|
Последняя строка `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 — но не тем, что команда отработала.
|
||||||
|
|
||||||
|
**Знак рубля приходит не из формата валюты.** Символ Superset берет у
|
||||||
|
`Intl.NumberFormat` по коду валюты и английской локали стенда: код `RUB`
|
||||||
|
печатается как «RUB», а знак «₽» вызов роняет, и форматирование молча
|
||||||
|
откатывается к числу без валюты. Поэтому единица стоит в названии панели —
|
||||||
|
«Выручка, ₽», — а метрика несет обычный формат `,d`.
|
||||||
|
|
||||||
|
**`label_colors` знает имя ряда, а не подпись.** Для разреза имя ряда — это
|
||||||
|
значение колонки («Бытовая техника»), для графика без разреза — имя метрики
|
||||||
|
(`known_share`), а не ее русская подпись. Цвет, закрепленный за подписью
|
||||||
|
«Доля известных», не применяется.
|
||||||
|
|
||||||
|
**`--overwrite` синхронизирует датасет разрушительно.** Колонки и метрики
|
||||||
|
берутся из YAML целиком: добавленная в интерфейсе метрика или подпись колонки
|
||||||
|
исчезнет при следующем старте контейнера. Правка датасета — это правка файла.
|
||||||
|
|
||||||
|
## Что проверяет стенд
|
||||||
|
|
||||||
|
`make check-services` входит в Superset, находит подключение `ClickHouse`,
|
||||||
|
сверяет его uuid и безопасный URI и требует схему `dm` — то есть подтверждает
|
||||||
|
живое подключение и хранилище пароля. Сами графики и дашборд ни одна цель не
|
||||||
|
проверяет: их смотрят глазами после переинициализации. Полная карта целей — в
|
||||||
|
[доке проверок](testing.md).
|
||||||
|
|
||||||
|
## Отклоненные варианты
|
||||||
|
|
||||||
|
Оживить дашборд предложат снова, поэтому отказы записаны с доводами. Замерено
|
||||||
|
на живом стенде 7 сентября 2026 года, Superset 6.1.
|
||||||
|
|
||||||
|
**Карта регионов России.** Замысел стоял на клике: щелчок по региону
|
||||||
|
перестраивает весь экран. Плагин Country Map кросс-фильтр не отдает вовсе —
|
||||||
|
`behaviors` не объявлены, `setDataMask` не вызывается, а щелчок приближает
|
||||||
|
карту. Без клика остается картинка, и она рассказывает меньше, чем кажется: 16
|
||||||
|
городов стенда сворачиваются в 14 регионов из 83, и больше половины сессий
|
||||||
|
приходится на один. Карта отдана факультативу «Добавь названия регионов в
|
||||||
|
отчет»: там читатель собирает справочник сам, и она становится наградой за
|
||||||
|
него.
|
||||||
|
|
||||||
|
**Регион как общее измерение витрин.** Чтобы фильтр по региону действовал на
|
||||||
|
весь экран, регион пришлось бы завести в каждой витрине: Superset датасеты
|
||||||
|
между собой не соединяет, а фильтр по колонке, которой нет в датасете панели,
|
||||||
|
отбрасывается с причиной `COL_NOT_IN_DATASOURCE`. У заказа географии нет — она
|
||||||
|
бралась бы от человека через карту идентичностей и приписывалась бы сущности, у
|
||||||
|
которой ее в источнике не было. Вдобавок не сходятся ритмы: витрина от карты
|
||||||
|
идентичностей не замерзает никогда, а выручка живет окном последнего слепка
|
||||||
|
([дока DM](dm.md)). Стендовый словарь регионов отклонен раньше и по своей
|
||||||
|
причине — он повторил бы урок `dictGet` каталога вторым разом.
|
||||||
|
|
||||||
|
**Кросс-фильтрация между панелями.** Общих измерений у витрин почти нет:
|
||||||
|
категория есть только у выручки, а день у сверки называется `order_day` против
|
||||||
|
`report_date` у остальных. Клик перестроил бы часть экрана и оставил другую
|
||||||
|
нетронутой, а вывод из этого читатель делает про данные, а не про устройство
|
||||||
|
витрин. Панель фильтров этой болезнью не болеет — там колонка задается явно для
|
||||||
|
каждой витрины, — поэтому фильтр периода дашборду разрешен, а кросс-фильтрация
|
||||||
|
нет.
|
||||||
|
|
||||||
|
**Вкладки и блоки «что смотреть».** Вкладки были условным вариантом: их
|
||||||
|
заводят, когда экран перестает читаться одним взглядом. Четыре строки панелей
|
||||||
|
читаются сразу, поэтому вкладки спрятали бы половину экрана за щелчком без
|
||||||
|
выигрыша. Блок с текстом «что смотреть» рядом с панелью означал
|
||||||
|
бы, что панель сама себя не объясняет: тогда чинить надо название и подписи, а
|
||||||
|
не дописывать к ним пояснение.
|
||||||
|
|
||||||
|
**Пороги и раскраска панелей.** Правило
|
||||||
|
[спеки](../specs/2026-07-30-stand-v2-realism.md), раздел 9: на дашборд смотрят
|
||||||
|
глазами, красное и зеленое живет в проверках.
|
||||||
|
|
||||||
|
Поводы вернуться к разговору: плагин карты начинает отдавать кросс-фильтр; у
|
||||||
|
заказа появляется адрес доставки; витрины получают общее измерение по своей
|
||||||
|
причине, не ради дашборда.
|
||||||
+303
-51
@@ -23,9 +23,9 @@
|
|||||||
заметит потерянного подключения Superset: он про ClickHouse и только.
|
заметит потерянного подключения Superset: он про ClickHouse и только.
|
||||||
|
|
||||||
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
|
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
|
||||||
против описи — вопрос к ClickHouse: строки в `ods.event` считает сам
|
против паспорта — вопрос к ClickHouse: строки в `ods.event` считает сам сервер и
|
||||||
сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене
|
отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене они
|
||||||
они подошли бы смоуку.
|
подошли бы смоуку.
|
||||||
|
|
||||||
## Карта целей
|
## Карта целей
|
||||||
|
|
||||||
@@ -33,21 +33,22 @@
|
|||||||
генератора: он отдельный пакет со своим `pyproject.toml`, локом и образом, и
|
генератора: он отдельный пакет со своим `pyproject.toml`, локом и образом, и
|
||||||
спрашивают его отдельно.
|
спрашивают его отдельно.
|
||||||
|
|
||||||
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено»:
|
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
|
||||||
`make test`, `make smoke`, `make check-clickhouse` и `make check-services`
|
`make smoke` и `make check-services` перемерены 28 августа 2026 года,
|
||||||
перемерены 7 августа 2026 года на приёмке этапа 2, корневой `make lint` —
|
`make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
|
||||||
9 августа при его появлении; остальные стоят с замера 6 августа.
|
корневой `make lint` — 3 сентября, `make test` — 30 августа; остальные стоят с
|
||||||
|
замера 6 августа.
|
||||||
|
|
||||||
| Цель | Откуда | Что утверждает | Стенд | Цена |
|
| Цель | Откуда | Что утверждает | Стенд | Цена |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `make config-test` | корень | Compose разбирается, файлы DAG синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
||||||
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
|
| `make lint` | корень | Код стенда и сборщик карты отформатированы и проходят ruff; готовый HTML карты соответствует исходникам | не нужен | 0,8 с |
|
||||||
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 9 с |
|
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
|
||||||
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с |
|
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против паспорта | нужен | 24 с |
|
||||||
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 59 с |
|
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
|
||||||
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||||
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
||||||
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 71 с |
|
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [паспорт мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
|
||||||
|
|
||||||
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
|
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
|
||||||
разными дверями охватывают разное: корневая не видит ни одного файла
|
разными дверями охватывают разное: корневая не видит ни одного файла
|
||||||
@@ -71,9 +72,24 @@
|
|||||||
тогда окружение окупится, и условие возврата — именно это, а не «стало
|
тогда окружение окупится, и условие возврата — именно это, а не «стало
|
||||||
неудобно».
|
неудобно».
|
||||||
|
|
||||||
Цена самого `make up` — 2 м 50 с с нуля (`make clean` перед ним — ещё 23 с) и
|
Цена самого `make up` с пустых томов — 8 м 18 с до переезда жизненного цикла в
|
||||||
1 м 5 с на живом стенде. В цену целей она не входит, но записана здесь по той
|
Airflow. В цену целей она не входит. На готовом мире подъём больше не
|
||||||
же причине: с #42 подъём стенда заливает стартовый мир и стал заметно дороже.
|
пересчитывает данные: `world_initialize` зеленеет без изменений.
|
||||||
|
|
||||||
|
`make up` собирает локальные образы Airflow, Superset и Grafana, поднимает весь
|
||||||
|
стенд и ждёт здорового состояния долгоживущих контейнеров. В образ Airflow
|
||||||
|
добавлены закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и
|
||||||
|
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
|
||||||
|
подготавливает администратора и два подключения к `clickhouse-01`. Второй
|
||||||
|
обновляет Superset, создаёт администратора и импортирует подключение к
|
||||||
|
`clickhouse-02`. На ноутбуке в режиме энергосбережения приёмочный подъём с
|
||||||
|
пустых томов занял около десяти минут, повторный на живом стенде — около трёх.
|
||||||
|
|
||||||
|
`make rebuild-storage` тоже не проверка. Команда через Airflow удаляет
|
||||||
|
прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL,
|
||||||
|
заливает стартовый мир и возвращает его позицию к концу стартовых дней. Службы и
|
||||||
|
их тома она сохраняет, поэтому возврат к каноническим данным не требует
|
||||||
|
холодного запуска всего стенда.
|
||||||
|
|
||||||
## Правило: смоук обязан оставаться быстрым
|
## Правило: смоук обязан оставаться быстрым
|
||||||
|
|
||||||
@@ -96,20 +112,28 @@ ClickHouse отвечает сразу.
|
|||||||
Деление целей ценно ровно до тех пор, пока оно не превратилось в «гонять
|
Деление целей ценно ровно до тех пор, пока оно не превратилось в «гонять
|
||||||
всегда всё».
|
всегда всё».
|
||||||
|
|
||||||
|
Состояние перед проверкой восстанавливается в самой узкой достаточной области:
|
||||||
|
сначала объект или партиция, затем база слоя. Если нужен целиком чистый
|
||||||
|
прикладной мир, `make rebuild-storage` пересобирает его базы, топики, позиции
|
||||||
|
потребителей и данные. `make clean` нужен, только когда меняется или сломано
|
||||||
|
постоянное состояние вне этих баз либо проверяется сам запуск с пустых томов.
|
||||||
|
Требование к обоснованию полного сброса задаёт `AGENTS.md`. Неизвестную поломку
|
||||||
|
сначала исследуют: полный сброс уничтожает её следы.
|
||||||
|
|
||||||
| Когда | Что гонять |
|
| Когда | Что гонять |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Правка в работе | `make config-test` и `make smoke` |
|
| Правка в работе | `make config-test` и `make smoke` |
|
||||||
| PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` |
|
| PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` |
|
||||||
| Приёмка этапа | `make up` с нуля и все три цели на стенде |
|
| Приёмка этапа | все три цели на стенде; запуск с пустых томов — только для изменений начальной сборки или постоянного состояния служб |
|
||||||
|
|
||||||
Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test`
|
Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test`
|
||||||
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
|
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
|
||||||
дорогая. Правки дагов и Superset — корневой `make lint`.
|
дорогая. Правки дагов, Superset и карты — корневой `make lint`.
|
||||||
|
|
||||||
Тринадцать секунд из её семидесяти одной — пересборка восьми модельных дней
|
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
|
||||||
для сверки с описью мира. Дешевле хеши не сравнить: чтобы узнать, тот ли
|
для сверки с паспортом мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
|
||||||
получается мир, его надо получить. Место выбрано по той же оси «кого
|
узнать, тот ли получается мир, его надо получить. Место выбрано по той же оси
|
||||||
спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и
|
«кого спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и
|
||||||
краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
|
краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
|
||||||
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
|
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
|
||||||
поломкой хранилища, а не забытой пересборкой.
|
поломкой хранилища, а не забытой пересборкой.
|
||||||
@@ -147,10 +171,10 @@ ClickHouse отвечает сразу.
|
|||||||
жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он
|
жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он
|
||||||
становится темой урока.
|
становится темой урока.
|
||||||
|
|
||||||
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырья срок
|
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырых данных срок
|
||||||
жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в
|
жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в
|
||||||
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и
|
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и их.
|
||||||
их. Хуже явного мусора: явный видно.
|
Хуже явного мусора: явный видно.
|
||||||
|
|
||||||
Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.**
|
Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.**
|
||||||
Дешевле всего это выходит партицией — опыт играет модельный день за границей
|
Дешевле всего это выходит партицией — опыт играет модельный день за границей
|
||||||
@@ -159,10 +183,10 @@ ClickHouse отвечает сразу.
|
|||||||
же механизм, на котором стоит переобработка дня X.
|
же механизм, на котором стоит переобработка дня X.
|
||||||
|
|
||||||
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
|
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
|
||||||
что стенд произвёл сам: подневный счёт событий против описи мира ловит и
|
что стенд произвёл сам: подневный счёт событий против паспорта мира ловит и
|
||||||
сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге,
|
сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге,
|
||||||
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки
|
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки Kafka →
|
||||||
Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
STG → ODS, и живёт он в `make check-clickhouse`.
|
||||||
|
|
||||||
Правило, которое такая проверка обязана выдержать и которое стоит держать в
|
Правило, которое такая проверка обязана выдержать и которое стоит держать в
|
||||||
голове для следующей: **на растущих данных утверждать можно только про
|
голове для следующей: **на растущих данных утверждать можно только про
|
||||||
@@ -194,12 +218,14 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
|||||||
| `make check-services` | `scripts/stand-services.sh` |
|
| `make check-services` | `scripts/stand-services.sh` |
|
||||||
| `make check-clickhouse` | `scripts/check-clickhouse.sh` |
|
| `make check-clickhouse` | `scripts/check-clickhouse.sh` |
|
||||||
|
|
||||||
Особняком — `scripts/wait-for-world.sh`: он не проверка, а вторая половина
|
Особняком стоит `scripts/run-dag.sh`: это не проверка, а общий REST-переход от
|
||||||
`make up`. `docker compose up --wait` дожидается служб, в том числе успешно
|
`make` к Airflow. `make up` запускает через него `world_initialize`, а
|
||||||
отработавшей заливки, но «заливка кончилась» значит «события в топике», а не
|
`make rebuild-storage` — `world_recreate`; сценарий ждёт появления дага в API,
|
||||||
«события в хранилище»: приём асинхронный. Скрипт ждёт доезда ограниченным
|
а затем до минуты повторяет только стартовый ответ `Dag with dag_id not found`.
|
||||||
циклом опроса. Без него `make check-clickhouse` следом краснел бы по
|
Прочие ответы и сетевые ошибки он не повторяет: при потерянном ответе запуск мог
|
||||||
устройству, а не по поломке.
|
уже создаться. Сценарий печатает идентификаторы дага и прогона и возвращает
|
||||||
|
вызывающему его итог. Ожидание доставки событий и запуск `orders_ingest` теперь
|
||||||
|
видны задачами `world_initialize`, а не спрятаны в shell.
|
||||||
|
|
||||||
Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две
|
Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две
|
||||||
проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается.
|
проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается.
|
||||||
@@ -209,7 +235,7 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
|||||||
контейнер Docker поднимает сам, и проверка здоровья об этом промолчит
|
контейнер Docker поднимает сам, и проверка здоровья об этом промолчит
|
||||||
(ADR 0004). Стенд нагружает `check-services`, а увидеть последствия нужно и
|
(ADR 0004). Стенд нагружает `check-services`, а увидеть последствия нужно и
|
||||||
тому, кто гонял один смоук. Это единственная проверка, которая считается
|
тому, кто гонял один смоук. Это единственная проверка, которая считается
|
||||||
дважды: 20 у смоука плюс 7 у `check-services` — это 26 разных проверок.
|
дважды: 21 у смоука плюс 7 у `check-services` — это 27 разных проверок.
|
||||||
- **Зависимости машины считает только смоук.** «На машине есть Docker, curl и
|
- **Зависимости машины считает только смоук.** «На машине есть Docker, curl и
|
||||||
jq» — вопрос к машине, а не к службам, и на оси он стоит рядом с «стенд
|
jq» — вопрос к машине, а не к службам, и на оси он стоит рядом с «стенд
|
||||||
собран». Для `check-services` это условие запуска: без них он не начнёт
|
собран». Для `check-services` это условие запуска: без них он не начнёт
|
||||||
@@ -219,41 +245,267 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
|||||||
скриптов нет. Отдельной проверкой этот вход тоже не считается: то же самое
|
скриптов нет. Отдельной проверкой этот вход тоже не считается: то же самое
|
||||||
утверждает смоук.
|
утверждает смоук.
|
||||||
|
|
||||||
|
### Что спрашивает смоук
|
||||||
|
|
||||||
|
`make smoke` за секунды спрашивает, собран ли стенд: зависимости машины,
|
||||||
|
здоровье контейнеров, устройство keeper, ответ Kafka с машины через отображённый
|
||||||
|
порт, четыре цели Prometheus, два источника Grafana, компоненты Airflow и
|
||||||
|
подготовленное подключение к `clickhouse-01`.
|
||||||
|
|
||||||
|
Двенадцать проверок здоровья сразу после `make up --wait` повторяют то, чего
|
||||||
|
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.
|
||||||
|
|
||||||
|
`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse:
|
||||||
|
описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`,
|
||||||
|
`Distributed`, очередь распределённых DDL и очистку временных таблиц. Девятая из
|
||||||
|
десяти проверок спрашивает словарь товаров на обеих нодах. Последняя — подневный
|
||||||
|
счёт событий против паспорта, о нем сказано выше.
|
||||||
|
|
||||||
## Что проверено
|
## Что проверено
|
||||||
|
|
||||||
|
**Перезамер 3 сентября 2026 года при исполнении #222.** `make lint` прошел за
|
||||||
|
0,768 секунды; в таблице 0,8 с. `ruff format --check` проверил 13 файлов стенда,
|
||||||
|
включая исходники карты, а затем проверка рендера подтвердила свежесть HTML.
|
||||||
|
|
||||||
|
**Калибровка заказной стороны 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.** После добавления десятой
|
**Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой
|
||||||
проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за
|
проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за
|
||||||
24 секунды. Это цена всей цели, а не одной новой строки.
|
24 секунды. Это цена всей цели, а не одной новой строки.
|
||||||
|
|
||||||
**Замер 9 августа 2026 года при появлении корневого `lint` (#65).** Три прогона
|
**Замер 9 августа 2026 года при появлении корневого `lint` (#65).** Три прогона
|
||||||
подряд — 0,39, 0,36 и 0,39 секунды на трёх файлах стенда; в таблице 0,4 с. На
|
подряд — 0,39, 0,36 и 0,39 секунды на трех файлах стенда, которые тогда входили в проверку; в таблице 0,4 с. На
|
||||||
машине с непрогретым кешем первый прогон дороже: `uvx` сначала скачивает ruff.
|
машине с непрогретым кешем первый прогон дороже: `uvx` сначала скачивает ruff.
|
||||||
|
|
||||||
Тогда же снято, что двери не перекрываются: `ruff check --show-files` из корня
|
Тогда же снято, что двери не перекрываются: `ruff check --show-files` из корня
|
||||||
перечисляет ровно три файла стенда, из `generator/` — только файлы генератора.
|
перечислял ровно три файла стенда, из `generator/` — только файлы генератора.
|
||||||
Корневой конфиг правила генератору не подменяет: конфиг ищется вверх по дереву
|
Корневой конфиг правила генератору не подменяет: конфиг ищется вверх по дереву
|
||||||
от файла, и ближний выигрывает.
|
от файла, и ближний выигрывает.
|
||||||
|
|
||||||
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
|
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
|
||||||
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка:
|
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка: 18,7
|
||||||
18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka.
|
с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka. Повторный
|
||||||
Повторный `make up` на живом стенде — 1 м 5 с: мир заливается заново, и это
|
`make up` на живом стенде — 1 м 5 с: мир заливается заново, и это не оплошность
|
||||||
не оплошность — `WatchID` те же, повтор схлопнет `ReplacingMergeTree`.
|
— `WatchID` те же, повтор схлопнет `ReplacingMergeTree`. Смоук — 9 с,
|
||||||
Смоук — 9 с, `check-clickhouse` — 8 с с новой девятой проверкой. `make test`
|
`check-clickhouse` — 8 с с новой девятой проверкой. `make test` вырос до 71 с:
|
||||||
вырос до 71 с: 58 с прежних тестов плюс 13 с на пересборку восьми дней для
|
58 с прежних тестов плюс 13 с на пересборку восьми дней для сверки с паспортом.
|
||||||
сверки с описью. Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
|
Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
|
||||||
#41 и #43.
|
#41 и #43.
|
||||||
|
|
||||||
Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и
|
Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и
|
||||||
проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в
|
проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в
|
||||||
`ods.event_rep` — проверка покраснела, показала недостающий день и назвала
|
`ods.event_rep` — проверка покраснела, показала недостающий день и назвала
|
||||||
адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца
|
адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца топика».
|
||||||
топика». Затем положили в сырьё заведомо негодную строку — брак появился, и
|
Затем положили в STG заведомо негодную строку — брак появился, и диагноз
|
||||||
диагноз сменился на второй, с признаком для различения: «сойдётся недостача с
|
сменился на второй, с признаком для различения: «сойдётся недостача с числом
|
||||||
числом брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки
|
брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки опыта
|
||||||
опыта убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт
|
убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт вернулся к
|
||||||
вернулся к 401 185, и это заодно показало дедупликацию: повтор дня не удвоил
|
401 185, и это заодно показало дедупликацию: повтор дня не удвоил счёт под
|
||||||
счёт под `FINAL`.
|
`FINAL`.
|
||||||
|
|
||||||
Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и
|
Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и
|
||||||
совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону:
|
совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону:
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Мир и модельное время
|
||||||
|
|
||||||
|
Оператор управляет модельным временем стенда. Здесь разобрано, как рождается
|
||||||
|
мир, что двигает его ось, какие даги играют дни и где хранится позиция. Доводы
|
||||||
|
об устройстве дагов модельного времени — в [ADR
|
||||||
|
0009](../adr/0009-world-control.md), о безопасном повторном подъёме и жизненном
|
||||||
|
цикле мира — в [ADR 0013](../adr/0013-world-lifecycle.md). Почему последний день
|
||||||
|
стартового мира неполон и что хранит паспорт, разобрано в [справочнике
|
||||||
|
стартового мира](orders/start-world.md) и [справочнике
|
||||||
|
паспорта](orders/inventory.md).
|
||||||
|
|
||||||
|
## Стартовый мир
|
||||||
|
|
||||||
|
Стенд поднимается не пустым: `make up` через REST API запускает даг Airflow
|
||||||
|
`world_initialize`. Тот создаёт прикладные топики и схему, собирает образ
|
||||||
|
генератора и играет в `hits` первые восемь дней модельного времени — понедельник
|
||||||
|
по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в
|
||||||
|
`orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только
|
||||||
|
тогда ставит позицию мира в `8`. Так у каждой лабы есть данные, и всегда одни и
|
||||||
|
те же.
|
||||||
|
|
||||||
|
Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
|
||||||
|
уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика
|
||||||
|
выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так
|
||||||
|
виден разный темп потокового трекера и ночной выгрузки магазина.
|
||||||
|
|
||||||
|
Повторный `make up` безопасен: `world_initialize` видит готовую позицию,
|
||||||
|
сообщает в журнале, что мир уже существует, и не применяет DDL, не собирает
|
||||||
|
образ и не заливает данные повторно. Изменённую схему применяют адресно либо
|
||||||
|
явной командой `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`. Всё, что
|
||||||
|
принимается флагами, принимается и переменными окружения — так генератор
|
||||||
|
запускают даги модельного времени; полный список у `--help`.
|
||||||
|
|
||||||
|
**Значения по умолчанию есть не у всех параметров, и это сделано специально.**
|
||||||
|
Зерно, число дней и темп живого дня описывают модель мира — их генератор знает
|
||||||
|
сам. День на оси, приёмник и имя топика относятся к конкретному запуску
|
||||||
|
генератора на стенде: если их не задать, он откажется работать и вернёт
|
||||||
|
ненулевой код ещё до первого события. Поэтому значение дня по умолчанию живёт в
|
||||||
|
`Makefile`: день задаёт тот, кто запускает, а не тот, кого запускают.
|
||||||
|
|
||||||
|
## Даги модельного времени
|
||||||
|
|
||||||
|
Считать дни самому необязательно. В Airflow живут **даги модельного времени**:
|
||||||
|
два дага создают начальное состояние мира, а три двигают ось дальше.
|
||||||
|
|
||||||
|
| Даг | Что делает |
|
||||||
|
| --- | --- |
|
||||||
|
| `world_initialize` | создаёт стартовый мир, если его ещё нет; повторный запуск ничего не меняет |
|
||||||
|
| `world_recreate` | удаляет только прикладной мир и запускает его создание заново |
|
||||||
|
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
|
||||||
|
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
|
||||||
|
| `world_live` | выключатель: пока снят с паузы, запускает `world_live_day` день за днём |
|
||||||
|
|
||||||
|
Сыграв день, шаговый даг отправляет слепок заказов и запускает даг
|
||||||
|
`orders_ingest`. Тот забирает слепок из топика `orders` в `stg`; шаговый даг
|
||||||
|
ждёт его завершения. Так две половины стенда идут в ногу: упал приём — падает и
|
||||||
|
шаговый даг, а позиция остаётся на месте ([ADR
|
||||||
|
0008](../adr/0008-order-ingestion.md)).
|
||||||
|
|
||||||
|
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
|
||||||
|
дня. Шаговый даг записывает позицию только после успешного завершения дня:
|
||||||
|
оборванный прогон позицию не двигает, и следующий запуск играет тот же день
|
||||||
|
заново. Отсутствующая переменная означает, что мир ещё не создавали; `-1` —
|
||||||
|
очистка не закончена, `0` — очистка закончена, а начальная генерация ещё не
|
||||||
|
завершена. Шаговые даги начинают играть только с позиции `8` и больше.
|
||||||
|
|
||||||
|
В форме запуска можно выбрать только число дней. Первый день шаговый даг берёт
|
||||||
|
из `world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced
|
||||||
|
Options». Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и
|
||||||
|
новую позицию шаговый даг пишет в журнал шага `remember_played`.
|
||||||
|
|
||||||
|
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
|
||||||
|
встанет на границе модельных суток, доиграв начатый день. Форма дагов времени и
|
||||||
|
доводы целиком — [ADR 0009](../adr/0009-world-control.md).
|
||||||
|
|
||||||
|
**У этого решения есть цена: планировщик Airflow подключён к сокету Docker.**
|
||||||
|
Без него планировщик не сможет запустить контейнер генератора. Доступ к сокету
|
||||||
|
равен правам root на машине; для локального учебного стенда это приемлемо, но
|
||||||
|
знать об этом нужно. Доступ даёт не само монтирование сокета, а членство в
|
||||||
|
группе `docker`: у неё на каждой машине свой GID. Он записан в `.env`, а узнать
|
||||||
|
его можно командой `getent group docker`.
|
||||||
@@ -11,10 +11,12 @@
|
|||||||
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
|
по элементу на товар. Отдельной сущности «визит» в выгрузке нет — визиты
|
||||||
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
|
собирают на стороне хранилища, а `VisitID` дан как эталон для самопроверки.
|
||||||
|
|
||||||
Имена и типы колонок — стороны источника. Хранилище принимает их как есть и
|
Имена и типы колонок описывают сторону источника. Обычно ODS принимает их без
|
||||||
нормализует у себя: то же имя в нашем стиле ждёт в столбце «Нормализованное
|
изменений. Исключение — `purchaseRevenue`: источник объявляет Float64, а ODS
|
||||||
имя». Это имя источника, приведённое к snake_case, а не имя атрибута в
|
хранит деньги в Decimal. Точный локальный тип назван в документации
|
||||||
модели данных: слой DDS складывает свою модель и называет атрибуты по ней.
|
хранилища. Имя в нашем стиле ждет в столбце «Нормализованное имя». Это имя
|
||||||
|
источника, приведенное к snake_case, а не имя атрибута в модели данных: слой
|
||||||
|
DDS складывает свою модель и называет атрибуты по ней.
|
||||||
Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора;
|
Столбец «Тип numpy» показывает, чем колонка представлена внутри генератора;
|
||||||
у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан
|
у массивов это тип элемента. Номер — место колонки в выгрузке: порядок задан
|
||||||
контрактом.
|
контрактом.
|
||||||
@@ -90,13 +92,13 @@
|
|||||||
| № | Колонка | Тип 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 товаров события |
|
||||||
| 42 | `productName` | `Array(String)` | `object` | `product_name` | названия тех же товаров |
|
| 42 | `productName` | `Array(String)` | `object` | `product_name` | названия тех же товаров |
|
||||||
| 43 | `productCategory` | `Array(String)` | `object` | `product_category` | категории тех же товаров |
|
| 43 | `productCategory` | `Array(String)` | `object` | `product_category` | категории тех же товаров |
|
||||||
| 44 | `productPrice` | `Array(Int64)` | `int64` | `product_price` | цена за штуку целым числом: деньги генератор считает целыми |
|
| 44 | `productPrice` | `Array(Int64)` | `int64` | `product_price` | цена за штуку, округленная до целых рублей |
|
||||||
| 45 | `productQuantity` | `Array(UInt64)` | `uint64` | `product_quantity` | количество штук каждого товара |
|
| 45 | `productQuantity` | `Array(UInt64)` | `uint64` | `product_quantity` | количество штук каждого товара |
|
||||||
| 46 | `productEventType` | `Array(String)` | `object` | `product_event_type` | действие с товаром: стенд шлёт add и purchase, полный словарь Метрики (detail, remove, impressions) не берём |
|
| 46 | `productEventType` | `Array(String)` | `object` | `product_event_type` | действие с товаром: стенд шлёт add и purchase, полный словарь Метрики (detail, remove, impressions) не берём |
|
||||||
| 47 | `ecommerce` | `String` | `object` | `ecommerce` | сырой JSON события, как отдаёт Метрика — материал лабы про разбор JSON внутри колонки |
|
| 47 | `ecommerce` | `String` | `object` | `ecommerce` | сырой JSON события, как отдаёт Метрика — материал лабы про разбор JSON внутри колонки |
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Путеводитель по стенду
|
||||||
|
|
||||||
|
Путеводитель объясняет, как данные поступают в хранилище, обрабатываются
|
||||||
|
и попадают в отчеты. В главах есть короткие примеры, которые можно выполнить
|
||||||
|
на запущенном стенде перед переходом к учебным заданиям.
|
||||||
|
|
||||||
|
## Порядок чтения
|
||||||
|
|
||||||
|
Сначала запустите стенд по [инструкции](../../README.md#быстрый-старт).
|
||||||
|
Затем читайте главы по порядку и выполняйте примеры из них.
|
||||||
|
Незнакомые термины можно найти в
|
||||||
|
[словаре понятий](../../CONTEXT.md), а подробности устройства —
|
||||||
|
в [технических справочниках](../architecture/). К ним достаточно обращаться
|
||||||
|
по мере необходимости. После путеводителя переходите к
|
||||||
|
[учебным заданиям](../labs/README.md).
|
||||||
|
|
||||||
|
## Карта хранилища
|
||||||
|
|
||||||
|
[Интерактивная карта](architecture-map.html) показывает путь данных через слои.
|
||||||
|
Gitea показывает HTML как текст. Откройте файл из своей копии репозитория
|
||||||
|
или скачайте его кнопкой «Raw» и откройте в браузере.
|
||||||
|
Сверяйтесь с картой при чтении главы «Хранилище и его слои».
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
### [Хранилище и его слои](storage-layers.md)
|
||||||
|
|
||||||
|
Как меняются данные при переходе из Kafka в слои STG, ODS, DDS и DM.
|
||||||
|
В конце главы вы посмотрите события, сессии и выручку с помощью SQL.
|
||||||
|
|
||||||
|
### [Генерация и обработка данных в Airflow](airflow-dags.md)
|
||||||
|
|
||||||
|
Какие даги создают новые данные, а какие обрабатывают их в хранилище.
|
||||||
|
Вы сравните содержимое витрин до и после обработки нового дня.
|
||||||
|
|
||||||
|
### [Мониторинг в Grafana](monitoring.md)
|
||||||
|
|
||||||
|
Что показывают панели Grafana и как меняются их показания, пока в стенд
|
||||||
|
поступают события и заказы. Вы увидите, почему заказы обновляются реже событий.
|
||||||
|
|
||||||
|
### [Отчеты в Superset](superset.md)
|
||||||
|
|
||||||
|
Как Superset строит графики по витринам. Вы сравните числа на готовом графике
|
||||||
|
с результатом SQL и узнаете, какие отчеты сохраняются при перезапуске стенда.
|
||||||
|
|
||||||
|
### [Генератор: откуда берутся данные](generator.md)
|
||||||
|
|
||||||
|
Откуда берутся события и заказы, почему начальные данные повторяются
|
||||||
|
и как сверить число загруженных событий с ожидаемым.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Генерация и обработка данных в Airflow
|
||||||
|
|
||||||
|
Airflow управляет генерацией данных и их обработкой в хранилище. Это разные
|
||||||
|
этапы: сначала в Kafka поступают события и заказы за день работы магазина,
|
||||||
|
затем по ним рассчитываются данные DDS и витрины DM.
|
||||||
|
Даг описывает задачи Airflow и порядок их выполнения.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
world_initialize --> etl_pipeline
|
||||||
|
world_live_day --> etl_pipeline
|
||||||
|
manual[ручной запуск] --> etl_pipeline
|
||||||
|
etl_pipeline --> dds_transform --> dm_transform --> dq_check
|
||||||
|
world_next_day --> sources[источники без ETL]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Загрузка событий и заказов
|
||||||
|
|
||||||
|
События поступают в топик `hits` и обрабатываются по мере поступления:
|
||||||
|
материализованные представления переносят строки из STG в ODS без участия
|
||||||
|
Airflow.
|
||||||
|
|
||||||
|
Заказы поступают в топик `orders` раз в модельные сутки одной выгрузкой.
|
||||||
|
Она содержит состояние заказов на конец дня и называется слепком.
|
||||||
|
Даг `orders_ingest` загружает ее целиком. Такой способ приема называется
|
||||||
|
[пакетным](../../CONTEXT.md).
|
||||||
|
|
||||||
|
## Генерация модельных дней
|
||||||
|
|
||||||
|
В документации миром называется учебный магазин с его посетителями,
|
||||||
|
товарами и заказами. Время в нем идет отдельно от календарного.
|
||||||
|
Два дага с тегом «жизненный цикл мира» создают начальные данные:
|
||||||
|
|
||||||
|
| Даг | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| `world_initialize` | Создает начальные данные, если их еще нет |
|
||||||
|
| `world_recreate` | Удаляет прикладные данные и создает их заново |
|
||||||
|
|
||||||
|
Для добавления следующих дней есть три дага с тегом «модельное время»:
|
||||||
|
|
||||||
|
| Даг | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| `world_next_day` | Генерирует данные за следующие дни без пауз между событиями |
|
||||||
|
| `world_live_day` | Генерирует данные за следующий день постепенно, примерно за 24 минуты |
|
||||||
|
| `world_live` | Запускает `world_live_day` день за днем, пока снят с паузы |
|
||||||
|
|
||||||
|
В переменной Airflow `world_position` хранится номер следующего дня для
|
||||||
|
генерации. Например, при значении `8` генератор уже отправил события
|
||||||
|
за дни с 0 по 7.
|
||||||
|
|
||||||
|
`world_next_day` и `world_live_day` сначала отправляют события дня D,
|
||||||
|
затем — слепок заказов за предыдущий день D−1. Они ждут, пока `orders_ingest`
|
||||||
|
загрузит слепок, и только после успешного завершения дня обновляют
|
||||||
|
`world_position`. Если генерация или прием данных завершились с ошибкой,
|
||||||
|
номер дня остается прежним. Подробности — в [справочнике генератора
|
||||||
|
и модельного времени](../architecture/world.md#даги-модельного-времени).
|
||||||
|
|
||||||
|
## Обработка данных в хранилище
|
||||||
|
|
||||||
|
Даг `etl_pipeline` по очереди запускает `dds_transform`, `dm_transform`
|
||||||
|
и `dq_check`, дожидаясь завершения каждого. Они рассчитывают данные DDS,
|
||||||
|
обновляют витрины DM и проверяют качество результата.
|
||||||
|
|
||||||
|
При запуске `etl_pipeline` не нужно указывать даты. Дни для обработки
|
||||||
|
определяются по `world_position` и по уже загруженным данным в слоях.
|
||||||
|
Детали есть в
|
||||||
|
[справочнике конвейера](../architecture/etl.md#объём-прогона).
|
||||||
|
|
||||||
|
После `world_next_day` новые события и заказы уже загружены, но данные DDS
|
||||||
|
и DM еще не пересчитаны: запустите `etl_pipeline` вручную.
|
||||||
|
`world_live_day` запускает его автоматически и ждет завершения. Поэтому
|
||||||
|
`world_live_day` может продолжать работу после окончания генерации за день.
|
||||||
|
|
||||||
|
## Если даг завершился с ошибкой
|
||||||
|
|
||||||
|
Ошибка в `etl_pipeline` не меняет `world_position`: генерация и прием
|
||||||
|
данных за день уже завершились. После устранения причины ошибки
|
||||||
|
перезапустите конвейер вручную. Даты для повторной обработки он определит сам.
|
||||||
|
Кто запускает конвейер, описано в
|
||||||
|
[разделе о трех путях запуска](../architecture/etl.md#три-пути-запуска).
|
||||||
|
|
||||||
|
За качество данных отвечают проверки DQ (Data Quality) в даге `dq_check`.
|
||||||
|
Если проверка находит расхождение, задача завершается с ошибкой и записывает
|
||||||
|
в журнал ключи строк, которые ее не прошли. Состав
|
||||||
|
проверок описан в [разделе «Звено DQ
|
||||||
|
конвейера»](../architecture/dm.md#звено-dq-конвейера).
|
||||||
|
|
||||||
|
## Поиск дагов в Airflow
|
||||||
|
|
||||||
|
Даги мира ищите по тегам «модельное время» и «жизненный цикл мира». Остальные
|
||||||
|
даги ищите по тегам «заказы», `etl`, `dds`, `dm` и `dq`. `homework_check` с
|
||||||
|
тегами `dm` и `labs` проверяет домашние работы; о нем рассказано в учебных
|
||||||
|
заданиях.
|
||||||
|
|
||||||
|
## Один день от генерации до витрин
|
||||||
|
|
||||||
|
Откройте Airflow по адресу из раздела [«Адреса и
|
||||||
|
пароли»](../../README.md#адреса-и-пароли). В Admin → Variables найдите
|
||||||
|
`world_position` и запомните его значение. Затем запустите `world_next_day`
|
||||||
|
на один день по [инструкции из README](../../README.md#добавить-данные-за-следующие-дни). Убедитесь, что
|
||||||
|
`orders_ingest` завершился успешно, а в витринах нового дня еще нет.
|
||||||
|
Значение `world_position` должно увеличиться на один.
|
||||||
|
Запустите `etl_pipeline` вручную и дождитесь его завершения.
|
||||||
|
|
||||||
|
В DBeaver, подключенном по
|
||||||
|
[инструкции](../../README.md#подключиться-из-dbeaver), выполните два запроса:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT report_date, visitors, known_users
|
||||||
|
FROM dm.daily_traffic_v
|
||||||
|
ORDER BY report_date DESC
|
||||||
|
LIMIT 3;
|
||||||
|
|
||||||
|
SELECT report_date, sum(revenue) AS revenue
|
||||||
|
FROM dm.revenue_daily_v
|
||||||
|
GROUP BY report_date
|
||||||
|
ORDER BY report_date DESC
|
||||||
|
LIMIT 3;
|
||||||
|
```
|
||||||
|
|
||||||
|
В витрине трафика уже есть новый день, а в выручке последняя дата —
|
||||||
|
предыдущий день. Причина в выгрузке заказов: вместе с событиями дня D
|
||||||
|
приходит слепок за день D−1. Выручка дня D появится после генерации
|
||||||
|
дня D+1 и запуска конвейера.
|
||||||
|
|
||||||
|
## Следующая глава и справочники
|
||||||
|
|
||||||
|
Детали — в справочниках [конвейера](../architecture/etl.md) и
|
||||||
|
[мира](../architecture/world.md). Следующая глава —
|
||||||
|
[«Мониторинг в Grafana»](monitoring.md).
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,9 @@
|
|||||||
|
# Интерактивная карта хранилища
|
||||||
|
|
||||||
|
`map.yaml` хранит слои, узлы, стрелки и тексты карточек.
|
||||||
|
`template.html` задает внешний вид и поведение карты.
|
||||||
|
`render.py` вкладывает данные в шаблон и пишет файл рядом с главами.
|
||||||
|
Правьте исходники, а не `architecture-map.html`.
|
||||||
|
Пересоберите карту командой:
|
||||||
|
`uv run docs/handbook/architecture-map/render.py`.
|
||||||
|
Проверьте свежесть готового файла с флагом `--check`.
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
title: Карта слоев хранилища
|
||||||
|
lead: Проследите путь событий и заказов от Kafka до отчетов.
|
||||||
|
chapter_url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои
|
||||||
|
intro:
|
||||||
|
- Выберите объект, чтобы узнать, что он хранит, как его читать и откуда в него поступают данные.
|
||||||
|
- Выберите стрелку, чтобы посмотреть способ обработки и открыть SQL-запрос.
|
||||||
|
- Для выбранного объекта подсвечиваются все пути, по которым данные поступают в него и используются дальше.
|
||||||
|
- Кнопки «События» и «Заказы» выделяют нужный поток. Остальные объекты остаются на месте, но становятся бледнее.
|
||||||
|
- Если вы впервые знакомитесь со слоями хранилища, начните с главы по ссылке ниже.
|
||||||
|
|
||||||
|
streams:
|
||||||
|
all: Все
|
||||||
|
events: События
|
||||||
|
orders: Заказы
|
||||||
|
|
||||||
|
dictionary:
|
||||||
|
title: Словарь
|
||||||
|
caption: справочные данные
|
||||||
|
|
||||||
|
layers:
|
||||||
|
- id: kafka
|
||||||
|
title: Kafka
|
||||||
|
caption: сообщения источников
|
||||||
|
- id: stg
|
||||||
|
title: STG
|
||||||
|
caption: исходные сообщения
|
||||||
|
- id: ods
|
||||||
|
title: ODS
|
||||||
|
caption: данные разобраны по полям
|
||||||
|
- id: dds
|
||||||
|
title: DDS
|
||||||
|
caption: события, заказы и сессии
|
||||||
|
- id: dm
|
||||||
|
title: DM
|
||||||
|
caption: витрины для отчетов
|
||||||
|
|
||||||
|
mechanisms:
|
||||||
|
- id: materialized_view
|
||||||
|
label: материализованное представление
|
||||||
|
line: solid
|
||||||
|
rhythm: непрерывно, по мере прихода сообщений
|
||||||
|
- id: airflow_dag
|
||||||
|
label: даг Airflow
|
||||||
|
line: dashed
|
||||||
|
rhythm: 'пакетно: раз в модельные сутки (`orders_ingest`) или при каждом запуске конвейера'
|
||||||
|
- id: view
|
||||||
|
label: представление
|
||||||
|
line: dotted
|
||||||
|
rhythm: при запросе к представлению, без копирования данных
|
||||||
|
|
||||||
|
nodes:
|
||||||
|
- id: kafka_hits
|
||||||
|
name: hits
|
||||||
|
layer: kafka
|
||||||
|
order: 0
|
||||||
|
streams: [events]
|
||||||
|
what: Топик Kafka с событиями кликстрима. Каждое сообщение содержит одно событие.
|
||||||
|
read: "Для просмотра загруженных сообщений используйте `stg.hits_raw_dist`. Таблица `stg.hits_raw_kafka` служит для приема из топика."
|
||||||
|
fill:
|
||||||
|
- "Генератор отправляет события без пауз в пакетном режиме или постепенно в живом режиме."
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
|
||||||
|
- label: Формат
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/formats/clickstream-event.md#user-content-описание-выгрузки-событие-кликстрима
|
||||||
|
|
||||||
|
- id: kafka_orders
|
||||||
|
name: orders
|
||||||
|
layer: kafka
|
||||||
|
order: 1
|
||||||
|
streams: [orders]
|
||||||
|
what: "Топик Kafka с дневными выгрузками заказов — слепками. Каждый слепок содержит состояние заказов за последние семь модельных дней."
|
||||||
|
read: "Для просмотра загруженных сообщений используйте `stg.orders_raw_dist`. Таблица `stg.orders_raw_kafka` служит для приема из топика."
|
||||||
|
fill:
|
||||||
|
- "После генерации событий дня D источник отправляет слепок заказов за предыдущий день D−1."
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных
|
||||||
|
- label: Устройство слепка
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/snapshot.md#user-content-слепок-и-его-доставка
|
||||||
|
|
||||||
|
- id: stg_hits
|
||||||
|
name: stg.hits_raw_dist
|
||||||
|
layer: stg
|
||||||
|
order: 0
|
||||||
|
streams: [events]
|
||||||
|
what: "Исходное сообщение события и сведения о его доставке из Kafka. Содержимое сохраняется целой строкой, без разбора на поля."
|
||||||
|
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
|
||||||
|
parts:
|
||||||
|
- "`stg.hits_raw_kafka` — таблица для чтения сообщений топика `hits`."
|
||||||
|
- "`stg.hits_raw_rep` — реплицируемая таблица на шарде."
|
||||||
|
- "`stg.hits_raw_dist` — распределенная таблица для записи и чтения."
|
||||||
|
- "`stg.hits_raw_mv` — материализованное представление, которое записывает сообщения из Kafka в STG."
|
||||||
|
fill:
|
||||||
|
- '`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.'
|
||||||
|
- Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL.
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
|
||||||
|
- label: DDL таблиц
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql
|
||||||
|
- label: DDL материализованного представления
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql
|
||||||
|
|
||||||
|
- id: stg_orders
|
||||||
|
name: stg.orders_raw_dist
|
||||||
|
layer: stg
|
||||||
|
order: 1
|
||||||
|
streams: [orders]
|
||||||
|
what: "Исходные строки выгрузки заказов, сведения о доставке и идентификатор загруженной партии `_load_id`."
|
||||||
|
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
|
||||||
|
parts:
|
||||||
|
- "`stg.orders_raw_kafka` — таблица для чтения топика `orders` на первом узле."
|
||||||
|
- "`stg.orders_raw_rep` — реплицируемая таблица на шарде."
|
||||||
|
- "`stg.orders_raw_dist` — распределенная таблица для записи и чтения."
|
||||||
|
fill:
|
||||||
|
- Задача `pull_batch` дага `orders_ingest` загружает слепок раз в модельные сутки.
|
||||||
|
- Строки хранятся не менее трех суток от загрузки. Удаление целыми частями таблицы добавляет до суток к этому сроку; оно выполняется при очередном слиянии по TTL.
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-поток-данных
|
||||||
|
- label: DDL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/10-stg-tables.sql
|
||||||
|
|
||||||
|
- id: ods_event
|
||||||
|
name: ods.event_v
|
||||||
|
layer: ods
|
||||||
|
order: 0
|
||||||
|
streams: [events]
|
||||||
|
what: "События, разобранные на колонки с типами данных. Имена полей сохранены как в источнике."
|
||||||
|
read: "Читайте `ods.event_v`: представление использует `FINAL` и возвращает последнюю версию каждого события."
|
||||||
|
parts:
|
||||||
|
- '`ods.event_rep` — реплицируемая таблица версий события на шарде.'
|
||||||
|
- '`ods.event_dist` — распределенная таблица записи и диагностики.'
|
||||||
|
- '`ods.event_v` — представление актуального состояния с `FINAL`.'
|
||||||
|
- '`ods.event_mv` — материализованное представление для разбора сообщений STG без ошибок.'
|
||||||
|
keys:
|
||||||
|
- '`WatchID` — тот же идентификатор, что `event_id` в DDS.'
|
||||||
|
- '`ClientID` — тот же посетитель, что `client_id` в DDS.'
|
||||||
|
- '`EventDate` — тот же день, что `event_date` в DDS.'
|
||||||
|
fill:
|
||||||
|
- '`ods.event_mv` разбирает строки непрерывно, по мере записи в STG.'
|
||||||
|
- '`ReplacingMergeTree(_load_ts)` хранит версии, а `FINAL` выбирает актуальную.'
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-актуальные-события
|
||||||
|
- label: DDL таблиц
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
|
||||||
|
- label: DDL представлений
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
|
||||||
|
|
||||||
|
- id: ods_event_errors
|
||||||
|
name: ods.event_errors_dist
|
||||||
|
layer: ods
|
||||||
|
order: 1
|
||||||
|
streams: [events]
|
||||||
|
what: "Сообщения событий, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и сведения о доставке."
|
||||||
|
read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.'
|
||||||
|
parts:
|
||||||
|
- '`ods.event_errors_rep` — реплицируемая таблица ошибок на шарде.'
|
||||||
|
- '`ods.event_errors_dist` — распределенная таблица чтения и записи.'
|
||||||
|
- '`ods.event_errors_mv` — материализованное представление для отбора сообщений STG с ошибками.'
|
||||||
|
fill:
|
||||||
|
- '`ods.event_errors_mv` пишет непрерывно, по мере записи в STG.'
|
||||||
|
- Ошибки хранятся один месяц. Остальные события продолжают загружаться.
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
|
||||||
|
- label: DDL таблиц
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
|
||||||
|
- label: DDL материализованного представления
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
|
||||||
|
|
||||||
|
- id: ods_order
|
||||||
|
name: ods.order_v
|
||||||
|
layer: ods
|
||||||
|
order: 2
|
||||||
|
streams: [orders]
|
||||||
|
what: "Последняя загруженная версия заказа с именами полей из источника. Состав заказа пока хранится как фрагмент JSON."
|
||||||
|
read: 'Читайте `ods.order_v`: запрос представления с `FINAL` возвращает последнюю версию по `snapshot_date`.'
|
||||||
|
parts:
|
||||||
|
- '`ods.order_rep` — реплицируемая таблица версий заказа на шарде.'
|
||||||
|
- '`ods.order_dist` — распределенная таблица записи и диагностики.'
|
||||||
|
- '`ods.order_v` — представление текущего состояния с `FINAL`.'
|
||||||
|
keys:
|
||||||
|
- '`order_id` — тот же заказ в `dds.order_v` и `dm.purchase_vs_orders_v`.'
|
||||||
|
- '`user_id` — пользователь магазина, который переходит в заказ DDS.'
|
||||||
|
fill:
|
||||||
|
- Задача `parse_batch` дага `orders_ingest` разбирает загруженный слепок раз в модельные сутки.
|
||||||
|
- '`ReplacingMergeTree(snapshot_date)` оставляет версию из последнего принятого слепка.'
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-версии-заказов
|
||||||
|
- label: DDL таблиц
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
|
||||||
|
- label: DDL представления
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
|
||||||
|
|
||||||
|
- id: ods_order_errors
|
||||||
|
name: ods.order_errors_dist
|
||||||
|
layer: ods
|
||||||
|
order: 3
|
||||||
|
streams: [orders]
|
||||||
|
what: "Строки выгрузки заказов, которые не удалось разобрать по правилам ODS. Сохранены исходная строка, класс ошибки и `_load_id`."
|
||||||
|
read: 'Читайте `_dist`: представления `_v` нет, а распределенная таблица показывает ошибки с обоих шардов.'
|
||||||
|
parts:
|
||||||
|
- '`ods.order_errors_rep` — реплицируемая таблица ошибок на шарде.'
|
||||||
|
- '`ods.order_errors_dist` — распределенная таблица чтения и записи.'
|
||||||
|
fill:
|
||||||
|
- Задача `parse_batch` дага `orders_ingest` сохраняет строки с ошибками при разборе той же выгрузки, из которой загружаются заказы.
|
||||||
|
- Ошибки хранятся один месяц. Остальные заказы продолжают загружаться.
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
|
||||||
|
- label: DDL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/20-ods-tables.sql
|
||||||
|
|
||||||
|
- id: dds_event
|
||||||
|
name: dds.event_v
|
||||||
|
layer: dds
|
||||||
|
order: 0
|
||||||
|
streams: [events]
|
||||||
|
what: "События с названиями полей, принятыми в DDS. Например, `WatchID` переименован в `event_id`, а числовой код устройства заменен названием категории."
|
||||||
|
read: "Читайте `dds.event_v`: представление возвращает явно перечисленные колонки DDS."
|
||||||
|
parts:
|
||||||
|
- '`dds.event_v` — только представление над `ods.event_v`; своей таблицы в DDS нет.'
|
||||||
|
keys:
|
||||||
|
- '`event_id` — тот же идентификатор, что `WatchID` в ODS.'
|
||||||
|
- '`client_id` — тот же посетитель, что `ClientID` в ODS; соединяет событие с сессией и картой идентичностей.'
|
||||||
|
- '`event_date` — тот же день, что `EventDate` в ODS.'
|
||||||
|
- '`purchase_id[1]` — тот же заказ, что `order_id` в DDS и DM.'
|
||||||
|
fill:
|
||||||
|
- "При запросе к `dds.event_v` данные читаются из актуальных событий ODS. Отдельная загрузка в DDS не нужна."
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма
|
||||||
|
- label: DDL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
|
||||||
|
|
||||||
|
- id: dds_session
|
||||||
|
name: dds.session_v
|
||||||
|
layer: dds
|
||||||
|
order: 1
|
||||||
|
streams: [events]
|
||||||
|
what: "Сессии, рассчитанные дагом `dds_transform` по событиям посетителя. Новая сессия начинается после паузы дольше 30 минут или при смене модельной даты."
|
||||||
|
read: "Читайте `dds.session_v`: здесь есть начало, конец, длительность сессии, показатели событий и служебные поля загрузки."
|
||||||
|
parts:
|
||||||
|
- '`dds.session_rep` — реплицируемая таблица сессий на шарде.'
|
||||||
|
- '`dds.session_dist` — распределенная таблица загрузки и диагностики.'
|
||||||
|
- '`dds.session_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
|
||||||
|
- '`dds.session_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
|
||||||
|
- '`dds.session_v` — представление для чтения готовых данных.'
|
||||||
|
keys:
|
||||||
|
- '`client_id` — тот же посетитель, что в событии DDS и карте идентичностей.'
|
||||||
|
- '`session_date` — день, который в `dm.daily_traffic_v` называется `report_date`.'
|
||||||
|
fill:
|
||||||
|
- "При каждом запуске `dds_transform` выбирает дни от первого пропущенного в DDS до последнего дня с завершенной генерацией."
|
||||||
|
- "Задача `session.rebuild` рассчитывает данные в промежуточной таблице (доноре). `session.replace` заменяет ими дневные партиции основной таблицы."
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение
|
||||||
|
- label: DDL таблиц
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/45-dds-tables.sql
|
||||||
|
- label: DDL представления
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
|
||||||
|
|
||||||
|
- id: dds_order
|
||||||
|
name: dds.order_v
|
||||||
|
layer: dds
|
||||||
|
order: 2
|
||||||
|
streams: [orders]
|
||||||
|
what: "Последнее известное состояние заказа, подготовленное для анализа. Артикулы, количества и цены товаров хранятся в массивах; элементы с одинаковым индексом относятся к одной позиции заказа."
|
||||||
|
read: "Читайте `dds.order_v`, чтобы получить готовые заказы из основной таблицы."
|
||||||
|
parts:
|
||||||
|
- '`dds.order_rep` — реплицируемая таблица заказов на шарде.'
|
||||||
|
- '`dds.order_dist` — распределенная таблица загрузки и диагностики.'
|
||||||
|
- '`dds.order_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
|
||||||
|
- '`dds.order_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
|
||||||
|
- '`dds.order_v` — представление для чтения готовых данных.'
|
||||||
|
keys:
|
||||||
|
- '`order_id` — тот же заказ, что в ODS, событии покупки и витрине сверки.'
|
||||||
|
- '`user_id` — пользователь магазина, который входит в карту идентичностей.'
|
||||||
|
- '`item_sku` — тот же артикул, что `sku` в `dic.products`.'
|
||||||
|
- '`order_date` — день, который в витринах называется `report_date` или `order_day`.'
|
||||||
|
fill:
|
||||||
|
- "При каждом запуске `dds_transform` пересчитывает дни заказов из последнего слепка и заполняет пропуски в DDS."
|
||||||
|
- "Задача `order.rebuild` рассчитывает данные в промежуточной таблице (доноре). `order.replace` заменяет ими дневные партиции основной таблицы."
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение
|
||||||
|
- label: DDL таблиц
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/45-dds-tables.sql
|
||||||
|
- label: DDL представления
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
|
||||||
|
|
||||||
|
- id: dds_identity
|
||||||
|
name: dds.identity_map_v
|
||||||
|
layer: dds
|
||||||
|
order: 3
|
||||||
|
streams: [events, orders]
|
||||||
|
what: "Связи между посетителем сайта и пользователем магазина. Они появляются, когда событие покупки сопоставлено с заказом по его номеру."
|
||||||
|
read: "Читайте `dds.identity_map_v`: представление с `FINAL` возвращает последнюю версию каждой пары посетителя и пользователя."
|
||||||
|
parts:
|
||||||
|
- '`dds.identity_map_rep` — реплицируемая таблица версий пар на шарде.'
|
||||||
|
- '`dds.identity_map_dist` — распределенная таблица записи.'
|
||||||
|
- '`dds.identity_map_v` — представление последних версий строк с `FINAL`.'
|
||||||
|
keys:
|
||||||
|
- "`client_id` — идентификатор посетителя из cookie браузера, как в событии и сессии DDS."
|
||||||
|
- "`user_id` — пользователь магазина из заказа DDS."
|
||||||
|
fill:
|
||||||
|
- "Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов."
|
||||||
|
- "При каждом запуске записывается новая версия набора связей."
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка
|
||||||
|
- label: DDL таблиц
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/45-dds-tables.sql
|
||||||
|
- label: DDL представления
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
|
||||||
|
|
||||||
|
- id: dm_daily_traffic
|
||||||
|
name: dm.daily_traffic_v
|
||||||
|
layer: dm
|
||||||
|
order: 0
|
||||||
|
streams: [events]
|
||||||
|
what: "Число посетителей и известных пользователей за каждый день. Известными считаются пользователи магазина, связанные с посетителями сайта через карту идентичностей."
|
||||||
|
read: "Читайте `dm.daily_traffic_v`: представление с `FINAL` возвращает последний результат расчета за каждый день."
|
||||||
|
parts:
|
||||||
|
- '`dm.daily_traffic_rep` — реплицируемая таблица версий счетчиков.'
|
||||||
|
- '`dm.daily_traffic_dist` — распределенная таблица записи.'
|
||||||
|
- '`dm.daily_traffic_v` — представление последних версий строк с `FINAL`.'
|
||||||
|
keys:
|
||||||
|
- '`report_date` — тот же день, что `session_date` в сессии DDS.'
|
||||||
|
fill:
|
||||||
|
- "Задача `daily_traffic` дага `dm_transform` при каждом запуске пересчитывает всю историю и записывает новую версию."
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v
|
||||||
|
- label: DDL таблиц
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql
|
||||||
|
- label: DDL представления
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql
|
||||||
|
|
||||||
|
- id: dm_revenue
|
||||||
|
name: dm.revenue_daily_v
|
||||||
|
layer: dm
|
||||||
|
order: 1
|
||||||
|
streams: [orders]
|
||||||
|
what: "Выручка, число заказов и единиц товара за день по категориям. В расчет входят оплаченные заказы."
|
||||||
|
read: "Читайте `dm.revenue_daily_v`, чтобы получить готовые показатели по дням и категориям товаров."
|
||||||
|
parts:
|
||||||
|
- '`dm.revenue_daily_rep` — реплицируемая таблица витрины на шарде.'
|
||||||
|
- '`dm.revenue_daily_dist` — распределенная таблица загрузки.'
|
||||||
|
- '`dm.revenue_daily_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
|
||||||
|
- '`dm.revenue_daily_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
|
||||||
|
- '`dm.revenue_daily_v` — представление для чтения готовых данных.'
|
||||||
|
keys:
|
||||||
|
- '`report_date` — тот же день, что `order_date` в заказе DDS.'
|
||||||
|
- '`product_category` — категория, найденная по `item_sku` заказа и ключу `sku` словаря.'
|
||||||
|
fill:
|
||||||
|
- "При каждом запуске `dm_transform` пересчитывает дни заказов из последнего слепка и заполняет пропущенные дни витрины."
|
||||||
|
- "Задача `revenue_daily.rebuild` рассчитывает данные в промежуточной таблице (доноре). `revenue_daily.replace` заменяет ими дневные партиции витрины."
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v
|
||||||
|
- label: DDL таблиц
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql
|
||||||
|
- label: DDL представления
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql
|
||||||
|
|
||||||
|
- id: dm_purchase
|
||||||
|
name: dm.purchase_vs_orders_v
|
||||||
|
layer: dm
|
||||||
|
order: 2
|
||||||
|
streams: [events, orders]
|
||||||
|
what: "Результат сверки покупок из кликстрима с заказами магазина. Для каждого номера заказа указано, совпали ли данные двух источников и какое расхождение найдено."
|
||||||
|
read: "Читайте `dm.purchase_vs_orders_v`: если покупка или заказ отсутствует, поля этой стороны содержат `NULL`."
|
||||||
|
parts:
|
||||||
|
- '`dm.purchase_vs_orders_rep` — реплицируемая таблица сверки на шарде.'
|
||||||
|
- '`dm.purchase_vs_orders_dist` — распределенная таблица загрузки.'
|
||||||
|
- '`dm.purchase_vs_orders_stage_rep` — промежуточная таблица для пересчета данных по дням на шарде.'
|
||||||
|
- '`dm.purchase_vs_orders_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
|
||||||
|
- '`dm.purchase_vs_orders_v` — представление для чтения готовых данных.'
|
||||||
|
keys:
|
||||||
|
- "`order_id` — тот же заказ, что `purchase_id[1]` в событии и `order_id` в заказе DDS."
|
||||||
|
- "`order_day` — дата заказа; если заказа еще нет, берется дата события покупки."
|
||||||
|
fill:
|
||||||
|
- "При каждом запуске `dm_transform` пересчитывает дни последнего слепка, добавляет новые дни покупок, заполняет пропуски и повторяет сверку дней с покупками без заказа."
|
||||||
|
- "Задача `purchase_vs_orders.rebuild` рассчитывает данные в промежуточной таблице (доноре). `purchase_vs_orders.replace` заменяет ими дневные партиции витрины."
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v
|
||||||
|
- label: DDL таблиц
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql
|
||||||
|
- label: DDL представления
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql
|
||||||
|
|
||||||
|
- id: dm_dq
|
||||||
|
name: dm.dq_summary_v
|
||||||
|
layer: dm
|
||||||
|
order: 3
|
||||||
|
streams: [events, orders]
|
||||||
|
what: "Последние результаты пяти проверок качества данных за каждый день. Историю запусков можно посмотреть в Airflow."
|
||||||
|
read: "Читайте `dm.dq_summary_v`, чтобы увидеть результат каждой проверки за день."
|
||||||
|
parts:
|
||||||
|
- '`dm.dq_summary_rep` — реплицируемая таблица опубликованной сводки.'
|
||||||
|
- '`dm.dq_summary_dist` — распределенная таблица чтения и записи.'
|
||||||
|
- '`dm.dq_summary_stage_rep` — промежуточная таблица для результатов всех проверок за день.'
|
||||||
|
- '`dm.dq_summary_stage_dist` — распределенная таблица для записи и чтения промежуточных данных.'
|
||||||
|
- '`dm.dq_summary_v` — представление для чтения готовых данных.'
|
||||||
|
fill:
|
||||||
|
- Даг `dq_check` сначала рассчитывает результаты всех пяти проверок в промежуточной таблице, затем заменяет ими дневные партиции сводки.
|
||||||
|
- '`sessions_vs_reference` — `sql/dq/sessions_vs_reference.sql`.'
|
||||||
|
- '`classes_vs_inventory` — `sql/dq/classes_vs_inventory.sql` и эталонные числа из паспорта мира `data/world-inventory.json`.'
|
||||||
|
- '`revenue_daily_vs_dds` — `sql/dq/revenue_daily_vs_dds.sql`.'
|
||||||
|
- '`purchase_vs_orders_vs_dds` — `sql/dq/purchase_vs_orders_vs_dds.sql`.'
|
||||||
|
- '`daily_traffic_vs_dds` — `sql/dq/daily_traffic_vs_dds.sql`.'
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdq_summary
|
||||||
|
- label: DDL таблиц
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/55-dm-tables.sql
|
||||||
|
- label: DDL представления
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/60-dm-views.sql
|
||||||
|
|
||||||
|
- id: dic_products
|
||||||
|
name: dic.products
|
||||||
|
layer: dic
|
||||||
|
order: 0
|
||||||
|
streams: [orders]
|
||||||
|
what: "Словарь товаров. По артикулу можно получить название, бренд, категорию и цену. Эти данные доступны для расчетов в разных слоях."
|
||||||
|
read: "Читайте `dic.products`: к словарю обращаются напрямую, суффикса `_v` у него нет."
|
||||||
|
parts:
|
||||||
|
- "`dic.products_file` — таблица, которая читает каталог товаров из CSV-файла репозитория."
|
||||||
|
- "`dic.products` — словарь в памяти, загруженный из этой таблицы."
|
||||||
|
keys:
|
||||||
|
- '`sku` — тот же артикул, что `item_sku` в позиции заказа DDS.'
|
||||||
|
fill:
|
||||||
|
- "ClickHouse обновляет словарь из `dic.products_file` каждые 60–90 секунд согласно настройке `LIFETIME`."
|
||||||
|
links:
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-справочники
|
||||||
|
- label: DDL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/05-dic-catalog.sql
|
||||||
|
|
||||||
|
edges:
|
||||||
|
- id: hits_to_stg
|
||||||
|
from: kafka_hits
|
||||||
|
to: stg_hits
|
||||||
|
mechanism: materialized_view
|
||||||
|
streams: [events]
|
||||||
|
rhythm: непрерывно, по мере прихода сообщений
|
||||||
|
actor: "Таблица `stg.hits_raw_kafka` и материализованное представление `stg.hits_raw_mv`."
|
||||||
|
join: "Каждое сообщение Kafka записывается целой строкой, без разбора на поля."
|
||||||
|
links:
|
||||||
|
- label: SQL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/40-stg-views.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
|
||||||
|
|
||||||
|
- id: stg_to_event
|
||||||
|
from: stg_hits
|
||||||
|
to: ods_event
|
||||||
|
mechanism: materialized_view
|
||||||
|
streams: [events]
|
||||||
|
rhythm: непрерывно, по мере прихода сообщений
|
||||||
|
actor: Материализованное представление `ods.event_mv` разбирает сообщения без ошибок.
|
||||||
|
join: "Материализованное представление разбирает сообщение из STG на колонки с нужными типами данных."
|
||||||
|
links:
|
||||||
|
- label: SQL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-при%D1%91м-поток-и-его-свойства
|
||||||
|
|
||||||
|
- id: stg_to_event_errors
|
||||||
|
from: stg_hits
|
||||||
|
to: ods_event_errors
|
||||||
|
mechanism: materialized_view
|
||||||
|
streams: [events]
|
||||||
|
rhythm: непрерывно, по мере прихода сообщений
|
||||||
|
actor: "Материализованное представление `ods.event_errors_mv` отбирает сообщения с ошибками."
|
||||||
|
join: "Сообщение, которое не удалось разобрать по правилам ODS, сохраняется целиком вместе с классом ошибки."
|
||||||
|
links:
|
||||||
|
- label: SQL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/30-ods-views.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/storage.md#user-content-таблица-ошибок
|
||||||
|
|
||||||
|
- id: orders_to_stg
|
||||||
|
from: kafka_orders
|
||||||
|
to: stg_orders
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [orders]
|
||||||
|
rhythm: одной выгрузкой, раз в модельные сутки
|
||||||
|
actor: Задача `pull_batch` дага `orders_ingest` читает `stg.orders_raw_kafka`.
|
||||||
|
join: "Строки выгрузки сохраняются целиком. Общий `_load_id` позволяет найти все строки одной загрузки."
|
||||||
|
links:
|
||||||
|
- label: SQL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/stg/orders_raw_load.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-одно-чтение-kafka
|
||||||
|
|
||||||
|
- id: stg_to_order
|
||||||
|
from: stg_orders
|
||||||
|
to: ods_order
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [orders]
|
||||||
|
rhythm: одной выгрузкой, раз в модельные сутки
|
||||||
|
actor: Задача `parse_batch` дага `orders_ingest` записывает версии заказов без ошибок разбора.
|
||||||
|
join: "Строка разбирается на колонки заказа. Версия определяется датой слепка `snapshot_date`."
|
||||||
|
links:
|
||||||
|
- label: SQL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-граница-строгого-при%D1%91ма
|
||||||
|
|
||||||
|
- id: stg_to_order_errors
|
||||||
|
from: stg_orders
|
||||||
|
to: ods_order_errors
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [orders]
|
||||||
|
rhythm: одной выгрузкой, раз в модельные сутки
|
||||||
|
actor: Задача `parse_batch` дага `orders_ingest` записывает строки с ошибками из загруженной выгрузки.
|
||||||
|
join: "Строка с ошибкой сохраняется целиком вместе с классом ошибки и идентификатором загрузки."
|
||||||
|
links:
|
||||||
|
- label: SQL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ods/order_parse.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/orders/ingestion.md#user-content-граница-строгого-при%D1%91ма
|
||||||
|
|
||||||
|
- id: ods_to_dds_event
|
||||||
|
from: ods_event
|
||||||
|
to: dds_event
|
||||||
|
mechanism: view
|
||||||
|
streams: [events]
|
||||||
|
rhythm: при запросе к представлению, без копирования данных
|
||||||
|
actor: "Представление `dds.event_v` читает данные из `ods.event_v`."
|
||||||
|
join: "Колонки события переименовываются по правилам DDS. Данные не копируются в отдельную таблицу."
|
||||||
|
links:
|
||||||
|
- label: SQL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/ddl/50-dds-views.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/event.md#user-content-форма
|
||||||
|
|
||||||
|
- id: event_to_session
|
||||||
|
from: dds_event
|
||||||
|
to: dds_session
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [events]
|
||||||
|
rhythm: при каждом запуске `dds_transform`
|
||||||
|
actor: Задачи `session.rebuild` и `session.replace` дага `dds_transform`.
|
||||||
|
join: "События одного `client_id` за день `event_date` упорядочиваются по времени. Пауза дольше 30 минут начинает новую сессию. Ее `session_id` рассчитывается как `cityHash64(client_id, min(event_time))`."
|
||||||
|
links:
|
||||||
|
- label: SQL сборки
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/session_rebuild.sql
|
||||||
|
- label: SQL замены
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/session_replace.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/session.md#user-content-движок-и-наполнение
|
||||||
|
|
||||||
|
- id: order_to_dds
|
||||||
|
from: ods_order
|
||||||
|
to: dds_order
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [orders]
|
||||||
|
rhythm: при каждом запуске `dds_transform`
|
||||||
|
actor: Задачи `order.rebuild` и `order.replace` дага `dds_transform`.
|
||||||
|
join: "Поля заказа приводятся к типам DDS, а позиции из JSON разбираются в массивы. Дата `order_date` рассчитывается из `created_at`."
|
||||||
|
links:
|
||||||
|
- label: SQL сборки
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_rebuild.sql
|
||||||
|
- label: SQL замены
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/order_replace.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/order.md#user-content-движок-и-наполнение
|
||||||
|
|
||||||
|
- id: event_to_identity
|
||||||
|
from: dds_event
|
||||||
|
to: dds_identity
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [events]
|
||||||
|
rhythm: при каждом запуске `dds_transform`
|
||||||
|
actor: Задача `identity_map` дага `dds_transform` соединяет покупки с заказами.
|
||||||
|
join: "Событие покупки соединяется с заказом по `purchase_id[1] = order_id`. В результате связываются посетитель сайта и пользователь магазина."
|
||||||
|
links:
|
||||||
|
- label: SQL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка
|
||||||
|
|
||||||
|
- id: order_to_identity
|
||||||
|
from: dds_order
|
||||||
|
to: dds_identity
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [orders]
|
||||||
|
rhythm: при каждом запуске `dds_transform`
|
||||||
|
actor: Задача `identity_map` дага `dds_transform` соединяет заказы с покупками.
|
||||||
|
join: "Заказ соединяется с событием покупки по `order_id = purchase_id[1]`. Из заказа берется `user_id` пользователя магазина."
|
||||||
|
links:
|
||||||
|
- label: SQL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dds/identity_map_rebuild.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dds/identity.md#user-content-сборка
|
||||||
|
|
||||||
|
- id: session_to_traffic
|
||||||
|
from: dds_session
|
||||||
|
to: dm_daily_traffic
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [events]
|
||||||
|
rhythm: при каждом запуске `dm_transform`
|
||||||
|
actor: Задача `daily_traffic` дага `dm_transform` считает посетителей по сессиям.
|
||||||
|
join: "К сессиям присоединяется карта идентичностей по `client_id`, затем данные группируются по `session_date`. Эта дата записывается в `report_date`."
|
||||||
|
links:
|
||||||
|
- label: SQL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v
|
||||||
|
|
||||||
|
- id: identity_to_traffic
|
||||||
|
from: dds_identity
|
||||||
|
to: dm_daily_traffic
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [events]
|
||||||
|
rhythm: при каждом запуске `dm_transform`
|
||||||
|
actor: Задача `daily_traffic` дага `dm_transform` считает известных пользователей по `client_id`.
|
||||||
|
join: "`GLOBAL LEFT JOIN` присоединяет карту идентичностей к сессиям по `client_id`. Сессии без известного пользователя тоже участвуют в расчете посетителей."
|
||||||
|
links:
|
||||||
|
- label: SQL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/daily_traffic_rebuild.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmdaily_traffic_v
|
||||||
|
|
||||||
|
- id: order_to_revenue
|
||||||
|
from: dds_order
|
||||||
|
to: dm_revenue
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [orders]
|
||||||
|
rhythm: при каждом запуске `dm_transform`
|
||||||
|
actor: Задачи `revenue_daily.rebuild` и `revenue_daily.replace` дага `dm_transform`.
|
||||||
|
join: "`ARRAY JOIN` разворачивает массивы `item_sku`, `item_quantity`, `item_price` в строки позиций заказа. Выручка считается по оплаченным заказам; `order_date` записывается в `report_date`."
|
||||||
|
links:
|
||||||
|
- label: SQL сборки
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql
|
||||||
|
- label: SQL замены
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_replace.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v
|
||||||
|
|
||||||
|
- id: products_to_revenue
|
||||||
|
from: dic_products
|
||||||
|
to: dm_revenue
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [orders]
|
||||||
|
rhythm: при каждом запуске `dm_transform`
|
||||||
|
actor: "Задача `revenue_daily.rebuild` дага `dm_transform`."
|
||||||
|
join: "Функция `dictGet` получает категорию товара по артикулу `item_sku` из заказа. В словаре этому артикулу соответствует ключ `sku`."
|
||||||
|
links:
|
||||||
|
- label: SQL
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/revenue_daily_rebuild.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmrevenue_daily_v
|
||||||
|
|
||||||
|
- id: event_to_purchase
|
||||||
|
from: dds_event
|
||||||
|
to: dm_purchase
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [events]
|
||||||
|
rhythm: при каждом запуске `dm_transform`
|
||||||
|
actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают покупки.
|
||||||
|
join: "События покупки группируются по номеру `purchase_id[1]`, который получает имя `purchase_order_id`. Дата `event_date` записывается в `purchase_day`. Затем `GLOBAL FULL OUTER JOIN` сопоставляет покупки с заказами по `purchase_order_id = backend_order_id`."
|
||||||
|
links:
|
||||||
|
- label: SQL сборки
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql
|
||||||
|
- label: SQL замены
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_replace.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v
|
||||||
|
|
||||||
|
- id: order_to_purchase
|
||||||
|
from: dds_order
|
||||||
|
to: dm_purchase
|
||||||
|
mechanism: airflow_dag
|
||||||
|
streams: [orders]
|
||||||
|
rhythm: при каждом запуске `dm_transform`
|
||||||
|
actor: Задачи `purchase_vs_orders.rebuild` и `purchase_vs_orders.replace` дага `dm_transform` читают заказы.
|
||||||
|
join: "`order_id` заказа получает имя `backend_order_id`, а `purchase_id[1]` покупки — `purchase_order_id`. `GLOBAL FULL OUTER JOIN` соединяет их по `purchase_order_id = backend_order_id`. Покупки без заказа и заказы без покупки сохраняются; поля отсутствующей стороны содержат `NULL`."
|
||||||
|
links:
|
||||||
|
- label: SQL сборки
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_rebuild.sql
|
||||||
|
- label: SQL замены
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/sql/dm/purchase_vs_orders_replace.sql
|
||||||
|
- label: Справочник
|
||||||
|
url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/architecture/dm.md#user-content-dmpurchase_vs_orders_v
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.13"
|
||||||
|
# dependencies = ["pyyaml"]
|
||||||
|
# ///
|
||||||
|
|
||||||
|
"""Собрать интерактивную карту хранилища из YAML и HTML-шаблона."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent
|
||||||
|
SOURCE = ROOT / "map.yaml"
|
||||||
|
TEMPLATE = ROOT / "template.html"
|
||||||
|
OUTPUT = ROOT.parent / "architecture-map.html"
|
||||||
|
MARKER = "/*__MAP_DATA__*/"
|
||||||
|
NOTICE = (
|
||||||
|
"<!-- Файл собран автоматически. Правьте architecture-map/map.yaml и "
|
||||||
|
"template.html, затем выполните uv run "
|
||||||
|
"docs/handbook/architecture-map/render.py. -->\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render() -> str:
|
||||||
|
"""Подставить данные карты в шаблон без изменения порядка ключей."""
|
||||||
|
data = yaml.safe_load(SOURCE.read_text(encoding="utf-8"))
|
||||||
|
payload = json.dumps(data, ensure_ascii=False, indent=2)
|
||||||
|
template = TEMPLATE.read_text(encoding="utf-8")
|
||||||
|
return NOTICE + template.replace(MARKER, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Записать карту или проверить, что готовый файл не устарел."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--check", action="store_true", help="проверить готовый файл")
|
||||||
|
args = parser.parse_args()
|
||||||
|
rendered = render()
|
||||||
|
if args.check:
|
||||||
|
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != rendered:
|
||||||
|
print(f"{OUTPUT} устарел; пересоберите карту")
|
||||||
|
return 1
|
||||||
|
print(f"{OUTPUT} соответствует исходникам")
|
||||||
|
return 0
|
||||||
|
OUTPUT.write_text(rendered, encoding="utf-8")
|
||||||
|
print(f"собрана карта: {OUTPUT}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Карта слоев хранилища</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--paper: #f7f7f5;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--ink: #272a2d;
|
||||||
|
--muted: #6b7177;
|
||||||
|
--line: #8c9298;
|
||||||
|
--soft: #e6e8e9;
|
||||||
|
--accent: #b54a2a;
|
||||||
|
--accent-soft: #f5ded7;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
header { padding: 22px 26px 12px; }
|
||||||
|
h1 { margin: 0 0 5px; font-size: 25px; }
|
||||||
|
.lead { margin: 0; color: var(--muted); }
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 26px 16px;
|
||||||
|
}
|
||||||
|
.switcher { display: flex; gap: 6px; }
|
||||||
|
button {
|
||||||
|
border: 1px solid #aeb3b7;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 6px 13px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button[aria-pressed="true"] {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: #71301e;
|
||||||
|
}
|
||||||
|
button:focus-visible, [tabindex="0"]:focus-visible {
|
||||||
|
outline: 3px solid #d79078;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.legend { display: flex; flex-wrap: wrap; gap: 15px; color: var(--muted); }
|
||||||
|
.legend-item { display: inline-flex; align-items: center; gap: 7px; }
|
||||||
|
.legend-line { width: 34px; border-top: 2px solid var(--line); }
|
||||||
|
.legend-line.dashed { border-top-style: dashed; }
|
||||||
|
.legend-line.dotted { border-top-style: dotted; }
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 350px;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 18px 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.map-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #d7d9da;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
svg { display: block; width: 100%; min-width: 980px; height: auto; }
|
||||||
|
.layer-title { font-size: 15px; font-weight: 700; text-anchor: middle; }
|
||||||
|
.layer-caption { font-size: 11px; fill: var(--muted); text-anchor: middle; }
|
||||||
|
.zone-caption { font-size: 12px; fill: var(--muted); }
|
||||||
|
.column-guide { stroke: #eef0f1; stroke-width: 1; }
|
||||||
|
.edge-line {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--line);
|
||||||
|
stroke-width: 1.8;
|
||||||
|
marker-end: url(#arrowhead);
|
||||||
|
transition: opacity .15s, stroke .15s, stroke-width .15s;
|
||||||
|
}
|
||||||
|
.edge-line.dashed { stroke-dasharray: 8 6; }
|
||||||
|
.edge-line.dotted { stroke-dasharray: 2 6; stroke-linecap: round; }
|
||||||
|
.edge-hit { fill: none; stroke: transparent; stroke-width: 14; cursor: pointer; }
|
||||||
|
.node { cursor: pointer; transition: opacity .15s; }
|
||||||
|
.node rect {
|
||||||
|
fill: #f1f2f2;
|
||||||
|
stroke: #858b90;
|
||||||
|
stroke-width: 1.2;
|
||||||
|
rx: 7;
|
||||||
|
transition: fill .15s, stroke .15s, stroke-width .15s;
|
||||||
|
}
|
||||||
|
.node text {
|
||||||
|
fill: var(--ink);
|
||||||
|
font: 12px ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
text-anchor: middle;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.node .stream-mark { fill: var(--muted); font: 10px system-ui, sans-serif; }
|
||||||
|
.is-filtered, .is-context-dim { opacity: .16; }
|
||||||
|
.is-path:not(.is-filtered), .is-hover:not(.is-filtered) { opacity: 1; }
|
||||||
|
.is-path .edge-line, .edge.is-path .edge-line {
|
||||||
|
stroke: var(--accent); stroke-width: 2.8; marker-end: url(#arrowhead-active);
|
||||||
|
}
|
||||||
|
.node.is-path rect { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
|
||||||
|
.node.is-hover rect { fill: #faefeb; stroke: #d79078; }
|
||||||
|
.edge.is-hover .edge-line { stroke: #d79078; stroke-width: 2.2; }
|
||||||
|
.node.is-selected rect { stroke-width: 3; }
|
||||||
|
.edge.is-selected .edge-line { stroke-width: 4; }
|
||||||
|
aside {
|
||||||
|
min-height: 470px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #d7d9da;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
@media (max-width: 1530px) { main { grid-template-columns: minmax(0, 1fr); } aside { min-height: 0; } }
|
||||||
|
aside h2 { margin: 0 0 3px; font-size: 20px; overflow-wrap: anywhere; }
|
||||||
|
.meta { margin: 0 0 18px; color: var(--muted); }
|
||||||
|
.card-section { margin-top: 17px; }
|
||||||
|
.card-section h3 { margin: 0 0 5px; font-size: 13px; }
|
||||||
|
.card-section p { margin: 0; }
|
||||||
|
.card-section ul { margin: 5px 0 0; padding-left: 20px; }
|
||||||
|
.card-section li + li { margin-top: 5px; }
|
||||||
|
code { font: .92em ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
a { color: #8a351e; text-underline-offset: 2px; }
|
||||||
|
.hint { color: var(--muted); font-size: 13px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1 id="page-title"></h1>
|
||||||
|
<p class="lead" id="lead"></p>
|
||||||
|
</header>
|
||||||
|
<div class="controls">
|
||||||
|
<div class="switcher" id="switcher" aria-label="Выбор потока"></div>
|
||||||
|
<div class="legend" id="legend" aria-label="Механизмы переноса"></div>
|
||||||
|
</div>
|
||||||
|
<main>
|
||||||
|
<div class="map-scroll">
|
||||||
|
<svg id="map" viewBox="0 0 1230 470" role="group" aria-label="Путь данных через слои хранилища"></svg>
|
||||||
|
</div>
|
||||||
|
<aside id="details" aria-live="polite"></aside>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const MAP = /*__MAP_DATA__*/;
|
||||||
|
const NS = "http://www.w3.org/2000/svg";
|
||||||
|
const state = { stream: "all", selected: null, hovered: null };
|
||||||
|
const nodeById = new Map(MAP.nodes.map((node) => [node.id, node]));
|
||||||
|
const mechanismById = new Map(MAP.mechanisms.map((item) => [item.id, item]));
|
||||||
|
const positions = new Map();
|
||||||
|
const svg = document.getElementById("map");
|
||||||
|
const details = document.getElementById("details");
|
||||||
|
function svgElement(name, attributes = {}) {
|
||||||
|
const element = document.createElementNS(NS, name);
|
||||||
|
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value));
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
function layerTitle(layerId) { return MAP.layers.find((layer) => layer.id === layerId)?.title ?? MAP.dictionary.title; }
|
||||||
|
|
||||||
|
function appendRichText(parent, value) {
|
||||||
|
value.split("`").forEach((part, index) => {
|
||||||
|
const element = index % 2 ? document.createElement("code") : document.createTextNode(part);
|
||||||
|
if (index % 2) element.textContent = part;
|
||||||
|
parent.append(element);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function addSection(title, value) {
|
||||||
|
if (!value || (Array.isArray(value) && !value.length)) return;
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.className = "card-section";
|
||||||
|
const heading = document.createElement("h3");
|
||||||
|
heading.textContent = title;
|
||||||
|
section.append(heading);
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
value.forEach((item) => {
|
||||||
|
const row = document.createElement("li");
|
||||||
|
appendRichText(row, item);
|
||||||
|
list.append(row);
|
||||||
|
});
|
||||||
|
section.append(list);
|
||||||
|
} else {
|
||||||
|
const paragraph = document.createElement("p");
|
||||||
|
appendRichText(paragraph, value);
|
||||||
|
section.append(paragraph);
|
||||||
|
}
|
||||||
|
details.append(section);
|
||||||
|
}
|
||||||
|
function addLinks(links) {
|
||||||
|
if (!links?.length) return;
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.className = "card-section";
|
||||||
|
const heading = document.createElement("h3");
|
||||||
|
heading.textContent = "Ссылки";
|
||||||
|
const list = document.createElement("ul");
|
||||||
|
links.forEach((item) => {
|
||||||
|
const row = document.createElement("li");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = item.url;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
link.textContent = item.label;
|
||||||
|
row.append(link);
|
||||||
|
list.append(row);
|
||||||
|
});
|
||||||
|
section.append(heading, list);
|
||||||
|
details.append(section);
|
||||||
|
}
|
||||||
|
function renderIntro() {
|
||||||
|
details.replaceChildren();
|
||||||
|
const heading = document.createElement("h2");
|
||||||
|
heading.textContent = "Как пользоваться картой";
|
||||||
|
details.append(heading);
|
||||||
|
MAP.intro.forEach((line) => {
|
||||||
|
const paragraph = document.createElement("p");
|
||||||
|
paragraph.textContent = line;
|
||||||
|
details.append(paragraph);
|
||||||
|
});
|
||||||
|
const paragraph = document.createElement("p");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = MAP.chapter_url;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
link.textContent = "Открыть главу «Хранилище и его слои»";
|
||||||
|
paragraph.append(link);
|
||||||
|
details.append(paragraph);
|
||||||
|
const hint = document.createElement("p");
|
||||||
|
hint.className = "hint";
|
||||||
|
hint.textContent = "Повторный щелчок или Esc снимает выделение.";
|
||||||
|
details.append(hint);
|
||||||
|
}
|
||||||
|
function renderNodeCard(node) {
|
||||||
|
details.replaceChildren();
|
||||||
|
const heading = document.createElement("h2");
|
||||||
|
heading.textContent = node.name;
|
||||||
|
const meta = document.createElement("p");
|
||||||
|
meta.className = "meta";
|
||||||
|
meta.textContent = `${layerTitle(node.layer)} — ${node.streams.map((id) => MAP.streams[id]).join(", ")}`;
|
||||||
|
details.append(heading, meta);
|
||||||
|
addSection("Что это", node.what);
|
||||||
|
addSection("Как читать", node.read);
|
||||||
|
addSection("Связанные таблицы и представления", node.parts);
|
||||||
|
addSection("Как связать данные", node.keys);
|
||||||
|
addSection("Как обновляются данные", node.fill);
|
||||||
|
addLinks(node.links);
|
||||||
|
}
|
||||||
|
function renderEdgeCard(edge) {
|
||||||
|
details.replaceChildren();
|
||||||
|
const heading = document.createElement("h2");
|
||||||
|
heading.textContent = `${nodeById.get(edge.from).name} → ${nodeById.get(edge.to).name}`;
|
||||||
|
details.append(heading);
|
||||||
|
addSection("Способ обработки", mechanismById.get(edge.mechanism).label);
|
||||||
|
addSection("Когда выполняется", edge.rhythm);
|
||||||
|
addSection("Что выполняет обработку", edge.actor);
|
||||||
|
addSection("Что происходит с данными", edge.join);
|
||||||
|
addLinks(edge.links);
|
||||||
|
}
|
||||||
|
function layoutMap() {
|
||||||
|
const nodeWidth = 180;
|
||||||
|
const nodeHeight = 48;
|
||||||
|
const columnStep = 250;
|
||||||
|
MAP.layers.forEach((layer, index) => {
|
||||||
|
const x = 20 + index * columnStep;
|
||||||
|
MAP.nodes.filter((node) => node.layer === layer.id).forEach((node) => {
|
||||||
|
positions.set(node.id, { x, y: 88 + node.order * 70, width: nodeWidth, height: nodeHeight });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const dicX = 20 + ((MAP.layers.findIndex((layer) => layer.id === "dds") + MAP.layers.findIndex((layer) => layer.id === "dm")) * columnStep) / 2 + nodeWidth / 2 - 130;
|
||||||
|
MAP.nodes.filter((node) => node.layer === "dic").forEach((node) => positions.set(node.id, { x: dicX, y: 402 + node.order * 70, width: 260, height: nodeHeight }));
|
||||||
|
}
|
||||||
|
function edgePath(edge, sameColumnIndex) {
|
||||||
|
const source = positions.get(edge.from);
|
||||||
|
const target = positions.get(edge.to);
|
||||||
|
if (nodeById.get(edge.from).layer === "dic") {
|
||||||
|
const x1 = source.x + source.width / 2;
|
||||||
|
const y1 = source.y;
|
||||||
|
const x2 = target.x;
|
||||||
|
const y2 = target.y + target.height / 2;
|
||||||
|
return `M ${x1} ${y1} C ${x1} ${y1 - 38}, ${x1} ${y2 + 48}, ${x2} ${y2}`;
|
||||||
|
}
|
||||||
|
const x1 = source.x + source.width;
|
||||||
|
const y1 = source.y + source.height / 2;
|
||||||
|
if (source.x === target.x) {
|
||||||
|
const route = source.x + source.width + 16 + sameColumnIndex * 18;
|
||||||
|
const y2 = target.y + target.height / 2;
|
||||||
|
return `M ${x1} ${y1} C ${route} ${y1}, ${route} ${y2}, ${x1} ${y2}`;
|
||||||
|
}
|
||||||
|
const x2 = target.x;
|
||||||
|
const y2 = target.y + target.height / 2;
|
||||||
|
const bend = Math.max(18, (x2 - x1) / 2);
|
||||||
|
return `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMap() {
|
||||||
|
const defs = svgElement("defs");
|
||||||
|
[["arrowhead", "#8c9298"], ["arrowhead-active", "#b54a2a"]].forEach(([id, fill]) => {
|
||||||
|
const marker = svgElement("marker", { id, viewBox: "0 0 8 8", refX: "7", refY: "4", markerWidth: "6", markerHeight: "6", orient: "auto" });
|
||||||
|
marker.append(svgElement("path", { d: "M 0 0 L 8 4 L 0 8 z", fill }));
|
||||||
|
defs.append(marker);
|
||||||
|
});
|
||||||
|
svg.append(defs);
|
||||||
|
MAP.layers.forEach((layer, index) => {
|
||||||
|
const center = 110 + index * 250;
|
||||||
|
svg.append(svgElement("line", { x1: center, y1: 65, x2: center, y2: 377, class: "column-guide" }));
|
||||||
|
const title = svgElement("text", { x: center, y: 28, class: "layer-title" });
|
||||||
|
title.textContent = layer.title;
|
||||||
|
const caption = svgElement("text", { x: center, y: 47, class: "layer-caption" });
|
||||||
|
caption.textContent = layer.caption;
|
||||||
|
svg.append(title, caption);
|
||||||
|
});
|
||||||
|
const dictionaryPosition = positions.get(MAP.nodes.find((node) => node.layer === "dic").id);
|
||||||
|
const zone = svgElement("text", { x: dictionaryPosition.x, y: dictionaryPosition.y - 13, class: "zone-caption" });
|
||||||
|
zone.textContent = MAP.dictionary.caption;
|
||||||
|
svg.append(zone);
|
||||||
|
let sameIndex = 0;
|
||||||
|
MAP.edges.forEach((edge) => {
|
||||||
|
const same = positions.get(edge.from).x === positions.get(edge.to).x;
|
||||||
|
const index = same ? sameIndex++ : 0;
|
||||||
|
const group = svgElement("g", { class: "edge", "data-id": edge.id, tabindex: "0", role: "button", "aria-label": `${nodeById.get(edge.from).name} — ${nodeById.get(edge.to).name}` });
|
||||||
|
const path = edgePath(edge, index);
|
||||||
|
const lineClass = `edge-line ${mechanismById.get(edge.mechanism).line}`;
|
||||||
|
group.append(svgElement("path", { d: path, class: lineClass }), svgElement("path", { d: path, class: "edge-hit" }));
|
||||||
|
bindSelection(group, "edge", edge.id);
|
||||||
|
svg.append(group);
|
||||||
|
});
|
||||||
|
MAP.nodes.forEach((node) => {
|
||||||
|
const position = positions.get(node.id);
|
||||||
|
const group = svgElement("g", { class: "node", "data-id": node.id, tabindex: "0", role: "button", "aria-label": node.name });
|
||||||
|
group.append(svgElement("rect", position));
|
||||||
|
const name = svgElement("text", { x: position.x + position.width / 2, y: position.y + 21 });
|
||||||
|
name.textContent = node.name;
|
||||||
|
const mark = svgElement("text", { x: position.x + position.width / 2, y: position.y + 38, class: "stream-mark" });
|
||||||
|
mark.textContent = node.streams.map((id) => MAP.streams[id]).join(" + ");
|
||||||
|
group.append(name, mark);
|
||||||
|
group.addEventListener("mouseenter", () => { state.hovered = node.id; updateHighlights(); });
|
||||||
|
group.addEventListener("mouseleave", () => { state.hovered = null; updateHighlights(); });
|
||||||
|
bindSelection(group, "node", node.id);
|
||||||
|
svg.append(group);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bindSelection(element, type, id) {
|
||||||
|
const choose = () => selectItem(type, id);
|
||||||
|
element.addEventListener("click", choose);
|
||||||
|
element.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); choose(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function findReachability(nodeId) {
|
||||||
|
const nodes = new Set([nodeId]);
|
||||||
|
const edges = new Set();
|
||||||
|
const walk = (start, direction) => {
|
||||||
|
const queue = [start];
|
||||||
|
while (queue.length) {
|
||||||
|
const current = queue.shift();
|
||||||
|
MAP.edges.forEach((edge) => {
|
||||||
|
const matches = direction === "out" ? edge.from === current : edge.to === current;
|
||||||
|
if (!matches) return;
|
||||||
|
edges.add(edge.id);
|
||||||
|
const next = direction === "out" ? edge.to : edge.from;
|
||||||
|
if (!nodes.has(next)) { nodes.add(next); queue.push(next); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(nodeId, "in");
|
||||||
|
walk(nodeId, "out");
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectItem(type, id) {
|
||||||
|
const repeated = state.selected?.type === type && state.selected.id === id;
|
||||||
|
state.selected = repeated ? null : { type, id };
|
||||||
|
if (!state.selected) renderIntro();
|
||||||
|
else if (type === "node") renderNodeCard(nodeById.get(id));
|
||||||
|
else renderEdgeCard(MAP.edges.find((edge) => edge.id === id));
|
||||||
|
updateHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHighlights() {
|
||||||
|
let activeNodes = new Set();
|
||||||
|
let activeEdges = new Set();
|
||||||
|
if (state.selected?.type === "node") ({ nodes: activeNodes, edges: activeEdges } = findReachability(state.selected.id));
|
||||||
|
if (state.selected?.type === "edge") {
|
||||||
|
const edge = MAP.edges.find((item) => item.id === state.selected.id);
|
||||||
|
activeNodes = new Set([edge.from, edge.to]);
|
||||||
|
activeEdges = new Set([edge.id]);
|
||||||
|
}
|
||||||
|
const hoverNodes = new Set(state.hovered ? [state.hovered] : []);
|
||||||
|
const hoverEdges = new Set();
|
||||||
|
if (state.hovered) MAP.edges.forEach((edge) => {
|
||||||
|
if (edge.from === state.hovered || edge.to === state.hovered) {
|
||||||
|
hoverEdges.add(edge.id); hoverNodes.add(edge.from); hoverNodes.add(edge.to);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".node").forEach((element) => {
|
||||||
|
const node = nodeById.get(element.dataset.id);
|
||||||
|
element.classList.toggle("is-filtered", state.stream !== "all" && !node.streams.includes(state.stream));
|
||||||
|
element.classList.toggle("is-context-dim", Boolean(state.selected) && !activeNodes.has(node.id));
|
||||||
|
element.classList.toggle("is-path", activeNodes.has(node.id));
|
||||||
|
element.classList.toggle("is-hover", hoverNodes.has(node.id));
|
||||||
|
element.classList.toggle("is-selected", state.selected?.type === "node" && state.selected.id === node.id);
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".edge").forEach((element) => {
|
||||||
|
const edge = MAP.edges.find((item) => item.id === element.dataset.id);
|
||||||
|
element.classList.toggle("is-filtered", state.stream !== "all" && !edge.streams.includes(state.stream));
|
||||||
|
element.classList.toggle("is-context-dim", Boolean(state.selected) && !activeEdges.has(edge.id));
|
||||||
|
element.classList.toggle("is-path", activeEdges.has(edge.id));
|
||||||
|
element.classList.toggle("is-hover", hoverEdges.has(edge.id));
|
||||||
|
element.classList.toggle("is-selected", state.selected?.type === "edge" && state.selected.id === edge.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderControls() {
|
||||||
|
const switcher = document.getElementById("switcher");
|
||||||
|
Object.entries(MAP.streams).forEach(([id, label]) => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = label;
|
||||||
|
button.setAttribute("aria-pressed", String(id === state.stream));
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
state.stream = id;
|
||||||
|
switcher.querySelectorAll("button").forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
|
||||||
|
updateHighlights();
|
||||||
|
});
|
||||||
|
switcher.append(button);
|
||||||
|
});
|
||||||
|
const legend = document.getElementById("legend");
|
||||||
|
MAP.mechanisms.forEach((item) => {
|
||||||
|
const row = document.createElement("span");
|
||||||
|
row.className = "legend-item";
|
||||||
|
const line = document.createElement("span");
|
||||||
|
line.className = `legend-line ${item.line}`;
|
||||||
|
const label = document.createElement("span");
|
||||||
|
appendRichText(label, `${item.label} — ${item.rhythm}`);
|
||||||
|
row.append(line, label);
|
||||||
|
legend.append(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("page-title").textContent = MAP.title;
|
||||||
|
document.getElementById("lead").textContent = MAP.lead;
|
||||||
|
layoutMap();
|
||||||
|
drawMap();
|
||||||
|
renderControls();
|
||||||
|
renderIntro();
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && state.selected) selectItem(state.selected.type, state.selected.id);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Генератор: откуда берутся данные
|
||||||
|
|
||||||
|
Генератор имитирует работу двух систем интернет-магазина. Счетчик
|
||||||
|
на сайте отправляет записи о действиях посетителей в топик `hits`. Серверная часть
|
||||||
|
магазина раз в модельные сутки выгружает состояние заказов в топик `orders`.
|
||||||
|
Эта дневная выгрузка называется слепком.
|
||||||
|
|
||||||
|
## События сайта и заказы
|
||||||
|
|
||||||
|
Событие содержит сведения о действии посетителя: например, время,
|
||||||
|
адрес страницы и идентификатор посетителя. Формат соответствует образцу
|
||||||
|
выгрузки Яндекс Метрики. Генератор создает три вида событий: просмотр
|
||||||
|
страницы, добавление в корзину и покупку. Все колонки перечислены
|
||||||
|
в [описании выгрузки](../formats/clickstream-event.md).
|
||||||
|
|
||||||
|
Заказ содержит сведения о покупке со стороны магазина: товары, суммы
|
||||||
|
и состояние заказа. После оформления он еще может измениться, например
|
||||||
|
при отмене. Каждый дневной слепок содержит текущее состояние заказов,
|
||||||
|
оформленных за последние семь модельных дней. Состав и формат слепка
|
||||||
|
разобраны в [отдельном
|
||||||
|
справочнике](../architecture/orders/snapshot.md).
|
||||||
|
|
||||||
|
## Почему начальные данные повторяются
|
||||||
|
|
||||||
|
Случайные решения генератора зависят от заданного числа —
|
||||||
|
[зерна генерации](../architecture/generator/seeds.md) — и номера дня.
|
||||||
|
При неизменных коде, каталоге товаров и настройках одинаковые зерно и день
|
||||||
|
дают одинаковые события. Поэтому при создании стенда вы каждый раз
|
||||||
|
получаете те же начальные данные. Повторный `make up` на работающем стенде
|
||||||
|
сохраняет уже загруженные данные.
|
||||||
|
|
||||||
|
В файле `data/world-inventory.json` записаны зерно, версия генератора,
|
||||||
|
число событий и контрольная сумма за каждый из восьми начальных дней.
|
||||||
|
В документации этот файл называется паспортом мира. С ним можно сверить
|
||||||
|
результат генерации и проверить, все ли события загрузились в ODS.
|
||||||
|
|
||||||
|
## Запуск генератора
|
||||||
|
|
||||||
|
На стенде генератором управляют [даги модельного
|
||||||
|
времени](airflow-dags.md#генерация-модельных-дней). Его можно запустить и напрямую. Команды
|
||||||
|
собраны в [справочнике генератора и модельного
|
||||||
|
времени](../architecture/world.md#как-запустить-генератор).
|
||||||
|
|
||||||
|
У генератора два режима. Пакетный отправляет все события дня без пауз.
|
||||||
|
В живом режиме события поступают постепенно, как в работающем магазине,
|
||||||
|
но время идет ускоренно: модельные сутки занимают около 24 минут.
|
||||||
|
|
||||||
|
## Сверка с паспортом мира
|
||||||
|
|
||||||
|
Подключитесь из DBeaver по [инструкции](../../README.md#подключиться-из-dbeaver)
|
||||||
|
и выполните запрос:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT EventDate, count() AS events
|
||||||
|
FROM ods.event_v
|
||||||
|
GROUP BY EventDate
|
||||||
|
ORDER BY EventDate;
|
||||||
|
```
|
||||||
|
|
||||||
|
Сравните число событий за каждый из первых восьми дней со значением
|
||||||
|
`events` за ту же дату в [паспорте мира](../../data/world-inventory.json).
|
||||||
|
Числа должны совпасть. Если вы уже генерировали следующие дни,
|
||||||
|
в результате запроса будут дополнительные даты; в паспорте их нет.
|
||||||
|
|
||||||
|
## Учебные задания и справочники
|
||||||
|
|
||||||
|
Подробнее генератор разобран в [README папки
|
||||||
|
`generator/`](../../generator/README.md) и в [справочнике его
|
||||||
|
устройства](../architecture/generator/README.md). Теперь можно переходить
|
||||||
|
к [учебным заданиям](../labs/README.md).
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Мониторинг в Grafana
|
||||||
|
|
||||||
|
В Grafana настроены три дашборда — экрана с показателями и графиками.
|
||||||
|
«Данные» показывает загрузку и обработку событий и заказов,
|
||||||
|
«Кластер» — состояние узлов и самого ClickHouse, а «Запросы» — выполненные
|
||||||
|
запросы и затраченные на них ресурсы.
|
||||||
|
|
||||||
|
На панелях не заданы пороги для цветовых предупреждений: оценивать показания
|
||||||
|
вы будете самостоятельно. Качество данных автоматически проверяет
|
||||||
|
даг `dq_check`.
|
||||||
|
|
||||||
|
## Дашборд «Данные»
|
||||||
|
|
||||||
|
«Свежесть» показывает, сколько реального времени прошло с поступления
|
||||||
|
последних успешно разобранных событий и заказов. Время поступления хранится
|
||||||
|
в колонке `_load_ts` слоя ODS. Показатель для заказов растет весь модельный день
|
||||||
|
и снижается, когда поступает очередная дневная выгрузка — слепок.
|
||||||
|
Это ожидаемо: заказы загружаются раз в день.
|
||||||
|
|
||||||
|
«Поток» показывает число сырых сообщений в STG за выбранный интервал. «Брак»
|
||||||
|
показывает долю строк, которые не удалось разобрать.
|
||||||
|
|
||||||
|
«Сходимость» сравнивает число событий в Kafka и в ClickHouse. Тревожный
|
||||||
|
признак — устойчивый разрыв между рядами. Разовое расхождение еще не говорит
|
||||||
|
о потере данных: метрики Kafka и строки ClickHouse могут попадать в соседние
|
||||||
|
интервалы на графике.
|
||||||
|
|
||||||
|
«Отставание» показывает, сколько сообщений в Kafka еще не прочитано
|
||||||
|
потребителями. «Дневной слепок» перечисляет последние десять слепков заказов в
|
||||||
|
STG: по нему видно, поступила ли очередная выгрузка.
|
||||||
|
|
||||||
|
Источники и правила чтения всех панелей разобраны в [справочнике дашборда
|
||||||
|
«Данные»](../architecture/monitoring/data.md).
|
||||||
|
|
||||||
|
## Источники показателей
|
||||||
|
|
||||||
|
Grafana получает данные напрямую из ClickHouse и через Prometheus.
|
||||||
|
Prometheus собирает метрики с двух узлов ClickHouse, ClickHouse Keeper
|
||||||
|
и экспортера Kafka — службы, которая предоставляет метрики Kafka.
|
||||||
|
|
||||||
|
Свежесть считается по ODS: если новые сообщения перестали разбираться,
|
||||||
|
показатель будет расти. Число поступивших сообщений берется из STG,
|
||||||
|
где сохраняются все сообщения, включая повторы. Для доли брака это число
|
||||||
|
сравнивается с числом строк в таблицах ошибок ODS.
|
||||||
|
|
||||||
|
В основных таблицах ODS повторы событий и старые версии заказов удаляются
|
||||||
|
при слияниях. Поэтому число строк в них не подходит для подсчета
|
||||||
|
всех доставленных сообщений. Подробности — в [разделе о слоях
|
||||||
|
панелей](../architecture/monitoring/data.md#почему-слои-у-панелей-разные).
|
||||||
|
|
||||||
|
## Состояние кластера и запросы
|
||||||
|
|
||||||
|
Открывайте «Кластер», чтобы проверить нагрузку на процессор, использование
|
||||||
|
памяти и дисков, число частей таблиц и ход их слияний в ClickHouse. Здесь же
|
||||||
|
показаны состояние ClickHouse Keeper и очередь распределенных DDL-команд.
|
||||||
|
Устройство панелей описано в [справочнике дашборда
|
||||||
|
«Кластер»](../architecture/monitoring/cluster.md).
|
||||||
|
|
||||||
|
«Запросы» поможет найти медленные запросы, ошибки и пользователей,
|
||||||
|
чьи запросы потребляют много ресурсов. Как читать панели, объясняет
|
||||||
|
[справочник дашборда
|
||||||
|
«Запросы»](../architecture/monitoring/queries.md).
|
||||||
|
|
||||||
|
## Показатели за один модельный день
|
||||||
|
|
||||||
|
Откройте Grafana по адресу из раздела [«Адреса и
|
||||||
|
пароли»](../../README.md#адреса-и-пароли), затем дашборд «Данные». Запустите в
|
||||||
|
Airflow [`world_live_day`](airflow-dags.md#генерация-модельных-дней): модельный день займет
|
||||||
|
около двадцати четырех минут.
|
||||||
|
|
||||||
|
Смотрите свежесть событий и свежесть заказов по отдельности. Пока день идет,
|
||||||
|
со времени последнего события проходят секунды, а время с последней
|
||||||
|
загрузки заказов растет. После завершения генерации событий отправляется
|
||||||
|
слепок заказов. Когда он загружен, показатель свежести заказов снижается.
|
||||||
|
Показатель для событий начинает расти: новые события поступят только
|
||||||
|
при генерации следующего дня. Даг пока продолжает работу, ожидая завершения
|
||||||
|
обработки данных в дневном конвейере.
|
||||||
|
|
||||||
|
## Следующая глава и справочники
|
||||||
|
|
||||||
|
Устройство мониторинга описано в [техническом
|
||||||
|
справочнике](../architecture/monitoring/README.md), панели дашборда «Данные» —
|
||||||
|
в [отдельном разделе](../architecture/monitoring/data.md).
|
||||||
|
Почему выбраны именно эти показатели, объясняет
|
||||||
|
[ADR 0002](../adr/0002-monitoring-scope.md). Следующая глава —
|
||||||
|
[«Отчеты в Superset»](superset.md).
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Хранилище и его слои
|
||||||
|
|
||||||
|
В Kafka поступают события сайта, или кликстрим, и заказы магазина.
|
||||||
|
Для событий выделен топик `hits`, для заказов — `orders`.
|
||||||
|
Из Kafka данные проходят четыре слоя хранилища. В STG сообщения сохраняются
|
||||||
|
как есть, в ODS разбираются по полям, в DDS из них собираются события,
|
||||||
|
заказы и сессии, а в DM — витрины для отчетов.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Kafka --> STG --> ODS --> DDS --> DM
|
||||||
|
```
|
||||||
|
|
||||||
|
## Имена таблиц и представлений
|
||||||
|
|
||||||
|
Для обычного чтения выбирайте представления с суффиксом `_v`. Они позволяют
|
||||||
|
менять устройство таблиц, сохраняя привычные запросы к данным.
|
||||||
|
Суффиксы `_rep`, `_dist`, `_kafka` и `_mv` относятся к внутреннему устройству
|
||||||
|
хранилища. Эти объекты нужны для загрузки, обслуживания и диагностики.
|
||||||
|
У словаря товаров `dic.products` суффикса нет.
|
||||||
|
Подробный разбор имен есть в [справочнике
|
||||||
|
хранилища](../architecture/storage.md#имена-объектов).
|
||||||
|
|
||||||
|
## STG: исходные сообщения
|
||||||
|
|
||||||
|
STG — сырой слой. Он сохраняет сообщение целой строкой, без разбора. Вместе
|
||||||
|
с ним записываются сведения о доставке: топик, партиция, смещение и время
|
||||||
|
приема. Содержимое сообщения сохраняется в STG, даже если в нем есть ошибки.
|
||||||
|
|
||||||
|
Сырые сообщения хранятся трое суток реального времени. В течение этого срока
|
||||||
|
их можно использовать для поиска ошибок и повторной обработки. Затем они
|
||||||
|
удаляются по правилу из [справочника
|
||||||
|
хранилища](../architecture/storage.md#срок-жизни-сырых-данных).
|
||||||
|
|
||||||
|
## ODS: разбор сообщений
|
||||||
|
|
||||||
|
В ODS содержимое сообщения разбирается на колонки с нужными типами данных:
|
||||||
|
датами, числами, строками. Колонки сохраняют имена из источника. Например,
|
||||||
|
в таблице событий есть `WatchID`, `ClientID` и `EventDate`.
|
||||||
|
|
||||||
|
Испорченное сообщение не останавливает загрузку. Оно попадает в таблицу
|
||||||
|
`*_errors` вместе с исходной строкой и классом ошибки. Правильно записанные
|
||||||
|
сообщения обрабатываются дальше.
|
||||||
|
|
||||||
|
## DDS: события, заказы и сессии
|
||||||
|
|
||||||
|
DDS — детальный слой: в нем данные подготовлены для анализа событий,
|
||||||
|
заказов и сессий. Здесь же хранится карта идентичностей — таблица связей
|
||||||
|
между идентификатором посетителя `ClientID` и пользователем магазина
|
||||||
|
из заказа. `ClientID` хранится в cookie браузера: по нему можно узнать
|
||||||
|
браузер посетителя, но не всегда самого человека.
|
||||||
|
|
||||||
|
В стенде визитом, или сессией, считают подряд идущие события одного
|
||||||
|
`ClientID` без пауз дольше 30 минут. Визит не пересекает границу модельных суток.
|
||||||
|
Модельные сутки — день в календаре учебного магазина. Этот календарь
|
||||||
|
не зависит от текущей даты на вашем компьютере.
|
||||||
|
|
||||||
|
Источник передает готовый идентификатор визита `VisitID` по образцу
|
||||||
|
Яндекс Метрики. В учебном хранилище сессии рассчитываются заново по времени
|
||||||
|
событий; каждой присваивается `session_id`. Поле `VisitID` остается
|
||||||
|
эталоном для проверки. Так в заданиях вы сможете самостоятельно определить
|
||||||
|
границы сессий и сравнить их с результатом источника.
|
||||||
|
|
||||||
|
Готовые сессии читайте через `dds.session_v`. События для самостоятельного
|
||||||
|
расчета доступны в `dds.event_v`: в этом представлении `VisitID` скрыт.
|
||||||
|
|
||||||
|
## DM: витрины для отчетов
|
||||||
|
|
||||||
|
DM — слой витрин. Витрина — готовый набор показателей для отчета или
|
||||||
|
дашборда. Здесь уже посчитаны, например, дневная выручка по категориям и
|
||||||
|
дневные показатели трафика.
|
||||||
|
|
||||||
|
К каждой витрине можно обращаться коротким запросом, не повторяя в нем весь
|
||||||
|
путь расчета. Состав готовых витрин смотрите в [карте
|
||||||
|
таблиц](../architecture/storage.md#карта-таблиц).
|
||||||
|
|
||||||
|
## Справочники в базе `dic`
|
||||||
|
|
||||||
|
В базе `dic` хранятся готовые справочные данные. Они загружаются отдельно
|
||||||
|
от событий и заказов и могут использоваться в расчетах любого слоя.
|
||||||
|
|
||||||
|
Каталог товаров `dic.products` — один из таких справочников. Его ключ `sku` —
|
||||||
|
артикул товара. По нему можно узнать название, бренд и категорию товара. Как
|
||||||
|
устроена зона, смотрите в [разделе о
|
||||||
|
справочниках](../architecture/storage.md#справочники).
|
||||||
|
|
||||||
|
## Связь данных между слоями
|
||||||
|
|
||||||
|
Идентификаторы позволяют связать данные из разных слоев. При переходе в DDS
|
||||||
|
часть колонок переименовывается: например, `WatchID` становится `event_id`.
|
||||||
|
В таблице указано, как найти соответствующие колонки и соединить по ним данные.
|
||||||
|
|
||||||
|
| Колонка | Что означает | Что соединяет |
|
||||||
|
|---|---|---|
|
||||||
|
| `WatchID` | Идентификатор события | Событие ODS с событием DDS, где колонка называется `event_id` |
|
||||||
|
| `ClientID` | Идентификатор посетителя из cookie браузера | События ODS с событиями, сессиями и картой идентичностей DDS; в DDS колонка называется `client_id` |
|
||||||
|
| `order_id` | Идентификатор заказа | Заказ ODS с заказом DDS и витриной сверки `dm.purchase_vs_orders_v` |
|
||||||
|
| `sku` | Артикул товара в каталоге | Справочник `dic.products` с позициями заказа DDS, где артикулы лежат в `item_sku`; через категорию товара — с витриной выручки DM |
|
||||||
|
| `EventDate` | Дата события в [модельном времени](../../CONTEXT.md), указанная счетчиком сайта в [его часовом поясе](../architecture/storage.md#часовые-пояса) | Событие ODS с событием DDS, где колонка называется `event_date`, и дневными расчетами DM |
|
||||||
|
|
||||||
|
Почему идентификатор посетителя не равнозначен человеку, объясняет
|
||||||
|
[словарь понятий](../../CONTEXT.md#язык). Точные имена объектов и полей сверяйте с
|
||||||
|
[картой таблиц](../architecture/storage.md#карта-таблиц).
|
||||||
|
|
||||||
|
## Примеры запросов
|
||||||
|
|
||||||
|
Подключитесь к ClickHouse по инструкции из разделов [«Адреса и
|
||||||
|
пароли»](../../README.md#адреса-и-пароли) и [«Подключиться из
|
||||||
|
DBeaver»](../../README.md#подключиться-из-dbeaver). Во всех трех примерах
|
||||||
|
`2026-06-01` — первый день работы учебного магазина. В документации он
|
||||||
|
обозначается D0; подробнее — в [словаре понятий](../../CONTEXT.md).
|
||||||
|
Чтобы посмотреть другой день, замените дату в условии.
|
||||||
|
Первый запрос покажет 20 событий за выбранный день:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
WatchID,
|
||||||
|
ClientID,
|
||||||
|
EventDate,
|
||||||
|
EventType,
|
||||||
|
URL
|
||||||
|
FROM ods.event_v
|
||||||
|
WHERE EventDate = '2026-06-01'
|
||||||
|
ORDER BY UTCEventTime
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждая строка здесь — отдельное действие посетителя.
|
||||||
|
|
||||||
|
Второй запрос покажет 20 сессий того же дня с наибольшим числом событий.
|
||||||
|
Сессии рассчитаны по всем событиям дня:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
client_id,
|
||||||
|
started_at,
|
||||||
|
finished_at,
|
||||||
|
events_total,
|
||||||
|
pageviews,
|
||||||
|
purchases,
|
||||||
|
entry_url
|
||||||
|
FROM dds.session_v
|
||||||
|
WHERE session_date = '2026-06-01'
|
||||||
|
ORDER BY events_total DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
Теперь одна строка представляет целый визит. `events_total` показывает,
|
||||||
|
сколько событий в него вошло; начало и конец визита записаны в
|
||||||
|
`started_at` и `finished_at`.
|
||||||
|
|
||||||
|
Третий запрос покажет выручку магазина по категориям за тот же день.
|
||||||
|
Эта витрина рассчитана по заказам:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
product_category,
|
||||||
|
orders,
|
||||||
|
units,
|
||||||
|
revenue
|
||||||
|
FROM dm.revenue_daily_v
|
||||||
|
WHERE report_date = '2026-06-01'
|
||||||
|
ORDER BY revenue DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
Здесь одна строка содержит итоги дня для категории товаров.
|
||||||
|
Все три запроса читают представления `_v`.
|
||||||
|
|
||||||
|
## Следующая глава и справочники
|
||||||
|
|
||||||
|
Откройте [интерактивную карту](architecture-map.html) из своей копии
|
||||||
|
репозитория и проследите путь данных.
|
||||||
|
Список объектов, сроки хранения и устройство таблиц — в [справочнике
|
||||||
|
хранилища](../architecture/storage.md). Как данные обрабатываются между слоями — в
|
||||||
|
следующей главе: [«Генерация и обработка данных в Airflow»](airflow-dags.md).
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Отчеты в Superset
|
||||||
|
|
||||||
|
Superset показывает витрины слоя DM в виде графиков. К ClickHouse он
|
||||||
|
подключается пользователем `bi`. Дашборд стенда «Бизнес-сюжеты» собран из трех
|
||||||
|
витрин:
|
||||||
|
|
||||||
|
| Витрина | Что показывает |
|
||||||
|
|---|---|
|
||||||
|
| `dm.revenue_daily_v` | Выручку, число заказов и средний чек; выручку по дням и категориям товаров, долю каждой категории |
|
||||||
|
| `dm.purchase_vs_orders_v` | Результат сверки покупок из кликстрима с заказами: сколько заказов за день относится к каждому виду расхождения |
|
||||||
|
| `dm.daily_traffic_v` | Число посетителей и известных пользователей по дням, долю известных пользователей |
|
||||||
|
|
||||||
|
Известные пользователи — те, кого удалось связать с посетителями сайта
|
||||||
|
по заказам. Эти связи хранятся в карте идентичностей DDS.
|
||||||
|
|
||||||
|
Слева от панелей стоит фильтр «Период». Он задает даты сразу для всех панелей.
|
||||||
|
|
||||||
|
## Как витрина становится графиком
|
||||||
|
|
||||||
|
В Superset сначала создается датасет — описание таблицы или представления,
|
||||||
|
из которого будут браться данные. В нем задаются подписи колонок, формат
|
||||||
|
чисел и показатели, например средний чек.
|
||||||
|
|
||||||
|
Затем на основе датасета создается график: выбираются показатели и способ
|
||||||
|
их отображения. Несколько графиков можно разместить на одном дашборде.
|
||||||
|
|
||||||
|
## Готовые и собственные отчеты
|
||||||
|
|
||||||
|
Готовые датасеты, графики и дашборд сохранены в файлах репозитория
|
||||||
|
и импортируются при каждом `make up`. Если изменить их через интерфейс,
|
||||||
|
следующий `make up` заменит изменения версией из файлов без предупреждения.
|
||||||
|
|
||||||
|
Созданные вами датасеты, графики и дашборды хранятся в базе метаданных
|
||||||
|
Superset. Импорт их не меняет: они сохраняются после `make up`
|
||||||
|
и `make rebuild-storage`. Команда `make clean` удалит их вместе со всеми
|
||||||
|
данными стенда. Для учебных отчетов создавайте собственные объекты,
|
||||||
|
чтобы повторный запуск не перезаписал вашу работу. Устройство
|
||||||
|
импорта описано в [справочнике
|
||||||
|
Superset](../architecture/superset.md#импорт-при-каждой-инициализации).
|
||||||
|
|
||||||
|
## Почему выручка отстает на день
|
||||||
|
|
||||||
|
Последняя дата с выручкой на день раньше последней даты с событиями
|
||||||
|
кликстрима. Выгрузка заказов за день D поступает при генерации следующего
|
||||||
|
дня D+1, а затем обрабатывается конвейером. Вы уже видели эту разницу
|
||||||
|
в [главе об Airflow](airflow-dags.md#один-день-от-генерации-до-витрин). Начальное состояние
|
||||||
|
показано в разделе [«Стартовый мир»](../architecture/world.md#стартовый-мир).
|
||||||
|
|
||||||
|
## Сверка графика с витриной
|
||||||
|
|
||||||
|
Откройте Superset по адресу из раздела [«Адреса и
|
||||||
|
пароли»](../../README.md#адреса-и-пароли) и выберите дашборд «Бизнес-сюжеты».
|
||||||
|
Найдите график «Посетители и известные пользователи».
|
||||||
|
Затем откройте SQL Lab и выполните запрос:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT report_date, visitors, known_users
|
||||||
|
FROM dm.daily_traffic_v
|
||||||
|
ORDER BY report_date DESC
|
||||||
|
LIMIT 7;
|
||||||
|
```
|
||||||
|
|
||||||
|
Выберите одну дату из результата. На дашборде задайте «Период», в который
|
||||||
|
она входит, и обновите график. Наведите указатель на точку этой даты:
|
||||||
|
числа посетителей и известных пользователей должны совпасть с `visitors`
|
||||||
|
и `known_users` из SQL.
|
||||||
|
|
||||||
|
Как построить собственный отчет, рассказывают [учебные задания](../labs/README.md).
|
||||||
|
|
||||||
|
## Следующая глава и справочники
|
||||||
|
|
||||||
|
Подключение и настройка Superset описаны в [справочнике
|
||||||
|
Superset](../architecture/superset.md), а состав витрин — в [справочнике
|
||||||
|
DM](../architecture/dm.md). Следующая глава — [«Генератор: откуда берутся
|
||||||
|
данные»](generator.md).
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Задание 1: события для анализа, `dm.events_enriched_v`
|
||||||
|
|
||||||
|
Вы подготовите представление с полями для анализа событий. Оно будет
|
||||||
|
добавлять вычисляемые поля при чтении, сохраняя одну строку на событие.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
- [«Хранилище и его слои»](../handbook/storage-layers.md).
|
||||||
|
- [Выполнение SQL в DBeaver](README.md#выполнение-sql-в-dbeaver): файл для решения
|
||||||
|
и подключение с правами на создание витрины.
|
||||||
|
|
||||||
|
## Данные для анализа событий
|
||||||
|
|
||||||
|
В `dds.event_v` 44 колонки, включая технические поля источника и массивы
|
||||||
|
товаров. Для анализа событий в Superset нужны сведения о посетителе и его
|
||||||
|
действиях, а также три вычисляемых поля: час события, путь страницы и домен,
|
||||||
|
с которого пришел посетитель. Тогда вам не придется
|
||||||
|
повторять эти выражения в каждом запросе.
|
||||||
|
|
||||||
|
Витрины следующих заданий читают DDS напрямую. Использовать
|
||||||
|
`dm.events_enriched_v` как источник для них не нужно: задания независимы, их
|
||||||
|
можно выполнять в любом порядке.
|
||||||
|
|
||||||
|
## Создание представления
|
||||||
|
|
||||||
|
Создайте обычное представление `CREATE VIEW dm.events_enriched_v`.
|
||||||
|
Единственный источник: `dds.event_v`. Каждой строке источника должна
|
||||||
|
соответствовать ровно одна строка витрины: одно событие.
|
||||||
|
|
||||||
|
Представление выполняет запрос при чтении и не хранит отдельную копию данных.
|
||||||
|
Для него не нужно создавать таблицы или добавлять задачу в `dm_transform`.
|
||||||
|
|
||||||
|
Укажите колонки явно. Иначе новая колонка источника попадет в витрину
|
||||||
|
автоматически. Отчет получит ее, даже если вы не собирались менять его набор
|
||||||
|
полей.
|
||||||
|
|
||||||
|
| Группа | Колонки |
|
||||||
|
|---|---|
|
||||||
|
| Время и посетитель | `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-метки | `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` |
|
||||||
|
|
||||||
|
Три колонки вычислите в представлении. Остальные возьмите из источника без
|
||||||
|
изменений, сохранив массивы `product_*`.
|
||||||
|
|
||||||
|
| Колонка | Как считается | Для чего нужна |
|
||||||
|
|---|---|---|
|
||||||
|
| `event_hour` | `toHour(event_time)` | Анализ событий по часам суток |
|
||||||
|
| `page_path` | путь `url` без домена и параметров | Группировка по странице |
|
||||||
|
| `referer_host` | домен `referer` | Анализ источников перехода |
|
||||||
|
|
||||||
|
Не добавляйте `_load_id` и `_load_ts`. Их нет и у `dds.event_v`: события в
|
||||||
|
DDS читают из ODS без отдельной загрузки. Эти служебные поля указывают на
|
||||||
|
запуск, который записал строку на диск. Представление ничего не загружает.
|
||||||
|
|
||||||
|
## Типичная ошибка
|
||||||
|
|
||||||
|
Не разворачивайте массивы товаров через `ARRAY JOIN`. У события с тремя
|
||||||
|
позициями получится три строки вместо одной.
|
||||||
|
|
||||||
|
Главная опасность — потеря событий с пустым массивом товаров: `ARRAY JOIN`
|
||||||
|
исключает их из результата. Товары есть только в событиях корзины и покупки.
|
||||||
|
Поэтому исчезнет большинство событий — девятнадцать строк из двадцати.
|
||||||
|
Запрос выполнится без ошибки, а витрина по имени и составу колонок
|
||||||
|
по-прежнему будет выглядеть таблицей событий.
|
||||||
|
|
||||||
|
Сохраните массивы как в источнике. Для анализа отдельных товарных позиций
|
||||||
|
нужна отдельная витрина, где одна строка соответствует товарной позиции.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
После применения DDL откройте Airflow по [адресу из README](../../README.md#адреса-и-пароли),
|
||||||
|
найдите даг `homework_check` и запустите его вручную. В новом запуске откройте
|
||||||
|
задачу `events_enriched`: она должна завершиться успешно. Если задача
|
||||||
|
красная, откройте ее журнал (`Logs`) — там будет причина ошибки, а при
|
||||||
|
расхождениях с источником — дни и примеры несовпадений. Задачи еще не
|
||||||
|
созданных витрин будут красными; это ожидаемо и не мешает проверке первой.
|
||||||
|
|
||||||
|
Выберите день и прочитайте его события из `dds.event_v` и своей витрины.
|
||||||
|
Сопоставьте строки по `event_id` через `FULL OUTER JOIN`.
|
||||||
|
Найдите события, которые есть только с одной стороны, и события, у которых
|
||||||
|
различаются перенесенные из источника поля. Расхождений должно быть ноль.
|
||||||
|
|
||||||
|
Отдельно сверьте `event_hour`, `page_path` и `referer_host` с теми же
|
||||||
|
выражениями, рассчитанными прямо по `dds.event_v`.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] у вас есть `dm.events_enriched_v` с явно перечисленными колонками;
|
||||||
|
- [ ] вы сверили число строк и набор `event_id` с `dds.event_v` за каждый
|
||||||
|
прожитый день; расхождений нет;
|
||||||
|
- [ ] проверка `events_enriched` в `homework_check` прошла успешно;
|
||||||
|
- [ ] витрина работает без физической таблицы и задачи в `dm_transform`.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Задание 2: показатели страниц по дням, `dm.top_pages_daily`
|
||||||
|
|
||||||
|
В этой работе вы рассчитаете показатели страниц за каждый день и сохраните
|
||||||
|
их в таблице. Затем подключите обновление витрины к `dm_transform`:
|
||||||
|
новый расчет будет заменять дневные партиции таблицы.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
- [«Хранилище и его слои»](../handbook/storage-layers.md);
|
||||||
|
- [«Генерация и обработка данных в Airflow»](../handbook/airflow-dags.md).
|
||||||
|
|
||||||
|
## Дневные показатели страниц
|
||||||
|
|
||||||
|
По дневным показателям страниц аналитик сравнивает просмотры, посетителей и
|
||||||
|
действия с товарами. Добавление в корзину происходит на карточке товара,
|
||||||
|
покупка — на странице подтверждения заказа. Отношение добавлений к
|
||||||
|
просмотрам карточки показывает ее конверсию.
|
||||||
|
|
||||||
|
Для воронки «просмотр → корзина → покупка» этих данных недостаточно:
|
||||||
|
действия происходят на разных страницах, а связи между ними в агрегате нет.
|
||||||
|
Путь человека исследуют по событиям или сессиям.
|
||||||
|
|
||||||
|
## Создание витрины
|
||||||
|
|
||||||
|
Читайте события из `dds.event_v`. В результате нужна одна строка на страницу
|
||||||
|
за день. Например, все просмотры одной карточки товара за один день попадут
|
||||||
|
в одну строку. Витрина задания 1 для этого расчета не нужна.
|
||||||
|
|
||||||
|
Сохраните рассчитанные показатели в таблице, чтобы отчеты читали готовые
|
||||||
|
итоги. Создайте пять объектов:
|
||||||
|
|
||||||
|
- основные таблицы `dm.top_pages_daily_rep` и `dm.top_pages_daily_dist`;
|
||||||
|
- промежуточные таблицы `dm.top_pages_daily_stage_rep` и `dm.top_pages_daily_stage_dist`;
|
||||||
|
- представление для чтения `dm.top_pages_daily_v`.
|
||||||
|
|
||||||
|
Их назначение и порядок обновления разобраны во вводной:
|
||||||
|
[«Создание и обновление витрин»](README.md#создание-и-обновление-витрин).
|
||||||
|
Разбейте данные на партиции по дням, чтобы можно было заменить результат
|
||||||
|
одного дня целиком.
|
||||||
|
|
||||||
|
### Состав колонок
|
||||||
|
|
||||||
|
| Колонка | Тип | Значение |
|
||||||
|
|---|---|---|
|
||||||
|
| `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')` | Время сборки |
|
||||||
|
|
||||||
|
Вычислите `page_path` из `url`, убрав домен и параметры запроса. Полный
|
||||||
|
адрес разделил бы показатели одной страницы на сотни строк. Учитывайте все
|
||||||
|
три типа событий, а их счетчики вычисляйте через `countIf`.
|
||||||
|
|
||||||
|
### Дни для пересчета
|
||||||
|
|
||||||
|
События завершившегося модельного дня больше не меняются. Найдите первый
|
||||||
|
такой день, которого еще нет в витрине, и рассчитайте все дни от него до
|
||||||
|
последнего завершившегося. Если начать после последнего дня, который уже
|
||||||
|
есть в витрине, можно пропустить более ранний день, не загруженный из-за сбоя.
|
||||||
|
|
||||||
|
Как у `revenue_daily`, запишите расчет за все выбранные дни в промежуточную
|
||||||
|
таблицу одной вставкой. Затем замените дневные партиции основной таблицы по
|
||||||
|
одной. Добавьте запуск этих запросов в `dm_transform`.
|
||||||
|
|
||||||
|
### Ключ шардирования
|
||||||
|
|
||||||
|
Выберите ключ самостоятельно. У `revenue_daily` строки сгруппированы по дню
|
||||||
|
и категории товара; категорий всего шесть. Здесь страниц много, и число
|
||||||
|
событий на них сильно различается.
|
||||||
|
|
||||||
|
У основной и промежуточной таблиц используйте одинаковый ключ шардирования.
|
||||||
|
Замена партиции не перераспределяет строки между шардами. Подробнее об этом
|
||||||
|
в разделе [«Раскладка по шардам»](../architecture/storage.md#раскладка-по-шардам).
|
||||||
|
|
||||||
|
## Типичная ошибка
|
||||||
|
|
||||||
|
Из-за имени витрины хочется отобрать только просмотры. Но фильтр источника
|
||||||
|
по `event_type = 'pageview'` обнулит `cart_adds` и `purchases`, а запрос не
|
||||||
|
завершится ошибкой. Конверсия карточек станет нулевой. Проверка одной суммы
|
||||||
|
`pageviews` не обнаружит этот фильтр: все просмотры остались на месте.
|
||||||
|
Ошибку покажет сверка `purchases` или `cart_adds` с источником, если за день
|
||||||
|
были покупки или добавления в корзину.
|
||||||
|
|
||||||
|
Выбирайте проверку под возможную ошибку. Этот подход описан в
|
||||||
|
[справочнике проверок](../architecture/testing.md).
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
Рассчитайте те же показатели по `dds.event_v` за выбранный день.
|
||||||
|
Сопоставьте результат со строками `dm.top_pages_daily_v` за этот день
|
||||||
|
через `FULL OUTER JOIN` по `page_path`. Найдите страницы, которые есть
|
||||||
|
только с одной стороны, и строки, где различается хотя бы один из четырех
|
||||||
|
счетчиков. Расхождений должно быть ноль.
|
||||||
|
|
||||||
|
Перед полным сравнением проверьте дневные суммы:
|
||||||
|
|
||||||
|
- сумма `pageviews` равна числу событий `pageview` в `dds.event_v`;
|
||||||
|
- сумма `purchases` равна числу событий `purchase` в том же источнике.
|
||||||
|
|
||||||
|
Сумма `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,124 @@
|
|||||||
|
# Задание 3: визиты по устройствам, `dm.session_overview`
|
||||||
|
|
||||||
|
Вы соберете дневные показатели визитов по устройствам. В таблице будут
|
||||||
|
храниться суммы и счетчики для расчета средних и долей. Заодно проверите,
|
||||||
|
когда сложение числа посетителей по устройствам завышает аудиторию.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
- [«Хранилище и его слои»](../handbook/storage-layers.md);
|
||||||
|
- [«Генерация и обработка данных в Airflow»](../handbook/airflow-dags.md).
|
||||||
|
|
||||||
|
## Показатели визитов
|
||||||
|
|
||||||
|
Витрина нужна, чтобы по дням сравнивать визиты с телефона и компьютера: их
|
||||||
|
число, длительность, просмотры страниц и частоту покупок.
|
||||||
|
|
||||||
|
Сессии уже рассчитаны в DDS. Из них вы соберете дневные показатели по
|
||||||
|
устройствам. Почему такие итоги хранят в отдельной таблице, объясняет
|
||||||
|
[справочник DM](../architecture/dm.md).
|
||||||
|
|
||||||
|
## Создание витрины
|
||||||
|
|
||||||
|
Читайте готовые сессии из `dds.session_v`. В результате нужна одна строка
|
||||||
|
на категорию устройства за день: например, итоги всех визитов с телефонов
|
||||||
|
за один день.
|
||||||
|
|
||||||
|
Создайте пять объектов по [общим правилам ступени 1](README.md#создание-и-обновление-витрин):
|
||||||
|
|
||||||
|
- основные таблицы `dm.session_overview_rep` и `dm.session_overview_dist`;
|
||||||
|
- промежуточные таблицы `dm.session_overview_stage_rep` и `dm.session_overview_stage_dist`;
|
||||||
|
- представление для чтения `dm.session_overview_v`.
|
||||||
|
|
||||||
|
Разбейте данные на партиции по дням.
|
||||||
|
|
||||||
|
### Состав колонок
|
||||||
|
|
||||||
|
| Колонка | Тип | Значение |
|
||||||
|
|---|---|---|
|
||||||
|
| `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')` | Время сборки |
|
||||||
|
|
||||||
|
Возьмите `device_category` из сессии: DDS уже заменил код словом. Из четырех
|
||||||
|
категорий встречаются `desktop`, `mobile` и `tablet`. Строку для `tv`
|
||||||
|
создавайте только при появлении таких визитов.
|
||||||
|
|
||||||
|
Для расчета средней длительности храните общую длительность и число сессий.
|
||||||
|
Готовую среднюю `avg_duration_seconds` сохранять не нужно. Число сессий
|
||||||
|
в категориях различается, поэтому среднее из трех средних даст неверный
|
||||||
|
ответ за день. Чтобы получить его правильно, разделите сумму
|
||||||
|
`duration_seconds_total` на сумму `sessions`. Для доли отказов разделите
|
||||||
|
сумму `bounce_sessions` на сумму `sessions`. Усреднение готовых долей тоже
|
||||||
|
даст неверный результат.
|
||||||
|
|
||||||
|
### Дни для пересчета
|
||||||
|
|
||||||
|
Сессии завершившегося модельного дня больше не меняются. Как в задании 2,
|
||||||
|
найдите первый завершившийся день, которого еще нет в витрине. Рассчитайте
|
||||||
|
все дни от него до последнего завершившегося, запишите их в промежуточную
|
||||||
|
таблицу и замените дневные партиции основной. Добавьте задачу в `dm_transform`.
|
||||||
|
|
||||||
|
### Ключ шардирования
|
||||||
|
|
||||||
|
Выберите ключ самостоятельно. Визитов с планшета мало, с телефонов и
|
||||||
|
компьютеров — значительно больше. Если хотите взять хеш только от
|
||||||
|
`device_category`, сначала посчитайте строки на каждом шарде. Затем
|
||||||
|
объясните, почему такое распределение вас устраивает.
|
||||||
|
|
||||||
|
У основной и промежуточной таблиц должен быть одинаковый ключ шардирования:
|
||||||
|
см. [общие правила ступени 1](README.md#создание-и-обновление-витрин).
|
||||||
|
|
||||||
|
## Число посетителей за день
|
||||||
|
|
||||||
|
Сравните сумму `visitors` по устройствам за день с `dm.daily_traffic_v`. На
|
||||||
|
стенде значения совпадут: одному `client_id` всегда соответствует одно
|
||||||
|
устройство, поэтому за день он учитывается только в одной категории.
|
||||||
|
Это свойство мира стенда; см.
|
||||||
|
раздел «Паспорт куки» в [словаре понятий](../../CONTEXT.md).
|
||||||
|
|
||||||
|
Если появятся визиты одного `client_id` с двух устройств, сумма начнет завышать
|
||||||
|
аудиторию. Проверки счетчиков по категориям этого не обнаружат. Число
|
||||||
|
посетителей дня берите из `dm.daily_traffic_v`, где оно рассчитано для дня
|
||||||
|
целиком.
|
||||||
|
|
||||||
|
## Типичная ошибка
|
||||||
|
|
||||||
|
Повторное выделение визитов из событий может дать другие границы сессий, чем
|
||||||
|
в DDS. Используйте готовые сессии из `dds.session_v`. Если общее число
|
||||||
|
событий совпало с источником, а число сессий завышено, вероятная причина —
|
||||||
|
сборка витрины из событий вместо сессий.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
Рассчитайте те же показатели по `dds.session_v` за выбранный день.
|
||||||
|
Сопоставьте результат со строками `dm.session_overview_v` за этот день
|
||||||
|
через `FULL OUTER JOIN` по `device_category`. Найдите категории, которые
|
||||||
|
есть только с одной стороны, и строки, где различается хотя бы один
|
||||||
|
счетчик. Расхождений должно быть ноль.
|
||||||
|
|
||||||
|
Еще две проверки дневных итогов:
|
||||||
|
|
||||||
|
- сумма `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,113 @@
|
|||||||
|
# Задание 4: ошибки загрузки по дням, `dm.dq_errors_daily`
|
||||||
|
|
||||||
|
Вы соберете дневную сводку ошибок загрузки. Ее итоги должны сохраниться
|
||||||
|
и после того, как исходные сообщения будут удалены из хранилища.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
- [«Хранилище и его слои»](../handbook/storage-layers.md);
|
||||||
|
- [«Генерация и обработка данных в Airflow»](../handbook/airflow-dags.md).
|
||||||
|
|
||||||
|
## Ошибки загрузки и их источники
|
||||||
|
|
||||||
|
Если сообщение не удалось разобрать, стенд записывает его в таблицу ошибок.
|
||||||
|
Такие строки дальше называются браком. Вы соберете сводку, по которой можно
|
||||||
|
увидеть, каких ошибок стало больше и сколько строк забраковано.
|
||||||
|
|
||||||
|
Источники сводки:
|
||||||
|
|
||||||
|
- `ods.event_errors_dist`: ошибки разбора событий;
|
||||||
|
- `ods.order_errors_dist`: ошибки разбора выгрузки заказов.
|
||||||
|
|
||||||
|
В этих таблицах есть исходный текст сообщения и сведения о его доставке:
|
||||||
|
они пригодятся при поиске причины ошибки. Представлений `_v` у таблиц ошибок
|
||||||
|
нет, создавать их для задания не нужно. Чтение локальной таблицы `_rep`
|
||||||
|
даст строки только одного шарда из двух, поэтому используйте `_dist`.
|
||||||
|
|
||||||
|
## Создание сводки
|
||||||
|
|
||||||
|
В результате нужна одна строка на день загрузки, источник и класс ошибки.
|
||||||
|
Например, ошибки `field_invalid` в заказах за один день загрузки попадут
|
||||||
|
в одну строку с их количеством.
|
||||||
|
|
||||||
|
Создайте пять объектов по [общим правилам ступени 1](README.md#создание-и-обновление-витрин):
|
||||||
|
|
||||||
|
- основные таблицы `dm.dq_errors_daily_rep` и `dm.dq_errors_daily_dist`;
|
||||||
|
- промежуточные таблицы `dm.dq_errors_daily_stage_rep` и `dm.dq_errors_daily_stage_dist`;
|
||||||
|
- представление для чтения `dm.dq_errors_daily_v`.
|
||||||
|
|
||||||
|
Разбейте данные на партиции по дням загрузки.
|
||||||
|
|
||||||
|
### Состав колонок
|
||||||
|
|
||||||
|
| Колонка | Тип | Значение |
|
||||||
|
|---|---|---|
|
||||||
|
| `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` как `toDate(_load_ts)` из таблицы ошибок — это
|
||||||
|
реальный день приема строки. Модельный день записан внутри исходной строки.
|
||||||
|
Ее не удалось разобрать, поэтому надежно получить из нее день не получится.
|
||||||
|
Служебная `_load_ts` витрины описывает сборку сводки; ее дата может
|
||||||
|
отличаться от `load_date`.
|
||||||
|
|
||||||
|
У событий и заказов свои классы брака. Группируйте строки также по `source`,
|
||||||
|
чтобы одноименные классы разных источников не объединялись.
|
||||||
|
|
||||||
|
### Пересчет и хранение итогов
|
||||||
|
|
||||||
|
Правило `TTL` удаляет строки брака через месяц, сырые данные через трое суток.
|
||||||
|
Сводку храните бессрочно: итоги нужны и после удаления исходных строк.
|
||||||
|
|
||||||
|
Получите список дней загрузки, еще имеющихся в источниках. Рассчитайте их
|
||||||
|
итоги в промежуточной таблице и замените только эти партиции основной таблицы.
|
||||||
|
Более старые итоги сохраните.
|
||||||
|
|
||||||
|
В течение текущих суток ошибки еще поступают: пересчитайте этот день на
|
||||||
|
следующем прогоне.
|
||||||
|
|
||||||
|
### Ключ шардирования
|
||||||
|
|
||||||
|
Выберите ключ самостоятельно. Классов брака у событий и заказов вместе
|
||||||
|
меньше десятка. Объясните, насколько существенен перекос при таком объеме.
|
||||||
|
|
||||||
|
У основной и промежуточной таблиц должен быть одинаковый ключ шардирования:
|
||||||
|
см. [общие правила ступени 1](README.md#создание-и-обновление-витрин).
|
||||||
|
|
||||||
|
## Типичная ошибка
|
||||||
|
|
||||||
|
Если заменить всю сводку новым расчетом, исчезнут итоги дней, за которые
|
||||||
|
исходные строки уже удалены по `TTL`. Восстановить эти итоги неоткуда.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
Сопоставляйте строки по `load_date`, `source` и `error_class`. Проверяйте дни,
|
||||||
|
оставшиеся хотя бы в одной таблице ошибок. Старые итоги, для которых исходные
|
||||||
|
строки уже удалены по `TTL`, сверять не с чем.
|
||||||
|
|
||||||
|
Посчитайте строки прямо в обеих таблицах ошибок и соедините результат с
|
||||||
|
`dm.dq_errors_daily_v` через `FULL OUTER JOIN` по этим трем полям. Посчитайте
|
||||||
|
строки, которые есть только с одной стороны или у которых различается `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,146 @@
|
|||||||
|
# Задание 5: показатели рекламы, `dm.utm_effectiveness`
|
||||||
|
|
||||||
|
Вы подготовите показатели рекламных каналов по событиям сайта. Суммы покупок
|
||||||
|
в этой витрине будут отличаться от выручки по заказам магазина. В конце
|
||||||
|
работы вы сравните их и объясните причины расхождения.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
- [«Хранилище и его слои»](../handbook/storage-layers.md);
|
||||||
|
- [«Генерация и обработка данных в Airflow»](../handbook/airflow-dags.md).
|
||||||
|
|
||||||
|
## Покупки по рекламным каналам
|
||||||
|
|
||||||
|
Аналитик сравнивает рекламные каналы по дням: трафик, добавления в корзину,
|
||||||
|
покупки и их суммы из событий сайта.
|
||||||
|
|
||||||
|
События сайта показывают действия посетителя и рекламные метки его переходов.
|
||||||
|
Заказы магазина служат источником выручки. Но в заказах нет UTM-меток, поэтому
|
||||||
|
в этой витрине вы возьмете суммы из событий покупки. Их передает браузер
|
||||||
|
посетителя; дальше они называются клиентскими суммами. Приставка `declared_`
|
||||||
|
в имени показателя напоминает об этом источнике.
|
||||||
|
|
||||||
|
## Создание витрины
|
||||||
|
|
||||||
|
Читайте события из `dds.event_v`, без других витрин DM. В результате нужна
|
||||||
|
одна строка на день и сочетание трех меток: `utm_source`, `utm_medium`,
|
||||||
|
`utm_campaign`. События одного дня с одинаковыми метками попадают в одну группу.
|
||||||
|
|
||||||
|
Создайте пять объектов по [общим правилам ступени 1](README.md#создание-и-обновление-витрин):
|
||||||
|
|
||||||
|
- основные таблицы `dm.utm_effectiveness_rep` и `dm.utm_effectiveness_dist`;
|
||||||
|
- промежуточные таблицы `dm.utm_effectiveness_stage_rep` и `dm.utm_effectiveness_stage_dist`;
|
||||||
|
- представление для чтения `dm.utm_effectiveness_v`.
|
||||||
|
|
||||||
|
Разбейте данные на партиции по дням.
|
||||||
|
|
||||||
|
### Состав колонок
|
||||||
|
|
||||||
|
| Колонка | Тип | Значение |
|
||||||
|
|---|---|---|
|
||||||
|
| `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` выбран тип `String`: имен кампаний со временем становится
|
||||||
|
много, и затраты на словарь `LowCardinality` перестают окупаться.
|
||||||
|
|
||||||
|
### Дни для пересчета
|
||||||
|
|
||||||
|
События завершившегося модельного дня больше не меняются. Как в задании 2,
|
||||||
|
найдите первый завершившийся день, которого еще нет в витрине. Рассчитайте
|
||||||
|
все дни от него до последнего завершившегося, запишите их в промежуточную
|
||||||
|
таблицу и замените дневные партиции основной. Добавьте задачу в `dm_transform`.
|
||||||
|
|
||||||
|
### Ключ шардирования
|
||||||
|
|
||||||
|
Выберите ключ самостоятельно. Если берете хеш одной из трех UTM-колонок,
|
||||||
|
посчитайте долю строк с ее самым частым значением и обоснуйте выбор.
|
||||||
|
|
||||||
|
У основной и промежуточной таблиц должен быть одинаковый ключ шардирования:
|
||||||
|
см. [общие правила ступени 1](README.md#создание-и-обновление-витрин).
|
||||||
|
|
||||||
|
## Рекламные метки событий
|
||||||
|
|
||||||
|
Группируйте события по их собственным меткам. Модели «первое касание» и
|
||||||
|
«последнее касание» здесь не рассчитываются.
|
||||||
|
|
||||||
|
В `dds.session_v` записаны UTM первого события визита. Распределение по
|
||||||
|
каналам может отличаться от этой витрины, и это не означает ошибку в
|
||||||
|
расчете. Укажите в отчете, что распределяете по рекламным каналам отдельные
|
||||||
|
события. Такое распределение называют атрибуцией.
|
||||||
|
|
||||||
|
## События без рекламных меток
|
||||||
|
|
||||||
|
Сохраните события без меток: прямой трафик обычно крупнейший канал, и без
|
||||||
|
него доля рекламы будет завышена. Если отфильтровать такие строки, дневные
|
||||||
|
итоги перестанут сходиться с источником — а по этому совпадению вы
|
||||||
|
проверяете сборку. Разница составит вклад исключенных событий.
|
||||||
|
|
||||||
|
Оставьте пустые метки пустыми строками: новое обозначение прямого трафика
|
||||||
|
потребовало бы объяснения.
|
||||||
|
|
||||||
|
## Расхождение с `dm.revenue_daily_v`
|
||||||
|
|
||||||
|
Сравните за один день `sum(declared_revenue)` своей витрины и `sum(revenue)`
|
||||||
|
из `dm.revenue_daily_v`. Суммы не сходятся; проверять их равенство не нужно.
|
||||||
|
|
||||||
|
В `revenue_daily` складываются цены позиций, умноженные на количество, без
|
||||||
|
скидок и доставки — та же основа, что у клиентской суммы. Расхождение
|
||||||
|
объясняется историей заказов, а не способом расчета:
|
||||||
|
|
||||||
|
- событие покупки остается после отмены заказа, а `revenue_daily` учитывает
|
||||||
|
только оплаченные заказы;
|
||||||
|
- событие покупки может потеряться при доставке, хотя заказ поступил, или
|
||||||
|
поступить дважды;
|
||||||
|
- клиентская сумма может отличаться от серверной суммы позиций: генератор
|
||||||
|
намеренно создает это расхождение у одного-двух заказов из ста.
|
||||||
|
|
||||||
|
Запишите свой вывод в двух-трех фразах. Число расхождений по заказам и их
|
||||||
|
причины можно посмотреть в `dm.purchase_vs_orders_v`, где выделены классы
|
||||||
|
расхождений.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
Рассчитайте те же показатели по `dds.event_v` за выбранный день.
|
||||||
|
Сопоставьте результат со строками `dm.utm_effectiveness_v` за этот день
|
||||||
|
через `FULL OUTER JOIN` по `utm_source`, `utm_medium` и `utm_campaign`.
|
||||||
|
Найдите сочетания меток, которые есть только с одной стороны, и строки,
|
||||||
|
где различается хотя бы один из пяти показателей. Расхождений должно быть ноль.
|
||||||
|
|
||||||
|
Дополнительно сверьте дневные итоги:
|
||||||
|
|
||||||
|
- суммы `pageviews`, `add_to_carts` и `purchases` равны числу событий
|
||||||
|
соответствующих типов в `dds.event_v`;
|
||||||
|
- сумма `declared_revenue` равна сумме `purchase_revenue[1]` по всем
|
||||||
|
событиям `purchase` того же дня.
|
||||||
|
|
||||||
|
Сумма `visitors` по строкам повторно учитывает одного посетителя,
|
||||||
|
приходящего по разным меткам. Для числа посетителей дня она не подходит.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] у вас есть пять объектов витрины; промежуточная таблица объявлена через
|
||||||
|
`AS` и использует ключ шардирования основной;
|
||||||
|
- [ ] строки без UTM-меток сохранены, а `declared_revenue` хранится
|
||||||
|
в `Decimal(18, 2)`;
|
||||||
|
- [ ] вы записали, почему сумма отличается от `dm.revenue_daily_v`;
|
||||||
|
- [ ] проверка `utm_effectiveness` в `homework_check` прошла успешно;
|
||||||
|
- [ ] витрина обновляется при запуске `dm_transform`.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Свой график в Superset
|
||||||
|
|
||||||
|
В этой работе вы построите график по своей витрине и проверите числа
|
||||||
|
на нем SQL-запросом.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
- [«Отчеты в Superset»](../handbook/superset.md);
|
||||||
|
- [задание 3: `dm.session_overview`](1-3-session-overview.md).
|
||||||
|
|
||||||
|
## График отказов по устройствам
|
||||||
|
|
||||||
|
В задании 3 первой ступени вы собрали витрину `dm.session_overview_v`.
|
||||||
|
Теперь покажите, с каких устройств чаще уходят после единственного события.
|
||||||
|
Такие визиты в стенде считаются отказами. Постройте столбиковый график доли
|
||||||
|
отказов по `device_category` за весь период витрины. Каждый столбик должен
|
||||||
|
показывать долю для одной категории устройств.
|
||||||
|
|
||||||
|
## Настройка графика
|
||||||
|
|
||||||
|
Добавьте представление как источник данных для графиков. В Superset такой
|
||||||
|
источник называется физическим датасетом. Выберите подключение `ClickHouse`,
|
||||||
|
схему `dm` и объект `session_overview_v`. Задайте русские подписи хотя бы двум
|
||||||
|
колонкам:
|
||||||
|
|
||||||
|
- `device_category` — «Категория устройства»;
|
||||||
|
- `report_date` — «Дата отчета».
|
||||||
|
|
||||||
|
В редакторе датасета, на вкладке «Metrics», создайте метрику:
|
||||||
|
|
||||||
|
| Поле | Значение |
|
||||||
|
|---|---|
|
||||||
|
| `Metric Key` | `bounce_share` — ключ метрики и ее SQL-псевдоним |
|
||||||
|
| `Label` | «Доля отказов» |
|
||||||
|
| `SQL expression` | `SUM(bounce_sessions) / SUM(sessions)` |
|
||||||
|
| `D3 format` | `.1%` |
|
||||||
|
|
||||||
|
Выражение делит общее число отказов на общее число сессий. Усреднение дневных
|
||||||
|
долей может дать неверный результат: число сессий в разные дни различается.
|
||||||
|
Сохраните метрику в датасете, чтобы все будущие графики использовали одно
|
||||||
|
выражение, подпись и формат. Это правило описано в
|
||||||
|
[справочнике Superset](../architecture/superset.md#дерево-импорта).
|
||||||
|
|
||||||
|
Не используйте вкладку «Custom SQL» конструктора графика. Созданная там
|
||||||
|
метрика доступна только этому графику, а нужна метрика датасета.
|
||||||
|
|
||||||
|
Создайте столбиковый график с метрикой «Доля отказов». В поле «X-axis»
|
||||||
|
укажите «Категория устройства», а «Dimensions» не заполняйте. Дашборд
|
||||||
|
создавать не нужно: сохраненного графика достаточно.
|
||||||
|
|
||||||
|
Для этого задания оставьте период «No filter»: график и проверочный запрос
|
||||||
|
ниже считают долю отказов за весь период витрины. Относительный период,
|
||||||
|
например «Last week», может дать пустой график: модельный календарь стенда
|
||||||
|
отстает от настоящего. Последнюю дату витрины посмотрите в SQL Lab:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT max(report_date)
|
||||||
|
FROM dm.session_overview_v;
|
||||||
|
```
|
||||||
|
|
||||||
|
Стендовые объекты не меняйте. Что переживает `make up`, а что стирает
|
||||||
|
`make clean`, описано в главе [«Отчеты в Superset»](../handbook/superset.md#готовые-и-собственные-отчеты).
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
Автоматической проверки этого задания нет. Сверьте числа на графике
|
||||||
|
с результатом запроса в SQL Lab:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT device_category,
|
||||||
|
round(100 * sum(bounce_sessions) / sum(sessions), 1) AS bounce_pct
|
||||||
|
FROM dm.session_overview_v
|
||||||
|
GROUP BY device_category
|
||||||
|
ORDER BY device_category;
|
||||||
|
```
|
||||||
|
|
||||||
|
Сверьте с графиком значение для каждой категории. В разделе «Customize»
|
||||||
|
включите «Show Value» или наведите указатель на столбик. Запрос возвращает
|
||||||
|
проценты с одним знаком, как формат `.1%`, поэтому числа должны совпасть
|
||||||
|
один в один.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] у вас есть физический датасет `dm.session_overview_v` в подключении
|
||||||
|
`ClickHouse`;
|
||||||
|
- [ ] в датасете есть метрика «Доля отказов» с выражением
|
||||||
|
`SUM(bounce_sessions) / SUM(sessions)` и форматом `.1%`;
|
||||||
|
- [ ] колонки `device_category` и `report_date` подписаны «Категория
|
||||||
|
устройства» и «Дата отчета»;
|
||||||
|
- [ ] сохраненный столбиковый график показывает долю отказов по категориям
|
||||||
|
устройств за весь период витрины;
|
||||||
|
- [ ] числа на графике совпадают с результатом запроса из SQL Lab;
|
||||||
|
- [ ] вы сохранили график отдельно, без создания дашборда и изменения
|
||||||
|
стендовых объектов.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Сессии из событий
|
||||||
|
|
||||||
|
Вы самостоятельно объедините события сайта в сессии и сравните их число
|
||||||
|
с готовыми визитами источника, обозначенными `VisitID`. Затем найдете
|
||||||
|
события, из-за которых расчеты расходятся.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
- [Хранилище и его слои, раздел DDS](../handbook/storage-layers.md#dds-события-заказы-и-сессии);
|
||||||
|
- [формат события: Идентификаторы и время](../formats/clickstream-event.md#идентификаторы-и-время).
|
||||||
|
|
||||||
|
## События и визиты
|
||||||
|
|
||||||
|
Визит, или сессия, объединяет действия посетителя за один заход на сайт.
|
||||||
|
По визитам аналитик считает длительность и глубину просмотра.
|
||||||
|
В хранилище сессии собираются из отдельных событий.
|
||||||
|
|
||||||
|
## Расчет сессий
|
||||||
|
|
||||||
|
### Границы визитов
|
||||||
|
|
||||||
|
В SQL Lab или DBeaver под `analyst` напишите запрос к `ods.event_v`.
|
||||||
|
Для своего расчета и эталона возьмите одни и те же строки за все доступные
|
||||||
|
дни: для расследования понадобятся события соседних дней.
|
||||||
|
Выберите `ClientID`, `UTCEventTime`, `EventDate`, `EventType` и `VisitID`.
|
||||||
|
В `dds.event_v` эталонного `VisitID` нет.
|
||||||
|
|
||||||
|
`ClientID` хранится в cookie браузера и обозначает посетителя в событиях
|
||||||
|
сайта. Для начала считайте визитом последовательные события одного
|
||||||
|
`ClientID`: пауза дольше 30 минут открывает новый визит.
|
||||||
|
Учитывайте события всех типов. Упорядочите их по `UTCEventTime`.
|
||||||
|
Присвойте каждому событию номер своей сессии внутри `ClientID`.
|
||||||
|
Подсказка: оконные функции.
|
||||||
|
|
||||||
|
Днем сессии считайте `EventDate` ее первого события: дата из `UTCEventTime`
|
||||||
|
отличается от `EventDate` для части событий, поэтому берите `EventDate`.
|
||||||
|
При сверке останутся расхождения из-за еще одной договоренности стенда.
|
||||||
|
|
||||||
|
### Сверка с эталоном
|
||||||
|
|
||||||
|
Посчитайте по эталонному `VisitID` число визитов каждого `ClientID`
|
||||||
|
за модельный день:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT ClientID, EventDate, uniqExact(VisitID) AS reference_sessions
|
||||||
|
FROM ods.event_v
|
||||||
|
GROUP BY ClientID, EventDate
|
||||||
|
ORDER BY EventDate, ClientID;
|
||||||
|
```
|
||||||
|
|
||||||
|
Посчитайте свои сессии по `ClientID` и дню сессии. Соедините результаты двух
|
||||||
|
расчетов через `FULL OUTER JOIN` по этим полям. Учтите строки, которые есть
|
||||||
|
только с одной стороны: полное соединение сохранит их в результате.
|
||||||
|
|
||||||
|
Выведите сводку по дням с числом `ClientID`, для которых расчеты разошлись,
|
||||||
|
и список этих идентификаторов с днем и обоими значениями. Общая сумма за день
|
||||||
|
могла бы скрыть ошибки: для одного посетителя вы посчитали лишнюю сессию,
|
||||||
|
для другого пропустили. По списку видно, чьи события нужно исследовать.
|
||||||
|
|
||||||
|
Расхождений будет всего несколько. Если их нет, мир прожил мало дней.
|
||||||
|
Прогоните несколько дней через [даги модельного времени](../handbook/airflow-dags.md#генерация-модельных-дней)
|
||||||
|
и повторите сверку.
|
||||||
|
|
||||||
|
### Поиск причины расхождения
|
||||||
|
|
||||||
|
Выберите один `ClientID` с расхождением. Выведите его события за два соседних дня
|
||||||
|
с `UTCEventTime`, `EventDate`, `VisitID` и номером своей сессии.
|
||||||
|
Сопоставьте сессии через события: общего ключа у двух расчетов нет,
|
||||||
|
зато у каждого события есть и `VisitID`, и присвоенный вами номер.
|
||||||
|
Найдите два соседних события, между которыми границы визитов разошлись.
|
||||||
|
Запишите `ClientID`, день, время обоих событий и причину одним предложением.
|
||||||
|
Уточните определение визита и добейтесь нуля расхождений во всех днях.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
Сохраните исходный и уточненный расчеты, результаты сверки и найденные строки.
|
||||||
|
Таблицы и представления создавать не нужно. Задачи в `homework_check`
|
||||||
|
для этого задания нет: вы проверяете расчет своим запросом.
|
||||||
|
|
||||||
|
В `sql/dds/session_rebuild.sql` и
|
||||||
|
`sql/dq/sessions_vs_reference.sql` лежат готовые запросы расчета сессий и сверки.
|
||||||
|
Откройте их после собственного решения и расследования. Сравните запросы
|
||||||
|
со своими.
|
||||||
|
На ступени 4 вы сравните свой результат с готовыми сессиями стенда
|
||||||
|
по `session_id`.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] ваш запрос собирает сессии из всех событий `ods.event_v` за все
|
||||||
|
доступные дни;
|
||||||
|
- [ ] у вас есть сводка расхождений по дням и список `ClientID`, полученные через
|
||||||
|
`FULL OUTER JOIN`;
|
||||||
|
- [ ] вы объяснили причину одного расхождения и записали `ClientID`, день и два
|
||||||
|
события, на которых оно видно;
|
||||||
|
- [ ] уточненный расчет дает ноль расхождений во всех днях.
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# Расчет сессий в стенде
|
||||||
|
|
||||||
|
На прошлой ступени вы рассчитали сессии отдельным запросом. Теперь
|
||||||
|
перепишете расчет внутри стенда, запустите его через Airflow и проверите
|
||||||
|
результат по исходным событиям.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
- [задание «Сессии из событий»](3-1-sessions-by-hand.md);
|
||||||
|
- [модель сессии DDS](../architecture/dds/session.md);
|
||||||
|
- [дневной конвейер трансформаций](../architecture/etl.md#объём-прогона);
|
||||||
|
- [раскладка по шардам](../architecture/storage.md#раскладка-по-шардам).
|
||||||
|
|
||||||
|
## Файлы для изменения
|
||||||
|
|
||||||
|
Даг, таблицы сессий и проверка их числа уже созданы.
|
||||||
|
Перепишите только три файла:
|
||||||
|
|
||||||
|
- `sql/dds/session_scope.sql`;
|
||||||
|
- `sql/dds/session_rebuild.sql`;
|
||||||
|
- `sql/dds/session_replace.sql`.
|
||||||
|
|
||||||
|
Удалите из них готовый SQL и напишите свой. Даг `dds_transform` передаст
|
||||||
|
параметры и выполнит запросы по порядку. Таблицы уже созданы: основные хранят
|
||||||
|
готовые сессии, промежуточные нужны для нового расчета перед заменой данных.
|
||||||
|
Запрос `sessions_vs_reference.sql` сравнивает число сессий с `VisitID` из ODS.
|
||||||
|
|
||||||
|
## Выполнение запросов в Airflow
|
||||||
|
|
||||||
|
Объем прогона — дни, которые соберет один запуск. Его вычисляет первый файл.
|
||||||
|
|
||||||
|
| Файл | Вход | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| `session_scope.sql` | `{position:UInt16}` — номер первого еще не прожитого модельного дня | `first_day` и `last_day` |
|
||||||
|
| `session_rebuild.sql` | `{first_day:Date}`, `{last_day:Date}`, `{load_id:String}` | Сессии в `dds.session_stage_dist` |
|
||||||
|
| `session_replace.sql` | День в `{{ params.day }}` | Партиция дня в `dds.session_rep` |
|
||||||
|
|
||||||
|
`first_day` должен быть первым завершившимся модельным днем, которого еще
|
||||||
|
нет в DDS, а `last_day` последним завершившимся днем. Начальную дату возьмите
|
||||||
|
из событий ODS: `position` считает дни от нее. Текущий модельный день еще
|
||||||
|
не закончен, поэтому сессии за него не рассчитываются.
|
||||||
|
|
||||||
|
Если в ODS нет ни одного события, запрос не возвращает строк. Если все
|
||||||
|
завершившиеся дни уже есть в DDS, запрос возвращает строку с `first_day = NULL`.
|
||||||
|
Тогда Airflow пропускает группу задач расчета сессий: ее состояние будет `skipped`.
|
||||||
|
|
||||||
|
`session_rebuild.sql` сначала очищает `dds.session_stage_rep` на всем кластере.
|
||||||
|
Затем он записывает в `dds.session_stage_dist` расчет за все выбранные дни. Добавьте к
|
||||||
|
вставке `SETTINGS distributed_foreground_insert = 1`. Так строки успеют попасть
|
||||||
|
на шарды до замены партиций.
|
||||||
|
|
||||||
|
Запрос `session_replace.sql` заменяет партицию в основной `dds.session_rep`
|
||||||
|
готовой партицией из промежуточной `dds.session_stage_rep`.
|
||||||
|
Операция выполняется на всем кластере, на локальных таблицах каждого шарда.
|
||||||
|
|
||||||
|
Не ставьте точку с запятой в конце `session_scope.sql`. Драйвер добавляет формат
|
||||||
|
ответа. С точкой с запятой получится несколько команд, которые ClickHouse
|
||||||
|
отклонит. День для замены подставляет шаблон Jinja в Airflow. Параметр
|
||||||
|
ClickHouse в `ALTER ... PARTITION` здесь не работает.
|
||||||
|
|
||||||
|
## Правила расчета сессий
|
||||||
|
|
||||||
|
Источник сборки — `dds.event_v`. Одна строка результата представляет один
|
||||||
|
визит. На третьей ступени вы уже получили правила разделения событий на сессии:
|
||||||
|
|
||||||
|
- сессия не пересекает модельные сутки;
|
||||||
|
- пауза больше 30 минут открывает новую сессию;
|
||||||
|
- в расчете участвуют события всех типов.
|
||||||
|
|
||||||
|
Повторный расчет должен давать тот же результат. При одинаковом времени
|
||||||
|
упорядочивайте события по `event_id`: порядок в оконных функциях должен быть
|
||||||
|
`(event_time, event_id)`. Один визит должен получать одинаковый `session_id`
|
||||||
|
при каждом запуске.
|
||||||
|
Вычислите идентификатор как `cityHash64(client_id, min(event_time))`.
|
||||||
|
|
||||||
|
Состав и типы строки заданы в `dds.session_rep`. Заполните начало, конец и
|
||||||
|
длительность. Посчитайте все события, просмотры, добавления в корзину и
|
||||||
|
покупки. Возьмите входную и выходную страницы, UTM первого события, устройство
|
||||||
|
и город. Заполните также служебные колонки из схемы таблицы.
|
||||||
|
|
||||||
|
Пишите через `dds.session_stage_dist`: шард для строки определяется по
|
||||||
|
`client_id`. При записи прямо в локальную таблицу он зависел бы от узла,
|
||||||
|
который выполнил запрос.
|
||||||
|
|
||||||
|
## Запуск нового расчета
|
||||||
|
|
||||||
|
Готовые дни повторно не собираются. Чтобы новый SQL точно выполнился, выберите
|
||||||
|
последний собранный день:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT max(session_date)
|
||||||
|
FROM dds.session_v;
|
||||||
|
```
|
||||||
|
|
||||||
|
Берите последний день: если удалить более ранний, расчет придется повторить
|
||||||
|
и за все последующие завершившиеся дни.
|
||||||
|
|
||||||
|
Сохраните десять строк с идентификатором, границами, длительностью и счетчиками:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
session_id,
|
||||||
|
client_id,
|
||||||
|
session_date,
|
||||||
|
started_at,
|
||||||
|
finished_at,
|
||||||
|
duration_seconds,
|
||||||
|
events_total,
|
||||||
|
pageviews,
|
||||||
|
cart_adds,
|
||||||
|
purchases
|
||||||
|
FROM dds.session_v
|
||||||
|
WHERE session_date = 'YYYY-MM-DD'
|
||||||
|
ORDER BY client_id, started_at, session_id
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
Создайте в DBeaver подключение к первому узлу по [инструкции из
|
||||||
|
README](../../README.md#подключиться-из-dbeaver). Укажите пользователя `etl` и
|
||||||
|
пароль `CLICKHOUSE_ETL_PASSWORD` из `.env`. У этой учетной записи есть право
|
||||||
|
менять таблицы DDS на кластере. Подставьте найденную дату и удалите только ее
|
||||||
|
партицию:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE dds.session_rep ON CLUSTER clickstream_cluster
|
||||||
|
DROP PARTITION 'YYYY-MM-DD';
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте Airflow и запустите `etl_pipeline`. В даге `dds_transform` раскройте
|
||||||
|
группу `session`: в ней должны выполниться задачи `scope` и `rebuild`, а также
|
||||||
|
`replace` для выбранного дня. Если после третьей ступени мир ушел вперед,
|
||||||
|
в группе появятся задачи `replace` и для последующих прожитых дней. Следом
|
||||||
|
обновятся витрины и выполнится `dq_check`.
|
||||||
|
|
||||||
|
Если прогон дошел до замены, но упал на проверке, ошибочная партиция уже
|
||||||
|
находится в DDS. После правки снова удалите тот же день перед следующим
|
||||||
|
запуском. Иначе день останется заполненным, а новая сборка для него не
|
||||||
|
выполнится.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
Сначала повторите запрос с десятью строками. `session_id`, границы,
|
||||||
|
длительность и счетчики должны совпасть с сохраненным результатом.
|
||||||
|
Это проверит, что ваш расчет восстановил те же сессии с теми же идентификаторами.
|
||||||
|
|
||||||
|
Задача `dq_check.sessions_vs_reference` должна стать зеленой. Та же проверка
|
||||||
|
видна в хранилище:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT data_date, reference_rows, actual_rows, failed_rows, status
|
||||||
|
FROM dm.dq_summary_v
|
||||||
|
WHERE check_name = 'sessions_vs_reference'
|
||||||
|
ORDER BY data_date;
|
||||||
|
```
|
||||||
|
|
||||||
|
Эта сверка сравнивает число сессий каждого `client_id` за день с `VisitID`.
|
||||||
|
При `failed_rows = 0` число сессий совпало, но их границы, страницы и UTM
|
||||||
|
еще могут различаться. Как на третьей ступени, проверьте отдельные сессии.
|
||||||
|
Для одного `client_id` выбранного дня найдите начало и конец каждого
|
||||||
|
`VisitID` по событиям и сравните с границами строк `dds.session_v`.
|
||||||
|
По исходным событиям проверьте страницы, UTM, устройство
|
||||||
|
и город. Страницы и UTM не входят в сохраненные десять строк: готовый SQL
|
||||||
|
стенда выбирал их через `argMin` и `argMax` только по `event_time`. При равном
|
||||||
|
времени ожидаемое значение определяйте по исходным событиям и полному порядку
|
||||||
|
`(event_time, event_id)`.
|
||||||
|
|
||||||
|
Проверьте также, на каком шарде лежит каждая строка. Напишите отдельный
|
||||||
|
запрос к `dds.session_dist`. Он должен:
|
||||||
|
|
||||||
|
1. получить фактический узел строки через `hostName()`;
|
||||||
|
2. определить ожидаемый узел по `cityHash64(client_id)`;
|
||||||
|
3. посчитать строки, у которых фактический и ожидаемый узлы не совпали.
|
||||||
|
|
||||||
|
У кластера два равных шарда: остаток 0 соответствует `clickhouse-01`, остаток
|
||||||
|
1 — `clickhouse-02`. Сгруппируйте ответ по фактическому узлу. На каждом узле
|
||||||
|
число строк на чужом шарде должно быть нулевым. Проверка числа сессий читает
|
||||||
|
Distributed-таблицу и такую ошибку не заметит.
|
||||||
|
|
||||||
|
После зеленого результата запустите `etl_pipeline` еще раз, не удаляя
|
||||||
|
партицию. Группа сессий должна быть пропущена: все завершившиеся дни уже
|
||||||
|
рассчитаны, новых дней и пропусков нет.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] вы переписали три файла `sql/dds/session_*.sql`, сохранив даг,
|
||||||
|
таблицы и проверку стенда;
|
||||||
|
- [ ] ваш расчет восстановил удаленный день, `etl_pipeline` завершился успешно;
|
||||||
|
- [ ] вы сверили десять сохраненных строк: `session_id`, границы,
|
||||||
|
длительность и счетчики совпали после пересборки;
|
||||||
|
- [ ] вы получили ноль расхождений в `sessions_vs_reference` за все
|
||||||
|
прожитые дни и вручную сверили границы и поля сессии одного `client_id`;
|
||||||
|
- [ ] ваш запрос с `hostName()` не нашел строк на чужом шарде;
|
||||||
|
- [ ] повторный прогон без удаления данных пропустил группу сессий.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Проверки качества заказов
|
||||||
|
|
||||||
|
Перед изменением обработки заказов вы добавите две проверки качества данных.
|
||||||
|
Затем намеренно испортите один заказ и убедитесь, что проверки обнаружили
|
||||||
|
ошибки.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
- [модель заказа DDS](../architecture/dds/order.md);
|
||||||
|
- [дневной конвейер](../architecture/etl.md#объём-прогона);
|
||||||
|
- [правила проверок](../architecture/testing.md#проверка-которая-не-умеет-краснеть-бесполезна).
|
||||||
|
|
||||||
|
Посмотрите также на устройство одной проверки в [`sql/dq/`](../../sql/dq/)
|
||||||
|
и на ее включение в [`dq_check`](../../dags/dq_check.py). Готового ответа для
|
||||||
|
двух проверок из этого задания в репозитории нет.
|
||||||
|
|
||||||
|
## Новые проверки
|
||||||
|
|
||||||
|
Добавьте две проверки и включите их в даг:
|
||||||
|
|
||||||
|
- `sql/dq/order_items_total.sql` проверяет сумму позиций заказа;
|
||||||
|
- `sql/dq/orders_vs_ods.sql` сверяет DDS с его источником;
|
||||||
|
- `dags/dq_check.py` запускает обе проверки, записывает их результаты в
|
||||||
|
`dm.dq_summary` и создает для них отдельные задачи.
|
||||||
|
|
||||||
|
Разбор ODS и сборку DDS вы перепишете в следующих заданиях. Здесь сохраните
|
||||||
|
их, даги загрузки, DDL, промежуточные таблицы, Kafka и службы как есть.
|
||||||
|
|
||||||
|
Перед опытами остановите автоматическое движение мира: поставьте `world_live`
|
||||||
|
на паузу и дождитесь завершения активных прогонов. До завершения опытов не
|
||||||
|
запускайте руками `world_next_day`, `world_live_day`, `world_initialize` и
|
||||||
|
`world_recreate`, чтобы новые данные не меняли результат между проверками.
|
||||||
|
|
||||||
|
## Требования к запросам
|
||||||
|
|
||||||
|
Чтобы `dq_check` мог запустить проверку, ее запрос возвращает по одной строке
|
||||||
|
на день с колонками `data_date`, `reference_rows`, `actual_rows`,
|
||||||
|
`failed_rows`, `diagnostics`. Даг сохраняет в `dm.dq_summary` только счетчики,
|
||||||
|
статус и сведения о запуске. Примеры из `diagnostics` ищите в
|
||||||
|
журнале упавшей задачи Airflow. Ограничьте их тем же малым числом, что и
|
||||||
|
существующие запросы в `sql/dq/`.
|
||||||
|
|
||||||
|
### Сумма позиций
|
||||||
|
|
||||||
|
Имя проверки - `order_items_total`. Для каждого заказа из `dds.order_v`
|
||||||
|
посчитайте сумму `item_price * item_quantity` и сравните ее с `items_total`.
|
||||||
|
В `reference_rows` запишите число заказов со стороны `items_total`,
|
||||||
|
в `actual_rows` число заказов со стороны вычисленной суммы, в `failed_rows`
|
||||||
|
число заказов, у которых суммы не совпали. На исправных данных первые два
|
||||||
|
счетчика равны, а `failed_rows` равен нулю. Для примеров расхождений
|
||||||
|
достаточно `order_id`, значения `items_total` и вычисленной суммы позиций.
|
||||||
|
|
||||||
|
Проверка поможет заметить ошибки разбора `items` при переносе заказа
|
||||||
|
из ODS в DDS. Скидку, доставку и равенство `total` здесь проверять не нужно.
|
||||||
|
|
||||||
|
### Полнота заказов в DDS
|
||||||
|
|
||||||
|
Имя проверки - `orders_vs_ods`. Источник модели DDS здесь - текущее состояние
|
||||||
|
`ods.order_v`. С ним вы и сравниваете DDS; обращаться к внутреннему состоянию
|
||||||
|
генератора для этой проверки не нужно.
|
||||||
|
Одним запросом рассчитайте итоги каждого дня заказа в обоих слоях и сравните:
|
||||||
|
|
||||||
|
- число заказов;
|
||||||
|
- максимальный `updated_at`.
|
||||||
|
|
||||||
|
День заказа в ODS вычисляйте в `Europe/Samara`, как `order_date` в DDS.
|
||||||
|
Проверьте все дни, которые есть хотя бы в одном слое: так вы заметите день,
|
||||||
|
целиком пропавший из другого. Перед сравнением приведите времена к одному
|
||||||
|
часовому поясу. `reference_rows` содержит число заказов ODS, `actual_rows`
|
||||||
|
число заказов DDS. В `failed_rows` запишите 1, если за день расходится
|
||||||
|
хотя бы одно значение, и 0, если оба совпали.
|
||||||
|
|
||||||
|
Обе стороны распределены по `order_id`. Дневные агрегаты соедините через
|
||||||
|
`GLOBAL FULL OUTER JOIN` с `join_use_nulls = 1`, как в существующих сверках.
|
||||||
|
|
||||||
|
Если строка потерялась при разборе в ODS, она не попадет и в DDS. Эта проверка
|
||||||
|
не заметит потерю: данные слоев совпадут. В [следующем задании](5-2-order-parsing.md)
|
||||||
|
вы отдельно сравните число исходных строк с числом разобранных заказов
|
||||||
|
и ошибок за один запуск, обозначенный `_load_id`.
|
||||||
|
|
||||||
|
## Проверка на намеренных ошибках
|
||||||
|
|
||||||
|
Сначала запустите `dq_check` на нетронутых данных. Обе новые задачи должны
|
||||||
|
завершиться успешно, а в `dm.dq_summary_v` у обоих имен не должно быть
|
||||||
|
дней с расхождениями.
|
||||||
|
|
||||||
|
Выберите оплаченный заказ из последнего слепка с непустым массивом позиций и
|
||||||
|
сохраните его `order_id` и `order_date`. Подключитесь к первому узлу из DBeaver
|
||||||
|
по [инструкции из README](../../README.md#подключиться-из-dbeaver) под
|
||||||
|
пользователем `etl`. Прибавьте одну копейку к `items_total` этого заказа в
|
||||||
|
локальной таблице `dds.order_rep` на всем кластере:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
|
||||||
|
UPDATE items_total = items_total + toDecimal64(0.01, 2)
|
||||||
|
WHERE order_id = '<order_id>'
|
||||||
|
SETTINGS mutations_sync = 2;
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем запустите только `dq_check`. Задача `order_items_total` должна
|
||||||
|
завершиться ошибкой, а ее журнал - назвать выбранный заказ.
|
||||||
|
`purchase_vs_orders_vs_dds` тоже даст красный результат: сохраненная витрина
|
||||||
|
содержит прежний `items_total`.
|
||||||
|
|
||||||
|
Запустите `etl_pipeline`. Текущее окно заказов будет заново собрано из ODS, и
|
||||||
|
исходная строка вернется в DDS. Повторный `dq_check` должен дать зеленый
|
||||||
|
результат для новой проверки.
|
||||||
|
|
||||||
|
Теперь удалите тот же заказ из `dds.order_rep` на всем кластере:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
|
||||||
|
DELETE WHERE order_id = '<order_id>'
|
||||||
|
SETTINGS mutations_sync = 2;
|
||||||
|
```
|
||||||
|
|
||||||
|
До восстановления снова запустите только `dq_check`. Красный результат нужен
|
||||||
|
для `orders_vs_ods`, `purchase_vs_orders_vs_dds` и `revenue_daily_vs_dds` на
|
||||||
|
дне оплаченного заказа. Затем запустите `etl_pipeline` и повторите проверку.
|
||||||
|
Если `orders_vs_ods` не обнаружил удаление, исправьте запрос до следующего
|
||||||
|
задания.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
В `dm.dq_summary_v` обе новые и пять прежних проверок должны быть успешны:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT check_name, sum(failed_rows) AS failed_rows, groupUniqArray(status)
|
||||||
|
FROM dm.dq_summary_v
|
||||||
|
GROUP BY check_name
|
||||||
|
ORDER BY check_name;
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка суммы должна охватывать каждый день `dds.order_v`. Сверка слоев
|
||||||
|
должна охватывать объединение дней ODS и DDS, включая дни только одной стороны.
|
||||||
|
|
||||||
|
## Завершение работы
|
||||||
|
|
||||||
|
После восстановления заказа и успешного `dq_check` можно закончить работу или
|
||||||
|
перейти к разбору заказов, оставив модельное время на паузе.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] две новые проверки запускаются в `dq_check`, а их дневные результаты
|
||||||
|
появляются в `dm.dq_summary`;
|
||||||
|
- [ ] вы увидели ошибку каждой проверки после соответствующего изменения
|
||||||
|
заказа, а в журнале нашли примеры заказов с расхождениями;
|
||||||
|
- [ ] вы восстановили заказ через `etl_pipeline` после обоих опытов;
|
||||||
|
- [ ] `order_items_total`, `orders_vs_ods` и пять прежних проверок успешны;
|
||||||
|
- [ ] разбор ODS и сборка DDS остались прежними.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Разбор заказов в ODS
|
||||||
|
|
||||||
|
Вы перепишете разбор заказов в ODS. На четырех пробных сообщениях проверите,
|
||||||
|
что правильно заполненный заказ и сообщения с ошибками попадают
|
||||||
|
в соответствующие таблицы без потерь.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
- [проверки заказов](5-1-order-checks.md);
|
||||||
|
- [прием заказов в ODS](../architecture/orders/ingestion.md);
|
||||||
|
- [раскладку по шардам](../architecture/storage.md#раскладка-по-шардам);
|
||||||
|
- [`orders_ingest`](../../dags/orders_ingest.py);
|
||||||
|
- [`order_parse.sql`](../../sql/ods/order_parse.sql).
|
||||||
|
|
||||||
|
## Порядок работы
|
||||||
|
|
||||||
|
Сначала проведите опыт на готовом `order_parse.sql`. Затем удалите его SQL,
|
||||||
|
соберите разбор заново и повторите опыт с другим маркером.
|
||||||
|
|
||||||
|
Сохраните устройство `orders_ingest`, DDL, Kafka, таблиц и преобразований
|
||||||
|
DDS. После каждого опыта удалите только добавленные вами строки.
|
||||||
|
|
||||||
|
Перед началом убедитесь, что после [предыдущего задания](5-1-order-checks.md)
|
||||||
|
все семь проверок качества прошли успешно. Поставьте `world_live` на паузу, дождитесь
|
||||||
|
активных прогонов и до завершения опыта не запускайте `world_next_day`,
|
||||||
|
`world_live_day`, `world_initialize` и `world_recreate`. Не запускайте
|
||||||
|
`etl_pipeline` между
|
||||||
|
отправкой пробной пачки и ее очисткой.
|
||||||
|
|
||||||
|
## Пробные сообщения
|
||||||
|
|
||||||
|
Сделайте четыре сообщения с общим уникальным маркером:
|
||||||
|
|
||||||
|
1. годную строку с маркером в `order_id`;
|
||||||
|
2. JSON-строку вместо объекта - класс `not_an_object`;
|
||||||
|
3. объект с лишним ключом - класс `keyset_mismatch`;
|
||||||
|
4. объект с точным набором ключей, но строкой в `user_id` - класс
|
||||||
|
`field_invalid`.
|
||||||
|
|
||||||
|
`not_an_object` означает, что корень JSON не объект. `keyset_mismatch` означает,
|
||||||
|
что у объекта есть лишние верхнеуровневые ключи или не хватает обязательных.
|
||||||
|
`field_invalid` означает, что ключи на месте, но значение имеет неверный тип,
|
||||||
|
формат или не помещается в заданный тип данных.
|
||||||
|
|
||||||
|
Соберите строки в `/tmp/<маркер>.jsonl`. Заготовка дает одну годную строку и
|
||||||
|
по одному примеру каждого класса брака:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LAB_MARKER="lab5-$(date +%s)"
|
||||||
|
LAB_BATCH="/tmp/${LAB_MARKER}.jsonl"
|
||||||
|
LAB_DAY="$(
|
||||||
|
docker compose exec -T clickhouse-01 sh -lc \
|
||||||
|
'clickhouse-client --user etl --password "$CLICKHOUSE_ETL_PASSWORD" \
|
||||||
|
--query "SELECT toString(max(snapshot_date)) FROM ods.order_dist"'
|
||||||
|
)"
|
||||||
|
|
||||||
|
jq -cn --arg marker "$LAB_MARKER" --arg day "$LAB_DAY" '
|
||||||
|
def good($id): {
|
||||||
|
order_id: $id, user_id: 1, status: "paid",
|
||||||
|
created_at: ($day + "T00:00:00.000Z"),
|
||||||
|
updated_at: ($day + "T01:00:00.000Z"),
|
||||||
|
items_total: "10.00", discount: "0.00",
|
||||||
|
delivery: "0.00", total: "10.00",
|
||||||
|
items: [{sku: "lab5", qty: 1, price: "10.00"}],
|
||||||
|
snapshot_date: $day
|
||||||
|
};
|
||||||
|
good($marker),
|
||||||
|
($marker + "-not-an-object"),
|
||||||
|
(good($marker + "-keys") + {lab_marker: true}),
|
||||||
|
(good($marker + "-field") | .user_id = "broken")
|
||||||
|
' > "$LAB_BATCH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Отправьте файл в обычный топик заказов утилитой из контейнера Kafka:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T kafka /opt/kafka/bin/kafka-console-producer.sh \
|
||||||
|
--bootstrap-server kafka:9092 --topic orders < "$LAB_BATCH"
|
||||||
|
```
|
||||||
|
|
||||||
|
Запустите `orders_ingest`. Найдите свои строки по маркеру в
|
||||||
|
`stg.orders_raw_dist` и сохраните их `_load_id`: это идентификатор запуска,
|
||||||
|
который принял сообщения. Он должен быть одинаковым у всех четырех строк.
|
||||||
|
|
||||||
|
## Проверка готового разбора
|
||||||
|
|
||||||
|
До запуска `etl_pipeline` проверьте строки с найденным `_load_id`:
|
||||||
|
|
||||||
|
- в STG четыре строки;
|
||||||
|
- в таблице `ods.order_dist` одна годная строка;
|
||||||
|
- в `ods.order_errors_dist` три строки, по одной каждого класса;
|
||||||
|
- `STG = годные + брак`;
|
||||||
|
- поля заказа в текущей строке `ods.order_v` совпадают с полями из годного
|
||||||
|
исходного сообщения; последнюю версию для сравнения выберите через
|
||||||
|
`argMax` по `snapshot_date`.
|
||||||
|
|
||||||
|
Считайте строки `ods.order_dist` именно по `_load_id`: представление
|
||||||
|
`ods.order_v` оставляет только текущую версию каждого заказа и не показывает,
|
||||||
|
сколько строк разобрал запуск. Запрос сверки с `argMax` напишите сами.
|
||||||
|
Значения для сравнения извлеките из `raw` пробных сообщений. Отдельно
|
||||||
|
сравните `created_at` и `updated_at`: в заготовке они намеренно различаются.
|
||||||
|
|
||||||
|
Проверьте раскладку годной строки через `hostName()` на `ods.order_dist`.
|
||||||
|
Узел, на котором лежит строка, должен соответствовать остатку `cityHash64(order_id) % 2`:
|
||||||
|
0 - `clickhouse-01`, 1 - `clickhouse-02`.
|
||||||
|
|
||||||
|
## Удаление пробных сообщений
|
||||||
|
|
||||||
|
Удалите только строки маркера из локальных таблиц:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE stg.orders_raw_rep ON CLUSTER clickstream_cluster
|
||||||
|
DELETE WHERE position(raw, '<маркер>') > 0
|
||||||
|
SETTINGS mutations_sync = 2;
|
||||||
|
|
||||||
|
ALTER TABLE ods.order_rep ON CLUSTER clickstream_cluster
|
||||||
|
DELETE WHERE startsWith(order_id, '<маркер>')
|
||||||
|
SETTINGS mutations_sync = 2;
|
||||||
|
|
||||||
|
ALTER TABLE ods.order_errors_rep ON CLUSTER clickstream_cluster
|
||||||
|
DELETE WHERE position(raw, '<маркер>') > 0
|
||||||
|
SETTINGS mutations_sync = 2;
|
||||||
|
```
|
||||||
|
|
||||||
|
В ClickHouse 26.3 мутации выполняются в фоне. С `mutations_sync = 2` команда
|
||||||
|
ждет их завершения на всех репликах. Запросами к трем Distributed-таблицам
|
||||||
|
убедитесь, что строк маркера не осталось, затем удалите временный файл.
|
||||||
|
|
||||||
|
Группа `clickstream_orders` уже подтвердила эти сообщения, поэтому обычный
|
||||||
|
повтор `orders_ingest` их не прочитает. В Kafka они остаются до удаления по
|
||||||
|
политике хранения. `world_recreate` удаляет группу и сам топик, поэтому в
|
||||||
|
пересозданный мир пробная пачка не переходит.
|
||||||
|
|
||||||
|
Очистка должна закончиться до запуска `etl_pipeline`. Если конвейер все же
|
||||||
|
успел собрать временный заказ в DDS, сначала очистите три таблицы выше, затем
|
||||||
|
запустите `etl_pipeline`: пересборка текущего окна удалит заказ из DDS и
|
||||||
|
обновит витрины.
|
||||||
|
|
||||||
|
## Собственный разбор заказов
|
||||||
|
|
||||||
|
Удалите готовый SQL из `sql/ods/order_parse.sql` и напишите свой. Чтобы
|
||||||
|
`orders_ingest` мог его использовать, сохраните следующие условия:
|
||||||
|
|
||||||
|
- запрос читает строки `stg.orders_raw_dist` с переданным `{load_id:String}`;
|
||||||
|
- каждая прочитанная строка попадает ровно в один результат: годный заказ или брак;
|
||||||
|
- ошибка в `raw` одной строки не прерывает разбор остальных;
|
||||||
|
- годные строки записываются в `ods.order_dist`, остальные - в
|
||||||
|
`ods.order_errors_dist`;
|
||||||
|
- классы брака и их порядок - `not_an_object`, `keyset_mismatch`,
|
||||||
|
`field_invalid`;
|
||||||
|
- метки доставки и `_load_id` переходят из STG без пересчета;
|
||||||
|
- задача завершается после записи и заказов, и ошибок на шарды через
|
||||||
|
Distributed; строки распределяются по ключу соответствующей целевой таблицы.
|
||||||
|
|
||||||
|
Точный набор полей, типы и условия проверки значений описаны в
|
||||||
|
[документе приема](../architecture/orders/ingestion.md#граница-строгого-приёма),
|
||||||
|
а целевые колонки - в [`20-ods-tables.sql`](../../sql/ddl/20-ods-tables.sql).
|
||||||
|
Содержимое `items` сохраните как JSON: его разбирает DDS.
|
||||||
|
|
||||||
|
Повторите опыт с новым маркером. Получите тот же баланс 4 = 1 + 3, те же три
|
||||||
|
класса, верную текущую версию заказа по `snapshot_date`, разные значения
|
||||||
|
`created_at` и `updated_at` и правильный шард. Затем удалите строки второго
|
||||||
|
опыта теми же командами и запустите
|
||||||
|
`dq_check`.
|
||||||
|
|
||||||
|
## Завершение работы
|
||||||
|
|
||||||
|
Убедитесь, что оба маркера отсутствуют в STG, ODS, таблице брака и DDS,
|
||||||
|
временные файлы удалены, а семь проверок успешны. Теперь можно перейти к
|
||||||
|
сборке DDS, не снимая паузу с модельного времени. Если вы прерываетесь и до
|
||||||
|
опыта `world_live` работал, снимите его с паузы.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] у вас сохранены результаты до переписывания: четыре строки дали
|
||||||
|
один заказ и три класса брака; вы сверили поля текущей версии и шард;
|
||||||
|
- [ ] вы переписали `sql/ods/order_parse.sql`, сохранив остальные части стенда;
|
||||||
|
- [ ] ваш разбор дал тот же результат на новой пробной пачке;
|
||||||
|
- [ ] вы проверили разные значения `created_at` и `updated_at` у годной
|
||||||
|
строки в обоих опытах и подтвердили правильный шард через `hostName()`;
|
||||||
|
- [ ] строки обоих опытов удалены по их маркерам;
|
||||||
|
- [ ] после очистки конвейер и все семь проверок успешны.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Сборка заказов в DDS
|
||||||
|
|
||||||
|
Вы перепишете загрузку заказов из ODS в DDS и выбор дней для пересчета.
|
||||||
|
Затем удалите данные за один из прошлых дней и проверите, что ваша
|
||||||
|
загрузка восстановила их.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
- [проверки заказов](5-1-order-checks.md);
|
||||||
|
- [разбор заказов в ODS](5-2-order-parsing.md);
|
||||||
|
- [модель заказа DDS](../architecture/dds/order.md);
|
||||||
|
- [дневной конвейер](../architecture/etl.md#объём-прогона);
|
||||||
|
- [раскладку по шардам](../architecture/storage.md#раскладка-по-шардам).
|
||||||
|
|
||||||
|
## Файлы для изменения
|
||||||
|
|
||||||
|
Удалите готовый SQL и перепишите только три файла:
|
||||||
|
|
||||||
|
- `sql/dds/order_scope.sql`;
|
||||||
|
- `sql/dds/order_rebuild.sql`;
|
||||||
|
- `sql/dds/order_replace.sql`.
|
||||||
|
|
||||||
|
Даг `dds_transform`, DDL, промежуточную таблицу, ODS и обе проверки из первого задания
|
||||||
|
оставьте как есть. Перед началом убедитесь, что пробные строки предыдущего
|
||||||
|
задания удалены, а семь проверок качества дают зеленый результат.
|
||||||
|
|
||||||
|
Поставьте `world_live` на паузу и дождитесь завершения активных прогонов. До
|
||||||
|
завершения опыта не запускайте `world_live_day`, `world_initialize` и
|
||||||
|
`world_recreate`. `world_next_day` понадобится только в явно указанном ниже
|
||||||
|
случае.
|
||||||
|
|
||||||
|
## Выполнение запросов в Airflow
|
||||||
|
|
||||||
|
Даг `dds_transform` ожидает от файлов следующие параметры и результаты:
|
||||||
|
|
||||||
|
| Файл | Вход | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| `order_scope.sql` | без параметров | одна строка `first_day`, `last_day` |
|
||||||
|
| `order_rebuild.sql` | `{first_day:Date}`, `{last_day:Date}`, `{load_id:String}` | выбранные дни в `dds.order_stage_dist` |
|
||||||
|
| `order_replace.sql` | день в `{{ params.day }}` | партиция дня в `dds.order_rep` |
|
||||||
|
|
||||||
|
Сначала определите дни, за которые заказы еще могут измениться. Возьмите
|
||||||
|
строки с последним `snapshot_date` и найдите минимальную и максимальную
|
||||||
|
дату заказа. Этот диапазон далее называется текущим окном источника.
|
||||||
|
|
||||||
|
Затем найдите первый день, который есть в ODS, но отсутствует в DDS.
|
||||||
|
Если он раньше начала текущего окна, начните расчет с него. Иначе начните
|
||||||
|
с начала окна. Полученная дата станет `first_day`, а конец окна `last_day`.
|
||||||
|
Один запуск пересчитывает все дни между ними. Если источник пуст,
|
||||||
|
`order_scope.sql` не возвращает строку.
|
||||||
|
|
||||||
|
`order_rebuild.sql` сначала очищает все партиции `dds.order_stage_rep` на
|
||||||
|
кластере, затем записывает в `dds.order_stage_dist` весь выбранный отрезок. Он
|
||||||
|
читает текущее состояние `ods.order_v`, вычисляет `order_date`, переводит
|
||||||
|
времена в `Europe/Samara`, разбирает `items` в три параллельных массива и
|
||||||
|
заполняет служебные поля запуска.
|
||||||
|
|
||||||
|
Дождитесь записи всех строк на шарды через Distributed перед заменой партиций.
|
||||||
|
Строки должны распределяться по ключу целевой таблицы. Способ рассчитать
|
||||||
|
выбранные дни внутри файла выберите сами.
|
||||||
|
|
||||||
|
`order_replace.sql` работает с локальными `dds.order_rep` и
|
||||||
|
`dds.order_stage_rep` на всем кластере. `REPLACE PARTITION` заменяет дневную
|
||||||
|
партицию основной таблицы готовой партицией из промежуточной. Промежуточная
|
||||||
|
партиция при этом сохраняется. Структура, ключи и политика хранения таблиц
|
||||||
|
совпадают: DDL создает промежуточную через `AS dds.order_rep`.
|
||||||
|
|
||||||
|
## Восстановление пропущенного дня
|
||||||
|
|
||||||
|
Найдите самый поздний день DDS перед началом текущего окна ODS. Заказы за этот
|
||||||
|
день уже не меняются, поэтому он считается закрытым. Удалив его, вы проверите,
|
||||||
|
что ваш запрос заметил пропуск и включил этот день в расчет.
|
||||||
|
|
||||||
|
Если такого дня еще нет, один раз продвиньте мир через `world_next_day`,
|
||||||
|
дождитесь `orders_ingest`, затем запустите `etl_pipeline`. После этого снова
|
||||||
|
оставьте модельное время неизменным до конца опыта и выберите этот день.
|
||||||
|
|
||||||
|
Сохраните для него число заказов и контрольную сумму их полей, исключив
|
||||||
|
служебные `_load_id` и `_load_ts`. Контрольная сумма позволит проверить,
|
||||||
|
что после восстановления значения остались прежними. Удалите только
|
||||||
|
партицию выбранного дня:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE dds.order_rep ON CLUSTER clickstream_cluster
|
||||||
|
DROP PARTITION 'YYYY-MM-DD';
|
||||||
|
```
|
||||||
|
|
||||||
|
Сразу запустите `dq_check`, до восстановления партиции. Красный результат
|
||||||
|
должны дать `orders_vs_ods` и `purchase_vs_orders_vs_dds`.
|
||||||
|
`revenue_daily_vs_dds` тоже даст красный результат, если в партиции были
|
||||||
|
оплаченные заказы. `classes_vs_inventory` останется зеленым: до запуска
|
||||||
|
`dm_transform` эта проверка читает прежнюю витрину сверки.
|
||||||
|
|
||||||
|
Запустите `etl_pipeline`. В группе `dds_transform.order` значение `first_day`
|
||||||
|
должно совпасть с удаленным днем, а задачи `replace` - покрыть непрерывный
|
||||||
|
отрезок от него до конца текущего окна. `first_day` ищите в строке про объем
|
||||||
|
пересборки в журнале задачи `order.scope` дага `dds_transform`. После успешного
|
||||||
|
прогона число заказов и контрольная сумма выбранного дня должны совпасть с
|
||||||
|
сохраненными.
|
||||||
|
|
||||||
|
## Распределение по шардам и повторный запуск
|
||||||
|
|
||||||
|
Напишите отдельный запрос к `dds.order_dist`, который получает фактический
|
||||||
|
узел каждой строки через `hostName()`, вычисляет ожидаемый по
|
||||||
|
`cityHash64(order_id) % 2` и считает несовпадения по каждому узлу.
|
||||||
|
Остаток 0 соответствует `clickhouse-01`, остаток 1 - `clickhouse-02`.
|
||||||
|
На обоих узлах число строк на чужом шарде должно быть нулевым.
|
||||||
|
Проверки количества строк читают данные со всех шардов через Distributed
|
||||||
|
и такую ошибку не обнаруживают.
|
||||||
|
|
||||||
|
Запустите `etl_pipeline` еще раз при неизменном модельном времени. Группа
|
||||||
|
заказов снова должна пересобрать текущее изменяемое окно. Контрольные суммы
|
||||||
|
полей заказов остаются прежними, а все семь проверок дают зеленый результат.
|
||||||
|
|
||||||
|
## Завершение работы
|
||||||
|
|
||||||
|
После восстановления партиции и успешных проверок снимите `world_live`
|
||||||
|
с паузы, если он работал до упражнения.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] вы переписали три файла `sql/dds/order_*.sql`, сохранив остальные
|
||||||
|
части стенда;
|
||||||
|
- [ ] вы удалили ближайший к окну закрытый день и увидели ошибку проверки до
|
||||||
|
восстановления;
|
||||||
|
- [ ] ваш расчет выбрал `first_day` с удаленного дня и заменил партиции
|
||||||
|
всего отрезка до конца окна;
|
||||||
|
- [ ] вы сверили число заказов и контрольную сумму восстановленного дня
|
||||||
|
с сохраненными значениями; они совпали;
|
||||||
|
- [ ] `order_items_total`, `orders_vs_ods` и пять прежних проверок успешны;
|
||||||
|
- [ ] ваш запрос с `hostName()` не нашел заказов на чужом шарде;
|
||||||
|
- [ ] после повторного `etl_pipeline` поля заказов остались прежними, проверки прошли.
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# Разбор сырых событий
|
||||||
|
|
||||||
|
В этой работе вы разберете сохраненные сообщения о событиях в свою таблицу.
|
||||||
|
Каждую полученную строку сравните с результатом обработки в стенде.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
- [формат события](../formats/clickstream-event.md);
|
||||||
|
- [справочник хранилища](../architecture/storage.md), разделы про имена
|
||||||
|
объектов, актуальные события, прием потока и срок жизни сырых данных;
|
||||||
|
- [ADR о приеме событий](../adr/0005-event-ingestion.md), раздел с принятым
|
||||||
|
решением;
|
||||||
|
- схему [`ods.event_rep`](../../sql/ddl/20-ods-tables.sql).
|
||||||
|
|
||||||
|
Готовый стендовый разбор находится в `sql/ddl/30-ods-views.sql`. Откройте его
|
||||||
|
после собственной сверки: в нем лежит ответ на основную часть задания.
|
||||||
|
|
||||||
|
## Порядок работы
|
||||||
|
|
||||||
|
Создайте в базе `default` свою таблицу для правильно заполненных событий.
|
||||||
|
Выберите небольшой набор сообщений из `stg.hits_raw_dist`, разберите их
|
||||||
|
и загрузите в таблицу одним `INSERT SELECT`. Работайте с первым узлом под
|
||||||
|
пользователем `etl`. Затем сравните результат с событиями в ODS.
|
||||||
|
|
||||||
|
Материализованные представления `ods.event_mv` и `ods.event_errors_mv`
|
||||||
|
продолжают обрабатывать поступающие сообщения. Оставьте их, STG, ODS, Kafka
|
||||||
|
и даги без изменений. Если пересоздать представления на работающем стенде,
|
||||||
|
часть сообщений может остаться без обработки.
|
||||||
|
|
||||||
|
Дайте своей таблице имя, которое не пересечется с чужой работой, и запишите
|
||||||
|
его вместе с запросами.
|
||||||
|
|
||||||
|
## Выбор исходных сообщений
|
||||||
|
|
||||||
|
STG хранит строки примерно три дня по `_load_ts`. Эта колонка отмечает время
|
||||||
|
поступления в хранилище. Модельный день события лежит в `EventDate`.
|
||||||
|
Посмотрите, сколько строк поступило за каждую из последних минут:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
toStartOfMinute(_load_ts) AS minute,
|
||||||
|
count() AS rows
|
||||||
|
FROM stg.hits_raw_dist
|
||||||
|
WHERE _load_ts >= now() - INTERVAL 30 MINUTE
|
||||||
|
AND _load_ts < toStartOfMinute(now())
|
||||||
|
GROUP BY minute
|
||||||
|
ORDER BY minute DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
Выберите короткий интервал `[slice_from, slice_to)`, который уже закончился и
|
||||||
|
дает удобное для построчной проверки число строк. Этот набор строк дальше
|
||||||
|
называется срезом. Если за одну минуту пришло слишком много строк,
|
||||||
|
сузьте ее точными границами `_load_ts`. Если недавних строк нет, откройте
|
||||||
|
Airflow. При включенном `world_live` посмотрите его текущий запуск и дочерний
|
||||||
|
`world_live_day`: дождитесь свежих строк от текущего или следующего планового
|
||||||
|
запуска, а при ошибке сначала разберите ее причину. Запустите `world_live_day`
|
||||||
|
вручную, только если `world_live` стоит на паузе и активного `world_live_day`
|
||||||
|
нет. Успешный прогон распределяет события модельного дня по реальным минутам.
|
||||||
|
|
||||||
|
Зафиксируйте до разбора:
|
||||||
|
|
||||||
|
- обе границы с миллисекундами и число строк;
|
||||||
|
- минимальный и максимальный `kafka_offset` по каждой паре
|
||||||
|
`kafka_topic`, `kafka_partition`.
|
||||||
|
|
||||||
|
Во всех дальнейших запросах читайте `stg.hits_raw_dist` только с этими
|
||||||
|
границами. Новые строки с более поздним `_load_ts` в срез не попадут. Повторите
|
||||||
|
опыт до удаления исходных строк по TTL; после него прежний срез уже не восстановить из ODS.
|
||||||
|
|
||||||
|
## Таблица для результата
|
||||||
|
|
||||||
|
Таблица содержит все 47 полей источника с именами и типами
|
||||||
|
`ods.event_rep`, а также `_load_ts`. Тип `purchaseRevenue` возьмите из ODS:
|
||||||
|
`Array(Decimal(18, 2))`.
|
||||||
|
|
||||||
|
Обработайте повторные доставки так же, как ODS. Возьмите
|
||||||
|
`ReplacingMergeTree(_load_ts)`, партицию `EventDate` и полный ключ замены:
|
||||||
|
`(CounterID, EventDate, intHash32(ClientID), WatchID)`. Достаточно одной
|
||||||
|
локальной таблицы: загрузка и сверка в этом опыте выполняются на одном узле.
|
||||||
|
|
||||||
|
`WatchID` обозначает событие и уникален по
|
||||||
|
[правилам генератора](../specs/2026-08-01-generator.md). Но `ReplacingMergeTree`
|
||||||
|
считает версиями строки, у которых совпадает весь ключ сортировки.
|
||||||
|
Поэтому для загрузки и сверки используйте все четыре части ключа ODS,
|
||||||
|
перечисленные выше.
|
||||||
|
|
||||||
|
## Разбор сообщений
|
||||||
|
|
||||||
|
Для каждой строки среза проверьте те же условия приема, что и на стенде:
|
||||||
|
|
||||||
|
1. корень `raw` — JSON-объект;
|
||||||
|
2. набор верхнеуровневых ключей в точности равен 47 ключам формата;
|
||||||
|
3. `WatchID`, `VisitID`, `ClientID`, `EventDate` и `UTCEventTime` разбираются
|
||||||
|
в ожидаемые типы.
|
||||||
|
|
||||||
|
Напишите разбор, который не прерывает пакет из-за одной грязной строки. Сами
|
||||||
|
выберите, как извлечь пять перечисленных полей без ошибки запроса, какие
|
||||||
|
выражения вынести в общую часть и где отфильтровать неподходящие строки.
|
||||||
|
Эти пять полей дальше называются опорными. Остальные поля извлеките
|
||||||
|
по описанию формата.
|
||||||
|
|
||||||
|
Перенесите `_load_ts` из STG без пересчета: это время исходной доставки и
|
||||||
|
колонка версии. Время запуска вашего запроса сюда не входит. Вставьте в свою
|
||||||
|
таблицу только годные строки выбранного среза.
|
||||||
|
|
||||||
|
Считайте отвергнутые строки прямо в исходном срезе, без отдельной таблицы
|
||||||
|
брака. Одним запросом посчитайте:
|
||||||
|
|
||||||
|
- все доставки;
|
||||||
|
- строки, прошедшие ваше условие отбора;
|
||||||
|
- строки, для которых это условие ложно.
|
||||||
|
|
||||||
|
Проверьте равенство общего числа строк сумме прошедших и отвергнутых. Так каждая
|
||||||
|
исходная строка учитывается ровно в одной группе. Если отвергнутые строки есть,
|
||||||
|
сохраните их `raw`, `_load_ts` и координаты Kafka. Найдите те же
|
||||||
|
координаты в `ods.event_errors_dist` и запишите стендовый `error_class`. Если
|
||||||
|
брака нет, так и зафиксируйте: опыт проверил успешную ветвь и выполнение
|
||||||
|
условия отбора на этом срезе, но не классификатор ошибок. Подкладывать грязные
|
||||||
|
сообщения ради задания не требуется.
|
||||||
|
|
||||||
|
## Подготовка к сравнению
|
||||||
|
|
||||||
|
### Повторные доставки
|
||||||
|
|
||||||
|
В STG одна строка представляет доставку. Повтор сообщения остается второй
|
||||||
|
строкой. В `ods.event_v` одна строка представляет актуальное событие, потому
|
||||||
|
что представление читает физическую таблицу с `FINAL`.
|
||||||
|
|
||||||
|
Свою таблицу тоже читайте с `FINAL`. Число строк должно совпасть с числом
|
||||||
|
различных полных ключей среди годных исходных сообщений. Разница между числом
|
||||||
|
годных доставок и этим числом показывает повторы в исходном срезе.
|
||||||
|
|
||||||
|
### События ODS для сравнения
|
||||||
|
|
||||||
|
Теперь нужно найти в ODS события из выбранного среза. Список стройте по
|
||||||
|
исходным доставкам: если взять его из своей таблицы, ошибочно отвергнутое
|
||||||
|
событие исчезнет с обеих сторон сверки и потеря останется незамеченной.
|
||||||
|
|
||||||
|
Снова возьмите все выбранные строки STG. По координатам Kafka исключите
|
||||||
|
доставки, которые есть в `ods.event_errors_dist`. У оставшихся извлеките
|
||||||
|
`WatchID` без риска прервать запрос и выберите эти события из свежего
|
||||||
|
`ods.event_v`. Свое условие отбора здесь не применяйте: решение о годности
|
||||||
|
доставки вы берете из результата стендового разбора.
|
||||||
|
|
||||||
|
`WatchID` связывает доставку с событием в ODS. Для удаления повторов и
|
||||||
|
сравнения полей дальше используйте полный физический ключ из схемы таблицы.
|
||||||
|
|
||||||
|
## Сверка полей
|
||||||
|
|
||||||
|
Соедините свою таблицу и выбранные строки `ods.event_v` через `FULL OUTER
|
||||||
|
JOIN` по полному ключу замены. Полное соединение сохраняет строки,
|
||||||
|
которых нет на одной из сторон. Найдите три вида расхождений:
|
||||||
|
|
||||||
|
- строка есть только в ODS — вы ее потеряли;
|
||||||
|
- строка есть только у вас — вы приняли лишнее или иначе разобрали часть
|
||||||
|
ключа;
|
||||||
|
- ключ есть с обеих сторон, но различается хотя бы одно из 47 полей.
|
||||||
|
|
||||||
|
ClickHouse по умолчанию заполняет отсутствующую сторону значениями по умолчанию
|
||||||
|
для соответствующих типов, например нулем или пустой строкой.
|
||||||
|
Включите для сверки `join_use_nulls = 1` или добавьте явный признак присутствия
|
||||||
|
в каждую сторону. Не определяйте отсутствие по значениям полей события.
|
||||||
|
|
||||||
|
Сравните все поля явно, включая массивы, `ecommerce` и точное десятичное
|
||||||
|
`purchaseRevenue`. Выберите, как показать расхождения, чтобы было видно,
|
||||||
|
какие колонки различаются в конкретной строке. Выведите число строк каждого
|
||||||
|
из трех видов расхождений. В верном разборе все три числа равны нулю.
|
||||||
|
|
||||||
|
`_load_ts` не входит в 47 полей источника и не проверяет JSON-разбор.
|
||||||
|
Проверьте его отдельно. Для каждой строки своей таблицы найдите самую позднюю
|
||||||
|
метку доставки того же полного ключа в исходном срезе. Она должна совпасть
|
||||||
|
с `_load_ts`, который вы читаете после `FINAL`. С ODS эту метку не сравнивайте: там может
|
||||||
|
храниться более поздняя доставка того же события из-за границ среза.
|
||||||
|
|
||||||
|
## Поиск ошибок разбора
|
||||||
|
|
||||||
|
Если нашлись расхождения, выберите одну строку. Найдите ее ключ в списке,
|
||||||
|
который вы получили из STG, и исходное сообщение `raw`. Выведите рядом
|
||||||
|
координаты Kafka и `_load_ts`.
|
||||||
|
|
||||||
|
Для измененной строки назовите поля, которые расходятся, и сравните каждое с
|
||||||
|
сырой строкой. Для пропавшей или лишней строки проверьте пять опорных полей и
|
||||||
|
результат условия отбора. Если координаты доставки есть в
|
||||||
|
`ods.event_errors_dist`, посмотрите `error_class`.
|
||||||
|
|
||||||
|
Исправьте разбор, удалите и заново создайте только собственную таблицу, затем
|
||||||
|
повторите вставку того же среза. Остановитесь, когда число исходных доставок по
|
||||||
|
прежним границам не изменилось, число годных и отвергнутых строк в сумме дает
|
||||||
|
число исходных доставок, а построчная сверка не находит расхождений.
|
||||||
|
|
||||||
|
## Ограничения проверки
|
||||||
|
|
||||||
|
`ods.event_v` — образец для сравнения. Оба разбора могли повторить одну
|
||||||
|
ошибку. Для пяти опорных полей у вас есть проверка преобразования в нужный тип
|
||||||
|
и исходный JSON. Остальные 42 поля можно вручную сопоставить с `raw`, однако
|
||||||
|
второго независимого источника для всех значений на стенде нет.
|
||||||
|
|
||||||
|
## Сохранение результата и удаление таблицы
|
||||||
|
|
||||||
|
Сохраните DDL таблицы, запрос разбора, границы среза, число исходных, годных
|
||||||
|
и отвергнутых строк и итоговую сверку без расхождений. Запишите также, чего
|
||||||
|
не подтверждает сравнение с ODS и как TTL ограничивает время для повторения
|
||||||
|
опыта. Затем удалите свою таблицу командой `DROP TABLE` с ее точным именем.
|
||||||
|
Убедитесь через `system.tables`, что объекта больше нет.
|
||||||
|
Данные стенда вы не меняли, поэтому удалять строки из STG, ODS или
|
||||||
|
Kafka после опыта не нужно.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] у вас сохранены границы выбранного среза, число строк и координаты
|
||||||
|
доставки по партициям Kafka;
|
||||||
|
- [ ] ваша таблица содержит все 47 полей и `_load_ts` и обрабатывает
|
||||||
|
повторы по тем же правилам, что и ODS;
|
||||||
|
- [ ] ваш разбор принимает только JSON-объекты с точным набором ключей и
|
||||||
|
пятью опорными полями, которые преобразуются в нужные типы;
|
||||||
|
- [ ] вы сверили число исходных строк с суммой годных и отвергнутых,
|
||||||
|
а найденный брак разобрали по `raw`, координатам и классу ошибки стенда;
|
||||||
|
- [ ] вы выбрали события ODS по исходному срезу и таблице ошибок,
|
||||||
|
независимо от своей таблицы и условия отбора;
|
||||||
|
- [ ] вы сравнили обе стороны по полному физическому ключу через
|
||||||
|
`FULL OUTER JOIN`: во всех 47 полях нет расхождений,
|
||||||
|
пропавших и лишних строк тоже нет;
|
||||||
|
- [ ] вы отдельно проверили перенос `_load_ts` и учли повторные доставки;
|
||||||
|
- [ ] вы записали ограничения сравнения с ODS и срок повторения опыта;
|
||||||
|
- [ ] после опыта вы удалили свою таблицу, сохранив объекты и данные стенда.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Мониторинг: куда смотреть
|
||||||
|
|
||||||
|
Вы познакомитесь с дашбордами мониторинга и проведете два расследования:
|
||||||
|
найдете запросы, которые создают нагрузку, и выясните, почему события сайта
|
||||||
|
и заказы обновляются с разной частотой.
|
||||||
|
|
||||||
|
## Знакомство с дашбордами
|
||||||
|
|
||||||
|
Откройте Grafana и Airflow по адресам из раздела [«Адреса и
|
||||||
|
пароли»](../../README.md#адреса-и-пароли). Для первого знакомства в Grafana
|
||||||
|
есть три дашборда:
|
||||||
|
|
||||||
|
- «Данные» — путь сообщений от источника до хранилища;
|
||||||
|
- «Кластер» — процессор, память, диски и внутренняя работа ClickHouse;
|
||||||
|
- «Запросы» — выполненные запросы, затраченные на них ресурсы и ошибки.
|
||||||
|
|
||||||
|
Откройте все три дашборда и запишите по одному вопросу, на который каждый из
|
||||||
|
них помогает ответить. «Данные» оставьте открытым в соседней вкладке.
|
||||||
|
|
||||||
|
Проведите два расследования на работающем стенде. Службы останавливать и данные
|
||||||
|
портить не понадобится.
|
||||||
|
|
||||||
|
## Расследование 1. Какие запросы читают больше всего данных
|
||||||
|
|
||||||
|
Начните в спокойный момент: не запускайте одновременно свои тяжелые запросы.
|
||||||
|
Откройте дашборд «Запросы». На панели «Тяжёлые запросы: что прочитало больше
|
||||||
|
всех» возьмите первую строку.
|
||||||
|
|
||||||
|
Сохраните:
|
||||||
|
|
||||||
|
- начало и конец выбранного периода, время запроса;
|
||||||
|
- место запроса в списке, пользователя и узел;
|
||||||
|
- текст запроса;
|
||||||
|
- длительность, память, число прочитанных и отданных строк.
|
||||||
|
|
||||||
|
По пользователю и тексту определите, кто отправил запрос и для чего. Затем
|
||||||
|
найдите запрос одного из трех открытых дашбордов, который прочитал больше всего
|
||||||
|
строк. Если это другая строка, сохраните для нее те же поля.
|
||||||
|
|
||||||
|
По тексту найдите панель, которая отправила запрос. Ищите ее среди трех
|
||||||
|
дашбордов по названиям колонок, таблиц и показателей. Запишите, зачем запрос
|
||||||
|
читает столько строк и согласуется ли это с вопросом, на который отвечает
|
||||||
|
панель.
|
||||||
|
|
||||||
|
В меню панели «Кто спрашивает: сколько запросов и какой у них аппетит»
|
||||||
|
откройте Inspect, затем Query. Ответьте на два вопроса:
|
||||||
|
|
||||||
|
1. Почему журнал читают сразу с обоих узлов?
|
||||||
|
2. Почему при подсчете запросов исключают записи о начале выполнения и
|
||||||
|
записи о выполнении частей запроса на шардах?
|
||||||
|
|
||||||
|
Проверьте второе объяснение по панели ошибок: ее фильтр не исключает записи
|
||||||
|
шардов. Объясните, на какие разные вопросы отвечают эти две панели
|
||||||
|
и зачем им разные условия отбора.
|
||||||
|
|
||||||
|
## Расследование 2. Как меняется свежесть заказов
|
||||||
|
|
||||||
|
Откройте дашборд «Данные». Панель «Свежесть: сколько прошло с последней
|
||||||
|
доехавшей строки» показывает, сколько времени прошло с последней загрузки.
|
||||||
|
Истории значений на ней нет. До наблюдения нарисуйте, как, по вашему ожиданию,
|
||||||
|
будут меняться эти числа для событий и заказов в течение одного запуска
|
||||||
|
`world_live_day`. Отдельно отметьте ожидаемый момент доставки слепка заказов.
|
||||||
|
|
||||||
|
Если `world_live` снят с паузы, наблюдайте его текущий или следующий
|
||||||
|
`world_live_day`: новый ручной запуск встанет в очередь. Если `world_live` на
|
||||||
|
паузе и активного `world_live_day` нет, запустите один `world_live_day` вручную.
|
||||||
|
Модельные сутки занимают около 24 реальных минут.
|
||||||
|
|
||||||
|
Запишите несколько наблюдений до конца дня и сразу после него:
|
||||||
|
|
||||||
|
| Реальное время | Свежесть событий, с | Свежесть заказов, с | Что происходило в Airflow |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| | | | |
|
||||||
|
|
||||||
|
Дашборд обновляется раз в минуту, поэтому точного совпадения момента
|
||||||
|
доставки с записью наблюдения ждать не нужно. Перенесите рост и сброс
|
||||||
|
показателя заказов на свой рисунок. Объясните форму линии: какую метку времени
|
||||||
|
вычитает панель из текущего времени, почему число растет и какое событие его
|
||||||
|
сбрасывает. Модельную дату наблюдаемого дня подпишите отдельно от реального
|
||||||
|
времени наблюдения.
|
||||||
|
|
||||||
|
Подтвердите, что сброс совпал с загрузкой заказов:
|
||||||
|
|
||||||
|
- в Airflow завершился прием слепка `orders_ingest`;
|
||||||
|
- на панели «Дневной слепок заказов: какой доехал, когда и после какой паузы»
|
||||||
|
появилась новая верхняя строка.
|
||||||
|
|
||||||
|
Если вы начали после доставки, дождитесь следующего дня либо запишите,
|
||||||
|
какую часть изменений успели увидеть. Одно текущее значение не доказывает ни рост, ни
|
||||||
|
сброс.
|
||||||
|
|
||||||
|
## Источники показателей
|
||||||
|
|
||||||
|
Не открывая справочник, объясните своими словами:
|
||||||
|
|
||||||
|
- почему время с последней загрузки считают по ODS, а число поступивших сообщений по STG;
|
||||||
|
- почему знаменатель доли брака берут из STG;
|
||||||
|
- почему числитель и знаменатель доли брака берут за один период.
|
||||||
|
|
||||||
|
Свяжите ответы с тем, что вы видели на панелях. Затем сверьте объяснение с
|
||||||
|
[главой о мониторинге](../handbook/monitoring.md) и справочниками дашбордов
|
||||||
|
[«Запросы»](../architecture/monitoring/queries.md) и
|
||||||
|
[«Данные»](../architecture/monitoring/data.md). Устройство живого дня и
|
||||||
|
доставки слепка описано в [главе об Airflow](../handbook/airflow-dags.md).
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] для каждого из трех дашбордов записан вопрос, на который он отвечает;
|
||||||
|
- [ ] у вас сохранены выбранный период, текст и показатели верхнего запроса;
|
||||||
|
вы выяснили, кто его отправил и зачем;
|
||||||
|
- [ ] вы нашли запрос открытых дашбордов, который прочитал больше всего
|
||||||
|
строк, и панель, которая его отправила;
|
||||||
|
- [ ] вы можете объяснить, зачем журнал собирают с двух узлов и какие записи
|
||||||
|
учитывают при подсчете запросов;
|
||||||
|
- [ ] у вас есть прогноз свежести, сделанный до наблюдения, и несколько
|
||||||
|
замеров; рост и сброс на рисунке объяснены через время последней доставки;
|
||||||
|
- [ ] вы подтвердили доставку заказов в Airflow и на панели слепков,
|
||||||
|
подписали модельную дату отдельно от реального времени наблюдений;
|
||||||
|
- [ ] вы можете объяснить выбор слоев для свежести, потока и доли брака
|
||||||
|
и почему числитель и знаменатель берут за один период;
|
||||||
|
- [ ] после наблюдений службы продолжают работать, данные и стендовые
|
||||||
|
дашборды остались без изменений.
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# Учебные задания
|
||||||
|
|
||||||
|
Вы будете достраивать хранилище небольшого интернет-магазина. Стенд уже
|
||||||
|
загружает события сайта и заказы, обрабатывает их и строит несколько отчетов.
|
||||||
|
Сначала вы подготовите данные для новых отчетов, затем разберетесь, как
|
||||||
|
работают расчеты и загрузка, и перепишете часть из них.
|
||||||
|
|
||||||
|
Основная программа состоит из семи ступеней. Проходите их по порядку,
|
||||||
|
факультативы выбирайте по желанию. Перед первым заданием прочитайте главу
|
||||||
|
[«Хранилище и его слои»](../handbook/storage-layers.md) и выполните запросы
|
||||||
|
из нее: они покажут данные, с которыми предстоит работать.
|
||||||
|
|
||||||
|
## Порядок заданий
|
||||||
|
|
||||||
|
| Ступень | Задания | Содержание |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Пять витрин для отчетов | подготовить данные о событиях, страницах, визитах, ошибках загрузки и рекламе; задания перечислены ниже |
|
||||||
|
| 2 | [Свой график в Superset](2-1-superset-chart.md) | построить график по своей витрине и проверить числа на нем |
|
||||||
|
| 3 | [Сессии из событий](3-1-sessions-by-hand.md) | объединить действия посетителя в визиты и сравнить их с визитами источника |
|
||||||
|
| 4 | [Расчет сессий в стенде](4-1-session-pipeline.md) | включить свой расчет в готовую обработку данных и проверить результат |
|
||||||
|
| 5 | [Проверки качества заказов](5-1-order-checks.md)<br>[Разбор заказов в ODS](5-2-order-parsing.md)<br>[Сборка заказов в DDS](5-3-order-pipeline.md) | сначала написать проверки качества, затем с их помощью проверить свою обработку заказов |
|
||||||
|
| 6 | [Разбор сырых событий](6-1-event-parsing.md) | извлечь поля из сообщений и сравнить результат с готовым разбором стенда |
|
||||||
|
| 7 | [Мониторинг: куда смотреть](7-1-monitoring.md) | найти тяжелые запросы и разобраться, почему данные обновляются с разной частотой |
|
||||||
|
|
||||||
|
## Ступень 1: витрины для отчетов
|
||||||
|
|
||||||
|
Витрина готовит данные для конкретного отчета. В стенде они находятся в базе
|
||||||
|
`dm`: это слой DM. Витрины выручки, трафика, сверки покупок с заказами и
|
||||||
|
сводка качества уже готовы. Вы добавите еще пять.
|
||||||
|
|
||||||
|
| № | Витрина | Содержание задания |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | [События для анализа: `events_enriched_v`](1-1-events-enriched.md) | выбрать нужные поля и добавить вычисляемые, сохранив одну строку на событие |
|
||||||
|
| 2 | [Показатели страниц по дням: `top_pages_daily`](1-2-top-pages-daily.md) | сохранить итоги в таблице и настроить их обновление через Airflow |
|
||||||
|
| 3 | [Визиты по устройствам: `session_overview`](1-3-session-overview.md) | хранить суммы и счетчики для расчета средних и долей |
|
||||||
|
| 4 | [Ошибки загрузки по дням: `dq_errors_daily`](1-4-dq-errors-daily.md) | сохранить число ошибок даже после удаления исходных строк |
|
||||||
|
| 5 | [Показатели рекламы: `utm_effectiveness`](1-5-utm-effectiveness.md) | различать суммы покупок из событий сайта и выручку по заказам магазина |
|
||||||
|
|
||||||
|
Начните с [первого задания](1-1-events-enriched.md): для него достаточно
|
||||||
|
одного представления. В заданиях 2–5 понадобятся таблицы и их обновление
|
||||||
|
по дням. К общим правилам ниже возвращайтесь по мере работы.
|
||||||
|
|
||||||
|
## Создание и обновление витрин
|
||||||
|
|
||||||
|
### Источники данных
|
||||||
|
|
||||||
|
События читайте из `dds.event_v`, сессии из `dds.session_v`. Суффикс `_v`
|
||||||
|
обозначает обычное представление: оно выполняет сохраненный запрос при
|
||||||
|
чтении. Такие представления сохраняют привычные имена и состав колонок,
|
||||||
|
когда внутреннее устройство таблиц меняется.
|
||||||
|
|
||||||
|
Каждая учебная витрина читает готовый источник и не зависит от других
|
||||||
|
учебных витрин. В задании 4 источниками служат таблицы ошибок ODS:
|
||||||
|
представлений для них нет.
|
||||||
|
|
||||||
|
### Таблицы и представления
|
||||||
|
|
||||||
|
В заданиях 2–5 вы группируете исходные строки. Например, в `top_pages_daily`
|
||||||
|
одна строка содержит итоги для одной страницы за день. То, чему соответствует
|
||||||
|
одна строка, называют **зерном**. На стенде итоги с новым зерном сохраняют
|
||||||
|
в таблицах. Отчеты читают готовые числа.
|
||||||
|
|
||||||
|
Новый расчет сначала записывают в промежуточную таблицу. Пока он готовится,
|
||||||
|
отчеты читают прежние данные из основной. Затем готовыми разделами
|
||||||
|
промежуточной таблицы заменяют разделы основной. Раздел называется
|
||||||
|
**партицией**; в этих заданиях он содержит данные одного дня.
|
||||||
|
Промежуточную таблицу также называют **донором**.
|
||||||
|
|
||||||
|
На примере `top_pages_daily` пять объектов выглядят так:
|
||||||
|
|
||||||
|
| Объект | Для чего нужен |
|
||||||
|
|---|---|
|
||||||
|
| `dm.top_pages_daily_rep` | Хранит рассчитанные строки на каждом шарде |
|
||||||
|
| `dm.top_pages_daily_dist` | Читает строки со всех шардов и распределяет запись между ними |
|
||||||
|
| `dm.top_pages_daily_stage_rep` | Хранит новый расчет до замены данных в основной таблице |
|
||||||
|
| `dm.top_pages_daily_stage_dist` | Распределяет запись нового расчета между шардами |
|
||||||
|
| `dm.top_pages_daily_v` | Дает отчетам доступ к готовой витрине; явно перечисляет ее колонки |
|
||||||
|
|
||||||
|
### Распределение по шардам
|
||||||
|
|
||||||
|
Каждый из двух шардов стенда хранит свою часть данных. Записывайте строки
|
||||||
|
через таблицы `_dist`: они выбирают шард по ключу шардирования. Прямая запись
|
||||||
|
в `_rep` оставит строки на том узле, где выполнен запрос.
|
||||||
|
|
||||||
|
Ключ для своей витрины выберите сами. Проверьте распределение строк между
|
||||||
|
шардами и объясните, почему выбранного ключа достаточно.
|
||||||
|
Пример обоснования для `dm.revenue_daily_dist` есть в
|
||||||
|
[`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
|
||||||
|
|
||||||
|
Создайте промежуточную таблицу через `AS <цель>_rep`, сохранив структуру и
|
||||||
|
ключи основной. У обеих таблиц `_dist` должен быть одинаковый ключ
|
||||||
|
шардирования: замена партиции выполняется внутри каждого шарда и не
|
||||||
|
перераспределяет строки. Подробности есть в разделе
|
||||||
|
[«Раскладка по шардам»](../architecture/storage.md#раскладка-по-шардам).
|
||||||
|
|
||||||
|
### SQL-файлы и запуск расчета
|
||||||
|
|
||||||
|
Команды создания таблиц и представлений (DDL) сохраняйте в
|
||||||
|
`sql/ddl/70-labs.sql`. При создании мира этот файл выполняется после
|
||||||
|
стендовых файлов. В работающем стенде примените команды сами по инструкции
|
||||||
|
ниже: сохранение файла и повторный `make up` их не выполнят.
|
||||||
|
|
||||||
|
Для витрины с таблицами нужны три файла в `sql/dm/`:
|
||||||
|
|
||||||
|
- `<витрина>_scope.sql` определяет, какие дни пересчитать;
|
||||||
|
- `<витрина>_rebuild.sql` рассчитывает их и записывает в промежуточную таблицу;
|
||||||
|
- `<витрина>_replace.sql` заменяет партицию одного дня в основной таблице.
|
||||||
|
|
||||||
|
Добавьте запуск расчета в [`dags/dm_transform.py`](../../dags/dm_transform.py),
|
||||||
|
чтобы витрина обновлялась вместе с остальными. Для представления из задания 1
|
||||||
|
достаточно выполнить `CREATE VIEW`.
|
||||||
|
|
||||||
|
Образец этого порядка работы есть у готовой витрины `revenue_daily`.
|
||||||
|
Ее таблицы, SQL-файлы и задача Airflow разобраны в
|
||||||
|
[справочнике DM](../architecture/dm.md#dmrevenue_daily_v).
|
||||||
|
Состав колонок и выбор дней для пересчета берите из своего задания.
|
||||||
|
|
||||||
|
## Выполнение SQL в DBeaver
|
||||||
|
|
||||||
|
В DBeaver создайте подключение по [инструкции в README](../../README.md#подключиться-из-dbeaver),
|
||||||
|
но укажите пользователя `etl` и пароль из `CLICKHOUSE_ETL_PASSWORD` в `.env`.
|
||||||
|
Пользователь `analyst` из той инструкции предназначен для чтения;
|
||||||
|
у `etl` есть права создавать таблицы и представления в `dm`.
|
||||||
|
|
||||||
|
Создайте файл `sql/ddl/70-labs.sql` и сохраняйте в нем DDL своих витрин.
|
||||||
|
Откройте его в SQL-редакторе этого подключения и выполните DDL текущего
|
||||||
|
задания. Создавайте объекты с
|
||||||
|
`ON CLUSTER clickstream_cluster`, чтобы они появились на обоих узлах;
|
||||||
|
образец для представления — [`sql/ddl/60-dm-views.sql`](../../sql/ddl/60-dm-views.sql).
|
||||||
|
Сохраненный файл позволит воссоздать ваши объекты при пересборке мира.
|
||||||
|
|
||||||
|
## Проверка витрин
|
||||||
|
|
||||||
|
В Airflow есть даг `homework_check` с отдельной задачей для каждой учебной
|
||||||
|
витрины. Запускайте его вручную: расписания у него нет. Задача завершится
|
||||||
|
ошибкой, если витрина еще не создана или ее данные расходятся с ожидаемыми.
|
||||||
|
Причину и примеры расхождений ищите в журнале задачи. Проверки еще не
|
||||||
|
выполненных заданий тоже будут красными.
|
||||||
|
|
||||||
|
Проверка заново рассчитывает показатели по источнику и сравнивает их с вашей
|
||||||
|
витриной. Строки сопоставляются по полям, которые их определяют: например,
|
||||||
|
по дню и странице. `FULL OUTER JOIN` позволяет найти и разные значения, и
|
||||||
|
строки, которых нет с одной стороны. `INNER JOIN` пропустил бы отсутствующие
|
||||||
|
строки. Когда расхождений нет, задача становится зеленой.
|
||||||
|
|
||||||
|
`homework_check` не сохраняет результаты в сводку качества `dm.dq_summary`.
|
||||||
|
|
||||||
|
Запросы проверок лежат в [`sql/dq/`](../../sql/dq/). Откройте их после собственного решения:
|
||||||
|
они заново рассчитывают показатели витрины, поэтому содержат ответ на
|
||||||
|
существенную часть задания. Сравните свой способ расчета с готовым.
|
||||||
|
|
||||||
|
## Задания по желанию
|
||||||
|
|
||||||
|
Полный список сюжетов — в разделе
|
||||||
|
[«Факультативы — пять разделов»](../specs/2026-09-01-labs-program.md#4-факультативы--пять-разделов)
|
||||||
|
программы.
|
||||||
|
|
||||||
|
### Почему выручка не сходится
|
||||||
|
|
||||||
|
Открывается после ступени 1. Четыре расследования качества данных:
|
||||||
|
|
||||||
|
- [Куда пропали покупки](optional/missing-purchases.md): найти заказы без
|
||||||
|
события, выбрать источник витрины и условие проверки полноты.
|
||||||
|
- [Почему повтор покупки завышает выручку](optional/duplicate-purchases.md):
|
||||||
|
рассчитать выручку с одной строкой на заказ и проверить результат на
|
||||||
|
повторных событиях.
|
||||||
|
- [Какая позиция объясняет разницу в сумме](optional/order-amount-difference.md):
|
||||||
|
разобрать товары заказа без потери точности и найти причину разницы в суммах.
|
||||||
|
- [Почему вчерашняя выручка меняется сегодня](optional/revenue-over-time.md):
|
||||||
|
определить, за какие дни пересчитывать выручку и какую историю заказов сохранять.
|
||||||
|
|
||||||
|
Первые три работы используют готовые данные. Четвертая продвигает мир через
|
||||||
|
Airflow; для нее вы сохраняете состояния заказов после каждого модельного дня.
|
||||||
|
|
||||||
|
### Данные о посетителях
|
||||||
|
|
||||||
|
Открывается после ступени 1.
|
||||||
|
|
||||||
|
- [Как новые заказы меняют отчет о посетителях](optional/identity-over-time.md):
|
||||||
|
сохранить историю связей, сравнить расчеты на прежних сессиях и определить,
|
||||||
|
какие сведения нужны для воспроизведения результата.
|
||||||
|
|
||||||
|
### Отчеты в Superset
|
||||||
|
|
||||||
|
Открывается после ступени 2. Два самостоятельных задания:
|
||||||
|
|
||||||
|
- [Свой дашборд в Superset](optional/superset-dashboard.md): собрать отчет
|
||||||
|
из своих витрин и подтвердить выводы запросами.
|
||||||
|
- [В DBeaver работает, в Superset нет](optional/superset-missing-object.md):
|
||||||
|
выяснить, почему клиенты по-разному видят один объект хранилища.
|
||||||
|
|
||||||
|
### Загрузка и обновление данных
|
||||||
|
|
||||||
|
Открывается после ступени 1.
|
||||||
|
|
||||||
|
- [Как обновлять витрину без полной пересборки](optional/incremental-daily-traffic.md):
|
||||||
|
продолжить работу о связях пользователей, пересчитать затронутые дни
|
||||||
|
и проверить повтор незавершенной загрузки. Повышенная сложность.
|
||||||
|
|
||||||
|
## Удаление временных объектов
|
||||||
|
|
||||||
|
После опытов удаляйте свои временные объекты. Если удалили учебную витрину,
|
||||||
|
ее задача в `homework_check` должна снова завершиться ошибкой: отсутствие
|
||||||
|
витрины тоже проверяется. Подробнее — в
|
||||||
|
[справочнике проверок](../architecture/testing.md).
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Почему повтор покупки завышает выручку
|
||||||
|
|
||||||
|
Несколько событий покупки могут относиться к одному заказу. Вы проверите,
|
||||||
|
как они завышают сумму, и составите расчет, который учитывает каждый заказ
|
||||||
|
один раз.
|
||||||
|
|
||||||
|
Факультатив доступен после ступени 1. Работайте в DBeaver пользователем
|
||||||
|
`analyst` по [инструкции](../../../README.md#подключиться-из-dbeaver).
|
||||||
|
Дождитесь завершения `etl_pipeline`; оставьте `world_live` на паузе и
|
||||||
|
дождитесь уже начатых прогонов, чтобы данные не менялись между запросами.
|
||||||
|
|
||||||
|
## Расчет по событиям покупки
|
||||||
|
|
||||||
|
В ODS уже устранены повторные доставки событий. Достаточно ли этого,
|
||||||
|
чтобы сложить суммы всех `purchase` для витрины заказов? Проверьте на данных.
|
||||||
|
|
||||||
|
Возьмите день, за который уже получены все слепки заказов: его дата должна быть не позднее
|
||||||
|
`max(snapshot_date) - 6` из `ods.order_v`. Во всех запросах к `dds.event_v`
|
||||||
|
отбирайте этот `event_date` и `event_type = 'purchase'`.
|
||||||
|
|
||||||
|
Номер заказа находится в `purchase_id[1]`, заявленная сумма — в
|
||||||
|
`purchase_revenue[1]`. По формату источника у события покупки один номер заказа.
|
||||||
|
|
||||||
|
Сначала посчитайте число событий и сумму всех заявленных сумм. Затем найдите
|
||||||
|
номера заказов, которые встречаются несколько раз. Если таких нет, выберите
|
||||||
|
другой день с полученными слепками.
|
||||||
|
|
||||||
|
Для двух повторившихся покупок выпишите `event_id`, `client_id`, `event_time`,
|
||||||
|
номер заказа и сумму. Сопоставьте число событий и заказов в `dds.order_v`.
|
||||||
|
|
||||||
|
## Расчет по заказам
|
||||||
|
|
||||||
|
Напишите запрос с одной строкой на номер заказа. Сохраните в результате
|
||||||
|
номер, число исходных событий и одну заявленную сумму. До выбора суммы
|
||||||
|
проверьте, что во всех событиях заказа она одинакова. Затем посчитайте итог
|
||||||
|
дня по результату этого запроса и сравните с расчетом по событиям.
|
||||||
|
|
||||||
|
Проверьте, что номера в результате уникальны, а число строк совпадает с числом
|
||||||
|
разных номеров в исходных событиях. Отдельно проверьте свое решение на случае
|
||||||
|
двух разных заказов с одинаковой суммой: сколько денег оно учтет?
|
||||||
|
|
||||||
|
## Заказы с повторными событиями
|
||||||
|
|
||||||
|
Возьмите один повторившийся номер заказа. Сколько денег он добавил в первый
|
||||||
|
расчет и сколько — во второй? Подтвердите разность его событиями.
|
||||||
|
|
||||||
|
Повторите расчет для остальных повторившихся номеров. Сохраните таблицу:
|
||||||
|
номер заказа, число событий и вклад в разность сумм. Проверьте, объясняют ли
|
||||||
|
эти заказы все расхождение за день до копейки.
|
||||||
|
|
||||||
|
Слой ODS убирает повторную доставку одного события по его идентификатору;
|
||||||
|
`dds.event_v` читает уже это состояние. Сравните число разных `event_id`
|
||||||
|
и число разных номеров заказа в выбранных строках. Почему устранение
|
||||||
|
повторов по первому ключу не дало тот же результат, что по второму?
|
||||||
|
Подтвердите ответ конкретными идентификаторами.
|
||||||
|
|
||||||
|
После собственного расчета найдите эти заказы в `dm.purchase_vs_orders_v`.
|
||||||
|
Если у повторившегося заказа класс отличается от `duplicate_event`, проверьте
|
||||||
|
его статус и [порядок присвоения классов](../../architecture/dm.md#dmpurchase_vs_orders_v).
|
||||||
|
Класс витрины не заменяет поиск всех повторов в событиях.
|
||||||
|
|
||||||
|
## Сравнение с выручкой магазина
|
||||||
|
|
||||||
|
Сложите `revenue` из `dm.revenue_daily_v` за тот же `report_date` по всем
|
||||||
|
категориям. Эта витрина считает стоимость позиций оплаченных заказов,
|
||||||
|
без скидки и доставки.
|
||||||
|
|
||||||
|
Сравните результат с суммой по событиям после устранения повторов. Совпадение не требуется:
|
||||||
|
удаление повторов устраняет одну причину расхождения. Найдите хотя бы один
|
||||||
|
заказ, который объясняет, почему деньги трекера и магазина все еще могут
|
||||||
|
различаться. Для этого сопоставьте события, заказ и готовую сверку.
|
||||||
|
|
||||||
|
Рядом с запросом укажите, чему соответствует одна строка результата
|
||||||
|
и какие поля ее определяют. Обоснуйте, где нужна одна строка на событие,
|
||||||
|
а где одна на заказ: следует ли ради этого расчета
|
||||||
|
удалять повторные покупки из `dds.event_v`? Используйте найденные `event_id`.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] сохранен запрос с одной строкой на заказ, числом событий и суммой;
|
||||||
|
- [ ] уникальность ключа и полнота набора номеров проверены по источнику;
|
||||||
|
- [ ] разность сумм полностью объяснена вкладом лишних событий;
|
||||||
|
- [ ] обоснованы ключ расчета и место устранения повторов;
|
||||||
|
- [ ] найден случай, показывающий границы расчета денег по трекеру.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Как новые заказы меняют отчет о посетителях
|
||||||
|
|
||||||
|
Вы сохраните историю связей посетителей сайта с покупателями магазина.
|
||||||
|
На ее основе проверите, как новые связи меняют старые отчеты и что нужно,
|
||||||
|
чтобы повторить прежний расчет.
|
||||||
|
|
||||||
|
Факультатив доступен после ступени 1. Понадобятся Airflow и DBeaver с
|
||||||
|
подключением `student` по [инструкции](../../../README.md#подключиться-из-dbeaver).
|
||||||
|
Результаты запросов сохраняйте в `default` через одно подключение:
|
||||||
|
ваши таблицы будут доступны только на узле этого подключения.
|
||||||
|
Если предложенное имя таблицы занято, выберите свое.
|
||||||
|
|
||||||
|
В событиях сайта посетитель обозначен `client_id` из cookie браузера,
|
||||||
|
а в заказах покупатель обозначен `user_id`. Сопоставив событие покупки
|
||||||
|
с заказом, можно связать эти идентификаторы. Тогда и к прежним визитам
|
||||||
|
этого `client_id` можно добавить
|
||||||
|
пользователя магазина. Вы проверите, как появление такой связи меняет число
|
||||||
|
известных пользователей в отчете за прошедший день.
|
||||||
|
|
||||||
|
## Связи посетителей с покупателями
|
||||||
|
|
||||||
|
Поставьте `world_live` на паузу, дождитесь завершения начатых прогонов
|
||||||
|
генерации и обработки. Запустите `etl_pipeline` и дождитесь успеха.
|
||||||
|
На время работы мир будете продвигать вручную; новые дни останутся в стенде.
|
||||||
|
|
||||||
|
Напишите запрос по двум источникам:
|
||||||
|
|
||||||
|
| Источник | Нужные поля |
|
||||||
|
|---|---|
|
||||||
|
| `dds.event_v` | `event_type`, `event_time`, `client_id`, `purchase_id` |
|
||||||
|
| `dds.order_v` | `order_id`, `user_id` |
|
||||||
|
|
||||||
|
В результате нужна одна строка на пару `client_id`, `user_id`. Для каждой пары
|
||||||
|
найдите самое раннее время события покупки, которое удалось сопоставить
|
||||||
|
с заказом. Назовите эту колонку `first_purchase_at`.
|
||||||
|
В событии `purchase` массив `purchase_id` содержит один номер заказа.
|
||||||
|
Статус заказа не ограничивайте: отмена покупки не меняет найденную связь
|
||||||
|
посетителя с покупателем.
|
||||||
|
|
||||||
|
Источники распределены по разным ключам. Для соединения используйте
|
||||||
|
`GLOBAL INNER JOIN`, заключив выборки событий и заказов в подзапросы.
|
||||||
|
Устройство источников описано в [карте идентичностей](../../architecture/dds/identity.md).
|
||||||
|
|
||||||
|
Проверьте уникальность пар и совпадение их полного набора с
|
||||||
|
`dds.identity_map_v`: одного равенства количества недостаточно.
|
||||||
|
Для одной пары покажите номера заказов и события, по которым получили
|
||||||
|
`first_purchase_at`.
|
||||||
|
|
||||||
|
## История связей
|
||||||
|
|
||||||
|
Создайте таблицу:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE default.lab_identity_history
|
||||||
|
(
|
||||||
|
observation_day Date,
|
||||||
|
client_id UInt64,
|
||||||
|
user_id UInt64,
|
||||||
|
first_purchase_at DateTime('Europe/Samara')
|
||||||
|
)
|
||||||
|
ENGINE = MergeTree
|
||||||
|
ORDER BY (observation_day, client_id, user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждая копия списка должна содержать все найденные пары. В колонку
|
||||||
|
`observation_day` запишите последний обработанный день событий:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT max(event_date) AS observation_day FROM dds.event_v;
|
||||||
|
```
|
||||||
|
|
||||||
|
Обозначим полученную дату **H0**. Она показывает, после обработки какого
|
||||||
|
модельного дня сохранен список; календарная дата запуска Airflow здесь
|
||||||
|
не используется. Сохраните полный результат своего запроса через
|
||||||
|
`INSERT INTO ... SELECT`, добавив H0 в `observation_day` каждой строки.
|
||||||
|
Сверьте число записанных строк с результатом исходного запроса и проверьте
|
||||||
|
уникальность пар для этой даты.
|
||||||
|
|
||||||
|
Затем дважды выполните последовательность:
|
||||||
|
|
||||||
|
1. Запустите `world_next_day` с `days = 1` и дождитесь успеха.
|
||||||
|
2. Запустите `etl_pipeline` и дождитесь успеха.
|
||||||
|
3. Получите новую `observation_day`, сохраните полный набор связей и проверьте его.
|
||||||
|
|
||||||
|
Получатся три копии списка: после дней **H0**, **H1** и **H2**.
|
||||||
|
Для каждой даты сохраняйте список один раз: обычный `MergeTree`
|
||||||
|
не устраняет повторы вставки. Сохраните и проверьте список перед тем,
|
||||||
|
как запускать следующий модельный день.
|
||||||
|
|
||||||
|
## Дата покупки и дата появления связи
|
||||||
|
|
||||||
|
Сравните списки за соседние даты: H0 с H1, затем H1 с H2. Найдите пары,
|
||||||
|
которых не было в прежнем списке. Для нескольких таких пар покажите
|
||||||
|
`client_id`, `user_id`, `first_purchase_at` и первую `observation_day`,
|
||||||
|
в которой пара появилась. Найдите пример, где покупка произошла раньше
|
||||||
|
этой даты.
|
||||||
|
|
||||||
|
Для одной такой связи вернитесь к заказу и событию. По данным и
|
||||||
|
[порядку доставки заказов](../../architecture/orders/ingestion.md) объясните,
|
||||||
|
почему пары не было в прежнем списке. Какие данные понадобились для ее
|
||||||
|
появления и когда они стали доступны?
|
||||||
|
|
||||||
|
Для пары, которая присутствовала уже в H0, нельзя определить дату появления:
|
||||||
|
у вас нет более ранних копий списка. Для новой пары можно установить
|
||||||
|
интервал между двумя сохранениями, но не точное время внутри него.
|
||||||
|
|
||||||
|
## Пересчет прежних отчетов
|
||||||
|
|
||||||
|
Возьмите один и тот же набор сессий из `dds.session_v` за даты до H0
|
||||||
|
включительно. Постройте два дневных расчета:
|
||||||
|
|
||||||
|
- со списком связей на H0;
|
||||||
|
- со списком связей на H2.
|
||||||
|
|
||||||
|
Для каждого дня посчитайте посетителей по разным `client_id` и известных
|
||||||
|
пользователей по разным `user_id`. Сессии без связи должны остаться в расчете,
|
||||||
|
но неизвестный пользователь не должен увеличивать число известных.
|
||||||
|
В соединении сессий с вашей таблицей используйте `GLOBAL LEFT JOIN`.
|
||||||
|
В таблице связей заранее отберите одну `observation_day`, чтобы копии
|
||||||
|
списка за разные дни не смешались.
|
||||||
|
|
||||||
|
Найдите день с разным числом известных пользователей. Проследите изменение
|
||||||
|
до конкретных связей и прежних сессий. Проверьте, что число посетителей
|
||||||
|
не изменилось, а число известных пользователей не больше числа посетителей.
|
||||||
|
|
||||||
|
Теперь проверьте, можно ли воспроизвести расчет на H0 без сохраненного
|
||||||
|
списка за эту дату. Возьмите список на H2 и оставьте только пары,
|
||||||
|
у которых `first_purchase_at` приходится на H0 или более ранний день.
|
||||||
|
Сравните полный набор пар с копией на H0. Выведите пары, которые есть
|
||||||
|
только в одном из списков.
|
||||||
|
На примере найденных различий объясните, какие данные нужно сохранять,
|
||||||
|
чтобы повторить прежний расчет.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
У вас есть запрос сопоставления посетителей с покупателями, три сохраненных
|
||||||
|
списка связей и сравнение двух расчетов на одном наборе сессий. На конкретных
|
||||||
|
парах вы можете показать разницу между датой покупки и датой появления пары
|
||||||
|
в сохраненном списке, а также объяснить, что нужно для повторения прежнего
|
||||||
|
расчета.
|
||||||
|
|
||||||
|
Сохраните SQL и таблицу `lab_identity_history`: они нужны в продолжении
|
||||||
|
[«Как обновлять витрину без полной пересборки»](incremental-daily-traffic.md).
|
||||||
|
Если продолжать не планируете, удалите свою таблицу через `DROP TABLE`.
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Как обновлять витрину без полной пересборки
|
||||||
|
|
||||||
|
Вы настроите обновление витрины только за дни, затронутые новыми данными.
|
||||||
|
Затем проверите, можно ли продолжить загрузку, которая успела записать
|
||||||
|
лишь часть результатов.
|
||||||
|
|
||||||
|
Факультатив повышенной сложности продолжает работу
|
||||||
|
[«Как новые заказы меняют отчет о посетителях»](identity-over-time.md).
|
||||||
|
Понадобятся ее запрос сохранения списка связей и таблица `default.lab_identity_history`.
|
||||||
|
Работайте под `student` через то же подключение DBeaver.
|
||||||
|
|
||||||
|
Стенд пересчитывает `dm.daily_traffic_v` целиком: новая связь может изменить
|
||||||
|
число известных пользователей в старом дне. Вы построите рядом свою витрину,
|
||||||
|
которая пересчитывает только затронутые дни и дает тот же результат.
|
||||||
|
|
||||||
|
При обычном продвижении модельного времени появляются новые дни сессий
|
||||||
|
и новые связи. Уже найденные пары сохраняются, а сессии прежних дней
|
||||||
|
не меняются.
|
||||||
|
Удаление данных, исправления старых сессий и пересоздание мира потребовали бы
|
||||||
|
другого способа обнаружения изменений.
|
||||||
|
|
||||||
|
## Витрина и журнал загрузок
|
||||||
|
|
||||||
|
Создайте две локальные таблицы. Если имена заняты, выберите свои.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE default.lab_daily_traffic
|
||||||
|
(
|
||||||
|
report_date Date,
|
||||||
|
visitors UInt64,
|
||||||
|
known_users UInt64,
|
||||||
|
observation_day Date
|
||||||
|
)
|
||||||
|
ENGINE = ReplacingMergeTree(observation_day)
|
||||||
|
ORDER BY report_date;
|
||||||
|
|
||||||
|
CREATE TABLE default.lab_traffic_runs
|
||||||
|
(
|
||||||
|
observation_day Date
|
||||||
|
)
|
||||||
|
ENGINE = MergeTree
|
||||||
|
ORDER BY observation_day;
|
||||||
|
```
|
||||||
|
|
||||||
|
В витрине одна актуальная строка на день. Для обновления вставляйте полный
|
||||||
|
новый результат дня с новой `observation_day`. Читайте витрину с `FINAL`:
|
||||||
|
для каждого `report_date` останется строка с максимальной `observation_day`.
|
||||||
|
Старые версии могут исчезнуть при слиянии,
|
||||||
|
поэтому для воспроизведения прежнего расчета нужна сохраненная история связей.
|
||||||
|
|
||||||
|
В журнал записывайте `observation_day` списка связей, использованного
|
||||||
|
для расчета. Добавляйте дату только после записи всех выбранных дней
|
||||||
|
и успешной сверки. Максимальная дата в журнале укажет, какой список
|
||||||
|
использовался в последней завершенной загрузке.
|
||||||
|
|
||||||
|
## Первая загрузка
|
||||||
|
|
||||||
|
Убедитесь, что `world_live` стоит на паузе и нет активных прогонов генерации
|
||||||
|
и обработки. Запустите `etl_pipeline` и дождитесь успеха. Получите текущую
|
||||||
|
`observation_day` способом из первой работы. Если списка за эту дату еще нет
|
||||||
|
в вашей истории, сохраните его. Повторно вставлять уже сохраненный список
|
||||||
|
не требуется.
|
||||||
|
|
||||||
|
Напишите `INSERT INTO ... SELECT`, который рассчитывает все даты сессий из
|
||||||
|
`dds.session_v` со списком связей на текущую дату. Правила результата:
|
||||||
|
|
||||||
|
| Колонка | Что считать |
|
||||||
|
|---|---|
|
||||||
|
| `report_date` | `session_date` |
|
||||||
|
| `visitors` | число разных `client_id` |
|
||||||
|
| `known_users` | число разных известных `user_id` |
|
||||||
|
| `observation_day` | дата выбранной копии списка связей |
|
||||||
|
|
||||||
|
Сессии без известного пользователя сохраняйте в расчете. Несколько кук одного
|
||||||
|
пользователя не должны увеличивать `known_users`. Для соединения с вашей
|
||||||
|
таблицей связей используйте `GLOBAL LEFT JOIN`, отобрав одну `observation_day`.
|
||||||
|
|
||||||
|
Построчно сверьте свои `report_date`, `visitors`, `known_users` с
|
||||||
|
`dm.daily_traffic_v`. Сверка должна находить лишние и отсутствующие даты
|
||||||
|
и различия обоих показателей. Учебную витрину читайте с `FINAL`.
|
||||||
|
После совпадения запишите дату наблюдения в журнал один раз.
|
||||||
|
|
||||||
|
## Выбор дней для пересчета
|
||||||
|
|
||||||
|
Продвиньте мир на один день: `world_next_day` с `days = 1`, затем
|
||||||
|
`etl_pipeline`; после каждого запуска дождитесь успеха. Сохраните новый
|
||||||
|
список связей запросом из первой работы.
|
||||||
|
|
||||||
|
Обозначим максимальную `observation_day` в журнале **P**, а дату нового
|
||||||
|
списка связей — **H**. До записи результата составьте запрос, возвращающий
|
||||||
|
даты для пересчета без повторов. Он должен учесть два изменения:
|
||||||
|
|
||||||
|
- в сессиях появились дни, которых еще нет в вашей витрине;
|
||||||
|
- в списке на H появились пары, которых не было в списке на P; у этих `client_id`
|
||||||
|
есть сессии, в том числе за прежние дни.
|
||||||
|
|
||||||
|
Сначала выведите даты и выберите один прежний день: какая новая пара
|
||||||
|
потребовала его пересчета и какие сессии с ней связаны? Рядом посчитайте,
|
||||||
|
сколько дат пересчитала бы полная загрузка. Зафиксируйте оба числа;
|
||||||
|
равенство тоже возможно, если новые связи затронули всю историю.
|
||||||
|
|
||||||
|
Для сравнения выберите из списка на H пары, у которых день покупки
|
||||||
|
по `first_purchase_at` позже P. Совпадет ли результат с новыми парами,
|
||||||
|
найденными сравнением списков? Покажите пару, которую ограничение по дате
|
||||||
|
покупки пропускает.
|
||||||
|
|
||||||
|
## Обновление витрины
|
||||||
|
|
||||||
|
Напишите второй `INSERT INTO ... SELECT`. Для выбранных дат он должен
|
||||||
|
рассчитать все сессии с полным списком связей на H. Прибавление числа новых
|
||||||
|
связей к прежнему `known_users` не заменяет пересчет разных пользователей.
|
||||||
|
|
||||||
|
Вставьте результаты с версией H. Полностью сверьте актуальную учебную витрину
|
||||||
|
со стендовой, включая дни, которые не пересчитывали. Только после совпадения
|
||||||
|
добавьте H в журнал.
|
||||||
|
|
||||||
|
Без продвижения мира снова определите P по журналу и повторите выбор дат.
|
||||||
|
Запрос должен вернуть пустой результат, а витрина — остаться прежней.
|
||||||
|
Повторно записывать H в журнал не требуется.
|
||||||
|
|
||||||
|
## Повтор незавершенной загрузки
|
||||||
|
|
||||||
|
Снова запустите `world_next_day` с `days = 1`, затем `etl_pipeline`,
|
||||||
|
дожидаясь успеха каждого. Сохраните новый список связей с его датой H.
|
||||||
|
Получите даты для пересчета, но запишите результат только за одну из
|
||||||
|
них. Остальные пока не загружайте и дату в журнал не добавляйте.
|
||||||
|
Так вы воспроизведете остановку между записями дней, не ломая стенд.
|
||||||
|
|
||||||
|
Снова определите P по журналу, выполните запрос выбора дат и пересчет
|
||||||
|
этих дней. Проверьте, что результат совпал со стендовой витриной без очистки
|
||||||
|
уже записанных строк. После сверки запишите H в журнал.
|
||||||
|
|
||||||
|
Объясните на этом опыте, почему сохранение нового списка связей еще
|
||||||
|
не означает, что витрина обновлена. Что изменится, если записать H в журнал
|
||||||
|
до обновления витрины?
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
Сохраните SQL первой загрузки, выбора дат, обновления и сверки.
|
||||||
|
После двух новых модельных дней ваша витрина совпадает со стендовой,
|
||||||
|
повтор без новых данных сохраняет результат, а незавершенную загрузку
|
||||||
|
можно продолжить без полной пересборки.
|
||||||
|
|
||||||
|
Сравните число пересчитанных дней с полной загрузкой и перечислите,
|
||||||
|
какие дополнительные данные пришлось хранить. Так можно оценить объем работы.
|
||||||
|
На небольшом стенде полный пересчет может быть дешевле.
|
||||||
|
В вашей реализации поиск связей пока читает всю историю покупок.
|
||||||
|
По новым данным обновляется только дневная витрина; такое обновление
|
||||||
|
называют инкрементальным.
|
||||||
|
|
||||||
|
После работы удалите свои `lab_daily_traffic`, `lab_traffic_runs` и
|
||||||
|
`lab_identity_history` через `DROP TABLE`, если они больше не нужны.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Куда пропали покупки
|
||||||
|
|
||||||
|
Вы сравните покупки из событий сайта с заказами магазина. Найденные
|
||||||
|
расхождения помогут выбрать источник для полной витрины заказов.
|
||||||
|
|
||||||
|
Факультатив доступен после ступени 1. В DBeaver подключитесь пользователем
|
||||||
|
`analyst` по [инструкции](../../../README.md#подключиться-из-dbeaver).
|
||||||
|
Дождитесь завершения `etl_pipeline`; на время расследования оставьте
|
||||||
|
`world_live` на паузе и дождитесь уже начатых прогонов.
|
||||||
|
|
||||||
|
## Выбор дня
|
||||||
|
|
||||||
|
Можно ли построить полную витрину заказов по событиям `purchase`?
|
||||||
|
Проверьте это на одном дне: сначала сопоставьте события сайта, затем сверьте
|
||||||
|
номера заказов с выгрузкой магазина.
|
||||||
|
|
||||||
|
Слепок заказов содержит их состояние на конец модельного дня. Состояние
|
||||||
|
заказа может меняться семь дней, поэтому для окончательного сравнения нужны
|
||||||
|
все слепки за этот период. Возьмите день, для которого уже получен последний
|
||||||
|
из них. Такой день называется закрытым. Запрос показывает самую позднюю
|
||||||
|
подходящую дату:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT max(snapshot_date) - 6 AS last_closed_day
|
||||||
|
FROM ods.order_v;
|
||||||
|
```
|
||||||
|
|
||||||
|
Используйте выбранный день во всех запросах. События отбирайте по
|
||||||
|
`dds.event_v.event_date`, заказы — по `dds.order_v.order_date`.
|
||||||
|
Сравнивайте все заказы дня, включая отмененные: сейчас вопрос в том,
|
||||||
|
зарегистрировал ли магазин заказ, а не в том, был ли он оплачен.
|
||||||
|
|
||||||
|
## События оформления заказа
|
||||||
|
|
||||||
|
При оформлении заказа посетитель видит страницу подтверждения, а трекер
|
||||||
|
отправляет событие покупки. Просмотр страницы и покупка — отдельные события.
|
||||||
|
|
||||||
|
Посчитайте просмотры страницы подтверждения и события покупки:
|
||||||
|
|
||||||
|
- у просмотра `event_type = 'pageview'`, а `url` заканчивается на `/confirmation`;
|
||||||
|
- у покупки `event_type = 'purchase'`.
|
||||||
|
|
||||||
|
Вычтите число событий покупки из числа просмотров подтверждения. Сохраните
|
||||||
|
оба счетчика и разность. Как соотносились бы эти числа, если бы для каждого
|
||||||
|
заказа пришло ровно по одному событию каждого вида? Что можно предположить
|
||||||
|
по вашему результату и чего эти два числа еще не доказывают?
|
||||||
|
|
||||||
|
## Сверка с заказами магазина
|
||||||
|
|
||||||
|
В событии номер заказа находится в `purchase_id[1]`, в заказах — в `order_id`.
|
||||||
|
Соберите список заказов выбранного дня, для которых нет события покупки.
|
||||||
|
Несколько событий с одним номером заказа должны оставаться одной покупкой.
|
||||||
|
Сохраните запрос сверки: на каждый такой заказ он должен возвращать дату,
|
||||||
|
номер и статус. Дату задавайте в одном месте, чтобы запрос можно было
|
||||||
|
повторить для другого дня.
|
||||||
|
|
||||||
|
Проверьте два равенства:
|
||||||
|
|
||||||
|
- число заказов магазина равно числу разных заказов из событий плюс число
|
||||||
|
заказов без события;
|
||||||
|
- разность просмотров подтверждения и событий покупки равна числу заказов
|
||||||
|
без события.
|
||||||
|
|
||||||
|
Первое равенство проверяет полноту сопоставления источников. Второе связывает
|
||||||
|
разность счетчиков с найденными заказами без события. Проверьте, как на него
|
||||||
|
влияют повторные покупки.
|
||||||
|
|
||||||
|
Найдите номер заказа с несколькими событиями `purchase`. Для его `client_id`
|
||||||
|
выведите события рядом с этими покупками по времени: `event_time`,
|
||||||
|
`event_type` и `url`. Найдите просмотры `/confirmation` перед первой
|
||||||
|
и повторной покупкой. Посчитайте, сколько повтор добавил просмотров
|
||||||
|
подтверждения и сколько — покупок. Изменилась ли от этих добавлений разность
|
||||||
|
счетчиков? Подтвердите ответ найденными строками. Второе равенство относится
|
||||||
|
к устройству этого стенда; для другого источника его нужно проверять заново.
|
||||||
|
|
||||||
|
Если в выбранном дне повторов нет, возьмите другой закрытый день.
|
||||||
|
|
||||||
|
## Сравнение с готовой витриной
|
||||||
|
|
||||||
|
Найдите свои заказы без события в `dm.purchase_vs_orders_v` по `order_id`.
|
||||||
|
Сравните их `status` и `mismatch_class`. Витрина присваивает заказу один класс,
|
||||||
|
хотя причин расхождения может быть несколько. По
|
||||||
|
[правилу классификации](../../architecture/dm.md#dmpurchase_vs_orders_v)
|
||||||
|
объясните, почему потеря события не всегда попадает в `lost_event`.
|
||||||
|
|
||||||
|
Возьмите один заказ из своего списка и сохраните два запроса: первый находит
|
||||||
|
его в магазине, второй показывает отсутствие покупки в трекере.
|
||||||
|
|
||||||
|
## Выбор источника для витрины
|
||||||
|
|
||||||
|
По результатам сверки укажите источник для витрины, в которой должен быть
|
||||||
|
каждый зарегистрированный заказ. Обоснуйте выбор найденными номерами.
|
||||||
|
|
||||||
|
Рядом с запросом запишите, когда его результат можно считать окончательным.
|
||||||
|
Что изменится, если запустить его до получения всех слепков выбранного дня?
|
||||||
|
Позволяет ли отсутствие события в `dds.event_v` установить место потери —
|
||||||
|
до приема в хранилище или при обработке? Какие данные нужны, чтобы это выяснить?
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] сохранен запрос сверки по дате, возвращающий заказы без события;
|
||||||
|
- [ ] оба равенства сходятся, влияние повторного просмотра объяснено;
|
||||||
|
- [ ] один случай подтвержден запросами к обоим источникам;
|
||||||
|
- [ ] источник витрины выбран по результатам сверки;
|
||||||
|
- [ ] указаны условие окончательной проверки и границы ее выводов.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Какая позиция объясняет разницу в сумме
|
||||||
|
|
||||||
|
Вы сравните товары в событии покупки и заказе магазина, чтобы найти причину
|
||||||
|
разницы в суммах. При разборе вложенных данных понадобится сохранить точность
|
||||||
|
цен до копейки.
|
||||||
|
|
||||||
|
Факультатив доступен после ступени 1. В DBeaver используйте пользователя
|
||||||
|
`analyst` из [инструкции подключения](../../../README.md#подключиться-из-dbeaver).
|
||||||
|
Дождитесь завершения `etl_pipeline`; оставьте `world_live` на паузе и
|
||||||
|
дождитесь уже начатых прогонов.
|
||||||
|
|
||||||
|
## Выбор заказа
|
||||||
|
|
||||||
|
Суммы покупки в трекере и заказа в магазине различаются. Разберите их позиции
|
||||||
|
и проверьте отдельно точность своего запроса и различия между событием и заказом.
|
||||||
|
|
||||||
|
В `dm.purchase_vs_orders_v` выберите один заказ с
|
||||||
|
`mismatch_class = 'amount_delta'`. Возьмите закрытый день: `order_day`
|
||||||
|
не позднее `max(snapshot_date) - 6` из `ods.order_v`. Сохраните `order_id`,
|
||||||
|
`order_day`, `declared_revenue` и `items_total`.
|
||||||
|
|
||||||
|
Найдите этот заказ в `dds.order_v`. Сопоставьте `items_total`, `discount`,
|
||||||
|
`delivery` и `total`. Запишите равенство, связывающее эти четыре значения,
|
||||||
|
и проверьте его на заказе. С какой суммой магазина следует сравнивать
|
||||||
|
`declared_revenue`, если мы ищем расхождение именно в составе корзины?
|
||||||
|
Проверьте свой выбор после разбора позиций.
|
||||||
|
|
||||||
|
## Товарные позиции события и заказа
|
||||||
|
|
||||||
|
Найдите событие покупки в `ods.event_v`: `purchaseID[1]` совпадает с номером
|
||||||
|
заказа, `EventDate` — с выбранным днем, `EventType = 'purchase'`.
|
||||||
|
Откройте поле `ecommerce`. Внутри JSON массив `purchase.products` содержит
|
||||||
|
товары из события покупки с полями `id`, `price` и `quantity`.
|
||||||
|
|
||||||
|
Разверните этот массив в строки и получите артикул, точную цену, количество
|
||||||
|
и стоимость каждой позиции. Для разбора пригодятся `JSONExtractArrayRaw`
|
||||||
|
и `JSONExtract`, для разворачивания массива — `ARRAY JOIN`.
|
||||||
|
Деньги извлекайте как `Decimal` с двумя знаками после запятой.
|
||||||
|
|
||||||
|
С другой стороны используйте массивы `item_sku`, `item_price` и
|
||||||
|
`item_quantity` заказа в `dds.order_v`. Элементы с одним индексом относятся
|
||||||
|
к одной позиции; при разворачивании сохраняйте эту связь.
|
||||||
|
|
||||||
|
Сопоставьте позиции по артикулу так, чтобы остались видны товары,
|
||||||
|
присутствующие только с одной стороны. Для каждой позиции сравните наличие,
|
||||||
|
цену и количество. Что различается и сколько это добавляет к расхождению
|
||||||
|
сумм?
|
||||||
|
Сохраните запрос сверки для выбранных даты и номера заказа; в результате
|
||||||
|
оставьте артикул, значения с обеих сторон и разность стоимостей позиций.
|
||||||
|
|
||||||
|
## Проверка сумм
|
||||||
|
|
||||||
|
Подтвердите три равенства:
|
||||||
|
|
||||||
|
- сумма стоимостей клиентских позиций равна `declared_revenue`;
|
||||||
|
- сумма стоимостей позиций магазина равна `items_total`;
|
||||||
|
- сумма расхождений по позициям равна
|
||||||
|
`declared_revenue - items_total`.
|
||||||
|
|
||||||
|
Затем сравните точные цены из JSON с готовым массивом `productPrice`
|
||||||
|
события. Пересчитайте стоимость клиентской корзины по этому массиву:
|
||||||
|
сойдется ли она с `declared_revenue` до копейки? Найдите цены, на которых
|
||||||
|
результаты различаются, и сверьте наблюдение с
|
||||||
|
[форматом выгрузки](../../formats/clickstream-event.md). Если все цены
|
||||||
|
выбранной корзины целые, возьмите еще один заказ с расхождением и дробной ценой.
|
||||||
|
|
||||||
|
Какие строки подтверждают найденное расхождение? Есть ли в источниках
|
||||||
|
сведения о том, почему оно возникло? Отделите наблюдения от предположений.
|
||||||
|
|
||||||
|
## Поля для точного расчета
|
||||||
|
|
||||||
|
Укажите рядом с запросом, откуда брать цену, каким типом ее читать и по какому
|
||||||
|
ключу сопоставлять позиции. Подтвердите выбор результатами обеих сверок:
|
||||||
|
суммы разобранных позиций с суммой своего источника и позиций двух источников
|
||||||
|
между собой. Какой результат укажет на ошибку вашего разбора, а какой
|
||||||
|
потребует искать причину выше — в данных, которые получил запрос?
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] сохранен запрос сверки позиций, не теряющий товары без пары;
|
||||||
|
- [ ] все три равенства сходятся до копейки;
|
||||||
|
- [ ] найден пример потери точности в готовом массиве цен;
|
||||||
|
- [ ] выбор полей, типа денег и ключа сопоставления подтвержден расчетами;
|
||||||
|
- [ ] расхождение источников сверки отделено от ошибки своего разбора и различия
|
||||||
|
между стоимостью корзины и итогом со скидкой и доставкой.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Почему вчерашняя выручка меняется сегодня
|
||||||
|
|
||||||
|
Вы проследите, как опоздавшие заказы и смена статусов меняют выручку
|
||||||
|
за прошедший день. По этим наблюдениям определите, какие дни пересчитывать
|
||||||
|
и какую историю заказов сохранять.
|
||||||
|
|
||||||
|
Факультатив доступен после ступени 1. Понадобятся Airflow и DBeaver с
|
||||||
|
подключением `student` по [инструкции](../../../README.md#подключиться-из-dbeaver).
|
||||||
|
Пользователь `student` может читать данные стенда и записывать ваши наблюдения
|
||||||
|
в базу `default`.
|
||||||
|
Вы будете наблюдать один день оформления заказов, продвигая мир по одному
|
||||||
|
модельному дню. Проверьте, можно ли при загрузке выручки каждый раз считать
|
||||||
|
только новый день. Новые данные останутся в стенде.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
Поставьте `world_live` на паузу. Дождитесь завершения начатых прогонов
|
||||||
|
генерации и обработки, затем запустите `etl_pipeline` и дождитесь успеха.
|
||||||
|
Так исходное наблюдение будет снято после обновления витрин.
|
||||||
|
|
||||||
|
Узнайте последний день событий и дату последнего полученного слепка:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
(SELECT max(event_date) FROM dds.event_v) AS event_day,
|
||||||
|
(SELECT max(snapshot_date) FROM ods.order_v) AS snapshot_day;
|
||||||
|
```
|
||||||
|
|
||||||
|
День `event_day` обозначим **D**. Наблюдайте именно его до конца работы.
|
||||||
|
Дата слепка, по которому сделано очередное наблюдение, будет **S**.
|
||||||
|
Это модельные даты магазина. Календарная дата запуска Airflow день D не задает.
|
||||||
|
|
||||||
|
Слепок содержит состояние заказов на конец модельных суток и доставляется
|
||||||
|
при следующем запуске генератора. Поэтому покупки последнего завершившегося
|
||||||
|
дня уже видны в событиях, а их заказов в
|
||||||
|
хранилище еще нет. Проверьте это для D в `dds.order_v` и
|
||||||
|
`dm.purchase_vs_orders_v`.
|
||||||
|
|
||||||
|
## Таблицы для истории заказов
|
||||||
|
|
||||||
|
Представление `dds.order_v` показывает текущее состояние заказов, а сырые слепки
|
||||||
|
хранятся ограниченное время. Сохраните историю в двух своих таблицах: потом
|
||||||
|
вы сможете сравнивать наблюдения обычными SQL-запросами в том же DBeaver.
|
||||||
|
|
||||||
|
Выполните DDL ниже. Это локальные таблицы на узле вашего подключения;
|
||||||
|
всю работу выполняйте через одно подключение. Если имена уже заняты вашей
|
||||||
|
прошлой работой, выберите другие и используйте их в дальнейших запросах.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE default.lab_order_history
|
||||||
|
(
|
||||||
|
order_date Date,
|
||||||
|
snapshot_date Date,
|
||||||
|
order_id String,
|
||||||
|
status String,
|
||||||
|
items_total Decimal(18, 2)
|
||||||
|
)
|
||||||
|
ENGINE = MergeTree
|
||||||
|
ORDER BY (order_date, snapshot_date, order_id);
|
||||||
|
|
||||||
|
CREATE TABLE default.lab_revenue_history
|
||||||
|
(
|
||||||
|
order_date Date,
|
||||||
|
snapshot_date Date,
|
||||||
|
revenue Decimal(18, 2),
|
||||||
|
awaiting_order_ids Array(String)
|
||||||
|
)
|
||||||
|
ENGINE = MergeTree
|
||||||
|
ORDER BY (order_date, snapshot_date);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Первое наблюдение
|
||||||
|
|
||||||
|
Напишите два `INSERT INTO ... SELECT` с явным списком колонок назначения:
|
||||||
|
|
||||||
|
| Куда записать | Что взять из стенда |
|
||||||
|
|---|---|
|
||||||
|
| `default.lab_order_history` | По строке на заказ дня D из `dds.order_v`: `order_date`, `order_id`, `status`, `items_total`; добавьте дату наблюдения S |
|
||||||
|
| `default.lab_revenue_history` | Одну строку с D, S, суммой `revenue` за `report_date = D` из `dm.revenue_daily_v` и массивом номеров `awaiting_order` за `order_day = D` из `dm.purchase_vs_orders_v` |
|
||||||
|
|
||||||
|
Во втором запросе сумму и массив получите отдельными агрегатами, чтобы
|
||||||
|
заказы не умножили строки выручки при соединении. Для массива пригодится
|
||||||
|
`groupArray(order_id)`. Дату S берите из `max(snapshot_date)` в `ods.order_v`.
|
||||||
|
|
||||||
|
Сначала выполните части `SELECT` отдельно и проверьте результаты, затем
|
||||||
|
сохраните их. До первого слепка заказов дня D еще нет: в таблицу заказов
|
||||||
|
строки не добавятся, но в таблице итогов должна появиться строка с нулевой
|
||||||
|
выручкой. Она зафиксирует, что наблюдение сделано.
|
||||||
|
|
||||||
|
Каждую пару D и S сохраняйте один раз: повторный `INSERT` добавит копии строк.
|
||||||
|
После записи проверьте, что на эту пару приходится одна строка итогов,
|
||||||
|
а число заказов и число разных `order_id` совпадают с `dds.order_v` за D.
|
||||||
|
|
||||||
|
Выручка этой витрины — стоимость позиций оплаченных заказов без скидки и
|
||||||
|
доставки. Для каждого сохраненного наблюдения посчитайте число заказов по
|
||||||
|
статусам и сумму `items_total` только у `paid`. Эта сумма должна сходиться
|
||||||
|
с сохраненной выручкой, сложенной по всем категориям.
|
||||||
|
|
||||||
|
## Наблюдения за следующие дни
|
||||||
|
|
||||||
|
Для каждого следующего наблюдения:
|
||||||
|
|
||||||
|
1. Запустите `world_next_day` с `days = 1` и дождитесь успеха.
|
||||||
|
2. Запустите `etl_pipeline` и дождитесь успеха: `world_next_day` сам витрины
|
||||||
|
не обновляет.
|
||||||
|
3. Повторите два запроса сохранения с новой датой S и проверьте записанные строки.
|
||||||
|
|
||||||
|
Во всех запросах остается прежний день D. За один запуск добавляйте один
|
||||||
|
день, чтобы сохранить промежуточные состояния. Пакетный `world_next_day`
|
||||||
|
отправляет события без пауз; ждать 24 минуты живого дня здесь не нужно.
|
||||||
|
|
||||||
|
Продолжайте до слепка **D + 7** включительно. Заказы дня D могут меняться
|
||||||
|
семь дней, от D до D + 6. Этот период называется окном изменяемости.
|
||||||
|
Последний шаг нужен для сравнения состояния в конце окна и после него.
|
||||||
|
От исходного наблюдения потребуется восемь однодневных запусков.
|
||||||
|
|
||||||
|
## Изменения заказов и выручки
|
||||||
|
|
||||||
|
Для сравнения прочитайте `default.lab_order_history` дважды: в одном
|
||||||
|
подзапросе отберите прежнюю S, в другом — новую; день D остается общим.
|
||||||
|
Соедините состояния по `order_id`, сохранив и заказы без пары. Итоги для этих
|
||||||
|
же D и S возьмите из `default.lab_revenue_history`. Найдите:
|
||||||
|
|
||||||
|
- заказы, отсутствовавшие в первом слепке D, но появившиеся в следующем;
|
||||||
|
- переходы из `created` в `paid`;
|
||||||
|
- отмены заказов, для которых раньше был виден статус `paid`.
|
||||||
|
|
||||||
|
Первый слепок, полученный на следующий день, — обычная доставка заказов.
|
||||||
|
Опоздавшими здесь считаются те, которых не было уже в этом слепке.
|
||||||
|
Покажите на нескольких номерах, как они исчезают из `awaiting_order`.
|
||||||
|
|
||||||
|
Выберите два соседних наблюдения, между которыми хотя бы один заказ перешел
|
||||||
|
из `paid` в `cancelled`. Разложите изменение выручки по заказам.
|
||||||
|
Составьте таблицу: номер заказа, прежний и новый статусы, вклад в выручку
|
||||||
|
до и после изменения. Вклад определите по правилу расчета витрины, указанному
|
||||||
|
выше; учтите и заказы, которых еще не было в первом наблюдении. Сумма
|
||||||
|
изменений вкладов должна совпасть с изменением `revenue` до копейки.
|
||||||
|
|
||||||
|
Покажите на этих наблюдениях, какой результат остался бы в витрине,
|
||||||
|
если бы конвейер после первого расчета обрабатывал только новые дни заказов.
|
||||||
|
Достаточно ли добавлять в старый день только опоздавшие заказы? Проверьте
|
||||||
|
свой ответ на найденных изменениях статусов.
|
||||||
|
|
||||||
|
## Требования к истории заказов
|
||||||
|
|
||||||
|
Возьмите отмененный заказ, у которого в вашей истории был статус `paid`.
|
||||||
|
Укажите два наблюдения, доказывающих, что он был оплачен, а затем отменен.
|
||||||
|
|
||||||
|
Теперь возьмите отмененный заказ, для которого ни в одном вашем наблюдении
|
||||||
|
нет `paid`. Проверьте утверждение «этот заказ никогда не был оплачен»:
|
||||||
|
что известно о его состоянии между двумя слепками? Достаточно ли сохраненных
|
||||||
|
наблюдений для такого вывода?
|
||||||
|
|
||||||
|
Сформулируйте требования к данным для двух задач: восстановить состояние
|
||||||
|
витрины на момент прошлого расчета и узнать, был ли заказ когда-либо оплачен.
|
||||||
|
Какие сведения для каждой задачи должен передавать источник и сохранять
|
||||||
|
хранилище? Проверьте, каких нужных сведений нет в ваших таблицах наблюдений.
|
||||||
|
|
||||||
|
## Правило пересчета витрины
|
||||||
|
|
||||||
|
Сравните сохраненные состояния при S = D + 6 и S = D + 7: состав заказов,
|
||||||
|
их статусы, суммы и выручку. Какой слепок последним мог изменить заказы дня D
|
||||||
|
и каким прогоном он доставлен? Укажите наблюдение, после которого отчет
|
||||||
|
можно считать окончательным. Было бы достаточно совпадения двух соседних
|
||||||
|
сумм, если бы окно еще не закончилось?
|
||||||
|
|
||||||
|
Запишите правило выбора дней для пересчета после очередного слепка.
|
||||||
|
Считайте, что начальная история уже загружена и ежедневные прогоны не
|
||||||
|
пропускались. Укажите, по какой дате определяются границы, какие дни нужно
|
||||||
|
пересчитать и когда день можно перестать включать в расчет. Подтвердите
|
||||||
|
правило своими наблюдениями, включая опоздание и отмену.
|
||||||
|
|
||||||
|
После собственных выводов сверьтесь с описанием
|
||||||
|
[слепка и его доставки](../../architecture/orders/snapshot.md#слепок-и-его-доставка)
|
||||||
|
и [границ истории отмен](../../architecture/orders/fate.md#судьба-заказа).
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] сохранена последовательность наблюдений одного дня D с датами слепков S;
|
||||||
|
- [ ] на опоздавших заказах и изменениях статусов показаны ошибки загрузки
|
||||||
|
только новых дней или только новых заказов;
|
||||||
|
- [ ] изменение выручки между наблюдениями сходится с изменением вкладов заказов;
|
||||||
|
- [ ] для одного отмененного заказа найдены два наблюдения: с оплатой и с отменой;
|
||||||
|
- [ ] сформулированы требования к истории расчетов и истории оплаты;
|
||||||
|
- [ ] правило пересчета и момент закрытия дня обоснованы наблюдениями
|
||||||
|
и правилом доставки слепков.
|
||||||
|
|
||||||
|
После работы оставьте `world_live` на паузе либо верните автоматическое
|
||||||
|
продвижение, если пользовались им до наблюдения. Таблицы истории останутся
|
||||||
|
для повторной проверки выводов. Когда они больше не нужны, удалите только
|
||||||
|
свои `default.lab_order_history` и `default.lab_revenue_history`.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Свой дашборд в Superset
|
||||||
|
|
||||||
|
Из своих витрин вы соберете отчет о поведении посетителей: подберете графики
|
||||||
|
и разместите их на одном экране так, чтобы отчет можно было понять
|
||||||
|
без ваших пояснений.
|
||||||
|
|
||||||
|
Это задание раздела «Отчеты в Superset». Его можно пройти после ступени 2,
|
||||||
|
когда готовы витрины ступени 1.
|
||||||
|
|
||||||
|
## Отчет о поведении посетителей
|
||||||
|
|
||||||
|
Соберите обзор трафика и поведения посетителей. Он должен отвечать на три
|
||||||
|
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
|
||||||
|
каналы дали результат, были ли ошибки приема данных.
|
||||||
|
|
||||||
|
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
|
||||||
|
соберите из пяти витрин, которые вы построили в заданиях. Копировать раскладку
|
||||||
|
стендового дашборда не нужно: выберите форму, в которой ответы читаются без
|
||||||
|
пояснений рядом с экраном.
|
||||||
|
|
||||||
|
## Отдельный дашборд для задания
|
||||||
|
|
||||||
|
Не добавляйте графики в стендовый дашборд и не правьте его датасеты. При
|
||||||
|
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
|
||||||
|
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
|
||||||
|
таких объектов описан в [справочнике Superset](../../architecture/superset.md), «Импорт
|
||||||
|
при каждой инициализации».
|
||||||
|
|
||||||
|
Используйте свои датасеты и графики, соберите из них свой дашборд.
|
||||||
|
Назовите его, например, «Мои витрины DM».
|
||||||
|
|
||||||
|
## Датасеты
|
||||||
|
|
||||||
|
Датасет `session_overview_v`, его метрика доли отказов и график уже готовы
|
||||||
|
после [ступени 2](../2-1-superset-chart.md). Используйте их здесь. Добавьте
|
||||||
|
недостающие датасеты, чтобы в подключении `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`. Среднее по готовым
|
||||||
|
долям для такого графика не подходит.
|
||||||
|
|
||||||
|
Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
|
||||||
|
|
||||||
|
## Период отчета
|
||||||
|
|
||||||
|
Выберите явный период из модельного календаря и задайте его в графиках
|
||||||
|
поведения посетителей. Последнюю доступную дату посмотрите в своих витринах.
|
||||||
|
Относительный период вроде `Last week` отсчитывается от настоящего времени
|
||||||
|
и может дать пустой экран.
|
||||||
|
|
||||||
|
Ошибки приема считаются по `load_date`, дню загрузки, а поведение посетителей
|
||||||
|
по модельному времени. Для панели ошибок выберите период загрузки отдельно
|
||||||
|
и укажите его в подписи. Общий фильтр времени для всего дашборда не требуется.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
Автоматической проверки этого задания нет. Числа вы сверите запросами,
|
||||||
|
а понятность графиков проверите по подписям.
|
||||||
|
|
||||||
|
Откройте список дашбордов и проверьте:
|
||||||
|
|
||||||
|
- «Бизнес-сюжеты» остался отдельным стендовым дашбордом;
|
||||||
|
- ваш дашборд открывается без ошибок и содержит график от каждого из пяти
|
||||||
|
датасетов;
|
||||||
|
- подписи, единицы измерения и легенды читаются без обращения к SQL;
|
||||||
|
- прямой трафик не исчез из графика UTM, а заявленная выручка не выдана за
|
||||||
|
серверную;
|
||||||
|
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
|
||||||
|
|
||||||
|
По готовому экрану запишите три коротких вывода за выбранный период:
|
||||||
|
|
||||||
|
- какой рекламный канал дал больше покупок;
|
||||||
|
- какая страница получила больше просмотров;
|
||||||
|
- у какой категории устройств выше доля отказов.
|
||||||
|
|
||||||
|
Для каждого вывода укажите период и число, на которое опираетесь. Проверьте
|
||||||
|
его запросом к своей витрине в SQL Lab с тем же периодом и способом расчета.
|
||||||
|
Сохраните запросы рядом с выводами. При равенстве значений назовите всех
|
||||||
|
лидеров. Если данных нет, это тоже результат: подтвердите отсутствие строк
|
||||||
|
запросом.
|
||||||
|
|
||||||
|
Проверьте, можно ли найти эти ответы по одним подписям графиков. Если есть
|
||||||
|
возможность, попросите другого человека прочитать экран без ваших пояснений.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] у вас есть пять датасетов по представлениям витрин заданий 1–5;
|
||||||
|
- [ ] вы собрали свой дашборд, сохранив стендовые объекты без изменений;
|
||||||
|
- [ ] на дашборде есть хотя бы один график от каждой витрины;
|
||||||
|
- [ ] вы проверили, что графики и подписи понятны по вопросам из раздела выше;
|
||||||
|
- [ ] вы записали три вывода и сверили числа с SQL; в отчете указаны периоды
|
||||||
|
поведения посетителей и ошибок приема.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# В DBeaver работает, в Superset нет
|
||||||
|
|
||||||
|
Вы создадите представление, которое читается из DBeaver, но недоступно
|
||||||
|
в Superset. Затем найдете причину и исправите ее.
|
||||||
|
|
||||||
|
Факультатив доступен после [ступени 2](../2-1-superset-chart.md).
|
||||||
|
Свой дашборд для опыта не нужен. Используйте готовую витрину стенда
|
||||||
|
`dm.daily_traffic_v`.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
В DBeaver подключитесь к первому узлу пользователем `etl`, как описано
|
||||||
|
в разделе [«Выполнение SQL в DBeaver»](../README.md#выполнение-sql-в-dbeaver).
|
||||||
|
Для опыта создайте отдельное представление:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE VIEW dm.bi_node_probe_v AS
|
||||||
|
SELECT * FROM dm.daily_traffic_v;
|
||||||
|
```
|
||||||
|
|
||||||
|
Запрос выполнится успешно. Теперь проверьте, откуда можно прочитать
|
||||||
|
созданное представление. Начните с того же подключения:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT count() FROM dm.bi_node_probe_v;
|
||||||
|
```
|
||||||
|
|
||||||
|
Повторите запрос в SQL Lab Superset, выбрав подключение `ClickHouse`.
|
||||||
|
Сохраните текст ошибки.
|
||||||
|
|
||||||
|
## Поиск причины
|
||||||
|
|
||||||
|
Выясните, к каким серверам обращаются DBeaver и Superset. Адреса подключения
|
||||||
|
показывают настройки клиентов, а имя ответившего сервера можно спросить
|
||||||
|
запросом `SELECT hostName()`.
|
||||||
|
|
||||||
|
Проверьте наличие `dm.bi_node_probe_v` на обоих узлах. Для второго узла
|
||||||
|
заведите подключение DBeaver с портом из
|
||||||
|
[README](../../../README.md#подключиться-из-dbeaver). Запишите, где объект
|
||||||
|
есть, и объясните, почему успешного создания в первом подключении оказалось
|
||||||
|
недостаточно.
|
||||||
|
|
||||||
|
Исправьте DDL так, чтобы представление появилось на обоих узлах. Подключение
|
||||||
|
Superset должно остаться прежним. Сохраните исправленный DDL и повторите
|
||||||
|
исходный запрос в обоих клиентах.
|
||||||
|
|
||||||
|
Если застряли, прочитайте раздел «Устройство зоны» в
|
||||||
|
[справочнике Superset](../../architecture/superset.md#устройство-зоны) и
|
||||||
|
[описание распределенного DDL ClickHouse](https://clickhouse.com/docs/reference/statements/distributed-ddl).
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
В обоих клиентах запрос к вашему представлению должен вернуть столько же
|
||||||
|
строк, сколько `SELECT count() FROM dm.daily_traffic_v`. Сравнивайте результаты
|
||||||
|
при завершенном прогоне конвейера, пока мир не продвигается.
|
||||||
|
|
||||||
|
Объясните причину ошибки через три факта: куда пришел DDL, куда пришел
|
||||||
|
запрос Superset и на каких узлах существовало представление.
|
||||||
|
|
||||||
|
После опыта удалите только `dm.bi_node_probe_v` на обоих узлах. Не включайте
|
||||||
|
его в постоянный DDL домашних витрин: это временный объект расследования.
|
||||||
|
|
||||||
|
## Результат работы
|
||||||
|
|
||||||
|
- [ ] у вас сохранены успешный запрос из DBeaver и ошибка из SQL Lab;
|
||||||
|
- [ ] вы можете объяснить, к каким серверам подключены клиенты, где было
|
||||||
|
создано представление и почему Superset его не видел;
|
||||||
|
- [ ] у вас есть исправленный DDL; оба клиента возвращают столько же строк,
|
||||||
|
сколько исходная витрина;
|
||||||
|
- [ ] после опыта вы удалили учебное представление с обоих узлов.
|
||||||
@@ -64,7 +64,7 @@ Debezium проводит ту же границу внутри одного с
|
|||||||
`created_at` и `updated_at` — аудит строки источника. Поэтому
|
`created_at` и `updated_at` — аудит строки источника. Поэтому
|
||||||
`toDate(created_at)` в
|
`toDate(created_at)` в
|
||||||
[спецификации приёма заказов](../architecture/orders/ingestion.md)
|
[спецификации приёма заказов](../architecture/orders/ingestion.md)
|
||||||
используется как стабильный технический ключ партиции `ods.order_snapshot`:
|
используется как стабильный технический ключ партиции `ods.order`:
|
||||||
это день создания строки
|
это день создания строки
|
||||||
источника, а не доказательство дня бизнес-события.
|
источника, а не доказательство дня бизнес-события.
|
||||||
|
|
||||||
@@ -126,6 +126,10 @@ parseDateTime64InJodaSyntaxOrNull(
|
|||||||
использовать и для типизированной строки, и для маршрутизации ошибки; нулевая
|
использовать и для типизированной строки, и для маршрутизации ошибки; нулевая
|
||||||
дата не нужна.
|
дата не нужна.
|
||||||
|
|
||||||
|
> Актуальный стенд: версию `ods.order` задаёт `snapshot_date`
|
||||||
|
> ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Следующий
|
||||||
|
> абзац фиксирует исходный вывод исследования и не описывает текущий контракт.
|
||||||
|
|
||||||
`updated_at` допустим как колонка версии `ReplacingMergeTree`: ClickHouse
|
`updated_at` допустим как колонка версии `ReplacingMergeTree`: ClickHouse
|
||||||
явно разрешает для `ver` тип `DateTime64` и оставляет строку с максимальной
|
явно разрешает для `ver` тип `DateTime64` и оставляет строку с максимальной
|
||||||
версией
|
версией
|
||||||
|
|||||||
@@ -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/optional/superset-dashboard.md`, `docs/architecture/superset.md`.\
|
||||||
|
Форма: построить свое.
|
||||||
|
Проверка: глазами — счетного инварианта у раскладки панелей нет.
|
||||||
|
|
||||||
|
**18. Инкремент против пересборки (#149, повышенная сложность).**
|
||||||
|
Урок: чтобы обновлять меньше данных, приходится хранить историю изменений. Полная перезагрузка эту историю стирает.
|
||||||
|
Материал: `sql/dm/daily_traffic_rebuild.sql` (полный пересчет), `docs/architecture/dm.md`, «Ритм пересборки» и отклоненный вариант; момент `first_matched_at` — из сюжета 12.
|
||||||
|
Форма: построить свое (инкрементальная альтернатива рядом со стендовой).
|
||||||
|
Проверка: бесплатный контроль — результат обязан совпасть с эталонной витриной `dm.daily_traffic_v`.
|
||||||
|
|
||||||
|
**19. Вычитание стендовых витрин.**
|
||||||
|
Урок: при сверке двух источников нужен `GLOBAL FULL OUTER JOIN` с подзапросами с обеих сторон, `join_use_nulls` и явный приоритет классов. Значение с отсутствующей стороны должно остаться `NULL`, а не превратиться в ноль.
|
||||||
|
Материал: `sql/dm/purchase_vs_orders_rebuild.sql` (и соседние `revenue_daily_*`, `daily_traffic_*`), `docs/architecture/dm.md`.
|
||||||
|
Форма: вычитание — с оговоркой: запросы `sql/dq/*_vs_dds.sql` лежат открыто и почти дословно повторяют рецепт витрины. Предупреждение из `docs/labs/README.md` («открыв их до того, как задание сделано, ты обменяешь урок на готовый SQL») здесь особенно важно.
|
||||||
|
Проверка: `dq_check`, группа `marts_vs_dds` + `classes_vs_inventory`.
|
||||||
|
|
||||||
|
## BI — Superset (сюжеты сверх готовой лабы)
|
||||||
|
|
||||||
|
**20. Ловушка ноды 2.**
|
||||||
|
Урок: забытый `ON CLUSTER` и представление над локальной таблицей могут проявиться только в дашборде. Superset намеренно читает ноду 2, а DDL запускается с ноды 1.
|
||||||
|
Материал: `docs/architecture/superset.md`, «Устройство зоны»; спека, «Роли нод».
|
||||||
|
Форма: расследование-опыт (создать объект без `ON CLUSTER`, открыть дашборд).
|
||||||
|
Проверка: своя же находка и починка.
|
||||||
|
|
||||||
|
## Поперек слоев
|
||||||
|
|
||||||
|
### Airflow-переобработка по дневным партициям
|
||||||
|
|
||||||
|
**21. Переиграть день X.**
|
||||||
|
Урок: объем повторного прогона определяется состоянием хранилища. После падения конвейеру не нужно помнить параметры прошлого запуска: он ищет первую несобранную дату, а не последнюю собранную.
|
||||||
|
Материал: `sql/dds/order_scope.sql` и `session_scope.sql` (окно + хвост от первой дыры, `GLOBAL NOT IN`), `dags/dds_transform.py` (день — отдельная размноженная задача замены), `docs/architecture/etl.md`.
|
||||||
|
Форма: расследование-опыт — снести партицию дня в `dds.session_rep`, запустить `etl_pipeline` без параметров, увидеть, как объем сам нашел дыру.
|
||||||
|
Проверка: `dq_check` зеленый, день вернулся тем же (детерминированный чекан).
|
||||||
|
|
||||||
|
**22. Атомарность — пошардовая.**
|
||||||
|
Урок: `REPLACE PARTITION` заменяет данные в локальных таблицах по одному шарду. Если одна из двух замен завершится ошибкой, половина дня останется старой. Сам конвейер этого не заметит, поэтому нужна отдельная сверка.
|
||||||
|
Материал: `sql/dds/order_replace.sql` (комментарий о границе обещания), `docs/architecture/etl.md`, «Чего конвейер о себе не знает»; правило донора — `sql/ddl/45-dds-tables.sql`.
|
||||||
|
Форма: расследование (в паре с сюжетом 15).
|
||||||
|
Проверка: `purchase_vs_orders_vs_dds` или самодельная сверка по дню.
|
||||||
|
|
||||||
|
### Сверка purchase vs orders (классы A–D)
|
||||||
|
|
||||||
|
**23. Воронка подтверждений и недосчет трекера (#186, класс B).**
|
||||||
|
Урок: со стороны трекера потерянное событие можно обнаружить только по разрыву воронки между `/confirmation` и `purchase`. Отсюда правило: поведение считаем по трекеру, деньги — по бэкенду.
|
||||||
|
Материал: `lost_event` в `dm.purchase_vs_orders_v`, события дня в `dds.event_v`; механика — `docs/architecture/orders/fate.md` («потеря точечная»).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: оцененный недосчет сходится с долей `lost_event` закрытых дней.
|
||||||
|
|
||||||
|
**24. Бизнес-дубль и наивная выручка (#187, класс D).**
|
||||||
|
Урок: движок удаляет технические дубли, а бизнес-дубли приходится определять самим — по `purchaseID`, а не по совпадению строк. `ReplacingMergeTree` не удалит дубль с новым `WatchID`, и это правильное поведение.
|
||||||
|
Материал: `duplicate_event`, свертка по `purchase_id[1]` в `sql/dm/purchase_vs_orders_rebuild.sql`; `fate.md` («дубль сюжетный»).
|
||||||
|
Форма: расследование — наивный `SUM` против дедуплицированного.
|
||||||
|
Проверка: разница равна вкладу дублей; сверка с `dm.revenue_daily_v`.
|
||||||
|
|
||||||
|
**25. Дельта суммы объясняется сравнением позиций (класс C).**
|
||||||
|
Урок: `amount_delta` обозначает конкретное расхождение, а не случайный шум. Его можно до копейки объяснить вычеркнутой позицией, если разобрать вложенный JSON и применить `ARRAY JOIN`. Ради этого навыка позиции и разбираются.
|
||||||
|
Материал: `amount_delta` в сверке, массивы `product_*` события против `item_*` заказа; `fate.md` («дельта — вычеркнутая позиция»).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: найденная позиция и ее полная стоимость равны дельте.
|
||||||
|
|
||||||
|
**26. Две дороги отмены и история версий (класс A).**
|
||||||
|
Урок: текущее состояние заказа не сохраняет его историю. Различить случаи «оплачен и отменен» и «отменен неоплаченным» можно только по следам: физическим версиям ODS до слияния и сырью STG, пока оно хранится трое суток.
|
||||||
|
Материал: `fate.md`, «Что из двух дорог видно»; `ods.order_dist`, `stg.orders_raw_dist`; выручка дня, которая выросла и убыла.
|
||||||
|
Форма: расследование с ограничением по времени — часть случаев неразличима принципиально, и признать это тоже урок.
|
||||||
|
Проверка: списки заказов по дорогам, сверенные с историей версий.
|
||||||
|
|
||||||
|
### Опоздания и закрытие дня
|
||||||
|
|
||||||
|
**27. Опоздавшие заказы и закрытое окно (#188).**
|
||||||
|
Урок: отчет за день зависит от момента расчета. День можно считать закрытым, когда источник уже не способен его изменить. Поэтому опись хранит счетчики классов только для закрытых дней.
|
||||||
|
Материал: `awaiting_order` в `dm.purchase_vs_orders_v`, задержка слепков D+1/D+2 (`fate.md`, «Опоздание»), `data/world-inventory.json` (счетчики `orders` только у дней с закрытым окном), возврат `awaiting_order`-дней в объем — `sql/dm/purchase_vs_orders_scope.sql`.
|
||||||
|
Форма: расследование по ходу проживаемых дней.
|
||||||
|
Проверка: вчерашний отчет, не сошедшийся утром, сходится завтра; разность слепков называет опоздавших.
|
||||||
|
|
||||||
|
**28. Дыхание выручки.**
|
||||||
|
Урок: значение выручки за день меняется, пока день внутри окна изменяемости, а после выхода из окна фиксируется. Эти изменения видны как повторные пересборки партиции.
|
||||||
|
Материал: `dm.revenue_daily_v` по ходу дней, окно из ярлыка `snapshot_date` (`sql/dm/revenue_daily_scope.sql`), K = 7 — константа мира.
|
||||||
|
Форма: расследование-наблюдение (снять один день несколько раз).
|
||||||
|
Проверка: за окном значение больше не меняется.
|
||||||
|
|
||||||
|
### Мониторинг и runbook
|
||||||
|
|
||||||
|
В репозитории пока нет runbook для случаев «keeper упал / DDL повис». Спека оставляет его в хвосте этапа 9 вместе с `system.text_log` (ADR 0002), потому что такой документ должен опираться на реальный опыт. Поэтому менти может написать runbook сам: по шагам сломать сценарий, записать наблюдения и оставить стенду документ, которого сейчас не хватает.
|
||||||
|
|
||||||
|
**29. Keeper — общая память кластера.**
|
||||||
|
Урок: без keeper останавливаются DDL и репликация. На дашборде это бывает видно раньше, чем в ошибках запросов. Читаемый путь в keeper помогает понять, какие данные он хранит и за что отвечает.
|
||||||
|
Материал: панель keeper и очередь DDL в `docs/architecture/monitoring/cluster.md`, `system.zookeeper` (читаемый путь — `docs/architecture/storage.md`, «Путь реплицированных таблиц»), `make smoke` / `make check-clickhouse`.
|
||||||
|
Форма: расследование-опыт «посмотрели — сломали — увидели — вернули» (остановить keeper), итог — свой runbook.
|
||||||
|
Проверка: возврат к зеленым целям `make`; шаги записаны.
|
||||||
|
|
||||||
|
**30. Повисший DDL.**
|
||||||
|
Урок: каждый запрос `ON CLUSTER` проходит через keeper. Очередь распределенных DDL показывает, дошел ли запрос до обеих нод и что ответила каждая из них.
|
||||||
|
Материал: `system.distributed_ddl_queue`, панель «Распределенный DDL» (`cluster.md`: единственная панель без `clusterAllReplicas` — и почему).
|
||||||
|
Форма: расследование-опыт (DDL при недоступной второй ноде, таймаут `ON CLUSTER`).
|
||||||
|
Проверка: запись в очереди со статусом по нодам; починка доводит до `Finished`.
|
||||||
|
|
||||||
|
**31. Мониторинг сам стоит денег.**
|
||||||
|
Урок: запросы Grafana попадают в `system.query_log` вместе с запросами людей. Самый частый клиент хранилища — сам мониторинг. Если отфильтровать его запросы, стоимость наблюдаемости станет невидимой.
|
||||||
|
Материал: `docs/architecture/monitoring/queries.md` (панель «Кто спрашивает», строки начала и конца, куски шардов в панели ошибок).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: собственное объяснение верха списка тяжелых запросов.
|
||||||
|
|
||||||
|
**32. Свежесть второго источника — пила, а не линия.**
|
||||||
|
Урок: возраст заказов растет в течение дня и резко уменьшается после нового слепка. Так устроен источник; это не признак деградации. Для панелей свежести, потока и брака выбираются разные слои данных. Если выбрать неверный слой, панель даст правдоподобный, но ложный ответ.
|
||||||
|
Материал: `docs/architecture/monitoring/data.md` (почему свежесть — ODS, поток и брак — сырье, окно доли брака).
|
||||||
|
Форма: расследование.
|
||||||
|
Проверка: предсказать форму графика до взгляда на него.
|
||||||
|
|
||||||
|
### За границей лаб — опорные точки лекций
|
||||||
|
|
||||||
|
Спека (раздел 12) оставляет несколько сюжетов для лекций и не включает их в лабораторные работы: `Sign` и CollapsingMergeTree (почему на стенде `sum(Sign)` равен `count()`, а в бою нет), «как выбирают топологии в бою», идентичность «как в бою» (`setUserID`, вероятностная склейка, identity graph), анти-паттерны ключа шардирования. Последний сюжет уже входит в домашние задания DM как требование защитить выбранный ключ (`docs/labs/README.md`).
|
||||||
|
|
||||||
|
## Гипотеза «вычитание + DQ» для нижних слоев
|
||||||
|
|
||||||
|
В #148 предложено удалить SQL трансформаций потока и написать его заново. Владелец уточнил, что даги менять не нужно: это несущая инфраструктура. Обратную связь дают инварианты, которые не зависят от реализации слоя. Ниже каждый поток проверяется отдельно: что можно отдать на переписывание, что должно остаться, чем проверять результат и где заканчивается эталон.
|
||||||
|
|
||||||
|
На всю таблицу влияют два общих факта:
|
||||||
|
|
||||||
|
- `homework_check` покрывает только пять витрин DM. `dq_check` содержит пять проверок, но внешний эталон есть лишь у двух: `sessions_vs_reference` использует чужой чекан `VisitID` из `ods.event_v`, а `classes_vs_inventory` — опись мира. Три проверки `*_vs_dds` пересчитывают витрину из DDS по тому же рецепту. Они находят ошибку в сборке витрины, но не ошибку в DDS, потому что обе стороны читают один слой.
|
||||||
|
- Все сверки читают таблицы `_dist`, поэтому их агрегаты не зависят от раскладки (спека, раздел 6). Если строка попала не на свой шард, счетные проверки останутся зелеными. Это ловит только пересчет ключа шардирования по `hostName()` (прием из `docs/architecture/storage.md`, замер #137). Такой пересчет нужно явно включать в ТЗ каждого вычитания.
|
||||||
|
|
||||||
|
### По потокам
|
||||||
|
|
||||||
|
**События STG (прием).** На переписывание этот участок не отдается. Здесь почти нет трансформации: `stg.hits_raw_mv` перекладывает колонки (`sql/ddl/40-stg-views.sql`). При этом пересоздание матвью на работающем стенде незаметно пропускает ближайшие сообщения (замер в `storage.md`, «Что проверено»). Чтец, таблицы сырья и матвью остаются несущей стеной. Урок этого участка раскрывают расследования из сюжетов 1–5.
|
||||||
|
|
||||||
|
**События STG → ODS (разбор).** Вся логика — в DDL, в паре матвью `sql/ddl/30-ods-views.sql`; отдельных файлов событий в `sql/ods/` нет. Удаление и пересоздание этих матвью остановит прием и снова создаст зазор с пропущенными сообщениями. Поэтому обычное вычитание здесь не подходит. Рабочая форма — **«собери рядом»**: выполнить пакетный `INSERT SELECT` из сырья в собственную таблицу по срезу `_load_ts`, а затем построчно сверить результат с нетронутым `ods.event_v`. Эталоном служат стендовый ODS и подневный счет против описи (`make check-clickhouse`). Однако опись проверяет только количество и только для покрытых ею дней. Содержимое неопорных колонок остается без независимой проверки: неверно разобранные UTM или браузер нигде не покраснеют, потому что витрины сверяются с DDS, а DDS читает ODS. Срок работы тоже ограничен: сырье хранится трое суток.
|
||||||
|
|
||||||
|
**Заказы STG (забор, `sql/stg/orders_raw_load.sql`).** Формально трансформация вынесена в отдельный файл, но ее учебный смысл сводится к перекладке колонок, а цена ошибки велика. Офсеты коммитятся при чтении, поэтому неверный повтор унесет слепок до следующего переигрыша дня через пульт. Этот участок остается стеной. Эталон описи — хеш байтов слепка — проверяет топик, но не сам забор.
|
||||||
|
|
||||||
|
**Заказы STG → ODS (`sql/ods/order_parse.sql`).** Вычитание возможно с оговоркой. Это пакетный шаг, идемпотентный по `_load_id`: повтор не меняет `ods.order_v` («Что проверено» в `orders/ingestion.md`). Брак обрабатывается отдельной целью, а слепок можно вернуть переигрышем дня. Обратную связь менти собирает сам: проверяет равенство «сырье среза = годные + брак» по `_load_id`, а текущее состояние сверяет с `argMax` по `updated_at` из сырья. Ниже по течению результат проверяет `classes_vs_inventory` через DDS и витрину. Слабое место — ветвь брака. Чистый мир не производит брак, поэтому отрицание предиката останется непроверенным, пока менти сам не подложит по строке каждого класса. Готовый рецепт есть в `orders/ingestion.md`, «Риски и проверка»; после опыта данные нужно обязательно убрать.
|
||||||
|
|
||||||
|
**DDS сессия (`session_scope` / `rebuild` / `replace`).** Это лучший поток для вычитания: здесь гипотеза подтверждается без дополнительных условий. Эталон `VisitID` внешний, а `sessions_vs_reference.sql` считает чужой чекан и не повторяет алгоритм нарезки. Ошибиться можно в важных местах: в полном порядке окон, границе суток в `PARTITION BY`, детерминированном чекане и расчете объема из позиции мира. Эталон заканчивается на атрибутах строки. Сверка работает на зерне кука × день и проверяет только число сессий. Поэтому неверные границы, длительность, вход, выход или UTM могут пройти зеленым. `session_overview` из `homework_check` тоже согласится, потому что сверяет витрину с тем же слоем. Вторая ступень обратной связи — поштучная сверка границ по `VisitID` из сюжета 11, которую менти пишет сам.
|
||||||
|
|
||||||
|
**DDS заказ (`order_scope` / `rebuild` / `replace`).** Вычитание возможно при условии: сначала нужно построить недостающую проверку. Косвенный эталон уже есть. `classes_vs_inventory` ловит потерянные заказы и неверные статусы закрытых дней, а `purchase_vs_orders` проверяет `items_total`. Но независимо не проверяются разбор массивов `item_*` и время в поясе `Europe/Samara`. Выручка сойдется сама с собой, а счетчиков выручки в описи намеренно нет (спека, раздел 8). Стенд уже подсказывает решение: до вычитания нужно реализовать инвариант «цена × количество = items_total» из сюжета 14. Последовательность «сначала сторож, потом ломать» сама становится частью урока.
|
||||||
|
|
||||||
|
**DDS карта идентичностей (`identity_map_rebuild.sql`).** Для чистого вычитания этот поток не подходит: независимого эталона нет. `daily_traffic_vs_dds` читает карту с обеих сторон, поэтому пустая или задвоенная карта пройдет проверку. Счетчики идентичности в опись не входят (спека, раздел 8). Менти может сам написать слабые инварианты: `known_users <= visitors` и «каждая пара подтверждена покупкой и заказом». Они проверят допустимость пар, но не их полноту. Подходящие формы — «собери рядом и сверь со стендовой картой» либо вычитание вместе с самодельной проверкой, которая честно названа неполной.
|
||||||
|
|
||||||
|
**DM стендовые витрины.** Формально это самый защищенный участок: `dq_check` независимо пересчитывает каждую витрину. На деле проверка содержит готовый ответ: `revenue_daily_vs_dds.sql` дословно повторяет рецепт витрины. Вычитание возможно как сюжет 19, но только с предупреждением из `docs/labs/README.md` о цене подглядывания.
|
||||||
|
|
||||||
|
### Сводная таблица
|
||||||
|
|
||||||
|
| Поток | Отдать на переписывание | Несущая стена | Обратная связь | Где кончается эталон |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| События STG | ничего | чтец, сырье, `hits_raw_mv` | опись (счет) | все, кроме счета |
|
||||||
|
| События STG→ODS | «собери рядом», не вычитание | обе матвью, `*_errors` | стендовый ODS рядом, опись | содержимое неопорных колонок; окно сырья 3 суток |
|
||||||
|
| Заказы STG | ничего | чтец, забор | хеш слепка в описи | сам забор |
|
||||||
|
| Заказы STG→ODS | `order_parse.sql` | `*_errors`, чтец | баланс среза по `_load_id`, `argMax(updated_at)`, опись классов | ветвь брака — пока не подложен брак руками |
|
||||||
|
| DDS сессия | `session_*` (все три) | донор, DDL, даг | `sessions_vs_reference` (внешний эталон) | атрибуты строки — нужна поштучная сверка |
|
||||||
|
| DDS заказ | `order_*` (все три) | донор, DDL, даг | опись классов, `items_total` в сверке | разбор позиций и пояс — сначала сюжет 14 |
|
||||||
|
| DDS карта | только с оговорками | DDL, даг | нет независимой | почти сразу; форма — «собери рядом» |
|
||||||
|
| DM витрины | `revenue/pvo/traffic` | доноры, DDL, даг | `dq_check` целиком | ответ лежит в самой проверке |
|
||||||
|
|
||||||
|
### Вывод
|
||||||
|
|
||||||
|
Гипотеза «вычитание + DQ» подходит не для всех нижних слоев. Без оговорок она работает только для сессий DDS: там есть внешний эталон, настоящие места для ошибок и проверка, которая не содержит готового решения.
|
||||||
|
|
||||||
|
Для заказов DDS и разбора заказов ODS нужна последовательность «сначала построй сторожа, потом ломай». Недостающие инварианты — сюжеты 14 и 15, а также баланс среза — сами входят в учебный материал. Их нужно построить до вычитания.
|
||||||
|
|
||||||
|
STG и прием событий нельзя отдавать в форме «удали и перепиши»: код там служит несущей частью стенда. Для этих участков подходят расследования и форма «собери рядом».
|
||||||
|
|
||||||
|
У всех счетных проверок остается общая дыра — раскладка по шардам. Поэтому пересчет ключа по `hostName()` должен входить в ТЗ каждого вычитания отдельной строкой.
|
||||||
@@ -138,8 +138,9 @@ Ecommerce (заполнены только у торговых событий):
|
|||||||
| `ecommerce` | String — сырой JSON события, как отдаёт Метрика (кандидат будущей лабы: сырое против разобранного) |
|
| `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 Ключи и движки
|
||||||
|
|
||||||
@@ -210,28 +211,31 @@ Ecommerce (заполнены только у торговых событий):
|
|||||||
ключей совпадает с порядком полей в таблице контракта, у позиции — `sku`,
|
ключей совпадает с порядком полей в таблице контракта, у позиции — `sku`,
|
||||||
`qty`, `price`: так байты воспроизводимы без сортировки ключей. Разница с
|
`qty`, `price`: так байты воспроизводимы без сортировки ключей. Разница с
|
||||||
кликстримом намеренна: там значения `purchaseRevenue` приезжают JSON-числами
|
кликстримом намеренна: там значения `purchaseRevenue` приезжают JSON-числами
|
||||||
и разбираются как `Array(Float64)`, а бэкенд передаёт деньги строками для
|
Float64, а бэкенд передает деньги строками. На границе ODS оба источника
|
||||||
точного `Decimal`. Это показывает расхождение представлений денег в двух
|
становятся `Decimal(18, 2)`: клиентское число разбирается сразу, без
|
||||||
источниках.
|
промежуточного Float64 в хранилище.
|
||||||
Запись собирает явная функция существующего канонического сериализатора:
|
Запись собирает явная функция существующего канонического сериализатора:
|
||||||
один словарь с вложенным списком и один `orjson.dumps`, без универсального
|
один словарь с вложенным списком и один `orjson.dumps`, без универсального
|
||||||
слоя кодеков.
|
слоя кодеков.
|
||||||
Обоснование и проверка разбора — в
|
Обоснование и проверка разбора — в
|
||||||
[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md).
|
[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md).
|
||||||
|
|
||||||
- `ods.order_snapshot` принимает версии заказа в
|
- `ods.order` принимает версии заказа в
|
||||||
`ReplacingMergeTree(updated_at)`: `ORDER BY order_id`, партиция по дню
|
`ReplacingMergeTree(snapshot_date)`: `ORDER BY order_id`, партиция по дню
|
||||||
неизменного `created_at`, шардирование по `cityHash64(order_id)`.
|
неизменного `created_at`, шардирование по `cityHash64(order_id)`. Дата
|
||||||
`snapshot_date` остаётся датой наблюдения источника, но не задаёт публикацию
|
слепка служит колонкой версии: выживает строка с максимальной
|
||||||
или идемпотентность. Физическое чтение может видеть несколько версий;
|
`snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)).
|
||||||
`ods.order_v` возвращает точное текущее состояние. Устройство модели DDS и
|
Дата не доказывает полноту принятой порции, поэтому ODS не заменяет партицию
|
||||||
способ её материализации решаются отдельно.
|
слепка целиком. Физическое чтение может видеть несколько версий;
|
||||||
|
`ods.order_v` возвращает точное текущее состояние. В DDS одна строка хранит
|
||||||
|
один заказ с позициями в типизированных массивах; физическая модель и
|
||||||
|
пакетное наполнение описаны в [доке заказа](../architecture/dds/order.md).
|
||||||
|
|
||||||
Пропущенный день ничего не ломает, следующий слепок самовосстанавливает.
|
Пропущенный день ничего не ломает, следующий слепок самовосстанавливает.
|
||||||
- `items` остаётся сырой строкой JSON в ODS. Разбирать позиции следует на
|
- `items` остаётся сырой строкой JSON в ODS. Позиции разбираются на границе
|
||||||
границе ODS → DDS, но их представление определяется вместе с будущей моделью
|
ODS → DDS в типизированные массивы строки заказа. Это остаётся носителем
|
||||||
заказов. Это остаётся носителем навыка «вложенный JSON в ClickHouse», не
|
навыка «вложенный JSON в ClickHouse», не превращая приём в преждевременную
|
||||||
превращая приём в преждевременную модель данных.
|
модель данных.
|
||||||
- Статусы держим все три: смена `created` → `paid` и есть причина «дыхания»
|
- Статусы держим все три: смена `created` → `paid` и есть причина «дыхания»
|
||||||
выручки внутри окна; сужение до двух — резервный срез 1. На выходе из окна
|
выручки внутри окна; сужение до двух — резервный срез 1. На выходе из окна
|
||||||
заказ либо `paid`, либо `cancelled`: неоплаченного отменяют, «навсегда
|
заказ либо `paid`, либо `cancelled`: неоплаченного отменяют, «навсегда
|
||||||
@@ -261,18 +265,20 @@ 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` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~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` >
|
||||||
`duplicate_event` > `amount_delta` > `match`.
|
`duplicate_event` > `amount_delta` > `match`.
|
||||||
|
|
||||||
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов впервые
|
||||||
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), ориентир
|
||||||
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
|
~10%. Точные доли зафиксированы замером на каноническом мире (28 дней,
|
||||||
опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
|
2026-08-30, #194) — запись в «Что проверено» [карты
|
||||||
только по дням с закрытым окном (раздел 8).
|
проверок](../architecture/testing.md); паспорт хранит счётчики наблюдаемых
|
||||||
|
классов после приоритета — в заказах и только по дням с закрытым окном (раздел
|
||||||
|
8).
|
||||||
|
|
||||||
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
||||||
механически дублирует B.
|
механически дублирует B.
|
||||||
@@ -282,7 +288,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
под именем `declared_*` там, где атрибуция без трекера невозможна (UTM);
|
под именем `declared_*` там, где атрибуция без трекера невозможна (UTM);
|
||||||
слово `declared` в имени — сигнал «это заявка клиента, не деньги
|
слово `declared` в имени — сигнал «это заявка клиента, не деньги
|
||||||
отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам
|
отчётности». Оно выучивается на конфликте: суммы не сойдутся, менти сам
|
||||||
раскопает почему (Float64 против Decimal, промокод, доставка, отмены).
|
раскопает почему (состав позиций, промокод, доставка, отмены).
|
||||||
|
|
||||||
## 5. Анонимность и склейка идентичностей
|
## 5. Анонимность и склейка идентичностей
|
||||||
|
|
||||||
@@ -292,21 +298,27 @@ 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)).
|
||||||
([что хранит опись](../architecture/orders/inventory.md)).
|
|
||||||
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
||||||
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
||||||
|
|
||||||
@@ -327,16 +339,14 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
|
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
|
||||||
VIEW поверх локальной таблицы проявляются в дашборде сами.
|
VIEW поверх локальной таблицы проявляются в дашборде сами.
|
||||||
- **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна
|
- **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна
|
||||||
consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели.
|
consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели. У
|
||||||
У заказов приём другого рода — пакетный забор без MV, чтец на одной ноде,
|
заказов приём другого рода — пакетный забор без MV, чтец на одной ноде, топик
|
||||||
топик `orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)).
|
`orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)).
|
||||||
Раскладку в обоих случаях решает ключ:
|
Раскладку в обоих случаях решает ключ: события — по `cityHash64(ClientID)`
|
||||||
события — по `cityHash64(ClientID)` (см. 1.3), заказы —
|
(см. 1.3), заказы — `cityHash64(order_id)`, STG — `cityHash64(сырой строки)`;
|
||||||
`cityHash64(order_id)`, сырьё STG — `cityHash64(сырой строки)`; полный
|
полный список и доводы — в [доке хранилища](../architecture/storage.md). Урок
|
||||||
список и доводы — в [доке хранилища](../architecture/storage.md). Урок
|
«какая нода читала топик — меняется между прогонами, куда легли данные — нет»
|
||||||
«какая нода читала топик — меняется между прогонами, куда легли данные —
|
живёт на событиях: при пакетном заборе читает та нода, которую спросили.
|
||||||
нет» живёт на событиях: при пакетном заборе читает та нода, которую
|
|
||||||
спросили.
|
|
||||||
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
|
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
|
||||||
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
|
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
|
||||||
сообщения сверяется с контрактным; строка с NULL среди опорных колонок
|
сообщения сверяется с контрактным; строка с NULL среди опорных колонок
|
||||||
@@ -357,19 +367,32 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы,
|
ошибка в `*_errors`, а не молчаливые нули: имена CamelCase регистрозависимы,
|
||||||
опечатка иначе не падает.
|
опечатка иначе не падает.
|
||||||
- **Политика соединений**: по ключу ко-локации — обычное соединение с
|
- **Политика соединений**: по ключу ко-локации — обычное соединение с
|
||||||
комментарием, почему локальный результат корректен; по любому другому ключу
|
комментарием, почему локальный результат корректен; если публичные
|
||||||
— только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка
|
представления скрывают ко-локацию — решение записывается рядом с витриной.
|
||||||
|
По любому другому ключу — только явный GLOBAL; `NOT IN` — только
|
||||||
|
`GLOBAL NOT IN`. Сверка
|
||||||
`purchase`↔заказ —
|
`purchase`↔заказ —
|
||||||
легитимная GLOBAL-витрина (заказы малы).
|
легитимная GLOBAL-витрина (заказы малы).
|
||||||
- **Конвейер без TRUNCATE**: поток версий — в ReplacingMergeTree, точное чтение
|
- **Конвейер без TRUNCATE**: поток версий — в ReplacingMergeTree, точное чтение
|
||||||
— через `FINAL` или равносильный выбор последней версии; батчевая
|
— через `FINAL` или равносильный выбор последней версии; батчевая
|
||||||
переобработка нижележащих объектов — по дневным партициям
|
переобработка нижележащих объектов — по дневным партициям
|
||||||
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не
|
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не
|
||||||
применяется вовсе — полный сброс стенда делается `make clean && make up`, то
|
применяется вовсе: обычная переобработка ограничена партицией, а полное
|
||||||
есть вместе с томами. `DROP/REPLACE PARTITION` работает только по
|
восстановление данных — базами слоёв в следующем пункте. `DROP/REPLACE
|
||||||
**локальным** таблицам ON CLUSTER, не по Distributed; замена через
|
PARTITION` работает только по **локальным** таблицам ON CLUSTER, не по
|
||||||
DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это
|
Distributed; замена через DROP+INSERT неатомарна — дашборд в середине прогона
|
||||||
осознанная цена, не баг).
|
честно моргает (это осознанная цена, не баг).
|
||||||
|
- **Область очистки следует за областью состояния**: обычная разработка не
|
||||||
|
требует пустых томов. Сначала пересоздаётся затронутый объект, партиция или
|
||||||
|
база слоя; если нужен целиком чистый мир, пересоздаются прикладные базы
|
||||||
|
ClickHouse, топики и позиции их потребителей Kafka, затем заново заливаются
|
||||||
|
данные. Сами службы и их тома сохраняются. `make clean` нужен, только когда
|
||||||
|
требуется удалить состояние вне прикладного мира либо проверить запуск именно
|
||||||
|
с пустых томов. Перед таким сбросом агент называет это состояние и объясняет,
|
||||||
|
почему более узкой очистки недостаточно. «Так проще и надёжнее» не считается
|
||||||
|
обоснованием. Обязательный `make clean` перед каждой приёмкой отвергнут: он
|
||||||
|
стирает следы неисправности и превращает редкий холодный запуск в постоянную
|
||||||
|
цену разработки.
|
||||||
- **Поздние заказы поглощает ODS** как новую версию `order_id`. Как их
|
- **Поздние заказы поглощает ODS** как новую версию `order_id`. Как их
|
||||||
подхватывают материализованные объекты DDS и DM, решается вместе с их моделью,
|
подхватывают материализованные объекты DDS и DM, решается вместе с их моделью,
|
||||||
а не при проектировании приёма.
|
а не при проектировании приёма.
|
||||||
@@ -410,35 +433,37 @@ README.
|
|||||||
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах |
|
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах |
|
||||||
| STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг |
|
| STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг |
|
||||||
| ODS | `ods.event` (+`_errors`), `ods.event_v` | типизированные версии событий, брак и точное актуальное состояние |
|
| ODS | `ods.event` (+`_errors`), `ods.event_v` | типизированные версии событий, брак и точное актуальное состояние |
|
||||||
| ODS | `ods.order_snapshot` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние |
|
| ODS | `ods.order` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние |
|
||||||
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) |
|
| DDS | `dds.session_rep` / `_dist`, `dds.session_v` | физическая сборка сессий и её публичный интерфейс |
|
||||||
| DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
|
| DDS | `dds.event_v` | представление над `ods.event_v`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
|
||||||
| DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS |
|
| DDS | `dds.order_rep` / `_dist`, `dds.order_v` | текущая модель заказа и её публичный интерфейс |
|
||||||
| DDS | `dds.identity_map` | карта кука↔пользователь |
|
| DDS | `dds.identity_map_rep` / `_dist`, `dds.identity_map_v` | физическая карта кука↔пользователь и её точный публичный интерфейс |
|
||||||
| DIC | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) |
|
| DIC | подложка `products_file` и словарь `products` | каталог из CSV, вне цепочки слоёв ([ADR 0012](../adr/0012-dictionary-home.md)) |
|
||||||
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
|
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
|
||||||
|
|
||||||
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
|
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
|
||||||
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья —
|
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырых данных
|
||||||
см. [доку хранилища](../architecture/storage.md).
|
— см. [доку хранилища](../architecture/storage.md). Потребительские запросы и
|
||||||
|
витрины читают сущности DDS через `_v` с явным списком колонок; `_rep` и `_dist`
|
||||||
|
служат загрузке, диагностике и обслуживанию. Договор держится конвенцией, а не
|
||||||
|
правами: модель доступа описана в [ADR 0007](../adr/0007-clickhouse-access.md),
|
||||||
|
договор чтения — в [доке хранилища](../architecture/storage.md), «Имена
|
||||||
|
объектов».
|
||||||
|
|
||||||
Заказы принимаются **пакетным забором** ([ADR
|
Заказы принимаются **пакетным забором** ([ADR
|
||||||
0008](../adr/0008-order-ingestion.md), [ADR
|
0008](../adr/0008-order-ingestion.md), [ADR
|
||||||
0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у событий, но
|
0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у
|
||||||
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
|
событий, но матвью к нему не привязана, и порцию в STG забирает шаг под
|
||||||
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска.
|
управлением Airflow — одно прямое чтение вставляет её в `stg.orders_raw` с
|
||||||
Один следующий `task_id` двумя последовательными запросами пишет годные строки
|
`_load_id` запуска. Один следующий `task_id` двумя последовательными запросами
|
||||||
и ошибки из того же среза. `ods.order_snapshot` хранит версии по `updated_at`,
|
пишет годные строки и ошибки из того же среза. `ods.order` выбирает текущую
|
||||||
а не публикует партицию `snapshot_date`. Слой сырья остаётся точкой повтора и
|
версию по максимальной `snapshot_date` ([ADR
|
||||||
разбора одной принятой порции.
|
0014](../adr/0014-order-version-snapshot-date.md)). Слой Сырой слой остаётся
|
||||||
|
точкой повтора и разбора одной принятой порции.
|
||||||
Стенд получает от этого два режима приёма рядом, поток и слепок, и сравнение
|
|
||||||
из опорных точек раздела 12 переформулировано под них.
|
|
||||||
|
|
||||||
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
||||||
`ods.event` и `ods.order_snapshot` получают метку загрузки `_load_ts`, но у
|
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию
|
||||||
заказа бизнес-версию задаёт `updated_at`; `_load_id` проходит через STG и обе
|
заказа задаёт дата слепка; `_load_id` проходит через STG и обе цели ODS. Таблицы
|
||||||
цели ODS. Таблицы
|
|
||||||
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
|
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
|
||||||
без них урок «какая нода читала топик» ненаблюдаем.
|
без них урок «какая нода читала топик» ненаблюдаем.
|
||||||
Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое
|
Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое
|
||||||
@@ -447,8 +472,8 @@ README.
|
|||||||
Kafka день переигрывается генератором заново: снимок — кэш чистой функции,
|
Kafka день переигрывается генератором заново: снимок — кэш чистой функции,
|
||||||
см. [спеку генератора](2026-08-01-generator.md).
|
см. [спеку генератора](2026-08-01-generator.md).
|
||||||
|
|
||||||
`ods.event_v` сохраняет имена, типы и зерно ODS, но скрывает `FINAL` и возвращает
|
`ods.event_v` сохраняет имена, типы и зерно ODS, но скрывает `FINAL` и
|
||||||
актуальную версию события. Будущее `dds.event_v` — другой контракт: оно
|
возвращает актуальную версию события. `dds.event_v` — другой контракт: оно
|
||||||
переводит событие на язык бизнес-модели, в том числе даёт snake_case-имена и
|
переводит событие на язык бизнес-модели, в том числе даёт snake_case-имена и
|
||||||
расшифровывает коды. Это первый на стенде пример правила «слой — это контракт,
|
расшифровывает коды. Это первый на стенде пример правила «слой — это контракт,
|
||||||
а не обязательно копия данных».
|
а не обязательно копия данных».
|
||||||
@@ -483,13 +508,20 @@ Kafka день переигрывается генератором заново:
|
|||||||
счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется
|
счётчики `purchases`/`add_to_carts` оживают из таксономии, добавляется
|
||||||
`declared_revenue` по UTM.
|
`declared_revenue` по UTM.
|
||||||
- **`daily_traffic_v`** — расширяется парой «посетители» / «известные
|
- **`daily_traffic_v`** — расширяется парой «посетители» / «известные
|
||||||
пользователи» (обогащение через `dds.identity_map`, локальное соединение
|
пользователи». Обогащение идет явным `GLOBAL LEFT JOIN` через
|
||||||
по ключу ко-локации).
|
`dds.identity_map_v`: опыт на ClickHouse 26.3 показал, что публичные `_v`
|
||||||
|
скрывают ко-локацию от локальной подстановки.
|
||||||
- `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`,
|
- `events_enriched_v`, `top_pages_daily_v`, `session_overview_v`,
|
||||||
`dq_errors_daily_v` — переезжают на новую модель без смены роли: источник —
|
`dq_errors_daily_v` — переезжают на новую модель без смены роли. Источник
|
||||||
`dds.event_v`, не `ods.event`.
|
и зерно у двух из них меняет сама новая модель, и это зафиксировано ТЗ
|
||||||
- `dm.dq_summary` переводится с TRUNCATE+INSERT на партиционную замену
|
домашек ([`docs/labs/`](../labs/README.md)):
|
||||||
(политика «без TRUNCATE»).
|
`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 получает три новых сюжета: выручка по дням и категориям,
|
||||||
таблица сверки с классами расхождений, пара посетители/известные.
|
таблица сверки с классами расхождений, пара посетители/известные.
|
||||||
@@ -498,30 +530,31 @@ Kafka день переигрывается генератором заново:
|
|||||||
пачками), Postgres остаётся только служебной базой Airflow. Смешанность
|
пачками), Postgres остаётся только служебной базой Airflow. Смешанность
|
||||||
ландшафта выражена режимами и частотами, а не второй трубой.
|
ландшафта выражена режимами и частотами, а не второй трубой.
|
||||||
|
|
||||||
## 8. Эталонный мир и опись
|
## 8. Эталонный мир и паспорт
|
||||||
|
|
||||||
В git хранится только опись эталонного мира; сам снимок (14 модельных
|
В git хранится только паспорт эталонного мира; сам снимок генерируется на месте
|
||||||
дней) генерируется на месте — при `make up` и при проверках (решение
|
— при `make up` и при проверках (решение развилки «Производительность» карты
|
||||||
развилки «Производительность» карты #26, подробности —
|
#26, подробности — [спека генератора](2026-08-01-generator.md), раздел 5).
|
||||||
[спека генератора](2026-08-01-generator.md), раздел 5). Опись несёт
|
Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней снято на этапе
|
||||||
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
|
7. Паспорт несёт шапку с каноническим зерном и версией генератора, контрольные
|
||||||
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
счётчики и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
||||||
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
|
сравни хеш» живут на нём. Политика версионирования артефакта (бывший туман карты
|
||||||
карты #10) закрыта этим же ходом: версионируется опись.
|
#10) закрыта этим же ходом: версионируется паспорт. Контрольные числа паспорта:
|
||||||
Контрольные числа описи:
|
|
||||||
|
|
||||||
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
|
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
|
||||||
«слепок переснимается и даёт те же байты» опись сторожит наравне с днями;
|
«слепок переснимается и даёт те же байты» паспорт сторожит наравне с днями;
|
||||||
- заказная сторона: заказы, выручка и счётчики наблюдаемых классов
|
- заказная сторона: счётчики наблюдаемых классов расхождений после
|
||||||
расхождений после приоритета, в заказах — по дням с закрытым окном
|
приоритета; каждый показывает число заказов и хранится только для дня с
|
||||||
(самопроверка лабы сверки; при N сыгранных днях таких дней N − 7);
|
закрытым окном (читатель — техническая проверка `classes_vs_inventory` звена
|
||||||
|
DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для общего числа
|
||||||
|
заказов и выручки в паспорте пока нет: для них не назван читатель;
|
||||||
- контрольные числа идентичности не заводятся: uniq известных пользователей
|
- контрольные числа идентичности не заводятся: uniq известных пользователей
|
||||||
и число двухкуковых покупателей растут, пока мир едет, а счётчик без
|
и число двухкуковых покупателей растут, пока мир едет, а счётчик без
|
||||||
названного читателя в опись не кладётся
|
названного читателя в паспорт не кладётся ([что хранит
|
||||||
([что хранит опись](../architecture/orders/inventory.md)).
|
паспорт](../architecture/orders/inventory.md)).
|
||||||
|
|
||||||
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
|
Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
|
||||||
при пересборке.
|
[карты проверок](../architecture/testing.md).
|
||||||
|
|
||||||
## 9. Оценка объёма исполнения
|
## 9. Оценка объёма исполнения
|
||||||
|
|
||||||
@@ -535,7 +568,7 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
|
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
|
||||||
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка), world_init/next_day, helpers; первый даг — переливка слепка — приходит раньше, этапом 3 | 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 |
|
||||||
|
|
||||||
@@ -582,18 +615,36 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
4. Трансформации и витрины: модель DDS заказа, сессии, identity_map,
|
4. Трансформации и витрины: модель DDS заказа, сессии, identity_map,
|
||||||
выручка, сверка A+C.
|
выручка, сверка A+C.
|
||||||
5. Airflow: `etl_pipeline` (партиционная переобработка).
|
5. Airflow: `etl_pipeline` (партиционная переобработка).
|
||||||
6. Расхождения B+D и опоздания; счётчики описи.
|
6. Расхождения B+D и опоздания; счётчики паспорта.
|
||||||
7. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация
|
7. Эталонный мир: паспорт и пересборка снимка, чек-скрипты. CI-генерация
|
||||||
на amd64 и arm64.
|
снята (спека генератора, раздел 5).
|
||||||
8. Superset-дашборд v2.
|
8. Superset-дашборд v2.
|
||||||
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
||||||
и границы — ADR 0002.
|
и границы — ADR 0002. Этап идёт не подряд, см. ниже.
|
||||||
|
|
||||||
|
**Мониторинг не ждёт своего номера.** Зависимость у дашбордов формально только
|
||||||
|
от этапа 1, а «в хвосте» было выбором, не необходимостью. Довод за переезд
|
||||||
|
пришёл со второй стороны: дашборд — точка наблюдения не только для менти, но и
|
||||||
|
для владельца на приёмке этапов 4–8, а шесть вопросов дашборда «Данные» из
|
||||||
|
ADR 0002 с приёмочными совпадают почти целиком. Поэтому дашборды строятся
|
||||||
|
параллельно этапам 4–8. Состав от переезда не меняется, меняется срок.
|
||||||
|
|
||||||
|
В хвосте остаётся то, чему нужен прожитый опыт или дозревший мир: runbook
|
||||||
|
«keeper упал / DDL повис в очереди», `system.text_log` (ADR 0002 привязал его
|
||||||
|
к runbook), правила оповещения и урок «посмотрели → сломали → увидели →
|
||||||
|
вернули» (раздел 10).
|
||||||
|
|
||||||
|
Порогов панели не несут вовсе, и это правило, а не срок: на дашборд смотрят
|
||||||
|
глазами; красное и зелёное живёт в проверках — у целей `make` про стенд и у
|
||||||
|
дагов качества данных про данные.
|
||||||
|
|
||||||
Что гонять на приёмке этапа — строка в [карте
|
Что гонять на приёмке этапа — строка в [карте
|
||||||
проверок](../architecture/testing.md), раздел «Какую проверку когда
|
проверок](../architecture/testing.md), раздел «Какую проверку когда
|
||||||
запускать»: стенд собирается с нуля и на нём зелены все три цели, которым
|
запускать»: на стенде зелены все три цели, которым он нужен, а состояние перед
|
||||||
нужен стенд. Это гигиена, а не приёмка: она обещает, что этап не сломал стенд,
|
проверкой восстанавливается в самой узкой достаточной области. Запуск с пустых
|
||||||
и молчит о его предмете — дашборд может быть не нарисован, а все три останутся
|
томов обязателен только для изменения начальной сборки или постоянного состояния
|
||||||
|
служб, а не как общая гигиена этапа. Эти цели обещают, что этап не сломал стенд,
|
||||||
|
и молчат о его предмете — дашборд может быть не нарисован, а все три останутся
|
||||||
зелёными. Свои наблюдаемые критерии каждый этап получает при разбиении в
|
зелёными. Свои наблюдаемые критерии каждый этап получает при разбиении в
|
||||||
/to-tickets. Документация правится в PR этапа (правило AGENTS.md).
|
/to-tickets. Документация правится в PR этапа (правило AGENTS.md).
|
||||||
|
|
||||||
@@ -603,9 +654,15 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
|
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
|
||||||
- Лабы и курс: 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 между нодами, настоящее хранилище и ротация секретов, внешние учётные
|
- TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные
|
||||||
@@ -641,8 +698,6 @@ Kafka Engine на двух нодах снят с этого списка при
|
|||||||
`ReplacingMergeTree`. Проверять то, что заведомо случается и заведомо
|
`ReplacingMergeTree`. Проверять то, что заведомо случается и заведомо
|
||||||
обезврежено, — работа без ответа на конце.
|
обезврежено, — работа без ответа на конце.
|
||||||
|
|
||||||
- Поведение соединения двух Distributed-таблиц и `distributed_product_mode` —
|
|
||||||
эмпирически на стенде (хвост #14).
|
|
||||||
- Размер артефакта эталонного мира после пересборки.
|
- Размер артефакта эталонного мира после пересборки.
|
||||||
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
|
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
|
||||||
сверять через MCP Context7 (правило AGENTS.md).
|
сверять через MCP Context7 (правило AGENTS.md).
|
||||||
|
|||||||
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user