Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -8,15 +8,26 @@
|
||||
Стенд строится по спеке
|
||||
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
|
||||
|
||||
Отсюда вопрос к любой доработке — коду, проверке, документу: **чему на ней
|
||||
научится менти?** Ответ называется одной фразой до того, как писать. Не
|
||||
Стенд рассчитан на начинающих инженеров данных. Они недавно изучили ClickHouse,
|
||||
но почти не работали с ним руками. Здесь они разбирают и достраивают хранилище
|
||||
небольшого интернет-магазина: проходят путь от загрузки данных до витрин и BI.
|
||||
Программа дает практику общепринятых инженерных приемов. После нее участники
|
||||
должны уметь построить похожее хранилище без готового образца. Состав и порядок
|
||||
работы задает [программа учебных заданий](docs/specs/2026-09-01-labs-program.md).
|
||||
|
||||
Стенд существует только ради обучения. Его поддержка имеет смысл, когда
|
||||
сохраняет возможность пройти программу или сама становится учебной задачей.
|
||||
Отдельной эксплуатационной ценности у стенда нет.
|
||||
|
||||
Отсюда вопрос к любой доработке — коду, проверке, документу: **чему она
|
||||
учит?** Ответ называется одной фразой до того, как писать. Не
|
||||
складывается фраза — это вопрос владельцу, а не строчка кода: проверки в этом
|
||||
репозитории однажды уже переросли продукт.
|
||||
|
||||
**Усложнение не бесплатно, и платит за него учебная ценность.** Чем сложнее
|
||||
стенд, тем хуже он как учебный материал: внимание менти конечно, и каждая
|
||||
стенд, тем хуже он как учебный материал: внимание читателя конечно, и каждая
|
||||
конструкция, которую он обязан расшифровать по дороге к уроку, списывается с
|
||||
этого счёта. Поэтому вопрос выше имеет вторую половину: **что менти платит,
|
||||
этого счёта. Поэтому вопрос выше имеет вторую половину: **что читатель платит,
|
||||
чтобы это прочесть, и что получает взамен?** Ответ «вопрос — и ничего»
|
||||
означает, что писать не нужно.
|
||||
|
||||
@@ -70,6 +81,9 @@ Airflow) и названия из кода. Если для понятия ес
|
||||
писал: простые слова, короткие фразы, сложную мысль поясняй при первом
|
||||
упоминании. Если понятность и буквальная точность спорят — выбирай понятность.
|
||||
|
||||
При создании или редактировании лабораторных следуй
|
||||
[правилам учебного текста](docs/specs/2026-09-01-labs-program.md#как-писать-задания).
|
||||
|
||||
Внутренние рассуждения и промежуточные пометки по ходу работы веди на
|
||||
английском — он экономнее по токенам. На русском остаётся всё, что видит и
|
||||
хранит проект: итоговые ответы, документы, комментарии в коде и SQL, сообщения
|
||||
@@ -157,10 +171,21 @@ Airflow) и названия из кода. Если для понятия ес
|
||||
дефис (`clickstream-event.md`). Ни даты, ни номера: документ не событие
|
||||
истории, а текущее описание живого формата — и собирается из кода, а не
|
||||
пишется руками.
|
||||
- Имена файлов в `docs/labs/` — слаг строчными латинскими буквами через дефис
|
||||
(`top-pages-daily.md`), индекс папки — `README.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`: сквозной номер из четырёх
|
||||
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
|
||||
в имени не нужна.
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
@AGENTS.md
|
||||
|
||||
# Читабельность языка
|
||||
|
||||
Пиши легко для чтения. Сокращай количество мыслей, а не количество слов, которыми выражена каждая мысль.
|
||||
|
||||
Please remove all mannered prose.
|
||||
+24
-11
@@ -11,6 +11,12 @@
|
||||
источников, строится по их документации.
|
||||
_Избегать_: склад, склад данных
|
||||
|
||||
**Сырые данные**:
|
||||
Слой STG хранилища и его содержимое: сообщение топика, положенное строкой,
|
||||
как пришло, без разбора. В прозе — сырые данные и сырой слой; про
|
||||
конкретную таблицу или базу — STG.
|
||||
_Избегать_: сырьё
|
||||
|
||||
**Состав мира**:
|
||||
Постоянная часть мира генератора — популяция посетителей, их привычки,
|
||||
двухкуковые пары. По дням его выдаёт план состава.
|
||||
@@ -122,13 +128,13 @@ ClickHouse. Форма файла решена, длина — нет: стро
|
||||
на нем принимаются следующие этапы.
|
||||
_Избегать_: зерновой мир
|
||||
|
||||
**Опись мира**:
|
||||
`data/world-inventory.json` — единственное, что о мире хранится в git: паспорт
|
||||
мира (зерно, версия генератора, хеш каталога), строка на каждый день событий
|
||||
**Паспорт мира**:
|
||||
`data/world-inventory.json` — единственное, что о мире хранится в git: шапка
|
||||
(зерно, версия генератора, хеш каталога), строка на каждый день событий
|
||||
(число и хеш байтов) и строка на каждый отправленный слепок (хеш байтов).
|
||||
Счётчик сверх этого заводится только с названным читателем. Сам мир не
|
||||
хранится, а пересчитывается; опись отвечает на один вопрос: тот ли это мир.
|
||||
_Избегать_: манифест, мини-манифест
|
||||
хранится, а пересчитывается; паспорт отвечает на один вопрос: тот ли это мир.
|
||||
_Избегать_: опись мира, манифест, мини-манифест
|
||||
|
||||
**Пошаговый режим**:
|
||||
Базовый способ движения по оси модельного времени: «прожить следующий
|
||||
@@ -137,16 +143,23 @@ _Избегать_: манифест, мини-манифест
|
||||
**Живой день**:
|
||||
Проигрывание текущего модельного дня в реальном времени с ускорением;
|
||||
включается по требованию — постоянным фоном идёт, только пока включён
|
||||
выключатель пульта.
|
||||
выключатель `world_live`.
|
||||
|
||||
**Пакетный режим**:
|
||||
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
|
||||
стенда, пересборки и проверки.
|
||||
|
||||
**Пульт мира**:
|
||||
Даги Airflow, которыми двигают ось модельного времени: работники играют день —
|
||||
пачкой или в темпе, — а выключатель держит стенд живущим, пока включён.
|
||||
_Избегать_: управляющие даги
|
||||
**Даги модельного времени**:
|
||||
Даги Airflow, которыми двигают ось модельного времени: шаговые даги играют
|
||||
день — пачкой или в темпе, — а выключатель держит стенд живущим, пока включён.
|
||||
Коротко — даги времени.
|
||||
_Избегать_: пульт мира, управляющие даги
|
||||
|
||||
**Шаговый даг**:
|
||||
Даг модельного времени, который играет день и ставит позицию на оси:
|
||||
`world_next_day` — пачкой, `world_live_day` — в темпе. Запускается руками,
|
||||
расписания и паузы не имеет; автоматику поверх него даёт выключатель.
|
||||
_Избегать_: работник
|
||||
|
||||
**Граница суток**:
|
||||
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
|
||||
@@ -211,6 +224,6 @@ _Избегать_: путать с приёмником и пакетным р
|
||||
генератора, про отправку; забор — про сторону хранилища, про чтение.
|
||||
|
||||
**Техническая проверка**:
|
||||
Проверка, чей оракул — внутренний эталон стенда (опись мира), которого у
|
||||
Проверка, чей оракул — внутренний эталон стенда (паспорт мира), которого у
|
||||
настоящего магазина нет. За учебный урок DQ не выдаётся.
|
||||
_Избегать_: DQ-проверка — та работает на наблюдаемых данных магазина.
|
||||
@@ -51,9 +51,11 @@ config-test:
|
||||
# того своя дверь и свой конфиг. Версия закреплена, потому что лока в корне
|
||||
# нет, а форматтер между версиями меняет вывод — иначе проверка однажды
|
||||
# покраснела бы сама, без единой правки в репозитории.
|
||||
# Проверка рендера быстрая, не требует стенда и ловит устаревший HTML.
|
||||
lint:
|
||||
uvx ruff@0.16.1 check dags infra/superset
|
||||
uvx ruff@0.16.1 format --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 docs/handbook/architecture-map
|
||||
uv run docs/handbook/architecture-map/render.py --check
|
||||
|
||||
# --- Наполнение миром ---
|
||||
|
||||
|
||||
@@ -1,426 +1,157 @@
|
||||
# Учебная дата-платформа кликстрима
|
||||
|
||||
Стенд для работы с кликстримом. Преемник учебного стенда
|
||||
[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).
|
||||
Сейчас работают кластер ClickHouse из двух шардов, отдельный
|
||||
clickhouse-keeper, односерверная Kafka в режиме KRaft, Airflow 3.3, Superset,
|
||||
Prometheus, Grafana, экспортёр метрик Kafka и общая база Postgres для
|
||||
метаданных. Генератор кликстрима в [`generator/`](generator/) собран
|
||||
целиком со стороны клиента: у него есть контракт схемы события, из которого
|
||||
собрано [описание выгрузки](docs/formats/clickstream-event.md), модельный мир и
|
||||
проигрыватель, отправляющий дни в Kafka или в файл. События доезжают до
|
||||
типизированного `ods.event`, а `dds.event_v` отдаёт их на языке бизнес-модели
|
||||
без копии данных. `make up` наполняет стенд стартовым миром — первой неделей
|
||||
модельного времени.
|
||||
Генератор создает заказы и кликстрим — записи о действиях посетителей
|
||||
магазина. Данные поступают через Kafka в ClickHouse и проходят четыре слоя
|
||||
обработки: STG, ODS, DDS и DM. В кластере ClickHouse два шарда и отдельный
|
||||
ClickHouse Keeper. Airflow управляет загрузкой и обработкой, Superset
|
||||
показывает отчеты, а Prometheus и Grafana помогают следить за работой стенда.
|
||||
|
||||
При первом запуске стенд загрузит данные за неделю работы магазина.
|
||||
После запуска начните с [путеводителя](docs/handbook/README.md): он объясняет
|
||||
устройство хранилища. Затем переходите к [учебным заданиям](docs/labs/README.md).
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
Нужны Docker с Compose и около 8 ГБ памяти, доступной Docker. Это не объём
|
||||
ноутбука, а то, что отдано самому Docker: в Docker Desktop и WSL2 он живёт
|
||||
внутри виртуальной машины и получает лишь часть памяти хозяина. Сколько выдано
|
||||
сейчас, в байтах, покажет `docker info --format '{{.MemTotal}}'`. В WSL2 это
|
||||
поднимается параметром `memory` в файле `.wslconfig` домашнего каталога
|
||||
пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не
|
||||
хватает, стенд одинаково хорошо живёт на недорогом VPS.
|
||||
Для запуска нужны Docker с Compose, `make`, `curl` и `jq`.
|
||||
Выделите Docker около 8 ГБ оперативной памяти. В Docker Desktop и WSL2
|
||||
проверьте лимит памяти виртуальной машины: он может быть меньше, чем общий
|
||||
объем памяти компьютера. Команда `docker info --format '{{.MemTotal}}'`
|
||||
покажет доступный Docker объем в байтах. В WSL2 лимит задается параметром
|
||||
`memory` в файле `.wslconfig` в домашнем каталоге пользователя Windows.
|
||||
После изменения выполните `wsl --shutdown`. Если ресурсов компьютера
|
||||
недостаточно, стенд можно запустить на VPS.
|
||||
|
||||
Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`,
|
||||
`grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им
|
||||
читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны
|
||||
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
|
||||
и `29092`. Проверкам без стенда — `make config-test` и `make lint` в корне,
|
||||
всем целям генератора в `generator/` — нужен `uv`.
|
||||
По умолчанию должны быть свободны порты
|
||||
`23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
|
||||
и `29092`. Для работы с командами `make` в каталоге `generator/` также
|
||||
понадобится `uv`.
|
||||
|
||||
Сначала скопируйте настройки стенда:
|
||||
В каталоге репозитория скопируйте образец настроек:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
В `.env` задаются имя экземпляра стенда, внешние порты, учебные логины,
|
||||
пароли и ключи. Проверьте `DOCKER_GID`: это числовой идентификатор группы
|
||||
`docker` на вашей машине. Узнать его можно командой `getent group docker`.
|
||||
Если число отличается от образца, замените значение в `.env`. От этой
|
||||
настройки зависит запуск генератора из Airflow.
|
||||
|
||||
Запустите стенд:
|
||||
|
||||
```bash
|
||||
make up
|
||||
make smoke
|
||||
make check-clickhouse
|
||||
make check-services
|
||||
```
|
||||
|
||||
Первому знакомству нужны все три проверки на стенде: `make smoke` говорит, что
|
||||
стенд собран, `make check-clickhouse` — что кластер работает кластером, а
|
||||
`make check-services` — что Airflow запускает DAG, а Superset ходит в базу.
|
||||
Дальше, в рабочей петле, обычно хватает `make smoke`.
|
||||
Команда запустит службы и дождется загрузки начальных данных. Порядок запуска
|
||||
и проверки результата описаны в [справочнике проверок](docs/architecture/testing.md).
|
||||
Если обязательной настройки в `.env` не хватает, Compose сообщит об ошибке.
|
||||
Внутренние адреса служб, имена топиков и версии образов заданы в `compose.yaml`
|
||||
и Dockerfile.
|
||||
|
||||
`.env.example` — образец обязательной локальной настройки. В нём живут имя
|
||||
экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца
|
||||
верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и
|
||||
свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь,
|
||||
кроме пульта мира — о нём ниже. `compose.yaml` не
|
||||
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
|
||||
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
|
||||
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
|
||||
Логины и пароли Postgres и Grafana сохраняются при первом запуске в томах
|
||||
Docker. Простая правка `.env` их не изменит. Чтобы задать новые значения
|
||||
через `.env`, потребуется `make clean` и повторный `make up`. При этом
|
||||
будут удалены все локальные данные стенда.
|
||||
|
||||
Учётные данные Postgres и Grafana применяются при создании их томов.
|
||||
После первого запуска меняйте их только вместе с `make clean`: команда удалит
|
||||
все локальные данные стенда, а следующий `make up` создаст их с новыми
|
||||
значениями.
|
||||
|
||||
`make up` собирает локальные образы Airflow, Superset и Grafana, поднимает весь
|
||||
стенд и ждёт здорового состояния долгоживущих контейнеров. В образ 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 rebuild-storage` запустит в
|
||||
Airflow его явную пересборку: удалит прикладные базы ClickHouse, топики и
|
||||
позиции потребителей Kafka, затем создаст схему и данные заново. Службы и их
|
||||
тома останутся на месте. `make clean` удаляет весь стенд вместе с именованными
|
||||
томами и для обычной разработки не нужен. Перед пересборкой поставьте
|
||||
`world_live` на паузу и дайте уже начатому работнику закончить день.
|
||||
|
||||
## Стартовый мир
|
||||
|
||||
Стенд поднимается не пустым: `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`. Всё, что принимается
|
||||
флагами, принимается и переменными окружения — так генератор позовёт даг
|
||||
этапа 5; полный список у `--help`.
|
||||
|
||||
**Умолчания есть не у всего, и это нарочно.** Зерно, число дней и темп живого
|
||||
дня описывают модель мира — их генератор знает сам. День на оси, приёмник и имя
|
||||
топика описывают стенд, на котором его запустили: не назвали — отказ и ненулевой
|
||||
код возврата, ещё до первого события. Поэтому умолчание дня и живёт в
|
||||
`Makefile`: день называет тот, кто запускает, а не тот, кого запускают.
|
||||
|
||||
### Пульт мира
|
||||
|
||||
Считать дни самому необязательно. В Airflow живёт **пульт мира**: два дага
|
||||
создают его начальное состояние, а три двигают ось дальше.
|
||||
|
||||
| Даг | Что делает |
|
||||
| --- | --- |
|
||||
| `world_initialize` | создаёт стартовый мир, если его ещё нет; повторный запуск ничего не меняет |
|
||||
| `world_recreate` | удаляет только прикладной мир и запускает его создание заново |
|
||||
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
|
||||
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
|
||||
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём |
|
||||
|
||||
Работник не только играет день: следом он отправляет слепок заказов за
|
||||
сыгранное и дёргает отдельный даг, `orders_ingest`, — тот забирает приехавший
|
||||
слепок из топика `orders` в сырьё, — и дожидается его конца. Так две половины
|
||||
стенда идут в ногу: упал приём — краснеет и работник, а позиция остаётся на
|
||||
месте ([ADR 0008](docs/adr/0008-order-ingestion.md)).
|
||||
|
||||
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
|
||||
дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон
|
||||
позицию не двигает, и следующий запуск играет тот же день заново. Отсутствующая
|
||||
переменная означает, что мир ещё не создавали; `-1` — очистка не закончена,
|
||||
`0` — очистка закончена, а начальная генерация ещё не завершена. Работники
|
||||
начинают играть только с позиции `8` и больше.
|
||||
|
||||
В форме запуска можно выбрать только число дней. Первый день работник берет из
|
||||
`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options».
|
||||
Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую
|
||||
позицию работник пишет в журнал шага `remember_played`.
|
||||
|
||||
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
|
||||
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
|
||||
целиком — [ADR 0009](docs/adr/0009-world-control.md).
|
||||
|
||||
**Плата названа вслух: планировщику Airflow отдан сокет докера** — иначе
|
||||
контейнер генератора ему не поднять. Доступ к сокету равен праву root на
|
||||
машине; для локального учебного стенда размен принят, но знать о нём надо.
|
||||
Открывает дверь не монтирование, а членство в группе: GID группы `docker` у
|
||||
каждой машины свой, живёт в `.env` и подсматривается командой
|
||||
`getent group docker`.
|
||||
|
||||
## Состав и доступ
|
||||
|
||||
- `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.
|
||||
## Адреса и пароли
|
||||
|
||||
С настройками из `.env.example` порты доступны только с локальной машины:
|
||||
|
||||
- нода 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
|
||||
- нода 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
|
||||
- узел ClickHouse 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
|
||||
- узел ClickHouse 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
|
||||
- Kafka — `127.0.0.1:29092`;
|
||||
- Airflow — `http://127.0.0.1:28080`, пользователь `admin`, пароль `airflow`;
|
||||
- Superset — `http://127.0.0.1:28088`, пользователь `admin`, пароль `superset`;
|
||||
- Prometheus — `http://127.0.0.1:29090`;
|
||||
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
|
||||
|
||||
В ClickHouse семь пользователей и шесть ролей:
|
||||
Подробнее о службах, правах пользователей и изменении настроек —
|
||||
в [справочнике стенда](docs/architecture/stand.md).
|
||||
|
||||
- `etl` подключает рабочие даги Airflow; роль `etl_writer` читает и пишет слои
|
||||
хранилища;
|
||||
- `lifecycle` применяет канонический DDL и пересоздаёт прикладные базы; роль
|
||||
`lifecycle_owner` не даёт эти административные права рабочим дагам;
|
||||
- `bi` подключает Superset; роль `bi_reader` читает ODS, DDS, DM и справочники
|
||||
`dic`, а запросы Superset обращаются к публичным `_v`;
|
||||
- `analyst` предназначен для подключения человека и читает все слои и
|
||||
справочники;
|
||||
- `grafana` с ролью `monitoring_reader` подключает Grafana и читает все слои,
|
||||
системные таблицы и данные со всех реплик кластера;
|
||||
- `dict` читает только базу `dic` и пароля не имеет: им словарь товаров ходит
|
||||
за своей подложкой;
|
||||
- `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри
|
||||
контейнеров, но не приложения.
|
||||
## Добавить данные за следующие дни
|
||||
|
||||
Почему пользователи объявлены файлом, а ноды доверяют общему секрету, — в
|
||||
[ADR 0007](docs/adr/0007-clickhouse-access.md).
|
||||
Пользователи и роли объявлены в `infra/clickhouse/users.d/access.xml`. Пароли и
|
||||
общий секрет нод живут в `.env` и передаются в конфигурацию через окружение;
|
||||
Superset получает пароль `bi`, а Grafana — пароль `grafana` тем же путём.
|
||||
Значения для локального стенда есть в `.env.example`.
|
||||
Время в учебном магазине идет отдельно от календарного. В документации оно
|
||||
называется модельным временем, а сам магазин с его посетителями и заказами —
|
||||
миром.
|
||||
|
||||
Изменения значений ClickHouse в `.env` и файлов настройки серверов в
|
||||
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/` применяются
|
||||
пересозданием нод:
|
||||
В Airflow запустите `world_next_day`. Это даг — набор связанных задач,
|
||||
которые Airflow выполняет в заданном порядке. Он создаст и загрузит данные
|
||||
за следующий день работы магазина. В форме запуска можно задать число дней.
|
||||
|
||||
```bash
|
||||
docker compose up --force-recreate --wait clickhouse-01 clickhouse-02
|
||||
```
|
||||
Чтобы новые данные поступали непрерывно, снимите с паузы даг `world_live`.
|
||||
Он будет запускать генерацию день за днем; один модельный день занимает
|
||||
около 24 минут. Подробности — в [справочнике генератора и модельного
|
||||
времени](docs/architecture/world.md#даги-модельного-времени).
|
||||
|
||||
Команда возвращает управление, когда обе ноды снова здоровы; именованные тома
|
||||
при этом сохраняются. `docker compose restart` оставит прежнее окружение и
|
||||
может оставить старую версию отдельно смонтированного файла.
|
||||
## Восстановить начальные данные
|
||||
|
||||
Пароли ClickHouse и интерфейсов, пароли Postgres, ключи Airflow и Superset,
|
||||
отсутствие проверки доступа у Kafka и Prometheus — намеренно простые и явно
|
||||
ненастоящие настройки локального учебного стенда. Это не пример настройки
|
||||
защиты: не копируйте значения из `.env.example` в рабочую среду. Все
|
||||
опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не
|
||||
опубликован.
|
||||
Если хотите начать заново, поставьте `world_live` на паузу и дождитесь
|
||||
завершения уже запущенного дага генерации. Затем выполните
|
||||
`make rebuild-storage`.
|
||||
|
||||
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
|
||||
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
|
||||
2×2 намеренно не входят в стенд.
|
||||
Команда удалит прикладные базы ClickHouse, топики и позиции потребителей
|
||||
Kafka, затем заново создаст схему хранилища и загрузит начальные данные.
|
||||
Службы и их тома останутся на месте.
|
||||
|
||||
Конфигурация сверена 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, отставание
|
||||
чтения и дневной слепок заказов. Устройство зоны — в [справочнике
|
||||
мониторинга](docs/architecture/monitoring/README.md), разбор панелей — в
|
||||
[описании дашборда](docs/architecture/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`; отдельное подключение и его обёртки здесь не нужны, поэтому
|
||||
клиент 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/).
|
||||
`make down` остановит стенд и сохранит данные. Для повторного запуска
|
||||
выполните `make up`.
|
||||
|
||||
## Что здесь будет
|
||||
`make clean` удалит контейнеры и все данные в томах Docker. Для обычного
|
||||
завершения работы достаточно `make down`.
|
||||
|
||||
- одно широкое событие кликстрима по образцу выгрузки Яндекс Метрики вместо
|
||||
четырёх топиков;
|
||||
- второй источник — заказы бэкенда, ежедневным слепком в ту же Kafka;
|
||||
- сверка клиентской покупки с заказом бэкенда: деньги считаем по бэкенду,
|
||||
поведение и атрибуцию — по трекеру;
|
||||
- анонимный кликстрим и склейка кука↔пользователь через покупки;
|
||||
- ClickHouse кластером как единственным режимом.
|
||||
## Подключиться из DBeaver
|
||||
|
||||
## Чем отличается от предшественника
|
||||
Драйвер ClickHouse в DBeaver подключается по JDBC через HTTP-порт. Укажите хост
|
||||
`127.0.0.1`, порт `28123`, пользователя `analyst` и пароль из переменной
|
||||
`CLICKHOUSE_ANALYST_PASSWORD` в `.env`; в образце это `analyst-26-reader`.
|
||||
Для подключения ко второму узлу укажите порт `28124`.
|
||||
|
||||
Предшественник остаётся стабильным учебным стендом и заморожен для новых фич:
|
||||
там событие разрезано на четыре топика, есть только просмотры страниц,
|
||||
посетители опознаны по email, ClickHouse — одна нода. Развитие идёт здесь.
|
||||
Пользователь `analyst` может читать данные во всех слоях и справочниках.
|
||||
Для создания учебных витрин используйте подключение, описанное
|
||||
в [инструкции к заданиям](docs/labs/README.md#как-применить-свой-ddl).
|
||||
Нативные порты `29000` и `29001` нужны `clickhouse-client`, но не DBeaver.
|
||||
Остальные пользователи и роли описаны в
|
||||
[справочнике стенда](docs/architecture/stand.md#пользователи-и-роли-clickhouse).
|
||||
|
||||
## Документация
|
||||
|
||||
- [docs/specs/](docs/specs/) — спеки: источник истины о задуманном.
|
||||
- [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми
|
||||
вариантами: почему сделано так, а не иначе.
|
||||
- [docs/architecture/](docs/architecture/) — рабочие справочники по зонам
|
||||
ответственности: [хранилище](docs/architecture/storage.md) — конвенции имён,
|
||||
раскладка по шардам, приём событий, карта таблиц;
|
||||
[проверки](docs/architecture/testing.md) — что утверждает каждая цель `make`
|
||||
и куда класть новую проверку.
|
||||
- [docs/handbook/](docs/handbook/) — путеводитель по стенду и порядок чтения.
|
||||
- [docs/labs/](docs/labs/) — учебные задания с порядком прохождения и
|
||||
самопроверкой.
|
||||
- [docs/specs/](docs/specs/) — требования к стенду и программа обучения.
|
||||
- [docs/adr/](docs/adr/) — решения об устройстве стенда: что выбрали,
|
||||
какие варианты отклонили и почему.
|
||||
- [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/formats/](docs/formats/) — описания форматов источников: по ним
|
||||
пишется сторона хранилища. Собираются из кода командой `make docs` в
|
||||
`generator/`, руками не правятся.
|
||||
- [AGENTS.md](AGENTS.md) — контракт работы в репозитории.
|
||||
- [docs/formats/](docs/formats/) — форматы исходных данных, необходимые
|
||||
для их загрузки и обработки. Эти документы собираются из кода командой
|
||||
`make docs` в `generator/`; вручную их менять не нужно.
|
||||
- [AGENTS.md](AGENTS.md) — правила работы в репозитории для ИИ-агентов.
|
||||
+3
-3
@@ -34,7 +34,7 @@ x-clickhouse-common: &clickhouse-common
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
|
||||
# Факты генератора и пульта мира названы по одному разу — иначе они однажды
|
||||
# Факты генератора и дагов модельного времени названы по одному разу — иначе они однажды
|
||||
# разойдутся, и заметит это не проверка, а менти с пустым топиком.
|
||||
x-generator-image: &generator-image clickstream-generator:local
|
||||
x-orders-topic: &orders-topic orders
|
||||
@@ -70,7 +70,7 @@ x-airflow-common: &airflow-common
|
||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
||||
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
||||
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
|
||||
# Пульт мира поднимает генератор отдельным контейнером. Имя сети собирается
|
||||
# Даги времени поднимают генератор отдельным контейнером. Имя сети собирается
|
||||
# из имени проекта: у второй копии стенда оно другое.
|
||||
<<: *kafka-target
|
||||
GENERATOR_IMAGE: *generator-image
|
||||
@@ -291,7 +291,7 @@ services:
|
||||
airflow-init:
|
||||
condition: service_completed_successfully
|
||||
command: scheduler
|
||||
# Пульт мира зовёт генератор отдельным контейнером, а при LocalExecutor
|
||||
# Даги времени зовут генератор отдельным контейнером, а при LocalExecutor
|
||||
# задачи исполняет сам планировщик — значит сокет докера нужен ему одному.
|
||||
# Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root
|
||||
# на машине. Открывает дверь не монтирование, а группа: у сокета права 660
|
||||
|
||||
@@ -46,8 +46,8 @@ HITS_PARTITIONS = (0, 1)
|
||||
READINESS_TIMEOUT_SECONDS = 300
|
||||
READINESS_POLL_SECONDS = 3
|
||||
|
||||
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит
|
||||
# пульт мира, и только она отличает прожитый день от живого — зачем это
|
||||
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставят
|
||||
# даги модельного времени, и только она отличает прожитый день от живого — зачем это
|
||||
# сессиям, разобрано в sql/dds/session_scope.sql.
|
||||
WORLD_POSITION = "world_position"
|
||||
|
||||
@@ -224,8 +224,8 @@ def _wait_for_phase(
|
||||
def _world_position() -> int:
|
||||
"""Позиция мира числом, с внятным отказом на непригодном значении.
|
||||
|
||||
Ту же переменную читает пульт, но общего модуля у дагов нет намеренно:
|
||||
связь между пультом и трансформацией держит сама переменная, а не импорт.
|
||||
Ту же переменную читают даги времени, но общего модуля у дагов нет намеренно:
|
||||
связь между дагами времени и трансформацией держит сама переменная, а не импорт.
|
||||
"""
|
||||
raw_position = Variable.get(WORLD_POSITION, default=None)
|
||||
if raw_position is None:
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ def build_classes_vs_inventory() -> list[str]:
|
||||
for mismatch_class, count in day["orders"].items()
|
||||
}
|
||||
if not expected:
|
||||
raise AirflowException("classes_vs_inventory: в описи нет закрытых дней")
|
||||
raise AirflowException("classes_vs_inventory: в паспорте нет закрытых дней")
|
||||
|
||||
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
|
||||
# GROUP BY закономерно не возвращает группу из нуля строк.
|
||||
|
||||
@@ -109,28 +109,28 @@ def homework_check():
|
||||
"""Дать отдельный результат самопроверки по каждой домашней витрине."""
|
||||
check_mart.override(task_id="events_enriched")(
|
||||
"dm.events_enriched_v",
|
||||
"docs/labs/events-enriched.md",
|
||||
"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/top-pages-daily.md",
|
||||
"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/session-overview.md",
|
||||
"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/dq-errors-daily.md",
|
||||
"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/utm-effectiveness.md",
|
||||
"docs/labs/1-5-utm-effectiveness.md",
|
||||
"utm_effectiveness_vs_dds.sql",
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Приём заказов: пакетный забор слепка из Kafka в хранилище.
|
||||
|
||||
Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор
|
||||
порции в сырьё, затем разбор того же среза в типизированный ODS и в таблицу
|
||||
порции в `stg`, затем разбор того же среза в типизированный ODS и в таблицу
|
||||
брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, —
|
||||
работник пульта мира после проигрыша дня, — и ждёт конца.
|
||||
шаговый даг модельного времени после проигрыша дня, — и ждёт конца.
|
||||
|
||||
Контраст с приёмом событий и есть урок. События тянет матвью, навсегда
|
||||
подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов
|
||||
|
||||
+15
-15
@@ -1,25 +1,25 @@
|
||||
"""Пульт мира: даги, которыми двигают ось модельного времени.
|
||||
"""Даги модельного времени: ими двигают ось мира.
|
||||
|
||||
Их три, и они двух родов. **Работники** играют день и запускаются руками:
|
||||
Их три, и они двух родов. **Шаговые даги** играют день и запускаются руками:
|
||||
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
|
||||
один день в темпе модельного времени. **Выключатель** `world_live` своей работы
|
||||
не делает: он тикает по расписанию и дёргает работника живого дня, дожидаясь
|
||||
не делает: он тикает по расписанию и дёргает шаговый даг живого дня, дожидаясь
|
||||
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
|
||||
границе модельных суток.
|
||||
|
||||
Сыграть день — половина работы. Вторая половина: отправить слепок заказов и
|
||||
дождаться, пока хранилище его заберёт. Поэтому у обоих работников за проигрышем
|
||||
дождаться, пока хранилище его заберёт. Поэтому у обоих шаговых дагов за проигрышем
|
||||
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
|
||||
`orders_ingest`.
|
||||
|
||||
Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка
|
||||
только разгоняет источники: слои после нее менти догоняет ручным запуском.
|
||||
|
||||
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку
|
||||
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник
|
||||
Разделение не косметическое. Расписание на самом шаговом даге заставило бы кнопку
|
||||
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а шаговый даг
|
||||
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
|
||||
|
||||
Календарь Airflow к оси мира отношения не имеет: какой день играть, работник
|
||||
Календарь Airflow к оси мира отношения не имеет: какой день играть, шаговый даг
|
||||
спрашивает у переменной, а не у логической даты прогона.
|
||||
|
||||
Решения и доводы целиком — ADR 0009.
|
||||
@@ -51,7 +51,7 @@ ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"]
|
||||
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
|
||||
|
||||
# Позиция на оси: номер первого несыгранного дня. До конца инициализации
|
||||
# работники не имеют права угадывать её — состояние объяснено в ADR 0013.
|
||||
# шаговые даги не имеют права угадывать её — состояние объяснено в ADR 0013.
|
||||
#
|
||||
# Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее
|
||||
# при таком правиле — сыгранный дважды день, а повтор схлопнет
|
||||
@@ -63,12 +63,12 @@ WORLD_POSITION = "world_position"
|
||||
# двадцати четырёх минут: тик только спрашивает «не пора ли снова».
|
||||
LIVE_TICK = datetime.timedelta(minutes=25)
|
||||
|
||||
# Какой день играть, работник узнаёт у первой задачи. Спрашивают её все
|
||||
# Какой день играть, шаговый даг узнаёт у первой задачи. Спрашивают её все
|
||||
# генераторные шаги: слепок снимается с той же позиции, что и проигрыш.
|
||||
PLAYED_DAY = "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}"
|
||||
|
||||
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||
TAGS = ["пульт мира"]
|
||||
TAGS = ["модельное время"]
|
||||
|
||||
|
||||
@task
|
||||
@@ -120,7 +120,7 @@ def _generator(task_id: str, command: list[str]) -> DockerOperator:
|
||||
def _ingest_orders() -> TriggerDagRunOperator:
|
||||
"""Задача забора: дёрнуть даг приёма и дождаться, чем он кончился.
|
||||
|
||||
Ожидание здесь несущее. Без него работник позеленел бы, не узнав, доехал
|
||||
Ожидание здесь несущее. Без него шаговый даг позеленел бы, не узнав, доехал
|
||||
ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа
|
||||
не должен переживать отказ выше (ADR 0003).
|
||||
"""
|
||||
@@ -128,7 +128,7 @@ def _ingest_orders() -> TriggerDagRunOperator:
|
||||
task_id="trigger_orders_ingest",
|
||||
trigger_dag_id="orders_ingest",
|
||||
wait_for_completion=True,
|
||||
# Умолчание — минута опроса, а весь прогон работника пачкой короче.
|
||||
# Умолчание — минута опроса, а весь прогон шагового дага пачкой короче.
|
||||
poke_interval=10,
|
||||
)
|
||||
|
||||
@@ -151,7 +151,7 @@ def _ingest_orders() -> TriggerDagRunOperator:
|
||||
# (params.rst, Airflow 3.3), чтобы правило было видно сразу.
|
||||
description_md=(
|
||||
"Форма задает только число дней. Номер первого несыгранного "
|
||||
"дня работник берет из переменной `world_position` (Admin → "
|
||||
"дня шаговый даг берёт из переменной `world_position` (Admin → "
|
||||
"Variables). Поля Logical date и Run ID нужны самому Airflow "
|
||||
"и на выбор дня не влияют."
|
||||
),
|
||||
@@ -163,7 +163,7 @@ def world_next_day():
|
||||
|
||||
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
|
||||
нажимом, а не десятью: сколько дней играть — параметр запуска. Первый
|
||||
день работник берет из `world_position`. Логическая дата прогона на выбор
|
||||
день шаговый даг берёт из `world_position`. Логическая дата прогона на выбор
|
||||
дня не влияет.
|
||||
"""
|
||||
|
||||
@@ -215,7 +215,7 @@ def world_live_day():
|
||||
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
|
||||
реальные минуты, и суточная волна разворачивается на глазах. Запускается
|
||||
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
|
||||
Следующий день работник берет из `world_position`. Логическая дата прогона
|
||||
Следующий день шаговый даг берёт из `world_position`. Логическая дата прогона
|
||||
на выбор дня не влияет.
|
||||
"""
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ def world_initialize():
|
||||
|
||||
@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"]
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Дата: 3 августа 2026 года. Статус: принято.
|
||||
|
||||
Позднее тикет #214 сменил имя слоя: вместо «сырья» — сырые данные, сырой слой,
|
||||
STG. Текст ниже оставлен в словах своего времени, действующее имя — в
|
||||
`CONTEXT.md`.
|
||||
|
||||
## Решение
|
||||
|
||||
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
Сетевых ограничений на `default` нет: порты стенда и так открыты только на
|
||||
`127.0.0.1`, а внутри сети Compose дверь заперта паролем.
|
||||
|
||||
Раздел README «Состав и доступ» описывает, кто каким пользователем ходит и где
|
||||
Раздел README «Адреса и пароли» описывает, кто каким пользователем ходит и где
|
||||
живут пароли; здесь остаются решение и доводы. Отдельного справочника по
|
||||
доступу в `docs/architecture/` нет: точные права видны в `access.xml`, конвенция
|
||||
чтения через `_v` описана в доке хранилища.
|
||||
@@ -107,7 +107,7 @@
|
||||
источник истины — файл в git. Стенд считает `access.xml` и переданные через
|
||||
`.env` значения настройкой при создании контейнера; отдельного способа
|
||||
применять их на живом стенде нет, потому что уроки не меняют базовую модель
|
||||
доступа. Порядок применения изменений — в разделе README «Состав и доступ».
|
||||
доступа. Порядок применения изменений — в разделе README «Адреса и пароли».
|
||||
Оба варианта с SQL — локальное хранилище доступа с `ON CLUSTER` и
|
||||
реплицированное в keeper — делают пользователей состоянием тома: `.env` на
|
||||
них больше не влияет, и появляется расхождение того же сорта, которое README
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
Дата: 12 августа 2026 года. Статус: частично заменено
|
||||
[ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93.
|
||||
|
||||
Позднее тикет #214 сменил имена: «сырьё» стало сырыми данными, «работник» —
|
||||
шаговым дагом. Текст ниже оставлен в словах своего времени, действующие
|
||||
имена — в `CONTEXT.md`.
|
||||
|
||||
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
|
||||
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
|
||||
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
|
||||
|
||||
Позднее тикет #214 сменил имена: «пульт мира» стал дагами модельного времени,
|
||||
«работник» — шаговым дагом. Текст ниже оставлен в словах своего времени,
|
||||
действующие имена — в `CONTEXT.md`.
|
||||
|
||||
## Решение
|
||||
|
||||
Ось модельного времени двигают даги пульта, и они двух родов.
|
||||
|
||||
@@ -77,9 +77,9 @@ ODS; точный тип и проверка описаны в разделе
|
||||
- **`Sign`** — колонка формата без механики: генератор исправлений не шлёт,
|
||||
и в модели события колонке ответить нечего. Честный рассказ о ней живёт в
|
||||
DDL ODS и лекции.
|
||||
- **`ecommerce`** — сырой JSON: сырьё, а не атрибут. Разобранные массивы
|
||||
`purchase_*` и `product_*` в контракте есть; лаба про разбор JSON внутри
|
||||
колонки читает ODS.
|
||||
- **`ecommerce`** — сырой JSON из STG. Это содержимое источника; отдельным
|
||||
атрибутом модели оно не становится. Разобранные массивы `purchase_*` и
|
||||
`product_*` в контракте есть; лаба про разбор JSON внутри колонки читает ODS.
|
||||
- **`_load_ts`** — у представления нет собственной загрузки, а чужие
|
||||
координаты слой не переносит (правило слоя — [модель
|
||||
заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по
|
||||
|
||||
@@ -75,12 +75,12 @@ ODS: это единственный устойчивый ключ сущнос
|
||||
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
|
||||
спрашиваются у их слоя по ключу сущности**.
|
||||
|
||||
Происшествие качества данных расследуется цепочкой, где каждый шаг
|
||||
спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой
|
||||
запуск собрал день; по `order_id` ODS показывает физические версии и брак —
|
||||
там свой `_load_id` приёма связывает годные строки, ошибки и сырьё; дальше
|
||||
сырьё STG, пока живо. Окно расследования задано сроками хранения: сырьё —
|
||||
трое суток, брак — месяц ([дока хранилища](../storage.md)).
|
||||
Происшествие качества данных расследуется цепочкой, где каждый шаг спрашивает
|
||||
свой слой на его языке: по `_load_id` строки DDS видно, какой запуск собрал
|
||||
день; по `order_id` ODS показывает физические версии и брак — там свой
|
||||
`_load_id` приёма связывает годные строки, ошибки и данные STG; дальше читают
|
||||
STG, пока данные живы. Окно расследования задано сроками хранения: сырые данные
|
||||
— трое суток, брак — месяц ([дока хранилища](../storage.md)).
|
||||
|
||||
## Читатели
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
|
||||
- Витрина обзора сессий `session_overview_v`: дневные итоги визитов в
|
||||
срезе устройств; читает `dds.session_v` и поднимает зерно до дня — состав и довод
|
||||
в ее [ТЗ](../../labs/session-overview.md) (#135).
|
||||
в ее [ТЗ](../../labs/1-3-session-overview.md) (#135).
|
||||
- DQ-сверка с эталоном (см. выше).
|
||||
- Лаба сессий: менти собирает нарезку сам до знакомства с кодом стенда,
|
||||
потом сравнивает — с эталоном и со стендовой.
|
||||
|
||||
+47
-43
@@ -5,24 +5,27 @@
|
||||
колонок каждой витрины задаёт раздел 7 мастер-спеки; здесь — форма,
|
||||
наполнение и контроль.
|
||||
|
||||
## Форма: по зерну, не по цене запроса
|
||||
## Таблица или представление
|
||||
|
||||
Витрина, меняющая зерно источника — дневной агрегат, сверка по заказам, —
|
||||
материализуется: `dm.<имя>_rep` / `_dist` и публичное `dm.<имя>_v` с явным
|
||||
списком колонок, по общим [конвенциям хранилища](storage.md). Наполняет её
|
||||
`dm_transform`; ритм и механика пересборки — ниже. Витрина, сохраняющая зерно
|
||||
источника — переименование, обогащение строки, — остаётся обычным
|
||||
представлением: копию событий ради других имён колонок не держит ни один
|
||||
боевой склад.
|
||||
Дневной агрегат и сверка по заказам меняют зерно источника. На этом стенде такие
|
||||
результаты хранятся в `dm.<имя>_rep` / `_dist` и читаются через публичное
|
||||
представление `dm.<имя>_v` с явным списком колонок. Имена и устройство объектов
|
||||
следуют общим [конвенциям хранилища](storage.md). Таблицы наполняет
|
||||
`dm_transform`; ритм и механика пересборки описаны ниже. В задании этот путь
|
||||
приходится пройти целиком: рассчитать агрегат, загрузить его и включить
|
||||
обновление в конвейер.
|
||||
|
||||
Правило выбрано учебной целью, а не ценой запроса. На объёмах стенда
|
||||
представлениями обошлось бы всё: условия сквозь цепочку представлений
|
||||
проходят ([исследование](../research/2026-08-22-view-predicate-pushdown.md)).
|
||||
Но пересборка агрегатов конвейером — производственный паттерн, ради
|
||||
которого стенд и строится, а пара «таблица рядом с представлением»
|
||||
показывает: форма — решение по роли витрины, у одного слоя законны обе.
|
||||
Переименование колонок и обогащение строки зерно не меняют. Такие витрины на
|
||||
стенде остаются обычными представлениями и не хранят вторую копию событий.
|
||||
|
||||
## Кто строит: стенд и менти
|
||||
На объёмах стенда все витрины могли бы остаться представлениями: условия
|
||||
проходят сквозь их цепочку
|
||||
([исследование](../research/2026-08-22-view-predicate-pushdown.md)).
|
||||
Мы все равно оставили материализованные агрегаты ради практики: без них в
|
||||
программе не было бы загрузки и пересборки витрин. Представления дают другой
|
||||
случай: преобразование строк на прежнем зерне.
|
||||
|
||||
## Что готово в стенде, а что остаётся заданием
|
||||
|
||||
Стенд привозит готовыми витрины трёх сюжетов дашборда этапа 8 и
|
||||
инфраструктуру контроля:
|
||||
@@ -35,20 +38,20 @@
|
||||
[картой идентичностей](dds/identity.md);
|
||||
- `dq_summary` — сводка проверок качества (ниже).
|
||||
|
||||
Остальные витрины спеки — учебные задания: менти строит их сам по ТЗ с
|
||||
образцом. Разминка — представление `events_enriched_v` (зерно события),
|
||||
затем агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`,
|
||||
последней — самая содержательная `utm_effectiveness` с уроком `declared_*`.
|
||||
Построенная витрина встаёт в конвейер руками менти: финальный пункт ТЗ
|
||||
каждого агрегата — добавить задачу своей витрины в `dm_transform`. До этого
|
||||
конвейер о ней не знает и остаётся зелёным, после — витрина пересобирается
|
||||
наравне со стендовыми. У разминочного представления такого пункта нет:
|
||||
пересобирать нечего. Тексты заданий — в [`docs/labs/`](../labs/README.md).
|
||||
Остальные витрины спеки нужно построить в учебных заданиях по готовому ТЗ и
|
||||
образцу. Разминка — представление `events_enriched_v` (зерно события), затем
|
||||
агрегаты `top_pages_daily`, `session_overview` и `dq_errors_daily`, последней —
|
||||
самая содержательная `utm_effectiveness` с уроком `declared_*`. В финале
|
||||
каждого задания с агрегатом его задачу нужно добавить в `dm_transform`. До
|
||||
этого конвейер о витрине не знает, после — пересобирает её вместе со
|
||||
стендовыми. Представление из разминки добавлять некуда: его не нужно
|
||||
пересобирать. Тексты заданий лежат в [`docs/labs/`](../labs/README.md).
|
||||
|
||||
Дашборд этапа 8 стоит только на витринах стенда: приёмка этапа не зависит
|
||||
от выполненных домашек. Свой дашборд по витринам менти —
|
||||
[необязательная лаба](../labs/superset-dashboard.md) после этапа 8. Устройство
|
||||
стендового дашборда — в [доке зоны Superset](superset.md).
|
||||
Дашборд этапа 8 читает только готовые витрины стенда, поэтому его приёмка не
|
||||
зависит от домашних заданий. Отдельный дашборд по учебным витринам можно
|
||||
собрать в [факультативном задании](../labs/optional/superset-dashboard.md),
|
||||
доступном после ступени 2. Устройство стендового дашборда описано в
|
||||
[справочнике Superset](superset.md).
|
||||
|
||||
## Ритм пересборки
|
||||
|
||||
@@ -176,9 +179,10 @@ Distributed-таблица вернула бы каждый непарный з
|
||||
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк
|
||||
`dds.session_v`;
|
||||
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
|
||||
`orders` в описи мира, объект — число заказов класса в
|
||||
`orders` в паспорте мира, объект — число заказов класса в
|
||||
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
|
||||
`duplicate_event` и `amount_delta` только для покрытых описью закрытых дней;
|
||||
`duplicate_event` и `amount_delta` только для покрытых паспортом закрытых
|
||||
дней;
|
||||
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
|
||||
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
|
||||
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
|
||||
@@ -194,14 +198,14 @@ Distributed-таблица вернула бы каждый непарный з
|
||||
сопоставления деловых ключей.
|
||||
|
||||
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
|
||||
подтверждает, что до витрины доехал записанный в описи мир, но не проверяет
|
||||
подтверждает, что до витрины доехал записанный в паспорте мир, но не проверяет
|
||||
сам генератор: у настоящего магазина такого оракула нет.
|
||||
|
||||
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow
|
||||
только сводку и малый срез диагностики. Техническая сверка с описью читает
|
||||
небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их
|
||||
успеха даг заменяет проверенные дневные партиции. Затем именованные задачи
|
||||
читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
|
||||
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
|
||||
сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой
|
||||
файл JSON в Python. Все пять сборок пишут в общий донор. После их успеха даг
|
||||
заменяет проверенные дневные партиции. Затем именованные задачи читают
|
||||
опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
|
||||
сохраниться до красного завершения, а ошибка сборки не затирает прежний
|
||||
результат. Красная задача печатает не больше двадцати нарушенных ключей за
|
||||
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
|
||||
@@ -214,8 +218,8 @@ business_key`.
|
||||
|
||||
## Даг самопроверки
|
||||
|
||||
Домашки контролирует `homework_check` — отдельный даг без расписания: менти
|
||||
запускает его рукой. По задаче на каждое из
|
||||
Домашние задания проверяет `homework_check` — отдельный даг без расписания,
|
||||
который запускают вручную. В нём есть задача для каждого из
|
||||
[пяти заданий по витринам](../labs/README.md).
|
||||
Задача красная, пока витрины нет или счёт не сходится, и зелёная, когда
|
||||
построено верно; сообщение об ошибке говорит, что не так и куда смотреть. Проверки — инварианты против источников витрины,
|
||||
@@ -223,8 +227,8 @@ business_key`.
|
||||
лабы, а не история качества конвейера.
|
||||
|
||||
Приём проверен на соседнем учебном стенде Airflow + Greenplum:
|
||||
валидационный даг с группами проверок по слоям давал менти обратную связь
|
||||
по каждому шагу домашки, не трогая рабочий конвейер.
|
||||
валидационный даг с группами проверок по слоям давал обратную связь по каждому
|
||||
шагу задания, не трогая рабочий конвейер.
|
||||
|
||||
## Отклонённые варианты
|
||||
|
||||
@@ -232,7 +236,7 @@ business_key`.
|
||||
производственный паттерн слоя — пересборка агрегатов — остался бы
|
||||
невидим, а `dm_transform` — без работы.
|
||||
- **Материализовать всё.** Копия событий ради переименования колонок —
|
||||
антипаттерн, который менти увёз бы в бой.
|
||||
лишняя; такой пример приучал бы без нужды дублировать исходные данные.
|
||||
- **Гибрид по цене запроса.** Выбор формы по стоимости, которой на объёмах
|
||||
стенда нет: сторож без настоящей опасности.
|
||||
- **Проверки домашек в конвейерном звене DQ.** Конвейер стоял бы красным до
|
||||
@@ -241,8 +245,8 @@ business_key`.
|
||||
- **«Жёлтый» статус недостроенной витрины.** У задачи Airflow нет такого
|
||||
состояния; изображать его логами — семантика, которую пришлось бы
|
||||
расшифровывать. Красное «ещё не построено» честнее.
|
||||
- **Самопроверка пишет в `dq_summary`.** Попытки менти смешались бы с
|
||||
историей качества конвейера.
|
||||
- **Самопроверка пишет в `dq_summary`.** Результаты учебных попыток смешались бы
|
||||
с историей качества конвейера.
|
||||
- **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили
|
||||
бы число известных на момент закрытия — витрина тихо расходится с картой
|
||||
и протаскивает обратно «знание на дату», отвергнутое развилкой карты
|
||||
|
||||
+12
-12
@@ -37,13 +37,13 @@ world_next_day ─────> источники без ETL
|
||||
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
|
||||
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
|
||||
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
|
||||
зеленеть, не узнав, разложился ли день, работник не должен
|
||||
([ADR 0003](../adr/0003-dag-run-state.md)).
|
||||
Такой отказ чинится перезапуском `etl_pipeline` рукой — перезапуску ничего
|
||||
не нужно помнить, см. «Объём прогона».
|
||||
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: пульт управляет
|
||||
генератором и только им. После разгона менти запускает конвейер сам —
|
||||
и видит, как слои догоняют уехавший вперёд мир.
|
||||
зеленеть, не узнав, разложился ли день, шаговый даг не должен ([ADR
|
||||
0003](../adr/0003-dag-run-state.md)). Такой отказ чинится перезапуском
|
||||
`etl_pipeline` рукой — перезапуску ничего не нужно помнить, см. «Объём
|
||||
прогона».
|
||||
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: даги модельного
|
||||
времени управляют генератором и только им. После разгона менти запускает
|
||||
конвейер сам — и видит, как слои догоняют уехавший вперёд мир.
|
||||
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
|
||||
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
|
||||
ручного запуска начинаются со следующих дней.
|
||||
@@ -145,9 +145,9 @@ ODS и ещё не собранный в DDS, затем при необходи
|
||||
|
||||
## Отклонённые варианты
|
||||
|
||||
- **Задачи трансформаций прямо в работниках пульта.** Один зелёный прогон —
|
||||
весь день, но пульт — про ось времени: работников два, шаги дублировались
|
||||
бы, а этапу 5 пришлось бы выдирать их обратно.
|
||||
- **Задачи трансформаций прямо в шаговых дагах.** Один зелёный прогон —
|
||||
весь день, но даги времени отвечают за ось времени: шаговых дагов два, шаги
|
||||
дублировались бы, а этапу 5 пришлось бы выдирать их обратно.
|
||||
- **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с
|
||||
Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом
|
||||
режиме слои копили бы отставание молча.
|
||||
@@ -163,8 +163,8 @@ ODS и ещё не собранный в DDS, затем при необходи
|
||||
|
||||
- [ADR 0003](../adr/0003-dag-run-state.md) — зелёный конец графа не
|
||||
переживает отказ выше: отсюда ожидания в триггерах.
|
||||
- [ADR 0009](../adr/0009-world-control.md) — пульт мира: его работник
|
||||
живого дня и дёргает конвейер.
|
||||
- [ADR 0009](../adr/0009-world-control.md) — даги модельного времени: их шаговый
|
||||
даг живого дня и дёргает конвейер.
|
||||
- [ADR 0013](../adr/0013-world-lifecycle.md) — жизненный цикл мира: почему
|
||||
прикладным процессом владеет Airflow и почему `world_initialize` кончается
|
||||
собранными слоями.
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
Новый бросок в середине функции сдвигает все последующие значения этого
|
||||
подпотока. Код продолжает работать, но тем же свойствам достается другая
|
||||
случайность. Соседние подпотоки остаются прежними, а измененная часть
|
||||
канонического мира проявляется в описи и побайтовом сравнении.
|
||||
канонического мира проявляется в паспорте и побайтовом сравнении.
|
||||
|
||||
Какое обещание воспроизводимости это защищает, записано в
|
||||
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
|
||||
|
||||
@@ -19,23 +19,23 @@
|
||||
## Почему слои у панелей разные
|
||||
|
||||
Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него
|
||||
длиннее: сырьё привозит забор, а типизированную строку кладёт разбор. Встань
|
||||
разбор — свежесть ODS это покажет, свежесть сырья нет.
|
||||
длиннее: в STG данные привозит забор, а типизированную строку кладёт разбор.
|
||||
Встань разбор — свежесть ODS это покажет, а свежесть STG — нет.
|
||||
|
||||
Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление
|
||||
прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос
|
||||
здесь про доставку. Местом диагностики доставленных версий физическая таблица
|
||||
названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`).
|
||||
|
||||
Поток и брак считаются по слою сырья. Сырьё записывает каждую доставку и ничего
|
||||
не схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события,
|
||||
и все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
|
||||
Поток и брак считаются по сырому слою. Он хранит каждую доставку и ничего не
|
||||
схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события, и
|
||||
все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
|
||||
сильнее, чем дольше живёт стенд.
|
||||
|
||||
Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не
|
||||
отдельная куча, а часть сырья, которая не прошла разбор.
|
||||
отдельная куча, а часть сырых данных, которая не прошла разбор.
|
||||
|
||||
Окно у панели брака — окно дашборда, и это не косметика. Сырьё живёт трое
|
||||
Окно у панели брака — окно дашборда, и это не косметика. Сырые данные живут трое
|
||||
суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток
|
||||
приехавшего и однажды перевалила бы за сотню процентов.
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
укладываются в минуты реального времени, и соседние дни мира помещаются в одно
|
||||
окно панели сами.
|
||||
|
||||
## Приземлившееся считают по слою сырья
|
||||
## Приземлившееся считают в сыром слое
|
||||
|
||||
Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам
|
||||
экспортёра. Таблицы движка Kafka `stg.hits_raw_kafka` и `stg.orders_raw_kafka`
|
||||
@@ -66,7 +66,7 @@
|
||||
Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse.
|
||||
Единицы общие: сообщения за интервал панели. Со стороны Kafka это разность
|
||||
офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со
|
||||
стороны ClickHouse — `count()` строк сырья по тому же интервалу.
|
||||
стороны ClickHouse — `count()` строк STG по тому же интервалу.
|
||||
|
||||
Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase`
|
||||
растягивает приращение на всё окно и на минутном интервале промахивается на
|
||||
@@ -87,11 +87,11 @@ Kafka» приходит из Prometheus, ряд «приземлилось в C
|
||||
Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в
|
||||
один столбец не значит ничего.
|
||||
|
||||
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден
|
||||
сразу. Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему
|
||||
другой — не «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него
|
||||
отвечает своя панель, и модельный день она берёт из самого сообщения: в сырьё
|
||||
он приезжает полем `snapshot_date`.
|
||||
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден сразу.
|
||||
Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему другой — не
|
||||
«сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него отвечает своя
|
||||
панель, и модельный день она берёт из самого сообщения: в STG он приезжает полем
|
||||
`snapshot_date`.
|
||||
|
||||
## Что проверено
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
|
||||
пересечения выходят арифметикой, приоритет классов работает по-настоящему —
|
||||
[классы расхождений и опоздание](fate.md).
|
||||
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
|
||||
каждого слепка и счётчики наблюдаемых классов —
|
||||
[что хранит опись](inventory.md).
|
||||
- **Паспорт хранит только то, чего движение мира не меняет**: хеш байтов
|
||||
каждого слепка и счётчики наблюдаемых классов — [что хранит
|
||||
паспорт](inventory.md).
|
||||
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
|
||||
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
|
||||
[мост к склейке](identity.md).
|
||||
|
||||
@@ -8,18 +8,18 @@
|
||||
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
|
||||
решённая при его рождении и уложенная в окно K целиком.
|
||||
|
||||
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты,
|
||||
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и
|
||||
дубль. Довод за разделение — различимость по описи: правка заказной механики
|
||||
не двигает хеши событий, правка событийной не двигает байты слепка, и по
|
||||
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
|
||||
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
||||
компонент называет часть мира; новые стороны занимают те же позиции.
|
||||
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
|
||||
событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо
|
||||
отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка
|
||||
событийной механики молча меняла бы байты слепка; *компонент на класс* —
|
||||
пять имён под ручки калибровки, которые крутятся разом.
|
||||
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, дельта,
|
||||
опоздание. Событийная сторона — порча событийного потока: потеря и дубль. Довод
|
||||
за разделение — различимость по паспорту: правка заказной механики не двигает
|
||||
хеши событий, правка событийной не двигает байты слепка, и по покрасневшим хешам
|
||||
видно, какую сторону трогали. Прежние имена `DISCREPANCIES` и `LATECOMERS`
|
||||
решения не переживают — они названы по классам витрины, а компонент называет
|
||||
часть мира; новые стороны занимают те же позиции. Заказная сторона названа при
|
||||
реализации — подпоток `ORDERS` на позиции 2; событийная — `BEACONS` на позиции
|
||||
3: маячок счетчика либо не долетает, либо отправляется повторно. Отклонено:
|
||||
*один компонент на всю судьбу* — правка событийной механики молча меняла бы
|
||||
байты слепка; *компонент на класс* — пять имён под ручки калибровки, которые
|
||||
крутятся разом.
|
||||
|
||||
**Правило формы, без которого разделение не работает: броски заказной стороны
|
||||
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
||||
@@ -62,15 +62,14 @@
|
||||
процентов» не заводим.
|
||||
|
||||
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
||||
версия; различает их история версий: сырьё STG и физические версии
|
||||
`ods.order` до фоновых слияний, а в витринах — выручка дня, которая
|
||||
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
|
||||
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
|
||||
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
|
||||
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда
|
||||
деться, кроме как остаться брошенным; *мгновенная отмена при рождении* —
|
||||
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1,
|
||||
он в резерве.
|
||||
версия; различает их история версий: строки STG и физические версии `ods.order`
|
||||
до фоновых слияний, а в витринах — выручка дня, которая сначала выросла, потом
|
||||
убыла. Полное различение не обещано: слепок — состояние на границе суток, и
|
||||
оплата с отменой в один день в сырых данных неразличимы; то же у сильно
|
||||
опоздавших, приехавших уже терминальными. Точную форму этого урока решает этап
|
||||
4. Отклонено: *отмена только после оплаты* — неоплаченному некуда деться, кроме
|
||||
как остаться брошенным; *мгновенная отмена при рождении* — «дыхание» окна на
|
||||
отменах исчезает; *отмен нет вовсе* — страховочный срез 1, он в резерве.
|
||||
|
||||
## Классы расхождений и опоздание
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
|
||||
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
|
||||
отдельные прогоны одного мира дают то же соответствие; принятый мир
|
||||
воспроизводит эффект склейки без случайных ложных объединений — конкретные
|
||||
числа пар контрактом описи не являются. Межкогортные столкновения принимаются
|
||||
по той же дисциплине, что у случайных `ClientID`: принимается конкретный
|
||||
канонический мир по внешнему результату.
|
||||
воспроизводит эффект склейки без случайных ложных объединений. Паспорт не
|
||||
закрепляет конкретное число пар. Межкогортные столкновения принимаются по той же
|
||||
дисциплине, что у случайных `ClientID`: принимается конкретный канонический мир
|
||||
по внешнему результату.
|
||||
|
||||
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
|
||||
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
|
||||
|
||||
@@ -35,10 +35,11 @@ PARTITION` могла бы заменить партицию дня неполн
|
||||
|
||||
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
|
||||
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
|
||||
дёргает его тот, кто положил слепок в топик, — работник пульта мира, — и ждёт
|
||||
конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
|
||||
запускает его шаговый даг модельного времени, который положил слепок в топик и
|
||||
ждёт конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
|
||||
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта.
|
||||
Запрос забора лежит в [`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
|
||||
Запрос забора лежит в
|
||||
[`sql/stg/orders_raw_load.sql`](../../../sql/stg/orders_raw_load.sql):
|
||||
`SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр
|
||||
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
|
||||
|
||||
@@ -157,7 +158,7 @@ dbt snapshots — стратегия `check` вместо `timestamp`.
|
||||
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
|
||||
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
|
||||
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
|
||||
Позиция мира при этом не двигается, и тот же день уедет заново — в сырье он
|
||||
Позиция мира при этом не двигается, и тот же день уедет заново — в STG он
|
||||
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
|
||||
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»).
|
||||
|
||||
@@ -191,10 +192,10 @@ dbt snapshots — стратегия `check` вместо `timestamp`.
|
||||
## Что проверено
|
||||
|
||||
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94.
|
||||
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение сырья на годные
|
||||
строки и брак полное и непересекающееся; две версии одного заказа легли в одну
|
||||
партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от фонового
|
||||
слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
|
||||
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение строк STG на
|
||||
годные строки и брак полное и непересекающееся; две версии одного заказа легли в
|
||||
одну партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от
|
||||
фонового слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
|
||||
`_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки
|
||||
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
|
||||
опирается, — [документ хранилища](../storage.md), «Что проверено».
|
||||
@@ -217,9 +218,9 @@ dbt snapshots — стратегия `check` вместо `timestamp`.
|
||||
|
||||
Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
|
||||
исполнении #165. После двух слепков физическая таблица содержала 3356 версий
|
||||
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним
|
||||
слепком заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из
|
||||
сырья. В обеих сверках расхождений нет.
|
||||
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним слепком
|
||||
заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из сырых
|
||||
данных. В обеих сверках расхождений нет.
|
||||
|
||||
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
|
||||
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Что хранит опись
|
||||
# Что хранит паспорт мира
|
||||
|
||||
Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что
|
||||
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72)
|
||||
и [«Места заказов в стартовом
|
||||
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
||||
Словарь описи — растяжка-хеш против опоры-счётчика — задан «Чем меряется
|
||||
генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
|
||||
термином «Опись мира» в [CONTEXT.md](../../../CONTEXT.md).
|
||||
Термины паспорта — «растяжка-хеш» и «опора-счётчик» — заданы разделом «Чем
|
||||
меряется генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
|
||||
статьёй «Паспорт мира» в [CONTEXT.md](../../../CONTEXT.md).
|
||||
|
||||
> Опись хранит только то, чего движение мира не меняет.
|
||||
> Хеш кладём всегда, счётчик — только когда назван его читатель.
|
||||
> Паспорт хранит только то, чего движение мира не меняет. Хеш кладём всегда,
|
||||
> счётчик — только когда назван его читатель.
|
||||
|
||||
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
|
||||
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
|
||||
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же
|
||||
байты») опись начинает сторожить.
|
||||
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же байты»)
|
||||
паспорт начинает сторожить.
|
||||
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
|
||||
итоговой судьбе заказов дня. В строке закрытого дня поле `orders` хранит
|
||||
число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных
|
||||
@@ -24,7 +24,7 @@
|
||||
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
|
||||
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
|
||||
не выходит; генератор сторожит хеш, транспорт — счёт событий.
|
||||
- **Опись описывает мир, а не доставку.** Работа генератора кончается на
|
||||
Kafka: дошли ли байты до `ods.order` — вопрос стенда и его
|
||||
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка
|
||||
приёма заказов — число заведётся вместе с ней.
|
||||
- **Паспорт описывает мир, а не доставку.** Работа генератора кончается на
|
||||
Kafka: дошли ли байты до `ods.order` — вопрос стенда и его проверок. Поэтому
|
||||
счёта строк у слепка в паспорте нет; понадобится проверка приёма заказов —
|
||||
число заведётся вместе с ней.
|
||||
@@ -112,5 +112,5 @@
|
||||
такого значения означает ошибку арифметики источника.
|
||||
|
||||
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
|
||||
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
|
||||
его даром, а хешу слепка в описи нужен именно названный порядок.
|
||||
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт его
|
||||
даром, а хешу слепка в паспорте нужен именно названный порядок.
|
||||
@@ -25,11 +25,11 @@
|
||||
|
||||
Цена принята с открытыми глазами: у части пар стартового мира поздний
|
||||
назначенный заказ падает на день 7, и до первого хода мира этих пар в
|
||||
`dds.identity_map` нет. Опись пар не считает
|
||||
([что хранит опись](inventory.md)), поэтому красной проверки из этого не
|
||||
выходит. Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на
|
||||
свежем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем
|
||||
ровно там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* —
|
||||
число верно ровно до первого хода мира; *отменить сдвиг отправки* —
|
||||
`awaiting_order` пропал бы навсегда; *подогнать план под горизонт* — мир
|
||||
перестал бы быть чистой функцией зерна.
|
||||
`dds.identity_map` нет. Паспорт пар не считает ([что хранит
|
||||
паспорт](inventory.md)), поэтому красной проверки из этого не выходит.
|
||||
Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на свежем
|
||||
стенде, в CLI заводится рычаг, стартовый мир становится особым случаем ровно
|
||||
там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* — число
|
||||
верно ровно до первого хода мира; *отменить сдвиг отправки* — `awaiting_order`
|
||||
пропал бы навсегда; *подогнать план под горизонт* — мир перестал бы быть чистой
|
||||
функцией зерна.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Стенд: службы, доступ, настройка
|
||||
|
||||
Здесь описано, из каких служб собран стенд, кто к кому подключается и как
|
||||
настройки доходят до контейнеров. Состав каркаса обоснован в
|
||||
[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` — учётная запись аналитика с правом чтения всех слоёв и
|
||||
справочников;
|
||||
- `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 из `.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/).
|
||||
+159
-161
@@ -7,20 +7,19 @@
|
||||
|
||||
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
|
||||
keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка
|
||||
`Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное
|
||||
событие с таблицей ошибок, поверхность актуального состояния и три матвью.
|
||||
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой
|
||||
чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего
|
||||
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью.
|
||||
Тот же этап принёс первый справочник — словарь товаров из общего с генератором
|
||||
CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а
|
||||
подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат
|
||||
публичным договором события `dds.event_v` над точным состоянием ODS, а следом
|
||||
получил две физические модели слоя — заказ и сессию: их собирает заменой
|
||||
партиций даг `dds_transform`. Сессия среди них особая: в потоке такой сущности
|
||||
нет, стенд режет её из событий сам.
|
||||
Дальше по тексту устройство описано так, как оно проектируется; построенное от
|
||||
заложенного отличает карта таблиц в конце.
|
||||
`Kafka → STG → ODS` — чтец топика `hits`, таблицы STG, типизированное событие с
|
||||
таблицей ошибок, поверхность актуального состояния и три матвью. Этап 3 добавил
|
||||
вход второго источника и довёл его до ODS: топик `orders`, свой чтец, свой сырой
|
||||
слой, версии заказов с таблицей ошибок и поверхность текущего состояния.
|
||||
Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью. Тот же этап
|
||||
принёс первый справочник — словарь товаров из общего с генератором CSV-файла.
|
||||
Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а подложку над ним
|
||||
([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат публичным договором
|
||||
события `dds.event_v` над точным состоянием ODS, а следом получил две физические
|
||||
модели слоя — заказ и сессию: их собирает заменой партиций даг `dds_transform`.
|
||||
Сессия среди них особая: в потоке такой сущности нет, стенд режет её из событий
|
||||
сам. Дальше по тексту устройство описано так, как оно проектируется; построенное
|
||||
от заложенного отличает карта таблиц в конце.
|
||||
|
||||
Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
|
||||
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
|
||||
@@ -97,7 +96,7 @@ ClickHouse и отклонённые варианты записаны в
|
||||
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
|
||||
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
|
||||
выбран против перекоса: структурированный числовой идентификатор распределяется
|
||||
по остатку от деления неравномерно. У сырья выбора нет — строку иначе не
|
||||
по остатку от деления неравномерно. У сырого слоя выбора нет — строку иначе не
|
||||
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
|
||||
шард.
|
||||
|
||||
@@ -157,10 +156,10 @@ FROM источник_dist`. Заводское значение `parallel_distr
|
||||
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и
|
||||
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
|
||||
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
|
||||
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а слой сырья
|
||||
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а сырой слой
|
||||
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
|
||||
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы
|
||||
либо падение приёма на первом сообщении, либо тихие нули за 1970 год.
|
||||
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы либо
|
||||
падение приёма на первом сообщении, либо тихие нули за 1970 год.
|
||||
|
||||
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
|
||||
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
|
||||
@@ -173,10 +172,10 @@ FROM источник_dist`. Заводское значение `parallel_distr
|
||||
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
|
||||
0010](../adr/0010-order-versions-in-ods.md)).
|
||||
|
||||
Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
||||
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и
|
||||
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради
|
||||
которого он заведён: повтор доставки в сырье обязан быть виден.
|
||||
Движок таблицы STG — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
||||
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у
|
||||
таблицы STG и нет. Замену версий сюда ставить нельзя — она отменила бы свойство
|
||||
слоя, ради которого он заведён: повтор доставки в STG обязан быть виден.
|
||||
|
||||
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
|
||||
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
|
||||
@@ -192,7 +191,7 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
|
||||
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
|
||||
|
||||
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
|
||||
Airflow. Переделать разобранное руками можно вставкой из сырья с фильтром по
|
||||
Airflow. Переделать разобранное руками можно вставкой из STG с фильтром по
|
||||
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
|
||||
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
|
||||
`_load_id`; дневные партиции ODS он не заменяет.
|
||||
@@ -223,10 +222,10 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
|
||||
|
||||
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
|
||||
исключение: источник объявляет `Array(Float64)`, а материализованное
|
||||
представление ODS разбирает числа сырого JSON сразу в
|
||||
`Array(Decimal(18, 2))`. DDS наследует этот тип без нового преобразования.
|
||||
Прямой разбор подтвержден документацией ClickHouse через Context7 и запросом
|
||||
к ClickHouse 26.3; сравнение с сырьем подтвердило: копейки всех покупок целы.
|
||||
представление ODS разбирает числа сырого JSON сразу в `Array(Decimal(18, 2))`.
|
||||
DDS наследует этот тип без нового преобразования. Прямой разбор подтвержден
|
||||
документацией ClickHouse через Context7 и запросом к ClickHouse 26.3; сравнение
|
||||
с сырыми данными подтвердило: копейки всех покупок целы.
|
||||
|
||||
## Версии заказов
|
||||
|
||||
@@ -257,16 +256,16 @@ DDS. `snapshot_date` в нём одновременно дата наблюде
|
||||
|
||||
**Линза называется явно — в типе колонки либо в вызове функции.** Третий
|
||||
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там,
|
||||
где линза что-то решает — в объявлении хранимой колонки и в выражении,
|
||||
считающем дату, — его не остаётся. Прописать этот пояс своей рукой —
|
||||
`<timezone>` в конфигурации ноды или `TZ` контейнеру — было бы той же болезнью
|
||||
с другим умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах
|
||||
и в разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
|
||||
поменяв его. Правило стоит на источнике пояса, а не на функции:
|
||||
`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и
|
||||
работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя
|
||||
пишется тогда, когда нужна другая линза, чем у колонки:
|
||||
`toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика».
|
||||
где линза что-то решает — в объявлении хранимой колонки и в выражении, считающем
|
||||
дату, — его не остаётся. Прописать этот пояс своей рукой — `<timezone>` в
|
||||
конфигурации ноды или `TZ` контейнеру — было бы той же болезнью с другим
|
||||
умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах и в
|
||||
разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
|
||||
поменяв его. Правило стоит на источнике пояса, а не на функции: `toDate` по
|
||||
колонке, чей тип пояс несёт, законен и имени не требует — так и работают ключи
|
||||
партиций `toDate(_load_ts)` у таблицы STG и у таблицы ошибок. Имя пишется тогда,
|
||||
когда нужна другая линза, чем у колонки: `toDate(UTCEventTime, 'Europe/Samara')`
|
||||
— это «день по часам счётчика».
|
||||
|
||||
**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок
|
||||
выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна,
|
||||
@@ -314,8 +313,8 @@ UTC+4), и пересчёт идёт один раз при наполнении
|
||||
0008](../adr/0008-order-ingestion.md) и [спецификация приёма
|
||||
заказов](orders/ingestion.md).
|
||||
|
||||
Цепочка одна: чтец топика → матвью → сырьё STG → матвью разбора → событие и
|
||||
таблица ошибок ODS.
|
||||
Цепочка одна: чтец топика → матвью → STG → матвью разбора → событие и таблица
|
||||
ошибок ODS.
|
||||
|
||||
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
|
||||
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
|
||||
@@ -344,15 +343,15 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
|
||||
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
|
||||
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
|
||||
как при фоновом первая нода продолжает принимать и копит спул для соседа.
|
||||
Платится при этом не одно ожидание на блок, а три распределённые вставки — сырьё,
|
||||
Платится при этом не одно ожидание на блок, а три распределённые вставки — STG,
|
||||
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
|
||||
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
|
||||
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
|
||||
компромисс полезнее показать, чем спрятать за галочкой.
|
||||
|
||||
Ещё одно место, где сырьё хранит не всё приехавшее: запись с пустым значением и
|
||||
запись-надгробие проходят молча, не оставляя строки. Свойство измерено и принято
|
||||
осознанно — подробности в разделе «Что проверено».
|
||||
Ещё одно место, где сырой слой хранит не всё приехавшее: запись с пустым
|
||||
значением и запись-надгробие проходят молча, не оставляя строки. Свойство
|
||||
измерено и принято осознанно — подробности в разделе «Что проверено».
|
||||
|
||||
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
|
||||
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
|
||||
@@ -361,14 +360,14 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
|
||||
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
|
||||
|
||||
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
|
||||
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня
|
||||
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения.
|
||||
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к
|
||||
сырому слою не относится.
|
||||
`ReplacingMergeTree`, а таблицы STG вовсе не устраняют дубли: перезаливка
|
||||
модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения
|
||||
срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности
|
||||
конвейера к сырому слою не относится.
|
||||
|
||||
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
|
||||
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
|
||||
Удвоение сырья проходит мимо неё только потому, что при повторном чтении
|
||||
Удвоение строк STG проходит мимо неё только потому, что при повторном чтении
|
||||
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
|
||||
отсутствии механизма.
|
||||
|
||||
@@ -384,53 +383,54 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
|
||||
роняет вставку и останавливает потребление до починки
|
||||
([ADR 0005](../adr/0005-event-ingestion.md)).
|
||||
|
||||
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырья
|
||||
срок жизни трое суток, а ODS хранит всё, поэтому равенство «сырьё = события +
|
||||
ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня
|
||||
задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не
|
||||
смотреть на оповещения
|
||||
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым
|
||||
опытом при исполнении #43, на управляемой пачке: отправили N сообщений —
|
||||
получили N строк сырья и N в сумме событий и ошибок. Постоянной целью такой
|
||||
опыт не становится, и почему — в [карте
|
||||
проверок](testing.md), раздел «Интеграционная проверка постоянной целью не
|
||||
становится». Счёт актуальных событий идёт через `ods.event_v`, которое скрывает
|
||||
`FINAL`: голый `count()` по `ReplacingMergeTree` зависит от того, сколько
|
||||
мержей успело пройти, и спека это прямо запрещает (раздел 6).
|
||||
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырого
|
||||
слоя срок жизни трое суток, а ODS хранит всё, поэтому равенство «строки STG =
|
||||
события + ошибки» разъедется само: строки STG истекут раньше, а перезаливка
|
||||
модельного дня задвоит их, тогда как в ODS тот же повтор схлопнется. Правило,
|
||||
красное в норме, учит не смотреть на оповещения ([ADR
|
||||
0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при
|
||||
исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк
|
||||
STG и N в сумме событий и ошибок. Постоянной целью такой опыт не становится, и
|
||||
почему — в [карте проверок](testing.md), раздел «Интеграционная проверка
|
||||
постоянной целью не становится». Счёт актуальных событий идёт через
|
||||
`ods.event_v`, которое скрывает `FINAL`: голый `count()` по `ReplacingMergeTree`
|
||||
зависит от того, сколько мержей успело пройти, и спека это прямо запрещает
|
||||
(раздел 6).
|
||||
|
||||
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
|
||||
против [описи мира](../../data/world-inventory.json), лежащей в git
|
||||
(`make check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в
|
||||
этом вся разница: опись описывает восемь дней стартового мира, счёт обрамлён
|
||||
их датами, повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не
|
||||
помеха — ODS хранит всё. Обе стороны равенства зафиксированы: одна кодом
|
||||
генератора, другая файлом в git. У межслойной сверки такой опоры нет ни с
|
||||
одной стороны.
|
||||
против [паспорта мира](../../data/world-inventory.json), лежащего в git (`make
|
||||
check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в этом вся
|
||||
разница: паспорт описывает восемь дней стартового мира, счёт обрамлён их датами,
|
||||
повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не помеха — ODS
|
||||
хранит всё. Обе стороны равенства зафиксированы: одна кодом генератора, другая
|
||||
файлом в git. У межслойной сверки такой опоры нет ни с одной стороны.
|
||||
|
||||
## Срок жизни сырья
|
||||
## Срок жизни сырых данных
|
||||
|
||||
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно
|
||||
отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты,
|
||||
после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же
|
||||
колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). В DDL
|
||||
значение проставлено явно, чтобы поведение не зависело от умолчания версии.
|
||||
Сырые данные в STG живут трое суток реального времени и уходят сами. Трое — это
|
||||
окно отладки: столько сырые данные хранятся на ноутбуке, чтобы менти успел
|
||||
разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню
|
||||
загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками
|
||||
(`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не
|
||||
зависело от умолчания версии.
|
||||
|
||||
Нарезать сырьё по модельному дню события было бы соблазнительно — он единица
|
||||
переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
|
||||
снимается целиком, только когда в нём истекли все строки, а в партиции модельного
|
||||
дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста,
|
||||
самая свежая строка удержит весь кусок, и данные переживут срок неограниченно.
|
||||
Нарезать сырые данные по модельному дню события было бы соблазнительно — он
|
||||
единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
|
||||
снимается целиком, только когда в нём истекли все строки, а в партиции
|
||||
модельного дня лежит приехавшее в разное реальное время: мерж склеит куски
|
||||
разного возраста, самая свежая строка удержит весь кусок, и данные переживут
|
||||
срок неограниченно.
|
||||
|
||||
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
|
||||
возраста — но не более чем на сутки, потому что партицию закрывает календарный
|
||||
день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно
|
||||
трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что
|
||||
«уходит само» здесь обещано, а «уходит вовремя» — нет.
|
||||
день. Отсюда и оценка: сырые данные живут трое суток плюс хвост до суток, а не
|
||||
ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику,
|
||||
так что «уходит само» здесь обещано, а «уходит вовремя» — нет.
|
||||
|
||||
Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а
|
||||
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё
|
||||
режется своими координатами: и разбор полётов, и переобработка фильтруют по
|
||||
`_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
|
||||
Модельного дня среди колонок STG нет вовсе. Он свойство содержимого, а
|
||||
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырые
|
||||
данные режутся по своим координатам: и разбор полётов, и переобработка фильтруют
|
||||
по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
|
||||
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
|
||||
|
||||
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
|
||||
@@ -440,21 +440,21 @@ D0 и к реальному календарю не привязана; паке
|
||||
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
|
||||
эталонные дни прямо на входе.
|
||||
|
||||
В бою слой сырья иногда собирают на движке `Null` — тогда он не хранится вовсе.
|
||||
Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не
|
||||
ноль.
|
||||
В бою сырой слой иногда собирают на движке `Null` — тогда он не хранится вовсе.
|
||||
Такой вариант отвергнут: на стенде сырые данные нужны для отладки, поэтому окно,
|
||||
а не ноль.
|
||||
|
||||
## Таблица ошибок
|
||||
|
||||
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
|
||||
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у
|
||||
брака нет разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у
|
||||
строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и
|
||||
сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним,
|
||||
разбираться к моменту разбирательства будет уже нечем. Снятие — целыми кусками
|
||||
(`ttl_only_drop_parts`), как у сырья и по той же причине: строки в партиции дня
|
||||
загрузки разного возраста не более чем на сутки, и куску незачем переживать
|
||||
срок из-за самой свежей строки.
|
||||
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что
|
||||
у брака нет разобранных полей: шардируется `cityHash64` сырой строки —
|
||||
`ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню
|
||||
загрузки, как и сырые данные; живёт месяц. Дольше сырых данных — намеренно: если
|
||||
брак истекает вместе с ними, разбираться к моменту разбирательства будет уже
|
||||
нечем. Снятие — целыми кусками (`ttl_only_drop_parts`), как у STG и по той же
|
||||
причине: строки в партиции дня загрузки разного возраста не более чем на сутки,
|
||||
и куску незачем переживать срок из-за самой свежей строки.
|
||||
|
||||
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
|
||||
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
|
||||
@@ -500,13 +500,12 @@ kafka_offset)`: смотрят такую таблицу от класса, а
|
||||
|
||||
## Раскладка SQL
|
||||
|
||||
Исполняемые дагами запросы лежат по правилу
|
||||
`sql/<слой>/<объект>_<роль>.sql`. Слой — слой цели запроса: забор заказов в
|
||||
сырьё живёт в `sql/stg/orders_raw_load.sql`, разбор — в
|
||||
`sql/ods/order_parse.sql`. Даг называет файлы, передаёт параметры и задаёт
|
||||
порядок выполнения, но не хранит текст преобразований. Два выражения разбора
|
||||
делят локальный макрос Jinja в том же файле; подключаемого SQL-фрагмента между
|
||||
ними нет.
|
||||
Исполняемые дагами запросы лежат по правилу `sql/<слой>/<объект>_<роль>.sql`.
|
||||
Слой — слой цели запроса: забор заказов в STG живёт в
|
||||
`sql/stg/orders_raw_load.sql`, разбор — в `sql/ods/order_parse.sql`. Даг
|
||||
называет файлы, передаёт параметры и задаёт порядок выполнения, но не хранит
|
||||
текст преобразований. Два выражения разбора делят локальный макрос Jinja в том
|
||||
же файле; подключаемого SQL-фрагмента между ними нет.
|
||||
|
||||
SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода
|
||||
обработчик DAG читает список DDL-файлов, загружает содержимое каждого
|
||||
@@ -524,10 +523,10 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
||||
|---|---|
|
||||
| `00-databases.sql` | базы слоёв и зона справочников |
|
||||
| `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё |
|
||||
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников |
|
||||
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы STG обоих источников |
|
||||
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
|
||||
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
|
||||
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
|
||||
| `40-stg-views.sql` | матвью приёма: чтец в STG |
|
||||
| `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций |
|
||||
| `50-dds-views.sql` | публичные договоры сущностей DDS |
|
||||
| `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций |
|
||||
@@ -537,14 +536,14 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
||||
правила порядка слоёв к ней не применяются.
|
||||
|
||||
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
|
||||
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому
|
||||
что наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся
|
||||
матвью приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает
|
||||
читать топик, когда к нему привязывают первую матвью; создай её раньше
|
||||
разбора — и всё, что доедет в зазоре, ляжет в сырьё и не попадёт в ODS
|
||||
никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден,
|
||||
поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse
|
||||
снесены, а данные Kafka целы, — то есть на обычной отладке.
|
||||
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что
|
||||
наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся матвью
|
||||
приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает читать
|
||||
топик, когда к нему привязывают первую матвью; создай её раньше разбора — и всё,
|
||||
что доедет в зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в
|
||||
ошибки. На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет.
|
||||
Проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на
|
||||
обычной отладке.
|
||||
|
||||
Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
|
||||
`lifecycle`: один файл — одна последовательная задача
|
||||
@@ -579,13 +578,13 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
||||
|---|---|---|
|
||||
| `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
|
||||
| `stg` | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки |
|
||||
| `stg` | `stg.hits_raw_mv` | наполняет сырьё из чтеца |
|
||||
| `stg` | `stg.hits_raw_mv` | наполняет STG из чтеца |
|
||||
| `stg` | `stg.orders_raw_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью |
|
||||
| `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` |
|
||||
| `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие |
|
||||
| `ods` | `ods.event_v` | актуальная версия события с полями источника |
|
||||
| `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём |
|
||||
| `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор сырья в событие и в ошибки |
|
||||
| `ods` | `ods.event_mv`, `ods.event_errors_mv` | разбор STG в событие и в ошибки |
|
||||
| `ods` | `ods.order_rep` / `_dist` | типизированные версии заказа |
|
||||
| `ods` | `ods.order_v` | текущая версия заказа на языке источника |
|
||||
| `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
|
||||
@@ -612,9 +611,8 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
||||
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
|
||||
| `dic` | `dic.products` | словарь товаров поверх подложки |
|
||||
|
||||
Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два
|
||||
`INSERT SELECT` одной задачи `parse_batch` из файла
|
||||
`sql/ods/order_parse.sql`.
|
||||
Матвью разбора у заказов нет: срез STG раскладывают по этим двум целям два
|
||||
`INSERT SELECT` одной задачи `parse_batch` из файла `sql/ods/order_parse.sql`.
|
||||
|
||||
В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие
|
||||
остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции;
|
||||
@@ -655,11 +653,11 @@ DDL-словарь с файловым источником внутри `user_f
|
||||
**Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и
|
||||
`system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку
|
||||
значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT`
|
||||
на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`.
|
||||
На живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов
|
||||
лежали на обоих шардах, после явного нуля и повторного разбора того же
|
||||
`_load_id` — ни одного. Вершина версий уцелела: деловые поля `ods.order_v`
|
||||
сошлись с эталоном, посчитанным прямо из сырья.
|
||||
на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`. На
|
||||
живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов лежали на
|
||||
обоих шардах, после явного нуля и повторного разбора того же `_load_id` — ни
|
||||
одного. Вершина версий уцелела: деловые поля `ods.order_v` сошлись с эталоном,
|
||||
посчитанным прямо из STG.
|
||||
|
||||
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
|
||||
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
|
||||
@@ -683,19 +681,19 @@ DDL-словарь с файловым источником внутри `user_f
|
||||
потолком разбор отдаёт как `NULL`. Это ловит опыт, который берёт «заведомо
|
||||
далёкий» год: 2999-й не разбирается вовсе.
|
||||
|
||||
Про сам приём заказов снято следующее. Предикат формы провода разложил все
|
||||
13 661 строку сырья, приехавшую при исполнении #93, без исключений и без
|
||||
`NULL`: 13 653 годных и 8 брака, сумма сошлась с общим счётом по каждому
|
||||
`_load_id`, пересечение ветвей — ноль. Все восемь строк брака — версии одного
|
||||
заказа с отрицательным `total`; это дефект генератора, разобранный в
|
||||
[спецификации приёма](orders/ingestion.md), «Что проверено». На управляемой
|
||||
порции из пяти строк (две версии одного заказа плюс по одной строке каждого
|
||||
класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с
|
||||
ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL`
|
||||
через распределённую таблицу вернул одну строку — ту, что несла позднейшую
|
||||
версию, — тогда как физический счёт показывал две. Повтор задачи с тем же
|
||||
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок
|
||||
записала тот же брак второй раз, как и обещано спецификацией приёма.
|
||||
Про сам приём заказов снято следующее. Предикат формы провода разложил все 13
|
||||
661 строку STG, приехавшую при исполнении #93, без исключений и без `NULL`: 13
|
||||
653 годных и 8 брака, сумма сошлась с общим счётом по каждому `_load_id`,
|
||||
пересечение ветвей — ноль. Все восемь строк брака — версии одного заказа с
|
||||
отрицательным `total`; это дефект генератора, разобранный в [спецификации
|
||||
приёма](orders/ingestion.md), «Что проверено». На управляемой порции из пяти
|
||||
строк (две версии одного заказа плюс по одной строке каждого класса брака)
|
||||
годные ушли в `ods.order`, брак — в `ods.order_errors` с ожидаемыми классами.
|
||||
Обе версии легли в одну партицию и на один шард; `FINAL` через распределённую
|
||||
таблицу вернул одну строку — ту, что несла позднейшую версию, — тогда как
|
||||
физический счёт показывал две. Повтор задачи с тем же `_load_id` строку в
|
||||
`ods.order_v` и её `_load_ts` не изменил, а таблица ошибок записала тот же брак
|
||||
второй раз, как и обещано спецификацией приёма.
|
||||
|
||||
Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у
|
||||
запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, —
|
||||
@@ -758,27 +756,26 @@ DDL-словарь с файловым источником внутри `user_f
|
||||
вовсе, до 7 августа утверждение держалось на опыте владельца.
|
||||
- Упавшая матвью роняет вставку и останавливает потребление до починки.
|
||||
Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за
|
||||
двадцать секунд (сброс блока идёт за 7,5) в сырьё не приехало ничего, а
|
||||
офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение
|
||||
доехало само, без повторной отправки, отставание ушло в ноль, событие
|
||||
разобралось. Сносить надо именно распределённую таблицу: вставка в
|
||||
`Distributed` кладёт блок в спул и сразу возвращает управление, так что на
|
||||
сносе локальной ошибка всплыла бы фоном и утверждение показалось бы
|
||||
опровергнутым.
|
||||
- `_load_ts` в `ods.event` — это метка исходной строки сырья, а не время
|
||||
двадцать секунд (сброс блока идёт за 7,5) в STG не приехало ничего, а офсет
|
||||
группы застыл с отставанием в одно сообщение. Цель вернули — сообщение доехало
|
||||
само, без повторной отправки, отставание ушло в ноль, событие разобралось.
|
||||
Сносить надо именно распределённую таблицу: вставка в `Distributed` кладёт
|
||||
блок в спул и сразу возвращает управление, так что на сносе локальной ошибка
|
||||
всплыла бы фоном и утверждение показалось бы опровергнутым.
|
||||
- `_load_ts` в `ods.event` — это метка исходной строки STG, а не время
|
||||
разбора. Сверено по `WatchID` на двух тысячах событий модельного дня,
|
||||
залитого дважды: у каждого события метка совпала с меткой одной из двух его
|
||||
доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди
|
||||
доставок» ноль, то есть `now64()` в матвью разбора нет.
|
||||
- Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново
|
||||
созданные матвью разбора при живом чтеце: сообщение, отправленное сразу
|
||||
после, легло в сырьё и не попало в ODS никуда — ни в событие, ни в ошибки;
|
||||
то же сообщение через минуту разобралось штатно. Воспроизведено дважды
|
||||
7 августа 2026 года. Это тот же зазор, о котором предупреждает нумерация
|
||||
файлов DDL, только приходит он с другой стороны — не при первом создании, а
|
||||
при замене матвью на работающем стенде. Практический вывод один: правишь
|
||||
матвью — не верь ближайшей отправке, повтори её. Чем именно держится
|
||||
задержка, не измерено; наблюдение записано как наблюдение.
|
||||
созданные матвью разбора при живом чтеце: сообщение, отправленное сразу после,
|
||||
легло в STG и не попало в ODS никуда — ни в событие, ни в ошибки; то же
|
||||
сообщение через минуту разобралось штатно. Воспроизведено дважды 7 августа
|
||||
2026 года. Это тот же зазор, о котором предупреждает нумерация файлов DDL,
|
||||
только приходит он с другой стороны — не при первом создании, а при замене
|
||||
матвью на работающем стенде. Практический вывод один: правишь матвью — не верь
|
||||
ближайшей отправке, повтори её. Чем именно держится задержка, не измерено;
|
||||
наблюдение записано как наблюдение.
|
||||
- Форма ключа `ods.event` принимается такой, как её задумала спека: выражение
|
||||
`intHash32(ClientID)` стоит в ключе сортировки `ReplacingMergeTree`, а
|
||||
`SAMPLE BY` — по тому же выражению. Вопрос стоял открытым в разделе 11
|
||||
@@ -811,18 +808,19 @@ DDL-словарь с файловым источником внутри `user_f
|
||||
|
||||
Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись
|
||||
Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`)
|
||||
читаются, двигают офсет потребителя и не дают строки вовсе — ни в сырьё, ни в
|
||||
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа
|
||||
2026 года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, —
|
||||
в сырьё приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при
|
||||
чём: `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на
|
||||
том же наборе даёт ровно те же две строки и тот же офсет.
|
||||
читаются, двигают офсет потребителя и не дают строки вовсе — ни в STG, ни в
|
||||
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа 2026
|
||||
года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, — в STG
|
||||
приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при чём:
|
||||
`LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на том
|
||||
же наборе даёт ровно те же две строки и тот же офсет.
|
||||
|
||||
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
|
||||
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
|
||||
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
|
||||
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
|
||||
самом сырье, `kafka_offset` лежит там колонкой, и дырка в офсетах — это он.
|
||||
самой таблице STG, `kafka_offset` лежит там колонкой, и дырка в офсетах — это
|
||||
он.
|
||||
|
||||
Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS
|
||||
<локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
|
||||
@@ -865,7 +863,7 @@ Kafka с пустым значением (ноль байт) и запись-н
|
||||
- **`JSONExtract` читает `Decimal` и из числа JSON.** Массив
|
||||
`purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается
|
||||
сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на
|
||||
ClickHouse 26.3; все суммы после ODS совпали с сырьем до копейки.
|
||||
ClickHouse 26.3; все суммы после ODS совпали с сырыми данными до копейки.
|
||||
|
||||
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
|
||||
матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
|
||||
@@ -36,7 +36,7 @@ Airflow. В ClickHouse он не пишет ничего.
|
||||
| `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` | три графика: столбики выручки, сводная таблица сверки, линии трафика |
|
||||
| `charts/*.yaml` | девять графиков: четыре крупных числа, столбики выручки и кольцо долей, сводная таблица сверки, линии трафика и доля известных |
|
||||
| `dashboards/business_stories.yaml` | дашборд «Бизнес-сюжеты» и раскладка панелей |
|
||||
|
||||
Связаны файлы не путями, а uuid. Датасет называет `database_uuid`, график —
|
||||
@@ -46,20 +46,51 @@ Airflow. В ClickHouse он не пишет ничего.
|
||||
|
||||
Uuid у стенда розданы рукой: общее начало
|
||||
`19600000-0000-4000-8000-`, дальше номер объекта в последнем блоке — `1`–`3`
|
||||
у датасетов, `101`–`103` у графиков, `201` у дашборда
|
||||
у датасетов, `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`:
|
||||
@@ -152,6 +183,17 @@ superset import-directory /app/import --overwrite
|
||||
завершается нулем в любом случае. Поэтому импорт проверяют глазами или
|
||||
запросом к API — но не тем, что команда отработала.
|
||||
|
||||
**Знак рубля приходит не из формата валюты.** Символ Superset берет у
|
||||
`Intl.NumberFormat` по коду валюты и английской локали стенда: код `RUB`
|
||||
печатается как «RUB», а знак «₽» вызов роняет, и форматирование молча
|
||||
откатывается к числу без валюты. Поэтому единица стоит в названии панели —
|
||||
«Выручка, ₽», — а метрика несет обычный формат `,d`.
|
||||
|
||||
**`label_colors` знает имя ряда, а не подпись.** Для разреза имя ряда — это
|
||||
значение колонки («Бытовая техника»), для графика без разреза — имя метрики
|
||||
(`known_share`), а не ее русская подпись. Цвет, закрепленный за подписью
|
||||
«Доля известных», не применяется.
|
||||
|
||||
**`--overwrite` синхронизирует датасет разрушительно.** Колонки и метрики
|
||||
берутся из YAML целиком: добавленная в интерфейсе метрика или подпись колонки
|
||||
исчезнет при следующем старте контейнера. Правка датасета — это правка файла.
|
||||
@@ -163,3 +205,50 @@ superset import-directory /app/import --overwrite
|
||||
живое подключение и хранилище пароля. Сами графики и дашборд ни одна цель не
|
||||
проверяет: их смотрят глазами после переинициализации. Полная карта целей — в
|
||||
[доке проверок](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: на дашборд смотрят
|
||||
глазами, красное и зеленое живет в проверках.
|
||||
|
||||
Поводы вернуться к разговору: плагин карты начинает отдавать кросс-фильтр; у
|
||||
заказа появляется адрес доставки; витрины получают общее измерение по своей
|
||||
причине, не ради дашборда.
|
||||
@@ -23,9 +23,9 @@
|
||||
заметит потерянного подключения Superset: он про ClickHouse и только.
|
||||
|
||||
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
|
||||
против описи — вопрос к ClickHouse: строки в `ods.event` считает сам
|
||||
сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене
|
||||
они подошли бы смоуку.
|
||||
против паспорта — вопрос к ClickHouse: строки в `ods.event` считает сам сервер и
|
||||
отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене они
|
||||
подошли бы смоуку.
|
||||
|
||||
## Карта целей
|
||||
|
||||
@@ -36,19 +36,19 @@
|
||||
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
|
||||
`make smoke` и `make check-services` перемерены 28 августа 2026 года,
|
||||
`make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
|
||||
корневой `make lint` — 9 августа, `make test` — 30 августа; остальные стоят с
|
||||
корневой `make lint` — 3 сентября, `make test` — 30 августа; остальные стоят с
|
||||
замера 6 августа.
|
||||
|
||||
| Цель | Откуда | Что утверждает | Стенд | Цена |
|
||||
|---|---|---|---|---|
|
||||
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
||||
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||
| `make lint` | корень | Код стенда и сборщик карты отформатированы и проходят ruff; готовый HTML карты соответствует исходникам | не нужен | 0,8 с |
|
||||
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
|
||||
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с |
|
||||
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против паспорта | нужен | 24 с |
|
||||
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
|
||||
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
||||
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
|
||||
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [паспорт мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
|
||||
|
||||
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
|
||||
разными дверями охватывают разное: корневая не видит ни одного файла
|
||||
@@ -76,9 +76,18 @@
|
||||
Airflow. В цену целей она не входит. На готовом мире подъём больше не
|
||||
пересчитывает данные: `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,
|
||||
заливает стартовый мир и возвращает его пульт к концу стартовых дней. Службы и
|
||||
заливает стартовый мир и возвращает его позицию к концу стартовых дней. Службы и
|
||||
их тома она сохраняет, поэтому возврат к каноническим данным не требует
|
||||
холодного запуска всего стенда.
|
||||
|
||||
@@ -119,13 +128,12 @@ ClickHouse отвечает сразу.
|
||||
|
||||
Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test`
|
||||
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
|
||||
дорогая. Правки дагов и Superset — корневой `make lint`.
|
||||
дорогая. Правки дагов, Superset и карты — корневой `make lint`.
|
||||
|
||||
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
|
||||
для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
|
||||
узнать, тот ли получается мир, его надо получить. Место выбрано по той же
|
||||
оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не
|
||||
нужен, — и
|
||||
для сверки с паспортом мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
|
||||
узнать, тот ли получается мир, его надо получить. Место выбрано по той же оси
|
||||
«кого спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и
|
||||
краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
|
||||
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
|
||||
поломкой хранилища, а не забытой пересборкой.
|
||||
@@ -163,10 +171,10 @@ ClickHouse отвечает сразу.
|
||||
жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он
|
||||
становится темой урока.
|
||||
|
||||
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырья срок
|
||||
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырых данных срок
|
||||
жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в
|
||||
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и
|
||||
их. Хуже явного мусора: явный видно.
|
||||
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и их.
|
||||
Хуже явного мусора: явный видно.
|
||||
|
||||
Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.**
|
||||
Дешевле всего это выходит партицией — опыт играет модельный день за границей
|
||||
@@ -175,10 +183,10 @@ ClickHouse отвечает сразу.
|
||||
же механизм, на котором стоит переобработка дня X.
|
||||
|
||||
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
|
||||
что стенд произвёл сам: подневный счёт событий против описи мира ловит и
|
||||
что стенд произвёл сам: подневный счёт событий против паспорта мира ловит и
|
||||
сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге,
|
||||
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки
|
||||
Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
||||
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки Kafka →
|
||||
STG → ODS, и живёт он в `make check-clickhouse`.
|
||||
|
||||
Правило, которое такая проверка обязана выдержать и которое стоит держать в
|
||||
голове для следующей: **на растущих данных утверждать можно только про
|
||||
@@ -237,8 +245,51 @@ 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 модельных дней,
|
||||
@@ -304,11 +355,11 @@ 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. Эта правка добавила один тест — проигрыш дня.
|
||||
Паспорт пересобрался за 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 секунды. Предел
|
||||
@@ -416,45 +467,45 @@ shell-версия узкой пересборки за 2 м 25 с пересо
|
||||
`make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за
|
||||
3 м 35 с. Отправка семи слепков заняла около 28 секунд.
|
||||
|
||||
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и пульта
|
||||
мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время
|
||||
ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й
|
||||
секунде, обработчик DAG стал здоров примерно на 458-й. Числа здесь называют
|
||||
цену нынешнего стенда, а не выносят вердикт о регрессии.
|
||||
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и дагов
|
||||
модельного времени. Весь рост приписывать слепкам нельзя: на холодном прогоне
|
||||
основное время ушло ещё до сценариев ожидания: `airflow-init` завершился
|
||||
примерно на 320-й секунде, обработчик DAG стал здоров примерно на 458-й. Числа
|
||||
здесь называют цену нынешнего стенда, а не выносят вердикт о регрессии.
|
||||
|
||||
**Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой
|
||||
проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за
|
||||
24 секунды. Это цена всей цели, а не одной новой строки.
|
||||
|
||||
**Замер 9 августа 2026 года при появлении корневого `lint` (#65).** Три прогона
|
||||
подряд — 0,39, 0,36 и 0,39 секунды на трёх файлах стенда; в таблице 0,4 с. На
|
||||
подряд — 0,39, 0,36 и 0,39 секунды на трех файлах стенда, которые тогда входили в проверку; в таблице 0,4 с. На
|
||||
машине с непрогретым кешем первый прогон дороже: `uvx` сначала скачивает ruff.
|
||||
|
||||
Тогда же снято, что двери не перекрываются: `ruff check --show-files` из корня
|
||||
перечисляет ровно три файла стенда, из `generator/` — только файлы генератора.
|
||||
перечислял ровно три файла стенда, из `generator/` — только файлы генератора.
|
||||
Корневой конфиг правила генератору не подменяет: конфиг ищется вверх по дереву
|
||||
от файла, и ближний выигрывает.
|
||||
|
||||
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
|
||||
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка:
|
||||
18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka.
|
||||
Повторный `make up` на живом стенде — 1 м 5 с: мир заливается заново, и это
|
||||
не оплошность — `WatchID` те же, повтор схлопнет `ReplacingMergeTree`.
|
||||
Смоук — 9 с, `check-clickhouse` — 8 с с новой девятой проверкой. `make test`
|
||||
вырос до 71 с: 58 с прежних тестов плюс 13 с на пересборку восьми дней для
|
||||
сверки с описью. Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
|
||||
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка: 18,7
|
||||
с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka. Повторный
|
||||
`make up` на живом стенде — 1 м 5 с: мир заливается заново, и это не оплошность
|
||||
— `WatchID` те же, повтор схлопнет `ReplacingMergeTree`. Смоук — 9 с,
|
||||
`check-clickhouse` — 8 с с новой девятой проверкой. `make test` вырос до 71 с:
|
||||
58 с прежних тестов плюс 13 с на пересборку восьми дней для сверки с паспортом.
|
||||
Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
|
||||
#41 и #43.
|
||||
|
||||
Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и
|
||||
проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в
|
||||
`ods.event_rep` — проверка покраснела, показала недостающий день и назвала
|
||||
адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца
|
||||
топика». Затем положили в сырьё заведомо негодную строку — брак появился, и
|
||||
диагноз сменился на второй, с признаком для различения: «сойдётся недостача с
|
||||
числом брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки
|
||||
опыта убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт
|
||||
вернулся к 401 185, и это заодно показало дедупликацию: повтор дня не удвоил
|
||||
счёт под `FINAL`.
|
||||
адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца топика».
|
||||
Затем положили в STG заведомо негодную строку — брак появился, и диагноз
|
||||
сменился на второй, с признаком для различения: «сойдётся недостача с числом
|
||||
брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки опыта
|
||||
убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт вернулся к
|
||||
401 185, и это заодно показало дедупликацию: повтор дня не удвоил счёт под
|
||||
`FINAL`.
|
||||
|
||||
Замеры 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`.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Путеводитель по стенду
|
||||
|
||||
Путеводитель объясняет, как данные поступают в хранилище, обрабатываются
|
||||
и попадают в отчеты. В главах есть короткие примеры, которые можно выполнить
|
||||
на запущенном стенде перед переходом к учебным заданиям.
|
||||
|
||||
## Как читать
|
||||
|
||||
Читайте главы в порядке ниже. Незнакомые термины можно найти в
|
||||
[словаре понятий](../../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)
|
||||
|
||||
Какие даги создают новые данные, а какие обрабатывают их в хранилище.
|
||||
Вы запустите генерацию и обработку за день и посмотрите результат в витринах.
|
||||
|
||||
### [Мониторинг: куда смотреть](monitoring.md)
|
||||
|
||||
Что показывают панели Grafana и как меняются их показания, пока в стенд
|
||||
поступают события и заказы.
|
||||
|
||||
### [Немного Superset](superset.md)
|
||||
|
||||
Как Superset строит графики по витринам и что происходит с вашими отчетами
|
||||
при перезапуске стенда.
|
||||
|
||||
### [Генератор: откуда берутся данные](generator.md)
|
||||
|
||||
Откуда берутся события и заказы, почему начальные данные повторяются
|
||||
и как сверить число загруженных событий с ожидаемым.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Потоки Airflow и даги модельного времени
|
||||
|
||||
Airflow управляет генерацией данных и их обработкой в хранилище. Это разные
|
||||
этапы: сначала в Kafka поступают события и заказы за день работы магазина,
|
||||
затем по ним рассчитываются данные DDS и витрины DM.
|
||||
|
||||
```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-конвейера).
|
||||
|
||||
## Как найти в интерфейсе
|
||||
|
||||
Даги мира ищите по тегам «модельное время» и «жизненный цикл мира». Остальные
|
||||
даги ищите по тегам «заказы», `etl`, `dds`, `dm` и `dq`. `homework_check` с
|
||||
тегами `dm` и `labs` проверяет домашние работы; о нем рассказано в учебных
|
||||
заданиях.
|
||||
|
||||
## Посмотрите сами
|
||||
|
||||
Откройте Airflow по адресу из раздела [«Адреса и
|
||||
пароли»](../../README.md#адреса-и-пароли). В Admin → Variables найдите
|
||||
`world_position`. Затем запустите `world_next_day` на один день по [инструкции
|
||||
из README](../../README.md#добавить-данные-за-следующие-дни). Убедитесь, что
|
||||
`orders_ingest` завершился успешно, а в витринах нового дня еще нет.
|
||||
Запустите `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). Следующая глава — [«Мониторинг: куда
|
||||
смотреть»](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,695 @@
|
||||
title: Карта слоев хранилища
|
||||
lead: Проследите, как один смысл меняет имя и поверхность между слоями.
|
||||
chapter_url: https://git.dementev.space/ddmitry/clickstream-data-platform/src/branch/main/docs/handbook/storage-layers.md#user-content-хранилище-и-его-слои
|
||||
intro:
|
||||
- Эта карта показывает путь событий и заказов от Kafka до витрин DM.
|
||||
- Щелкните по узлу, чтобы увидеть его назначение, устройство и ключи.
|
||||
- Щелкните по стрелке, чтобы узнать, кто и каким запросом переносит данные.
|
||||
- Когда вы выбираете узел, карта подсвечивает все пути к нему и от него.
|
||||
- Переключатель потока приглушает другую часть карты, но не меняет раскладку.
|
||||
- Сначала прочитайте главу «Хранилище и его слои», затем вернитесь к карте и рассмотрите объекты.
|
||||
|
||||
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_kafka` забирает сообщения из топика. Для данных событий используйте `stg.hits_raw_dist`.'
|
||||
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_kafka` забирает сообщения из топика. Для заказов используйте `stg.orders_raw_dist`.'
|
||||
fill:
|
||||
- Источник заказов публикует слепок пачкой после каждого модельного дня.
|
||||
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: Сырая строка события и метаданные доставки. STG сохраняет сообщение без разбора.
|
||||
read: 'Читайте `_dist`: у STG нет представления `_v`, а распределенная таблица показывает оба шарда.'
|
||||
parts:
|
||||
- '`stg.hits_raw_kafka` — чтец топика `hits`.'
|
||||
- '`stg.hits_raw_rep` — реплицируемая таблица на шарде.'
|
||||
- '`stg.hits_raw_dist` — распределенная поверхность записи и чтения.'
|
||||
- '`stg.hits_raw_mv` — матвью приема сообщений из чтеца.'
|
||||
fill:
|
||||
- '`stg.hits_raw_mv` пишет непрерывно, по мере прихода сообщений.'
|
||||
- Сырые строки живут трое суток реального времени плюс хвост до суток.
|
||||
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` забирает слепок пачкой раз в модельные сутки.
|
||||
- Сырые строки живут трое суток реального времени плюс хвост до суток.
|
||||
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: Типизированное широкое событие с именами полей источника. Запрос представления с `FINAL` возвращает актуальную версию события.
|
||||
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: Сырые события, которые не прошли строгий прием, с классом ошибки и метаданными доставки.
|
||||
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: Сырые строки слепка, которые не прошли строгий прием, с классом ошибки и `_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: Публичное представление события на языке бизнеса. Его запрос переименовывает поля и расшифровывает категорию устройства.
|
||||
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 без копии и отдельного расписания.
|
||||
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` собирает из последовательных событий одной куки. В источнике готовой сессии нет.
|
||||
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` пересобирает данные прожитых дней при каждом прогоне конвейера.
|
||||
- Задачи `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` пересобирает изменяемое окно при каждом прогоне конвейера.
|
||||
- Задачи `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` — кука из события и сессии DDS.'
|
||||
- '`user_id` — пользователь магазина из заказа DDS.'
|
||||
fill:
|
||||
- Задача `identity_map` дага `dds_transform` пересчитывает все известные пары после обновления заказов.
|
||||
- Новая версия полного набора пишется при каждом прогоне; `FINAL` скрывает физические повторы.
|
||||
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` при каждом прогоне пересчитывает всю историю и записывает новую версию.
|
||||
- '`FINAL` оставляет текущую строку каждого дня.'
|
||||
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` и паспорт мира.'
|
||||
- '`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`: у словаря одна поверхность без суффикса.'
|
||||
parts:
|
||||
- '`dic.products_file` — чтец CSV-каталога из файла репозитория.'
|
||||
- '`dic.products` — словарь в памяти поверх подложки.'
|
||||
keys:
|
||||
- '`sku` — тот же артикул, что `item_sku` в позиции заказа DDS.'
|
||||
fill:
|
||||
- ClickHouse перечитывает подложку каждые 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: 'Соединения нет: сообщение кладется строкой целиком.'
|
||||
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: 'Соединения нет: матвью разбирает строку в колонки.'
|
||||
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: 'Соединения нет: негодная строка уходит целиком вместе с классом ошибки.'
|
||||
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: 'Соединения нет: представление переименовывает колонки той же строки.'
|
||||
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` режутся по паузам; `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: 'Соединения нет: из заказа берутся поля и позиции, `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: 'Соединения нет: сессии группируются по `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: Даг `dm_transform` получает категорию через `dictGet` по `item_sku`; ключ словаря — `sku`.
|
||||
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: 'GLOBAL FULL OUTER JOIN: `purchase_order_id = backend_order_id` (`purchase_id[1]` и `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,83 @@
|
||||
# Мониторинг: куда смотреть
|
||||
|
||||
В Grafana настроены три дашборда. «Данные» показывает загрузку и обработку
|
||||
событий и заказов, «Кластер» — состояние узлов и самого ClickHouse,
|
||||
а «Запросы» — выполненные запросы и затраченные на них ресурсы.
|
||||
|
||||
Prometheus собирает метрики с двух узлов ClickHouse, ClickHouse Keeper
|
||||
и экспортера Kafka — службы, которая предоставляет метрики Kafka.
|
||||
Grafana получает данные из Prometheus и напрямую из ClickHouse.
|
||||
На панелях не заданы пороги для цветовых предупреждений: оценивать показания
|
||||
вы будете самостоятельно. Качество данных автоматически проверяет
|
||||
даг `dq_check`.
|
||||
|
||||
## Дашборд «Данные»
|
||||
|
||||
«Свежесть» показывает, сколько времени прошло с последнего события и с
|
||||
последнего заказа в ODS. Показатель для заказов растет весь модельный день
|
||||
и снижается, когда поступает очередная дневная выгрузка — слепок.
|
||||
Это ожидаемо: заказы загружаются раз в день.
|
||||
|
||||
«Поток» показывает число сырых сообщений в STG за выбранный интервал. «Брак»
|
||||
показывает долю строк, которые не удалось разобрать.
|
||||
|
||||
«Сходимость» сравнивает число событий в Kafka и в ClickHouse. Тревожный
|
||||
признак — устойчивый разрыв между рядами. Разовое расхождение еще не говорит
|
||||
о потере данных: метрики Kafka и строки ClickHouse могут попадать в соседние
|
||||
интервалы на графике.
|
||||
|
||||
«Отставание» показывает, сколько сообщений в Kafka еще не прочитано
|
||||
потребителями. «Дневной слепок» перечисляет последние десять слепков заказов в
|
||||
STG: по нему видно, поступила ли очередная выгрузка.
|
||||
|
||||
Источники и правила чтения всех панелей разобраны в [справочнике дашборда
|
||||
«Данные»](../architecture/monitoring/data.md).
|
||||
|
||||
## Почему у панелей разные источники
|
||||
|
||||
Свежесть считается по 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,165 @@
|
||||
# Хранилище и его слои
|
||||
|
||||
В хранилище приходят два потока из Kafka: в топике `hits` — события
|
||||
кликстрима, в `orders` — заказы магазина. Оба проходят один путь: STG → ODS →
|
||||
DDS → DM. В 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;
|
||||
```
|
||||
|
||||
Третий запрос покажет готовую выручку по категориям за тот же день:
|
||||
|
||||
```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,69 @@
|
||||
# Немного 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;
|
||||
```
|
||||
|
||||
Как построить свой отчет, рассказывают [учебные задания](../labs/README.md).
|
||||
|
||||
## Куда дальше
|
||||
|
||||
Подключение и настройка Superset описаны в [справочнике
|
||||
Superset](../architecture/superset.md), а состав витрин — в [справочнике
|
||||
DM](../architecture/dm.md). Следующая глава — [«Генератор: откуда берутся
|
||||
данные»](generator.md).
|
||||
@@ -0,0 +1,97 @@
|
||||
# Задание 1: `dm.events_enriched_v`
|
||||
|
||||
**Чему учит:** подготовить удобный для анализа набор полей события
|
||||
и обойтись представлением, сохранив одну строку на событие.
|
||||
|
||||
## Сначала прочитать
|
||||
|
||||
- [«Хранилище и его слои»](../handbook/storage-layers.md).
|
||||
- [Как применить свой DDL](README.md#как-применить-свой-ddl): файл для решения
|
||||
и подключение с правами на создание витрины.
|
||||
|
||||
## Для чего нужна витрина
|
||||
|
||||
В `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`. У события с тремя
|
||||
позициями получится три строки. Зерно изменится. Витрину с новым зерном на
|
||||
стенде хранят готовым агрегатом — причины описаны в разделе [«Как принято на
|
||||
ступени 1»](README.md#как-принято-на-ступени-1).
|
||||
|
||||
Главная опасность — потеря событий с пустым массивом товаров: `ARRAY JOIN`
|
||||
исключает их из результата. Товары есть только в событиях корзины и покупки.
|
||||
Поэтому исчезнет большинство событий — девятнадцать строк из двадцати.
|
||||
Запрос выполнится без ошибки, а витрина по имени и составу колонок
|
||||
по-прежнему будет выглядеть таблицей событий.
|
||||
|
||||
Сохраните массивы как в источнике. Для анализа отдельных товарных позиций
|
||||
нужна другая витрина с другим зерном и именем.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
После применения DDL откройте Airflow по [адресу из README](../../README.md#адреса-и-пароли),
|
||||
найдите даг `homework_check` и запустите его вручную. В новом запуске откройте
|
||||
задачу `events_enriched`: она должна завершиться успешно. Если задача
|
||||
красная, откройте ее журнал (`Logs`) — там будет причина ошибки, а при
|
||||
расхождениях с источником — дни и примеры несовпадений. Задачи еще не
|
||||
созданных витрин будут красными; это ожидаемо и не мешает проверке первой.
|
||||
|
||||
Деловой ключ — `event_id` внутри дня. Получите результат прямо из
|
||||
`dds.event_v` за тот же день. Соедините его с витриной полным внешним
|
||||
соединением по `event_id`. Посчитайте строки, где ключ отсутствует с одной
|
||||
из сторон или различаются значения деловых колонок. Расхождений должно быть
|
||||
ноль.
|
||||
|
||||
Отдельно сверьте `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,110 @@
|
||||
# Задание 2: `dm.top_pages_daily`
|
||||
|
||||
**Чему учит:** собрать дневные показатели страниц и настроить их
|
||||
обновление в `dm_transform` через замену партиций.
|
||||
|
||||
## Сначала прочитать
|
||||
|
||||
- [«Хранилище и его слои»](../handbook/storage-layers.md);
|
||||
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
|
||||
|
||||
## Для чего нужна витрина
|
||||
|
||||
По дневным показателям страниц аналитик сравнивает просмотры, посетителей и
|
||||
действия с товарами. Добавление в корзину происходит на карточке товара,
|
||||
покупка — на странице подтверждения заказа. Отношение добавлений к
|
||||
просмотрам карточки показывает ее конверсию.
|
||||
|
||||
Для воронки «просмотр → корзина → покупка» этих данных недостаточно:
|
||||
действия происходят на разных страницах, а связи между ними в агрегате нет.
|
||||
Путь человека исследуют по событиям или сессиям.
|
||||
|
||||
## Что сделать
|
||||
|
||||
Источник — `dds.event_v`. Зерно витрины — **день события × страница**.
|
||||
Читайте DDS напрямую: так выполнение задания не зависит от витрины
|
||||
задания 1.
|
||||
|
||||
На стенде витрину с новым зерном хранят как готовый агрегат, чтобы не
|
||||
рассчитывать ее заново для каждого запроса. Причины описаны в разделе
|
||||
[«Как принято на ступени 1»](README.md#как-принято-на-ступени-1). Создайте
|
||||
пять объектов:
|
||||
`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist` и
|
||||
публичное представление `dm.top_pages_daily_v`. Ключ партиции — день, он же
|
||||
единица пересборки.
|
||||
|
||||
### Состав колонок
|
||||
|
||||
| Колонка | Тип | Значение |
|
||||
|---|---|---|
|
||||
| `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`, заполните донор за весь диапазон одной вставкой,
|
||||
затем замените дневные партиции по одной.
|
||||
|
||||
### Ключ шардирования
|
||||
|
||||
Выберите ключ самостоятельно. У `revenue_daily` кроме дня одно измерение с
|
||||
шестью близкими по объему значениями. Здесь страниц много, и число событий
|
||||
на них сильно различается.
|
||||
|
||||
У цели и донора используйте одинаковый ключ: иначе при замене партиции
|
||||
строка потеряется на одном шарде и удвоится на другом. О том, [как устроен
|
||||
донор](../architecture/storage.md), читайте в разделе «Раскладка по шардам».
|
||||
|
||||
## Типичная ошибка
|
||||
|
||||
Из-за имени витрины хочется отобрать только просмотры. Но фильтр источника
|
||||
по `event_type = 'pageview'` обнулит `cart_adds` и `purchases`, а запрос не
|
||||
завершится ошибкой. Конверсия карточек станет нулевой. Проверка одной суммы
|
||||
`pageviews` не обнаружит этот фильтр: все просмотры остались на месте.
|
||||
Ошибку покажет сверка `purchases` или `cart_adds` с источником, если за день
|
||||
были покупки или добавления в корзину.
|
||||
|
||||
Выбирайте проверку под возможную ошибку. Этот подход описан в
|
||||
[справочнике проверок](../architecture/testing.md).
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — `page_path` внутри дня. Получите прямой агрегат из
|
||||
`dds.event_v` за день и соедините его с `dm.top_pages_daily_v` полным
|
||||
внешним соединением по ключу. Посчитайте строки, где ключ отсутствует с
|
||||
одной из сторон или различается любой из четырех счетчиков. Расхождений
|
||||
должно быть ноль.
|
||||
|
||||
Перед полным сравнением проверьте дневные суммы:
|
||||
|
||||
- сумма `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,115 @@
|
||||
# Задание 3: `dm.session_overview`
|
||||
|
||||
**Чему учит:** хранить слагаемые для расчета средних и долей и понимать,
|
||||
когда сложение числа посетителей по устройствам завышает аудиторию.
|
||||
|
||||
## Сначала прочитать
|
||||
|
||||
- [«Хранилище и его слои»](../handbook/storage-layers.md);
|
||||
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
|
||||
|
||||
## Для чего нужна витрина
|
||||
|
||||
Витрина нужна, чтобы по дням сравнивать визиты с телефона и компьютера: их
|
||||
число, длительность, просмотры страниц и частоту покупок.
|
||||
|
||||
Сессии уже рассчитаны в DDS. Из них вы соберете дневные показатели по
|
||||
устройствам. Почему такие итоги хранят в отдельной таблице, объясняет
|
||||
[справочник DM](../architecture/dm.md).
|
||||
|
||||
## Что сделать
|
||||
|
||||
Источник — `dds.session_v`. Зерно — **день сессии × категория устройства**.
|
||||
Зерно новое, поэтому витрина хранится готовым агрегатом — см. [«Как принято
|
||||
на ступени 1»](README.md#как-принято-на-ступени-1). Создайте пять объектов:
|
||||
`dm.session_overview_rep` / `_dist`, донор `_stage_rep` / `_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#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
|
||||
[справочника хранилища](../architecture/storage.md)).
|
||||
|
||||
## Почему сумма `visitors` сходится с `dm.daily_traffic_v`
|
||||
|
||||
Сравните сумму `visitors` по устройствам за день с `dm.daily_traffic_v`. На
|
||||
стенде значения совпадут: устройство приписано куке на всю жизнь, поэтому за
|
||||
день она учитывается только в одной строке. Это свойство мира стенда; см.
|
||||
раздел «Паспорт куки» в [словаре понятий](../../CONTEXT.md).
|
||||
|
||||
Если появятся визиты одной куки с двух устройств, сумма начнет завышать
|
||||
аудиторию. Проверки счетчиков по категориям этого не обнаружат. Число
|
||||
посетителей дня берите из `dm.daily_traffic_v`, где оно рассчитано для дня
|
||||
целиком.
|
||||
|
||||
## Типичная ошибка
|
||||
|
||||
Повторное выделение визитов из событий может дать другие границы сессий, чем
|
||||
в DDS. Используйте готовые сессии из `dds.session_v`. Если общее число
|
||||
событий совпало с источником, а число сессий завышено, вероятная причина —
|
||||
сборка витрины из событий вместо сессий.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — `device_category` внутри дня. Получите прямой агрегат из
|
||||
`dds.session_v` за день. Соедините его с `dm.session_overview_v` полным
|
||||
внешним соединением по ключу. Посчитайте строки, где ключ отсутствует с
|
||||
одной из сторон или различается любой счетчик. Расхождений должно быть ноль.
|
||||
|
||||
Еще две проверки дневных итогов:
|
||||
|
||||
- сумма `sessions` равна числу строк `dds.session_v` за день — без потерь и
|
||||
повторного учета визитов;
|
||||
- сумма `events_total` равна сумме `events_total` по сессиям того же дня.
|
||||
|
||||
## Что должно получиться
|
||||
|
||||
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
|
||||
использует ключ шардирования целевой таблицы;
|
||||
- [ ] вы явно перечислили колонки в `dm.session_overview_v` и собрали
|
||||
показатели из готовых сессий `dds.session_v`;
|
||||
- [ ] в таблице хранятся слагаемые для средних и долей;
|
||||
- [ ] проверка `session_overview` в `homework_check` прошла успешно;
|
||||
- [ ] витрина обновляется при запуске `dm_transform`.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Задание 4: `dm.dq_errors_daily`
|
||||
|
||||
**Чему учит:** считать брак по дню загрузки и сохранять сводку дольше
|
||||
исходных строк.
|
||||
|
||||
## Сначала прочитать
|
||||
|
||||
- [«Хранилище и его слои»](../handbook/storage-layers.md);
|
||||
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
|
||||
|
||||
## Для чего нужна витрина
|
||||
|
||||
Сводку открывают при росте брака в потоке: смотрят, каких ошибок стало
|
||||
больше и сколько строк забраковано. Причину конкретной ошибки исследуют
|
||||
в `ods.event_errors_dist` и `ods.order_errors_dist`, где есть сырой текст и
|
||||
координаты доставки.
|
||||
|
||||
## Что сделать
|
||||
|
||||
Зерно — **день загрузки × источник × класс брака**. В источнике зерно —
|
||||
строка брака. Зерно новое, поэтому витрина хранится готовым агрегатом — см.
|
||||
[«Как принято на ступени 1»](README.md#как-принято-на-ступени-1). Создайте
|
||||
пять объектов: `dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` /
|
||||
`_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#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
|
||||
[справочника хранилища](../architecture/storage.md)).
|
||||
|
||||
## Источники: таблицы `_dist`
|
||||
|
||||
Только это задание читает данные не через `_v`: брак нужен для диагностики
|
||||
приема и не входит в бизнес-модель. Публичных представлений у таблиц ошибок
|
||||
нет, создавать их для задания не нужно. Источники:
|
||||
|
||||
- `ods.event_errors_dist` — ошибки разбора событий;
|
||||
- `ods.order_errors_dist` — ошибки разбора слепка заказов.
|
||||
|
||||
Чтение локальной `_rep` даст строки одного шарда из двух: часть брака не
|
||||
попадет в сводку, хотя запрос выполнится без ошибки.
|
||||
|
||||
## Типичная ошибка
|
||||
|
||||
Если заменить всю таблицу данными из нового донора, исчезнут итоги дней,
|
||||
истекших по `TTL` источника. Восстановить их неоткуда.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — `source` и `error_class` внутри `load_date`. Проверяйте дни,
|
||||
оставшиеся хотя бы в одной таблице ошибок. Если дня за пределами `TTL` уже
|
||||
нет в источнике, это не ошибка: сверять его уже не с чем.
|
||||
|
||||
Посчитайте строки прямо в обеих таблицах ошибок и соедините результат с
|
||||
`dm.dq_errors_daily_v` полным внешним соединением по ключу. Затем посчитайте
|
||||
строки, где ключ отсутствует с одной из сторон или различается `error_rows`.
|
||||
Расхождений должно быть ноль.
|
||||
|
||||
Сумма `error_rows` по проверяемым дням равна общему числу строк в
|
||||
`ods.event_errors_dist` и `ods.order_errors_dist`. Если она примерно вдвое
|
||||
меньше, вероятная причина — чтение `_rep` вместо `_dist`.
|
||||
|
||||
Если в источниках нет брака, витрина должна быть пустой. В этом случае
|
||||
расхождений тоже ноль и проверка успешна.
|
||||
|
||||
## Что должно получиться
|
||||
|
||||
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
|
||||
использует ключ шардирования целевой таблицы;
|
||||
- [ ] сводка включает ошибки событий и заказов из таблиц `_dist`;
|
||||
- [ ] ваш запрос пересобирает только дни, еще доступные в источниках,
|
||||
сохраняя более старые итоги;
|
||||
- [ ] проверка `dq_errors_daily` в `homework_check` прошла успешно;
|
||||
отсутствие брака тоже дает успешный результат;
|
||||
- [ ] витрина обновляется при запуске `dm_transform`.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Задание 5: `dm.utm_effectiveness`
|
||||
|
||||
**Чему учит:** различать сумму покупки со слов клиента (`declared_`) и
|
||||
серверную выручку, находить причины их расхождения.
|
||||
|
||||
## Сначала прочитать
|
||||
|
||||
- [«Хранилище и его слои»](../handbook/storage-layers.md);
|
||||
- [«Потоки Airflow и даги модельного времени»](../handbook/airflow-dags.md).
|
||||
|
||||
## Для чего нужна витрина
|
||||
|
||||
Аналитик сравнивает рекламные каналы по дням: трафик, добавления в корзину,
|
||||
покупки и заявленные клиентом суммы.
|
||||
|
||||
На стенде поведение и атрибуцию считают по трекеру, деньги — по бэкенду.
|
||||
Здесь сделано исключение: UTM-метки есть только в кликстриме, поэтому для
|
||||
оценки покупок по меткам используют клиентскую сумму.
|
||||
|
||||
## Что сделать
|
||||
|
||||
Читайте `dds.event_v` напрямую, без соседних витрин DM. Зерно — **день
|
||||
события × `utm_source` × `utm_medium` × `utm_campaign`**. Зерно новое,
|
||||
поэтому витрина хранится готовым агрегатом — см. [«Как принято на ступени
|
||||
1»](README.md#как-принято-на-ступени-1). Создайте пять объектов:
|
||||
`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_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#как-принято-на-ступени-1) и раздел «Раскладка по шардам»
|
||||
[справочника хранилища](../architecture/storage.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`, где выделены классы
|
||||
расхождений.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — `utm_source`, `utm_medium`, `utm_campaign` внутри дня.
|
||||
Получите прямой агрегат из `dds.event_v` за день и соедините его с
|
||||
`dm.utm_effectiveness_v` полным внешним соединением по ключу. Посчитайте
|
||||
строки, где ключ отсутствует с одной из сторон или различается любая из пяти
|
||||
величин. Расхождений должно быть ноль.
|
||||
|
||||
Дополнительно сверьте дневные итоги:
|
||||
|
||||
- суммы `pageviews`, `add_to_carts` и `purchases` равны числу событий
|
||||
соответствующих типов в `dds.event_v`;
|
||||
- сумма `declared_revenue` равна сумме `purchase_revenue[1]` по всем
|
||||
событиям `purchase` того же дня.
|
||||
|
||||
Сумма `visitors` по строкам повторно учитывает одного посетителя,
|
||||
приходящего по разным меткам. Для числа посетителей дня она не подходит.
|
||||
|
||||
## Что должно получиться
|
||||
|
||||
- [ ] у вас есть пять объектов витрины; донор объявлен через `AS` и
|
||||
использует ключ шардирования целевой таблицы;
|
||||
- [ ] строки без UTM-меток сохранены, а `declared_revenue` хранится
|
||||
в `Decimal(18, 2)`;
|
||||
- [ ] вы записали, почему сумма отличается от `dm.revenue_daily_v`;
|
||||
- [ ] проверка `utm_effectiveness` в `homework_check` прошла успешно;
|
||||
- [ ] витрина обновляется при запуске `dm_transform`.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Свой график в Superset
|
||||
|
||||
**Чему учит:** увидеть, как собственный результат из хранилища становится
|
||||
отчетом в BI.
|
||||
|
||||
## Сначала прочитать
|
||||
|
||||
- [«Немного Superset»](../handbook/superset.md);
|
||||
- [задание 3: `dm.session_overview`](1-3-session-overview.md).
|
||||
|
||||
## На какой вопрос ответит график
|
||||
|
||||
В задании 3 вы собрали витрину `dm.session_overview_v`. Теперь покажите, с
|
||||
каких устройств чаще уходят после первой страницы. Постройте столбиковый
|
||||
график доли отказов по `device_category` за весь период витрины. Измерение
|
||||
одно — `device_category`, метрика тоже одна.
|
||||
|
||||
## Что сделать
|
||||
|
||||
Заведите физический датасет в подключении к `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,92 @@
|
||||
# Нарежьте сессии сами
|
||||
|
||||
**Чему учит:** собрать сессии из событий, сравнить их число с `VisitID`
|
||||
источника и найти события, из-за которых расчеты расходятся.
|
||||
|
||||
## Сначала прочитать
|
||||
|
||||
- [Хранилище и его слои, раздел DDS](../handbook/storage-layers.md#dds-язык-бизнеса);
|
||||
- [формат события: Идентификаторы и время](../formats/clickstream-event.md#идентификаторы-и-время).
|
||||
|
||||
## Для чего нужны сессии
|
||||
|
||||
Визит, или сессия, объединяет действия посетителя за один заход на сайт.
|
||||
По визитам аналитик считает длительность и глубину просмотра.
|
||||
В хранилище сессии собираются из отдельных событий.
|
||||
Сверьте самостоятельный расчет с `VisitID`, чтобы увидеть, как из событий
|
||||
получается сессия.
|
||||
|
||||
## Что сделать
|
||||
|
||||
### Нарезка
|
||||
|
||||
В SQL Lab или DBeaver под `analyst` напишите запрос к `ods.event_v`.
|
||||
Для своей нарезки и эталона возьмите одни и те же строки за все доступные
|
||||
дни: для расследования понадобятся события соседних дней.
|
||||
Выберите `ClientID`, `UTCEventTime`, `EventDate`, `EventType` и `VisitID`.
|
||||
В `dds.event_v` эталонного `VisitID` нет.
|
||||
|
||||
Для начала считайте визитом последовательные события одной куки (`ClientID`);
|
||||
пауза дольше 30 минут открывает новый визит.
|
||||
Учитывайте события всех типов. Упорядочите их по `UTCEventTime`.
|
||||
Присвойте каждому событию номер своей сессии внутри куки.
|
||||
Подсказка: оконные функции.
|
||||
|
||||
Днем сессии считайте `EventDate` ее первого события: дата из `UTCEventTime`
|
||||
отличается от `EventDate` для части событий, поэтому берите `EventDate`.
|
||||
При сверке останутся расхождения из-за еще одной договоренности стенда.
|
||||
|
||||
### Сверка с эталоном
|
||||
|
||||
В эталоне посчитайте число визитов каждой куки за модельный день:
|
||||
|
||||
```sql
|
||||
SELECT ClientID, EventDate, uniqExact(VisitID) AS reference_sessions
|
||||
FROM ods.event_v
|
||||
GROUP BY ClientID, EventDate
|
||||
ORDER BY EventDate, ClientID;
|
||||
```
|
||||
|
||||
Посчитайте свои сессии по куке и дню сессии.
|
||||
Соедините два готовых агрегата через `FULL OUTER JOIN` по куке и дню.
|
||||
В результате полного соединения останутся ключи обеих сторон: при расхождении
|
||||
на вашей стороне ключа может не быть совсем. Учтите такие строки при сравнении.
|
||||
Выведите сводку по дням с числом разошедшихся кук и список этих кук
|
||||
с днем и обоими значениями счета. Общая сумма за день могла бы скрыть
|
||||
взаимную компенсацию ошибок разных кук. По списку видно, у кого искать причину.
|
||||
|
||||
Расхождений будет всего несколько. Если их нет, мир прожил мало дней.
|
||||
Прогоните несколько дней через [даги модельного времени](../handbook/airflow-dags.md#даги-мира)
|
||||
и повторите сверку.
|
||||
|
||||
### Расследование
|
||||
|
||||
Выберите одну разошедшуюся куку. Выведите ее события за два соседних дня
|
||||
с `UTCEventTime`, `EventDate`, `VisitID` и номером своей сессии.
|
||||
Сопоставьте сессии через события: общего ключа у двух нарезок нет,
|
||||
зато у каждого события есть и `VisitID`, и присвоенный вами номер.
|
||||
Найдите два соседних события, между которыми границы визитов разошлись.
|
||||
Запишите куку, день, время обоих событий и причину одним предложением.
|
||||
Уточните определение визита и добейтесь нуля расхождений во всех днях.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Сохраните исходный и уточненный расчеты, результаты сверки и найденные строки.
|
||||
Таблицы и представления создавать не нужно. Задачи в `homework_check`
|
||||
для этого задания нет: вы проверяете расчет своим запросом.
|
||||
|
||||
В `sql/dds/session_rebuild.sql` и
|
||||
`sql/dq/sessions_vs_reference.sql` лежат готовые запросы нарезки и сверки.
|
||||
Откройте их после собственного решения и расследования. Сравните запросы
|
||||
со своими.
|
||||
Сопоставление со стендовой нарезкой по `session_id` остается на ступень 4.
|
||||
|
||||
## Что должно получиться
|
||||
|
||||
- [ ] ваш запрос собирает сессии из всех событий `ods.event_v` за все
|
||||
доступные дни;
|
||||
- [ ] у вас есть сводка расхождений по дням и список кук, полученные через
|
||||
`FULL OUTER JOIN`;
|
||||
- [ ] вы объяснили причину одного расхождения и записали куку, день и два
|
||||
события, на которых оно видно;
|
||||
- [ ] уточненный расчет дает ноль расхождений во всех днях.
|
||||
@@ -0,0 +1,191 @@
|
||||
# Перепишите нарезку стенда
|
||||
|
||||
**Чему учит:** переписать расчет сессий в готовом конвейере и проверить
|
||||
результат по исходным событиям.
|
||||
|
||||
## Сначала прочитать
|
||||
|
||||
- [задание «Нарежьте сессии сами»](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`. DDL задает целевые таблицы и донор. Донор —
|
||||
служебная таблица, где день собирается перед заменой. В
|
||||
`sessions_vs_reference.sql` число сессий сравнивается с `VisitID` из ODS.
|
||||
|
||||
## Как даг запускает ваши запросы
|
||||
|
||||
Объем прогона — дни, которые соберет один запуск. Его вычисляет первый файл.
|
||||
|
||||
| Файл | Вход | Результат |
|
||||
|---|---|---|
|
||||
| `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` |
|
||||
|
||||
Первый прожитый день, которого еще нет в DDS, становится `first_day`.
|
||||
`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`, границы,
|
||||
длительность и счетчики должны совпасть с сохраненным результатом. Так вы
|
||||
завершите отложенное с третьей ступени сопоставление со стендовой нарезкой по
|
||||
`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;
|
||||
```
|
||||
|
||||
Эта сверка сравнивает число сессий каждой куки за день с `VisitID`. Нулевой
|
||||
`failed_rows` не подтверждает границы, страницы и UTM: две ошибки могут
|
||||
оставить тот же счет. Возьмите поштучную сверку из третьей ступени. Для одной
|
||||
куки выбранного дня сопоставьте границы `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` за все
|
||||
прожитые дни и вручную сверили границы и атрибуты сессии одной куки;
|
||||
- [ ] ваш запрос с `hostName()` не нашел строк на чужом шарде;
|
||||
- [ ] повторный прогон без удаления данных пропустил группу сессий.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Научите проверки замечать ошибки в заказах
|
||||
|
||||
**Чему учит:** написать проверки заказов до изменения их обработки
|
||||
и убедиться на намеренной ошибке, что проверки ее замечают.
|
||||
|
||||
## Сначала прочитать
|
||||
|
||||
- [модель заказа 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`. Это условие стабильного модельного времени: одной паузы
|
||||
`world_live` для него мало.
|
||||
|
||||
## Что будут проверять ваши запросы
|
||||
|
||||
Чтобы `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` на границе
|
||||
ODS -> DDS. Скидку, доставку и равенство `total` здесь проверять не нужно.
|
||||
|
||||
### DDS против ODS
|
||||
|
||||
Имя проверки - `orders_vs_ods`. Источник модели DDS здесь - текущее состояние
|
||||
`ods.order_v`. С ним вы и сравниваете DDS; обращаться к внутреннему состоянию
|
||||
генератора для этой проверки не нужно.
|
||||
Одним запросом соберите обе стороны на зерне дня заказа и сравните:
|
||||
|
||||
- число заказов;
|
||||
- максимальный `updated_at`.
|
||||
|
||||
День ODS вычисляется в `Europe/Samara`, как `order_date` в DDS. Область
|
||||
проверки - объединение дней обеих сторон, включая день, который полностью
|
||||
пропал из одного слоя. Перед сравнением приведите времена к одному часовому
|
||||
поясу. `reference_rows` - число заказов ODS, `actual_rows` - число заказов DDS,
|
||||
а `failed_rows` равен единице для дня, где расходится хотя бы одно значение.
|
||||
|
||||
Обе стороны распределены по `order_id`. Дневные агрегаты соедините через
|
||||
`GLOBAL FULL OUTER JOIN` с `join_use_nulls = 1`, как в существующих сверках.
|
||||
|
||||
Если разбор ODS и сборка DDS одинаково потеряют строку, слои останутся равны
|
||||
по этой проверке. Общая потеря при разборе проявится в отдельном балансе по
|
||||
`_load_id` из [следующего задания](5-2-order-parsing.md).
|
||||
|
||||
## Получите красный и зеленый результаты
|
||||
|
||||
Сначала запустите `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,180 @@
|
||||
# Перепишите разбор заказов в 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)
|
||||
все семь DQ остались зелеными. Поставьте `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` и найдите `_load_id` по маркеру в
|
||||
`stg.orders_raw_dist`. У четырех строк должен быть один идентификатор прогона.
|
||||
|
||||
## Зафиксируйте исходный результат
|
||||
|
||||
До любого запуска `etl_pipeline` проверьте срез по найденному `_load_id`:
|
||||
|
||||
- в STG четыре строки;
|
||||
- в физической `ods.order_dist` одна годная строка;
|
||||
- в `ods.order_errors_dist` три строки, по одной каждого класса;
|
||||
- `STG = годные + брак`;
|
||||
- текущая строка `ods.order_v` совпадает по деловым полям с результатом
|
||||
`argMax` по `snapshot_date` из годного сырья среза.
|
||||
|
||||
Счет физической таблицы нужен именно по `_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`, разные времена и правильный
|
||||
шард. Затем очистите второй срез теми же адресными командами и запустите
|
||||
`dq_check`.
|
||||
|
||||
## Завершите опыт
|
||||
|
||||
Убедитесь, что оба маркера отсутствуют в STG, ODS, таблице брака и DDS,
|
||||
временные файлы удалены, а семь проверок успешны. Теперь можно перейти к
|
||||
сборке DDS, не снимая паузу с модельного времени. Если вы прерываетесь и до
|
||||
опыта `world_live` работал, снимите его с паузы.
|
||||
|
||||
## Что должно получиться
|
||||
|
||||
- [ ] у вас сохранены результаты до переписывания: четыре строки дали
|
||||
один заказ и три класса брака; вы сверили поля текущей версии и шард;
|
||||
- [ ] вы переписали `sql/ods/order_parse.sql`, сохранив остальные части стенда;
|
||||
- [ ] ваш разбор дал тот же результат на новой пробной пачке;
|
||||
- [ ] вы проверили разные значения `created_at` и `updated_at` у годной
|
||||
строки в обоих опытах и подтвердили правильный шард через `hostName()`;
|
||||
- [ ] строки обоих опытов удалены по их маркерам;
|
||||
- [ ] после очистки конвейер и все семь проверок успешны.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Перепишите сборку заказов в 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 и обе проверки из первого задания
|
||||
оставьте как есть. Перед началом убедитесь, что пробные строки предыдущего
|
||||
задания удалены, а семь DQ дают зеленый результат.
|
||||
|
||||
Поставьте `world_live` на паузу и дождитесь завершения активных прогонов. До
|
||||
завершения опыта не запускайте `world_live_day`, `world_initialize` и
|
||||
`world_recreate`. `world_next_day` понадобится только в явно указанном ниже
|
||||
случае.
|
||||
|
||||
## Как даг запускает ваши запросы
|
||||
|
||||
Сохраните интерфейс `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` |
|
||||
|
||||
Объем состоит из текущего окна источника и хвоста от первого дня, который есть
|
||||
в ODS, но отсутствует в DDS. Текущее окно дают строки последнего
|
||||
`snapshot_date`: их минимальный и максимальный день заказа. Если источник
|
||||
пуст, `order_scope.sql` не возвращает строку. Если дыр нет, `first_day` равен
|
||||
началу окна. Если дыра старше, непрерывный отрезок начинается с нее.
|
||||
|
||||
`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`. На обеих нодах число строк на
|
||||
чужом шарде должно быть нулевым. Счетные DQ читают Distributed-таблицы и такую
|
||||
ошибку раскладки не обнаруживают.
|
||||
|
||||
Запустите `etl_pipeline` еще раз при неизменном модельном времени. Группа
|
||||
заказов снова должна пересобрать текущее изменяемое окно. Контрольные суммы
|
||||
деловых полей остаются прежними, а все семь проверок дают зеленый результат.
|
||||
|
||||
## Завершите опыт
|
||||
|
||||
После восстановления партиции и успешных проверок снимите `world_live`
|
||||
с паузы, если он работал до упражнения.
|
||||
|
||||
## Что должно получиться
|
||||
|
||||
- [ ] вы переписали три файла `sql/dds/order_*.sql`, сохранив остальные
|
||||
части стенда;
|
||||
- [ ] вы удалили ближайший к окну закрытый день и увидели ошибку DQ до
|
||||
восстановления;
|
||||
- [ ] ваш расчет выбрал `first_day` с удаленного дня и заменил партиции
|
||||
всего отрезка до конца окна;
|
||||
- [ ] вы сверили число заказов и контрольную сумму восстановленного дня
|
||||
с сохраненными значениями; они совпали;
|
||||
- [ ] `order_items_total`, `orders_vs_ods` и пять прежних проверок успешны;
|
||||
- [ ] ваш запрос с `hostName()` не нашел заказов на чужом шарде;
|
||||
- [ ] повторный `etl_pipeline` сохранил деловые данные, проверки прошли.
|
||||
@@ -0,0 +1,219 @@
|
||||
# Разберите сырые события рядом со стендом
|
||||
|
||||
**Чему учит:** пакетно разобрать сообщения отдельно от живого приема и
|
||||
построчно проверить свой результат по нетронутому ODS.
|
||||
|
||||
## Сначала прочитать
|
||||
|
||||
- [формат события](../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` одну свою таблицу годных событий и наполните ее одним
|
||||
пакетным `INSERT SELECT` из `stg.hits_raw_dist`. Работайте с первой нодой под
|
||||
пользователем `etl`.
|
||||
|
||||
Матвью `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). Физический ключ ODS
|
||||
шире: в `ReplacingMergeTree` версиями считаются строки с одинаковым полным
|
||||
ключом сортировки. Используйте его при загрузке и сверке.
|
||||
|
||||
## Разберите годные строки
|
||||
|
||||
Для каждой строки среза проверьте те же условия приема, что и на стенде:
|
||||
|
||||
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. Для удаления повторов и
|
||||
сравнения полей дальше используйте полный физический ключ из схемы таблицы.
|
||||
|
||||
## Сверьте 47 полей
|
||||
|
||||
Соедините свою таблицу и выбранные строки `ods.event_v` через `FULL OUTER
|
||||
JOIN` по полному ключу замены. Полное соединение сохраняет строки,
|
||||
которых нет на одной из сторон. Разделите результат на три исхода:
|
||||
|
||||
- строка есть только в ODS — вы ее потеряли;
|
||||
- строка есть только у вас — вы приняли лишнее или иначе разобрали часть
|
||||
ключа;
|
||||
- ключ есть с обеих сторон, но различается хотя бы одно из 47 полей.
|
||||
|
||||
ClickHouse по умолчанию заполняет отсутствующую сторону значениями типов.
|
||||
Включите для сверки `join_use_nulls = 1` или добавьте явный признак присутствия
|
||||
в каждую сторону. Не определяйте отсутствие по значениям полей события.
|
||||
|
||||
Сравните все поля явно, включая массивы, `ecommerce` и точное десятичное
|
||||
`purchaseRevenue`. Удобный вид диагностики выберите сами: важно, чтобы по
|
||||
результату можно было назвать отличающиеся колонки одной строки. Выведите число
|
||||
строк каждого из трех исходов. В верном разборе все три числа равны нулю.
|
||||
|
||||
`_load_ts` не входит в 47 полей источника и не проверяет JSON-разбор.
|
||||
Проверьте его отдельно. Свяжите каждую строку своей таблицы с меткой одной из
|
||||
доставок того же полного ключа в исходном срезе. После `FINAL` остается самая
|
||||
поздняя метка этого ключа внутри среза. С ODS ее не сравнивайте: там может
|
||||
храниться более поздняя доставка того же события из-за границ среза.
|
||||
|
||||
## Расследуйте расхождение
|
||||
|
||||
Если сверка не пуста, возьмите одну строку. Вернитесь к независимому списку
|
||||
ключей и исходному `raw`, выведите рядом координаты Kafka и `_load_ts`.
|
||||
|
||||
Для измененной строки назовите поля, которые расходятся, и сравните каждое с
|
||||
сырой строкой. Для пропавшей или лишней строки проверьте пять опорных полей и
|
||||
результат условия отбора. Если координаты доставки есть в
|
||||
`ods.event_errors_dist`, посмотрите `error_class`.
|
||||
|
||||
Исправьте разбор, удалите и заново создайте только собственную таблицу, затем
|
||||
повторите вставку того же среза. Остановитесь, когда счет исходных доставок по
|
||||
прежним границам не изменился, число годных и отвергнутых строк в сумме дает
|
||||
число исходных доставок, а построчная сверка пуста.
|
||||
|
||||
## Чего не подтверждает сравнение с ODS
|
||||
|
||||
`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,118 @@
|
||||
# Мониторинг: куда смотреть
|
||||
|
||||
**Чему учит:** начать расследование с подходящего экрана, найти источник
|
||||
нагрузки и объяснить свежесть данных через путь доставки.
|
||||
|
||||
## Перед началом
|
||||
|
||||
Откройте Grafana и Airflow по адресам из раздела [«Адреса и
|
||||
пароли»](../../README.md#адреса-и-пароли). Для первого знакомства в Grafana
|
||||
есть три дашборда:
|
||||
|
||||
- «Данные» — путь сообщений от источника до хранилища;
|
||||
- «Кластер» — процессор, память, диски и внутренняя работа ClickHouse;
|
||||
- «Запросы» — выполненные запросы, их стоимость и ошибки.
|
||||
|
||||
Откройте все три дашборда и запишите по одному вопросу, на который каждый из
|
||||
них помогает ответить. «Данные» оставьте открытым в соседней вкладке.
|
||||
|
||||
Проведите два расследования на работающем стенде. Службы останавливать и данные
|
||||
портить не понадобится.
|
||||
|
||||
## Расследование 1. Кто читает больше всех
|
||||
|
||||
Начните в спокойный момент: не запускайте одновременно свои тяжелые запросы.
|
||||
Откройте дашборд «Запросы». На панели «Тяжёлые запросы: что прочитало больше
|
||||
всех» возьмите первую строку.
|
||||
|
||||
Сохраните:
|
||||
|
||||
- границы окна и время запроса;
|
||||
- место запроса в списке, пользователя и ноду;
|
||||
- текст запроса;
|
||||
- длительность, память, число прочитанных и отданных строк.
|
||||
|
||||
По пользователю и тексту определите, кто отправил запрос и для чего. Затем
|
||||
найдите запрос одного из трех открытых дашбордов, который прочитал больше всего
|
||||
строк. Если это другая строка, сохраните для нее те же поля.
|
||||
|
||||
По тексту найдите панель, которая отправила запрос. Ищите ее среди трех
|
||||
дашбордов по названиям колонок, таблиц и показателей. Запишите, зачем запрос
|
||||
читает столько строк и согласуется ли это с вопросом, на который отвечает
|
||||
панель.
|
||||
|
||||
В меню панели «Кто спрашивает: сколько запросов и какой у них аппетит»
|
||||
откройте Inspect, затем Query. Ответьте на два вопроса:
|
||||
|
||||
1. Почему журнал читают сразу с обеих нод?
|
||||
2. Почему из счета исключены строки начала запроса и работа шардов?
|
||||
|
||||
Проверьте второе объяснение по панели ошибок: ее фильтр не исключает записи
|
||||
шардов. Объясните, почему различаются вопросы панелей, а не только их SQL.
|
||||
|
||||
## Расследование 2. Как меняется свежесть заказов
|
||||
|
||||
Откройте дашборд «Данные». Панель «Свежесть: сколько прошло с последней
|
||||
доехавшей строки» показывает текущее число, а не историю. До наблюдения
|
||||
нарисуйте ожидаемую форму свежести событий и заказов в течение одного живого
|
||||
дня. Отдельно отметьте момент доставки слепка заказов.
|
||||
|
||||
Если `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 и на панели слепков,
|
||||
подписали модельную дату отдельно от реального времени наблюдений;
|
||||
- [ ] вы можете объяснить выбор слоев для свежести, потока и доли брака
|
||||
и почему числитель и знаменатель берут за одно окно;
|
||||
- [ ] после наблюдений службы продолжают работать, данные и стендовые
|
||||
дашборды остались без изменений.
|
||||
+132
-82
@@ -1,109 +1,159 @@
|
||||
# Учебные задания: витрины слоя DM и дашборд в Superset
|
||||
# Учебные задания
|
||||
|
||||
Пять домашних заданий — про витрины слоя DM. Стенд привозит слой
|
||||
наполовину: витрины трех сюжетов дашборда и контроль качества построены,
|
||||
остальные витрины строит менти. Каждое из них — самостоятельное ТЗ: что за
|
||||
витрина, какое у нее зерно, из чего она собирается и как проверить, что
|
||||
собралась верно.
|
||||
Программа состоит из двух частей: основная включает семь ступеней, которые вы
|
||||
проходите по порядку, а факультативы выбираете по желанию. Причины такого
|
||||
порядка и формы заданий описаны в [программе учебных лаб](../specs/2026-09-01-labs-program.md#2-устройство-программы-основная-программа-и-факультативы).
|
||||
|
||||
Шестое задание — необязательная лаба в Superset. В ней менти собирает свой
|
||||
дашборд поверх пяти витрин.
|
||||
## Основная программа
|
||||
|
||||
Общее устройство слоя — [дока DM](../architecture/dm.md). Она отвечает на
|
||||
вопросы «почему так», задание отвечает на вопрос «что сделать».
|
||||
| Ступень | Задание | Чему учит |
|
||||
|---|---|---|
|
||||
| 1 | Пять витрин слоя DM | построить результат для потребителя хранилища; уроки каждой витрины — в таблице ниже |
|
||||
| 2 | [Свой график в Superset](2-1-superset-chart.md) | превратить свой результат из хранилища в отчет в BI |
|
||||
| 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`](events-enriched.md) | слой — это контракт, а не копия данных |
|
||||
| 2 | [`top_pages_daily`](top-pages-daily.md) | зерно сменилось — витрина материализуется и встает в конвейер |
|
||||
| 3 | [`session_overview`](session-overview.md) | зерно решает, на какие вопросы витрина уже не ответит |
|
||||
| 4 | [`dq_errors_daily`](dq-errors-daily.md) | у брака свой день и свой срок жизни |
|
||||
| 5 | [`utm_effectiveness`](utm-effectiveness.md) | `declared_` в имени — предупреждение, а не украшение |
|
||||
| 6 | [Свой дашборд в Superset](superset-dashboard.md) — необязательная лаба | свои объекты BI живут отдельно от стендовых |
|
||||
| 1 | [`events_enriched_v`](1-1-events-enriched.md) | подготовить удобный для анализа набор полей, сохранив одну строку на событие |
|
||||
| 2 | [`top_pages_daily`](1-2-top-pages-daily.md) | создавать материализованную витрину с дневной пересборкой в конвейере |
|
||||
| 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) | читать `declared_` как предупреждение о сумме со слов клиента |
|
||||
|
||||
## Общие правила
|
||||
Общее устройство слоя — [справочник DM](../architecture/dm.md). Он отвечает на
|
||||
вопросы «почему так», задание отвечает на вопрос «что сделать».
|
||||
|
||||
Они одни для пяти заданий по витринам, поэтому записаны здесь, а не
|
||||
повторяются в каждом ТЗ.
|
||||
## Как принято на ступени 1
|
||||
|
||||
**Читать — через `_v`.** Публичный договор сущности DDS — представление с
|
||||
суффиксом `_v`: `dds.event_v`, `dds.session_v`. Витрина называет только
|
||||
его; физические `_rep` и `_dist` служат загрузке и обслуживанию. Соседнюю
|
||||
домашку витрина источником тоже не берет: витрина слоя DM читает договор
|
||||
DDS, и задания остаются независимыми. Исключение одно и оно оговорено в
|
||||
задании 4: у таблиц брака публичного договора нет.
|
||||
**Читать — через `_v`.** Физические `_rep` и `_dist` служат загрузке и
|
||||
обслуживанию, а публичный договор DDS — `dds.event_v` или `dds.session_v` —
|
||||
остается прежним даже при изменении физических объектов. Чтение DDS
|
||||
напрямую делает задания независимыми: соседняя витрина может быть еще не
|
||||
построена.
|
||||
Исключение — задание 4: у таблиц брака публичного договора нет.
|
||||
|
||||
**Писать — только в `_dist`.** При записи в локальную таблицу раскладку
|
||||
определяет то, какая нода случайно выполняла код, а не свойство данных.
|
||||
Правило и его следствия — [дока хранилища](../architecture/storage.md),
|
||||
«Раскладка по шардам».
|
||||
**Писать — в `_dist`.** Так строки распределяются по ключу шардирования. При
|
||||
записи в локальную таблицу распределение зависит от узла, выполнившего
|
||||
запрос, и при следующем запуске может измениться. Подробности — раздел
|
||||
«Раскладка по шардам» [справочника хранилища](../architecture/storage.md).
|
||||
|
||||
**Форма — по зерну.** Витрина, меняющая зерно источника, материализуется:
|
||||
`dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` / `_stage_dist` и
|
||||
публичное `dm.<имя>_v` с явным списком колонок. Витрина, сохраняющая зерно,
|
||||
остается обычным представлением. Довод — [дока DM](../architecture/dm.md),
|
||||
«Форма: по зерну, не по цене запроса».
|
||||
**Форма — по зерну.** На стенде агрегат с новым зерном принято хранить
|
||||
готовым: потребитель читает его много раз без повторного расчета по
|
||||
источнику. Создайте `dm.<имя>_rep` / `_dist`, донор `dm.<имя>_stage_rep` /
|
||||
`_stage_dist` и публичное `dm.<имя>_v` с явным списком колонок. При прежнем
|
||||
зерне достаточно представления: таблица дублировала бы данные. Доводы —
|
||||
раздел «Таблица или представление» [справочника
|
||||
DM](../architecture/dm.md).
|
||||
|
||||
**Ключ шардирования — твое решение.** Ни одно ТЗ ключа не называет.
|
||||
Правило выбора и требование к донору — [дока
|
||||
хранилища](../architecture/storage.md), «Раскладка по шардам»: донор
|
||||
объявляется через `AS <цель>_rep` и шардируется тем же ключом, что цель,
|
||||
иначе замена партиции потеряет строку на одном шарде и задвоит на другом.
|
||||
Названный ключ надо уметь защитить: почему он не дает перекоса и почему
|
||||
более сложный тут ничего не добавляет. Образец такой защиты —
|
||||
`dm.revenue_daily_dist` в [`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
|
||||
**Ключ шардирования — ваше решение.** Объясните, почему выбранный ключ не
|
||||
дает существенного перекоса и почему более сложный ничего не добавит.
|
||||
Объявите донор через `AS <цель>_rep` с ключом цели: иначе замена партиции
|
||||
потеряет строку на одном шарде и удвоит на другом. Детали — раздел
|
||||
«Раскладка по шардам» [справочника хранилища](../architecture/storage.md).
|
||||
Пример обоснования — `dm.revenue_daily_dist` в
|
||||
[`sql/ddl/55-dm-tables.sql`](../../sql/ddl/55-dm-tables.sql).
|
||||
|
||||
**Где лежит код.** DDL домашек — свой файл `sql/ddl/70-labs.sql`: он
|
||||
применяется после стендовых по порядку имени, и твои объекты не мешаются
|
||||
со стендовыми. У агрегатов рядом со стендовыми ложатся еще три запроса —
|
||||
`sql/dm/<витрина>_scope.sql` спрашивает у хранилища, какие дни пересобирает
|
||||
запуск, `_rebuild.sql` собирает их в донор, `_replace.sql` заменяет
|
||||
партицию, — и задача в
|
||||
[`dags/dm_transform.py`](../../dags/dm_transform.py). У разминочного
|
||||
представления нет ничего из этого: весь его код — одно `CREATE VIEW`.
|
||||
**Где лежит код.** DDL заданий — в `sql/ddl/70-labs.sql`.
|
||||
При создании мира он применяется после стендовых файлов.
|
||||
В уже работающем стенде сохранение файла и повторный `make up` новый DDL
|
||||
не применят — выполните его сами, как описано ниже. В
|
||||
`sql/dm/<витрина>_scope.sql` определяются дни прогона, в `_rebuild.sql` они
|
||||
собираются в донор, в `_replace.sql` заменяется партиция. Задачу сборки
|
||||
добавьте в [`dags/dm_transform.py`](../../dags/dm_transform.py): без нее
|
||||
конвейер не обновит витрину. Представлению достаточно `CREATE VIEW`.
|
||||
|
||||
**Образец на все случаи** — витрина `revenue_daily`: у нее есть все пять
|
||||
объектов, донор, сборка, замена дневных партиций и задача в конвейере.
|
||||
Списывать с нее устройство можно и нужно; списывать состав колонок — нет,
|
||||
он у каждой витрины свой.
|
||||
**Образец — витрина `revenue_daily`.** У нее есть пять объектов, донор,
|
||||
сборка, замена дневных партиций и задача в конвейере. Используйте ее как
|
||||
образец всего порядка пересборки. Состав колонок берите из своего ТЗ, иначе
|
||||
получите показатели другой витрины.
|
||||
|
||||
## Самопроверка
|
||||
## Как применить свой DDL
|
||||
|
||||
Даг `homework_check` запускается рукой, расписания у него нет. Задача
|
||||
В 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).
|
||||
Сохраненный файл позволит воссоздать ваши объекты при пересборке мира.
|
||||
|
||||
## Самопроверка ступени 1
|
||||
|
||||
Даг `homework_check` запускается руками, расписания у него нет. Задача
|
||||
витрины красная, пока витрины нет или счет не сходится, и зеленая, когда
|
||||
собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет:
|
||||
это инструмент лабы, а не история качества стенда.
|
||||
собрано верно. В сводку качества конвейера `dm.dq_summary` он не пишет: это
|
||||
инструмент лабы, а не история качества стенда.
|
||||
|
||||
Проверка пяти заданий по витринам устроена одинаково и золотых констант не
|
||||
содержит: рядом с витриной независимым запросом собирается тот же ответ —
|
||||
прямо из источника витрины, минуя ее саму. У четырех заданий источник эталона
|
||||
тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания 4 это
|
||||
таблицы брака ODS, потому что в DDS у брака представительства нет. Обе стороны
|
||||
соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число строк,
|
||||
где стороны разошлись или одной стороны нет. Ноль — зеленый.
|
||||
Полное соединение здесь обязательно: при обычном пропавший день или
|
||||
пропавший ключ просто не попал бы в результат и остался бы незамечен.
|
||||
Для каждой витрины проверка независимо рассчитывает ожидаемый результат
|
||||
прямо по ее источнику. У четырех заданий источник
|
||||
эталона тот же, что у витрины, `dds.session_v` или `dds.event_v`; у задания
|
||||
4 это таблицы брака ODS, потому что в DDS нет сущности брака. Обе стороны
|
||||
соединяются по деловому ключу через `FULL OUTER JOIN`, и считается число
|
||||
строк, где стороны разошлись или одной стороны нет. Ноль — зеленый. Полное
|
||||
соединение сохраняет ключи обеих сторон: при внутреннем пропавший день или
|
||||
ключ не попал бы в результат, и потеря осталась бы незамеченной.
|
||||
|
||||
Раздел «Как проверить себя» каждого ТЗ по витрине называет деловой ключ и
|
||||
Раздел «Как проверить себя» каждого задания по витрине называет деловой ключ и
|
||||
сравниваемые величины. Тот же прием в стендовом исполнении — [даг
|
||||
`dq_check`](../../dags/dq_check.py) и запросы в [`sql/dq/`](../../sql/dq).
|
||||
|
||||
**Предупреждение.** Запросы самой проверки лежат в `sql/dq/` открыто, и
|
||||
имена называют витрину и роль сверки. Проверка на инвариантах неизбежно
|
||||
близка к ответу: чтобы сверить витрину, надо ее пересчитать. Прятать эти
|
||||
файлы никто не станет — но открыв их до того, как задание сделано, ты
|
||||
обменяешь урок на готовый SQL.
|
||||
Смотреть туда стоит после своей сборки: сравнить два решения полезно,
|
||||
списать одно — нет.
|
||||
Запросы проверок лежат в `sql/dq/`. Откройте их после собственного решения:
|
||||
они заново рассчитывают показатели витрины, поэтому содержат ответ на
|
||||
существенную часть задания. Сравните свой способ расчета с готовым.
|
||||
|
||||
## Факультативы
|
||||
|
||||
Эти задания вы берете по желанию. Полный список сюжетов — в разделе
|
||||
[«Факультативы — пять разделов»](../specs/2026-09-01-labs-program.md#4-факультативы--пять-разделов)
|
||||
программы.
|
||||
|
||||
### Почему выручка не сходится
|
||||
|
||||
Открывается после ступени 1. Расхождения между источниками, повторы
|
||||
покупок и день, после которого отчет можно считать окончательным.
|
||||
|
||||
### Тонкий анализ данных
|
||||
|
||||
Открывается после ступени 1. Знание о пользователе на дату и обогащение
|
||||
отчета своим словарем.
|
||||
|
||||
### Superset и BI
|
||||
|
||||
Открывается после ступени 2. Два самостоятельных задания:
|
||||
|
||||
- [Свой дашборд в Superset](optional/superset-dashboard.md): собрать отчет
|
||||
из своих витрин и подтвердить выводы запросами.
|
||||
- [В DBeaver работает, в Superset нет](optional/superset-missing-object.md):
|
||||
выяснить, почему клиенты по-разному видят один объект хранилища.
|
||||
|
||||
### Инженерия стенда
|
||||
|
||||
Открывается после ступени 1. Прием и хранение данных, раскладка по шардам,
|
||||
повторная обработка дня и обновление витрин.
|
||||
|
||||
### Что делать, когда стенд сломался
|
||||
|
||||
Открывается после ступени 7. Поломки стенда и инструкции по восстановлению
|
||||
по итогам расследований; весь раздел — повышенная сложность.
|
||||
|
||||
## Уборка
|
||||
|
||||
Черновики и опыты за собой сносить: правило проверок стенда —
|
||||
[дока тестирования](../architecture/testing.md). Снесенная витрина обязана
|
||||
снова покраснить свою задачу в `homework_check` — если не покраснила,
|
||||
проверка ничего и не проверяла.
|
||||
После опытов удаляйте свои временные объекты. Если удалили учебную витрину,
|
||||
ее задача в `homework_check` должна снова завершиться ошибкой: отсутствие
|
||||
витрины тоже проверяется. Подробнее — в
|
||||
[справочнике проверок](../architecture/testing.md).
|
||||
@@ -1,118 +0,0 @@
|
||||
# Задание 4: `dm.dq_errors_daily`
|
||||
|
||||
**Чему учит:** у брака свой день и свой срок жизни. Витрина качества живет
|
||||
по времени загрузки, а не по модельному дню, и переживает собственный
|
||||
источник — поэтому «пересобрать все» здесь означает «стереть историю».
|
||||
|
||||
## Роль витрины
|
||||
|
||||
Сводка приемки: сколько строк за день не прошли разбор и по какой причине.
|
||||
Читают ее, когда качество потока просело: сначала смотрят, вырос ли класс
|
||||
брака, потом идут за сырым текстом в саму таблицу ошибок.
|
||||
|
||||
Витрина отвечает на вопрос «сколько и чего», а не «что именно сломалось».
|
||||
Разбирательство с конкретной строкой начинается в `ods.event_errors_dist` и
|
||||
`ods.order_errors_dist`, где рядом с классом лежат сырой текст и координаты
|
||||
доставки.
|
||||
|
||||
## Источник: единственное исключение из правила `_v`
|
||||
|
||||
Общее правило домашек — читать через публичный договор `_v`. У таблиц брака
|
||||
такого договора нет, и заводить его ради этой витрины не надо: брак — не
|
||||
сущность бизнес-модели, а диагностика слоя приема. Читать нужно
|
||||
распределенные таблицы напрямую:
|
||||
|
||||
- `ods.event_errors_dist` — брак разбора событий;
|
||||
- `ods.order_errors_dist` — брак разбора слепка заказов.
|
||||
|
||||
Именно `_dist`, а не `_rep`: локальная таблица вернет строки одного шарда, и
|
||||
витрина тихо покажет половину брака. Ошибки при этом не будет — ошибка
|
||||
появится позже, когда по витрине примут решение.
|
||||
|
||||
## Зерно и форма
|
||||
|
||||
Зерно — **день загрузки × источник × класс брака**. Зерно источника —
|
||||
строка брака, значит зерно сменилось и витрина материализуется:
|
||||
`dm.dq_errors_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
|
||||
публичное `dm.dq_errors_daily_v`. Ключ партиции — день загрузки.
|
||||
|
||||
День здесь — `toDate(_load_ts)`, то есть настоящее время, когда строка
|
||||
приехала в хранилище, а не модельный день мира. Модельного дня у брака нет
|
||||
и быть не может: он живет в содержимом, а содержимое как раз и не
|
||||
разобралось.
|
||||
|
||||
## Состав колонок
|
||||
|
||||
| Колонка | Тип | Что это |
|
||||
|---|---|---|
|
||||
| `load_date` | `Date` | день загрузки, в который приехал брак |
|
||||
| `source` | `LowCardinality(String)` | `event` или `order` |
|
||||
| `error_class` | `LowCardinality(String)` | класс брака из таблицы ошибок |
|
||||
| `error_rows` | `UInt64` | число забракованных строк |
|
||||
| `_load_id` | `String` | запуск, собравший день |
|
||||
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||
|
||||
Две похожие колонки рядом различаются намеренно: `load_date` — про день,
|
||||
когда приехал брак, `_load_ts` — про запуск, который собрал витрину. Первая
|
||||
деловая, вторая служебная, и совпадать они не обязаны.
|
||||
|
||||
Классы у двух источников свои и не пересекаются по смыслу, поэтому колонка
|
||||
`source` в зерне обязательна: без нее одинаковое имя класса у событий и у
|
||||
заказов слилось бы в одну строку.
|
||||
|
||||
## Ритм пересборки: витрина переживает источник
|
||||
|
||||
Таблицы брака живут месяц (`TTL`), сырье — трое суток. Витрина живет без
|
||||
срока: сводка «сколько брака было в марте» нужна и после того, как сами
|
||||
строки марта удалены.
|
||||
|
||||
Отсюда правило прогона: пересобираются **только те дни загрузки, которые
|
||||
еще есть в источнике**. Спроси у источника список живых дней и замени
|
||||
партиции ровно этих дней. Соблазн «собрать все заново в донор и подменить
|
||||
таблицу целиком» здесь стоит дороже обычного: истекшие по `TTL` дни исчезли
|
||||
бы из витрины навсегда, а вместе с ними — единственная оставшаяся память о
|
||||
них.
|
||||
|
||||
Текущий день загрузки не замерзает до конца суток: поток событий идет, и
|
||||
брак в него еще добавится. Это нормально, день просто пересоберется на
|
||||
следующем прогоне.
|
||||
|
||||
## Ключ шардирования
|
||||
|
||||
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||
шардам». Витрина крошечная: классов брака у событий и заказов вместе меньше
|
||||
десятка. Учти это в защите ключа — иногда правильный ответ звучит как
|
||||
«перекос здесь ничего не стоит, и вот почему».
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — пара `source` и `error_class` внутри `load_date`. Область
|
||||
проверки — дни загрузки, которые еще живы хотя бы в одной таблице ошибок:
|
||||
день за границей `TTL` сверять не с чем, и его отсутствие в источнике не
|
||||
ошибка.
|
||||
|
||||
Собери прямой счет по обеим таблицам ошибок, соедини с
|
||||
`dm.dq_errors_daily_v` полным внешним соединением по ключу и посчитай
|
||||
строки, где ключа нет с одной из сторон или где расходится `error_rows`.
|
||||
Ноль — зеленый.
|
||||
|
||||
Отдельно: сумма `error_rows` по живым дням равна общему числу строк в
|
||||
`ods.event_errors_dist` плюс `ods.order_errors_dist`. Если эта сумма вышла
|
||||
примерно вдвое меньше — витрина почти наверняка собрана с `_rep` вместо
|
||||
`_dist` и видит один шард из двух.
|
||||
|
||||
**Пустая витрина — верный ответ.** На чистом стенде брака нет, обе стороны
|
||||
пусты, расхождений ноль, задача зеленая. Проверка, которая требует хотя бы
|
||||
одной строки, на здоровом стенде краснела бы всегда и приучала бы себя
|
||||
игнорировать.
|
||||
|
||||
## Приемка
|
||||
|
||||
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||
ключом цели;
|
||||
- [ ] источники — `_dist`-таблицы обоих видов брака;
|
||||
- [ ] прогон заменяет партиции только живых дней источника и не трогает
|
||||
истекшие;
|
||||
- [ ] задача `dq_errors_daily` в `homework_check` зеленая — в том числе на
|
||||
чистом стенде, где брака нет;
|
||||
- [ ] задача витрины добавлена в `dm_transform`.
|
||||
@@ -1,114 +0,0 @@
|
||||
# Задание 1: `dm.events_enriched_v`
|
||||
|
||||
**Чему учит:** слой — это контракт, а не копия данных. Витрина, которая не
|
||||
меняет зерно источника, остается представлением, и копии событий ради
|
||||
других имен колонок не заводит ни один боевой склад.
|
||||
|
||||
Это разминка. Строк она не хранит, в конвейер не встает, ключа шардирования
|
||||
у нее нет. Задача одна: собрать плоское событие для BI и не поддаться
|
||||
соблазну разложить его на диск.
|
||||
|
||||
## Роль витрины
|
||||
|
||||
Договор события в DDS широкий — 44 колонки, почти весь формат выгрузки:
|
||||
часть из них технические координаты источника, часть — параллельные массивы
|
||||
товаров. Аналитику в Superset нужен плоский набор деловых полей и пара
|
||||
производных, которые иначе каждый пишет заново в каждом запросе.
|
||||
|
||||
Витрина отвечает на вопрос «что вообще происходило»: с нее начинают, когда
|
||||
надо посмотреть на события глазами человека, а не разбирать широкую строку
|
||||
источника.
|
||||
|
||||
Источником для витрин следующих заданий она не служит. Витрина слоя DM
|
||||
читает публичный договор DDS — так устроены и стендовые витрины, и потому
|
||||
задания независимы: соседнюю домашку можно делать первой, а можно не делать
|
||||
вовсе.
|
||||
|
||||
## Зерно и форма
|
||||
|
||||
Зерно — **событие**: одна строка витрины равна одной строке `dds.event_v`.
|
||||
Зерно источника сохранено, значит форма — обычное представление
|
||||
`CREATE VIEW dm.events_enriched_v`, без физической таблицы, без донора и
|
||||
без задачи в `dm_transform`. Пересобирать нечего.
|
||||
|
||||
Источник — `dds.event_v`, и только он.
|
||||
|
||||
## Состав колонок
|
||||
|
||||
Явный список обязателен: договор не должен расширяться сам, когда в
|
||||
источник добавят колонку.
|
||||
|
||||
**Время и посетитель:** `event_date`, `event_time`, `event_hour`,
|
||||
`client_id`, `event_id`.
|
||||
|
||||
**Действие:** `event_type` (`pageview`, `add_to_cart`, `purchase`), `url`,
|
||||
`page_path`, `referer`, `referer_host`, `title`.
|
||||
|
||||
**Атрибуция:** `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`,
|
||||
`utm_term`.
|
||||
|
||||
**Паспорт визита:** `device_category`, `browser`, `operating_system`,
|
||||
`region_country`, `region_city`.
|
||||
|
||||
**Торговый блок, массивами как в источнике:** `product_id`, `product_name`,
|
||||
`product_category`, `product_price`, `product_quantity`,
|
||||
`product_event_type`, `purchase_id`, `purchase_revenue`, `purchase_coupon`.
|
||||
|
||||
Три колонки в источнике не лежат и считаются здесь:
|
||||
|
||||
| Колонка | Как считается | Зачем |
|
||||
|---|---|---|
|
||||
| `event_hour` | `toHour(event_time)` | суточный профиль спрашивают в каждом отчете |
|
||||
| `page_path` | путь `url` без домена и параметров | группировка по странице без разбора строки в каждом запросе |
|
||||
| `referer_host` | домен `referer` | «откуда пришли» одним значением, а не полным адресом |
|
||||
|
||||
Служебных `_load_id` и `_load_ts` у витрины нет, и это не забывчивость. Их
|
||||
нет и у `dds.event_v`: событие в DDS не грузят, а читают из ODS, поэтому
|
||||
координат собственной загрузки у него не бывает. Служебная колонка называет
|
||||
запуск, который положил строку на диск, — здесь на диск никто ничего не
|
||||
кладет.
|
||||
|
||||
## Ловушка задания
|
||||
|
||||
Массивы `product_*` тянет развернуть через `ARRAY JOIN` — так плоская
|
||||
таблица становится по-настоящему плоской. Разворачивать нельзя, и вреда
|
||||
здесь два.
|
||||
|
||||
Очевидный: у события с тремя позициями получилось бы три строки, зерно
|
||||
витрины перестало бы совпадать с зерном источника, а вместе с ним развалилось
|
||||
бы и правило формы — такую витрину уже пришлось бы материализовать.
|
||||
|
||||
Неочевидный и куда более разрушительный: `ARRAY JOIN` — соединение
|
||||
внутреннее, и строку с пустым массивом он выбрасывает. Торговый блок пуст у
|
||||
всех событий, кроме корзин и покупок, то есть у подавляющего большинства.
|
||||
Так «плоская таблица событий» превращается в таблицу товарных позиций и
|
||||
теряет девятнадцать строк из двадцати — а по имени и по составу колонок она
|
||||
по-прежнему выглядит таблицей событий. Ошибка не падает и ничего не
|
||||
подсвечивает.
|
||||
|
||||
Массивы едут в договор как есть; разворачивает их тот, кому нужна позиция
|
||||
заказа, — и делает это уже в своей витрине, с другим зерном и другим именем.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — `event_id` внутри дня. Собери прямой ответ из `dds.event_v`
|
||||
за тот же день, соедини с витриной полным внешним соединением и посчитай
|
||||
строки, где ключа нет с одной из сторон или где различаются значения
|
||||
деловых колонок. Ноль — зеленый.
|
||||
|
||||
Отдельно проверяются три производные колонки: `event_hour`, `page_path` и
|
||||
`referer_host` должны совпасть с тем же выражением, посчитанным по
|
||||
`dds.event_v` на лету.
|
||||
|
||||
Если строк в витрине оказалось в разы меньше, чем в источнике, а остались в
|
||||
ней одни корзины и покупки — это развернутые массивы: `ARRAY JOIN` выбросил
|
||||
все события с пустым торговым блоком.
|
||||
|
||||
## Приемка
|
||||
|
||||
- [ ] `dm.events_enriched_v` создано, физических таблиц под ним нет;
|
||||
- [ ] список колонок в представлении явный;
|
||||
- [ ] число строк и набор `event_id` совпадают с `dds.event_v` за каждый
|
||||
прожитый день;
|
||||
- [ ] задача `events_enriched` в `homework_check` зеленая;
|
||||
- [ ] задачи в `dm_transform` **нет** — и не должно быть.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Свой дашборд в Superset
|
||||
|
||||
**Чему учит:** собрать из своих витрин отчет о поведении посетителей,
|
||||
подобрать графики под вопросы и разместить их на одном понятном экране.
|
||||
|
||||
Это факультатив раздела «Superset и BI». Его можно пройти после ступени 2,
|
||||
когда готовы витрины ступени 1.
|
||||
|
||||
## На какие вопросы ответит дашборд
|
||||
|
||||
Соберите обзор трафика и поведения посетителей. Он должен отвечать на три
|
||||
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
|
||||
каналы дали результат, были ли ошибки приема данных.
|
||||
|
||||
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
|
||||
соберите из пяти витрин, которые вы построили в заданиях. Копировать раскладку
|
||||
стендового дашборда не нужно: выберите форму, в которой ответы читаются без
|
||||
пояснений рядом с экраном.
|
||||
|
||||
## Свои объекты — отдельно
|
||||
|
||||
Не добавляйте графики в стендовый дашборд и не правьте его датасеты. При
|
||||
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
|
||||
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
|
||||
таких объектов описан в [доке Superset](../../architecture/superset.md), «Импорт
|
||||
при каждой инициализации».
|
||||
|
||||
Используйте свои датасеты и графики, соберите из них свой дашборд.
|
||||
Назовите его, например, «Мои витрины DM». Это то же правило, по которому DDL
|
||||
домашек лежит в `sql/ddl/70-labs.sql`: стендовые объекты уже есть, свои
|
||||
создаете вы.
|
||||
|
||||
## Датасеты
|
||||
|
||||
Датасет `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 нет
|
||||
|
||||
**Чему учит:** найти причину, по которой один клиент читает объект
|
||||
хранилища, а другой его не видит.
|
||||
|
||||
Факультатив доступен после [ступени 2](../2-1-superset-chart.md).
|
||||
Свой дашборд для опыта не нужен. Используйте готовую витрину стенда
|
||||
`dm.daily_traffic_v`.
|
||||
|
||||
## Подготовьте опыт
|
||||
|
||||
В DBeaver подключитесь к первой ноде пользователем `etl`, как описано
|
||||
в разделе [«Как применить свой DDL»](../README.md#как-применить-свой-ddl).
|
||||
Для опыта создайте отдельное представление:
|
||||
|
||||
```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; оба клиента возвращают столько же строк,
|
||||
сколько исходная витрина;
|
||||
- [ ] после опыта вы удалили учебное представление с обеих нод.
|
||||
@@ -1,121 +0,0 @@
|
||||
# Задание 3: `dm.session_overview`
|
||||
|
||||
**Чему учит:** агрегат хранит слагаемые, а не готовые средние и доли.
|
||||
Среднее по строкам витрины не складывается никогда, а уникальных нельзя
|
||||
складывать даже тогда, когда на сегодняшних данных сумма сходится.
|
||||
|
||||
## Роль витрины
|
||||
|
||||
Дневной портрет визитов в разрезе устройств: сколько было сессий, какие они
|
||||
были длинные, сколько страниц смотрели и как часто доходили до покупки.
|
||||
Вопрос витрины — «как люди ходят по магазину с телефона и с настольного
|
||||
компьютера, и чем эти визиты отличаются».
|
||||
|
||||
Сессия на стенде — расчетная сущность, ее уже нарезал слой DDS. Собирать
|
||||
визиты заново из событий не нужно и нельзя: это была бы вторая нарезка,
|
||||
которая тихо разойдется с первой.
|
||||
|
||||
Витрина с таким именем была и на прошлом стенде, но зерно у нее сменилось,
|
||||
и сменилось не по вкусу. Там визитов как сущности не было, и витрина
|
||||
нарезала их сама, строкой на визит. Здесь визит — сущность слоя DDS, и
|
||||
витрина, повторяющая его зерно, оказалась бы обычным представлением по
|
||||
[правилу формы](../architecture/dm.md): копией сессий с другими именами
|
||||
колонок. Чтобы задание осталось про дневной агрегат, витрина поднялась на
|
||||
зерно выше — до дня и среза.
|
||||
|
||||
## Зерно и форма
|
||||
|
||||
Зерно — **день сессии × категория устройства**. Источник —
|
||||
`dds.session_v`, у него зерно визита, значит зерно сменилось и витрина
|
||||
материализуется: `dm.session_overview_rep` / `_dist`, донор `_stage_rep` /
|
||||
`_stage_dist`, публичное `dm.session_overview_v`. Ключ партиции — день.
|
||||
|
||||
Категория устройства взята из сессии как есть: у куки она постоянна, и слой
|
||||
DDS уже перевел код источника в слово. Словарь расшифровки шире, чем мир
|
||||
стенда: слов в нем четыре, а встречаются три — `desktop`, `mobile` и
|
||||
`tablet`. Строку под `tv` заводить незачем, она возникнет сама, если такие
|
||||
визиты появятся.
|
||||
|
||||
## Состав колонок
|
||||
|
||||
| Колонка | Тип | Что это |
|
||||
|---|---|---|
|
||||
| `report_date` | `Date` | день сессии |
|
||||
| `device_category` | `LowCardinality(String)` | категория устройства |
|
||||
| `sessions` | `UInt64` | число визитов |
|
||||
| `visitors` | `UInt64` | число разных `client_id` |
|
||||
| `bounce_sessions` | `UInt64` | визиты ровно из одного события |
|
||||
| `purchase_sessions` | `UInt64` | визиты, в которых была хотя бы одна покупка |
|
||||
| `duration_seconds_total` | `UInt64` | суммарная длительность визитов |
|
||||
| `events_total` | `UInt64` | событий во всех визитах |
|
||||
| `pageviews` | `UInt64` | просмотров страниц |
|
||||
| `cart_adds` | `UInt64` | добавлений в корзину |
|
||||
| `purchases` | `UInt64` | покупок |
|
||||
| `_load_id` | `String` | запуск, собравший день |
|
||||
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||
|
||||
Средней длительности и доли отказов в витрине нет намеренно. Хранить
|
||||
`avg_duration_seconds` было бы удобнее ровно один раз — пока смотрят одну
|
||||
строку. Тот, кто захочет среднее по дню целиком, сложит средние по трем
|
||||
устройствам и получит неправду: средние не складываются, они взвешиваются.
|
||||
Пара «`duration_seconds_total` и `sessions`» дает верный ответ на любом
|
||||
срезе, а деление стоит потребителю один вызов. То же и с долей отказов:
|
||||
храним `bounce_sessions` и `sessions`, долю считает тот, кто спрашивает.
|
||||
|
||||
С `visitors` история тоньше, и в ней главная ловушка задания. Складывать
|
||||
уникальных по строкам нельзя: один и тот же посетитель, попавший в две
|
||||
строки, будет посчитан дважды. Проверь на стенде — и увидишь, что сумма
|
||||
`visitors` по устройствам за день **сходится** с `dm.daily_traffic_v`.
|
||||
|
||||
Это не отменяет правила, а показывает, откуда берется совпадение: в мире
|
||||
стенда устройство приписано куке на всю жизнь ([словарь
|
||||
понятий](../../CONTEXT.md), «Паспорт куки»), поэтому одна кука лежит ровно
|
||||
в одной строке дня, и пересекаться строкам не в чем.
|
||||
|
||||
Совпадение это — свойство мира, а не свойство витрины. Появятся визиты
|
||||
одной куки с двух устройств — сумма поедет, и ни одна проверка об этом не
|
||||
скажет: отчет просто начнет тихо завышать аудиторию. Отсюда правило,
|
||||
которое стоит унести с задания: складывать уникальных нельзя даже там, где
|
||||
сегодня получается. Число посетителей дня спрашивают у
|
||||
`dm.daily_traffic_v`, где оно посчитано на своем зерне.
|
||||
|
||||
## Ритм пересборки
|
||||
|
||||
Источник — сессии: день замерзает, как только прожит. Объем прогона тот же,
|
||||
что в задании 2, — хвост от первого несобранного прожитого дня; механика та
|
||||
же: донор и замена дневных партиций.
|
||||
|
||||
## Ключ шардирования
|
||||
|
||||
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||
шардам». Здесь у измерения всего три встречающихся значения, и распределены
|
||||
они неровно: планшеты — тонкая полоса, а телефоны и настольные машины держат
|
||||
основную массу. Прежде чем брать хеш от одного `device_category`, посчитай,
|
||||
сколько строк ляжет на каждый шард, и назови, чем тебя устраивает результат.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — `device_category` внутри дня. Собери прямой агрегат из
|
||||
`dds.session_v` за день, соедини с `dm.session_overview_v` полным внешним
|
||||
соединением и посчитай строки, где ключа нет с одной из сторон или где
|
||||
расходится любой счетчик. Ноль — зеленый.
|
||||
|
||||
Две проверки покрупнее ловят грубую ошибку сразу:
|
||||
|
||||
- сумма `sessions` за день равна числу строк `dds.session_v` этого дня:
|
||||
ни один визит не потерян и ни один не задвоен;
|
||||
- сумма `events_total` за день равна сумме `events_total` по сессиям того
|
||||
же дня.
|
||||
|
||||
Если суммы событий сходятся, а сумма сессий больше числа визитов — почти
|
||||
наверняка витрина собрана из событий, а не из сессий.
|
||||
|
||||
## Приемка
|
||||
|
||||
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||
ключом цели;
|
||||
- [ ] `dm.session_overview_v` перечисляет колонки явно;
|
||||
- [ ] источник витрины — `dds.session_v`, своей нарезки визитов в ней нет;
|
||||
- [ ] средних и долей в физической таблице нет;
|
||||
- [ ] задача `session_overview` в `homework_check` зеленая;
|
||||
- [ ] задача витрины добавлена в `dm_transform`.
|
||||
@@ -1,87 +0,0 @@
|
||||
# Задание 6 (необязательное): свой дашборд в Superset
|
||||
|
||||
**Чему учит:** довести свои витрины до потребителя — завести в Superset
|
||||
датасеты поверх публичных представлений, подобрать графики под вопросы и
|
||||
собрать из них один связный экран.
|
||||
|
||||
Эта лаба идет после заданий 1–5 и опирается на их результат. Ее можно
|
||||
пропустить: основной предмет стенда — ClickHouse, а здесь вся работа — в
|
||||
интерфейсе BI.
|
||||
|
||||
## Роль дашборда
|
||||
|
||||
Собери обзор трафика и поведения посетителей. Он должен отвечать на три
|
||||
вопроса: что происходило в магазине, какие страницы, устройства и рекламные
|
||||
каналы дали результат, были ли ошибки приема данных.
|
||||
|
||||
Стендовый дашборд «Бизнес-сюжеты» собран из готовых витрин. Свой дашборд
|
||||
собери из пяти витрин, которые ты построил в заданиях. Копировать раскладку
|
||||
стендового дашборда не нужно: выбери форму, в которой ответы читаются без
|
||||
пояснений рядом с экраном.
|
||||
|
||||
## Свои объекты — отдельно
|
||||
|
||||
Не добавляй графики в стендовый дашборд и не правь его датасеты. При
|
||||
инициализации Superset импорт с `--overwrite` заменит ручные правки стендовых
|
||||
объектов файлами из репозитория. `make up` свой дашборд не стирает; срок жизни
|
||||
таких объектов описан в [доке Superset](../architecture/superset.md), «Импорт
|
||||
при каждой инициализации».
|
||||
|
||||
Заведи отдельные датасеты и графики, собери из них свой дашборд. Назови его,
|
||||
например, «Мои витрины DM». Это то же правило, по которому DDL домашек лежит
|
||||
в `sql/ddl/70-labs.sql`: стенд привозит свои объекты, менти создает свои.
|
||||
|
||||
## Датасеты
|
||||
|
||||
Заведи в подключении `ClickHouse` пять физических датасетов схемы `dm`:
|
||||
|
||||
- `events_enriched_v`;
|
||||
- `top_pages_daily_v`;
|
||||
- `session_overview_v`;
|
||||
- `dq_errors_daily_v`;
|
||||
- `utm_effectiveness_v`.
|
||||
|
||||
## Состав дашборда
|
||||
|
||||
Каждая из пяти витрин должна появиться хотя бы в одном графике. Точный тип
|
||||
графика выбери сам. На дашборде покажи следующее:
|
||||
|
||||
| Витрина | Что показать |
|
||||
|---|---|
|
||||
| `events_enriched_v` | число событий по часу и типу события |
|
||||
| `top_pages_daily_v` | страницы с просмотрами, посетителями и добавлениями в корзину |
|
||||
| `session_overview_v` | сравнение устройств по визитам, отказам и визитам с покупкой |
|
||||
| `dq_errors_daily_v` | число ошибок по дню загрузки, источнику и классу |
|
||||
| `utm_effectiveness_v` | трафик, покупки и заявленную выручку по рекламным каналам |
|
||||
|
||||
Для долей используй слагаемые из витрин. Например, доля отказов считается
|
||||
как сумма `bounce_sessions`, деленная на сумму `sessions`. Среднее по готовым
|
||||
долям для такого графика не подходит — урок задания 3 остается в силе.
|
||||
|
||||
Названия графиков, осей и показателей должны объяснять экран без чтения SQL.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Эта лаба проверяется глазами. У расположения панелей и понятных подписей нет
|
||||
счетного инварианта, поэтому задачи для нее в `homework_check` нет.
|
||||
|
||||
Открой список дашбордов и проверь:
|
||||
|
||||
- «Бизнес-сюжеты» остался отдельным стендовым дашбордом;
|
||||
- твой дашборд открывается без ошибок и содержит график от каждого из пяти
|
||||
датасетов;
|
||||
- подписи, единицы измерения и легенды читаются без обращения к SQL;
|
||||
- прямой трафик не исчез из графика UTM, а заявленная выручка не выдана за
|
||||
серверную;
|
||||
- пустой график ошибок нормален, если в таблицах брака сейчас нет строк.
|
||||
|
||||
Последний проход сделай как читатель: найди на экране самый заметный канал,
|
||||
страницу и устройство, затем проверь, понятно ли из подписей, что именно
|
||||
сравнивается. Если ответ приходится угадывать, поправь график или его название.
|
||||
|
||||
## Приемка
|
||||
|
||||
- [ ] пять датасетов читают публичные представления витрин заданий 1–5;
|
||||
- [ ] создан отдельный дашборд, стендовые объекты не изменены;
|
||||
- [ ] каждая витрина представлена хотя бы одним графиком;
|
||||
- [ ] дашборд проверен глазами по разделу выше.
|
||||
@@ -1,114 +0,0 @@
|
||||
# Задание 2: `dm.top_pages_daily`
|
||||
|
||||
**Чему учит:** зерно сменилось — значит витрина материализуется, живет
|
||||
дневными партициями и встает в конвейер. Первая настоящая витрина: пять
|
||||
объектов, донор, замена партиции, задача в `dm_transform`.
|
||||
|
||||
## Роль витрины
|
||||
|
||||
Популярность страниц по дням: сколько раз страницу открыли, сколько разных
|
||||
посетителей ее видели и что на ней делали кроме просмотра.
|
||||
|
||||
Торговое событие садится на ту страницу, где случилось: корзина — на
|
||||
карточку товара, покупка — на страницу подтверждения заказа. Поэтому у
|
||||
карточки товара рядом стоят просмотры и корзины, и их отношение —
|
||||
конверсия карточки — главный вопрос к этой витрине: какие товары кладут в
|
||||
корзину, едва открыв, а какие смотрят и уходят.
|
||||
|
||||
Воронки «просмотр → корзина → покупка» здесь нет и быть не может: корзина и
|
||||
покупка живут на разных страницах, и связать их шаги в одну цепочку на
|
||||
зерне страницы не из чего. Путь одного человека собирают из событий или из
|
||||
сессий, а витрина страниц отвечает про страницу.
|
||||
|
||||
## Зерно и форма
|
||||
|
||||
Зерно — **день события × страница**. Источник — события, у него зерно
|
||||
события, значит зерно сменилось и витрина материализуется:
|
||||
`dm.top_pages_daily_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
|
||||
публичное `dm.top_pages_daily_v`.
|
||||
|
||||
Страница — это `page_path`, путь `url` без домена и параметров запроса;
|
||||
считается он здесь же, из колонки `url` источника. Полный адрес в зерно не
|
||||
берется: с параметрами запроса одна и та же страница рассыпалась бы на
|
||||
сотни строк, и витрина перестала бы отвечать на свой вопрос.
|
||||
|
||||
Ключ партиции — день: он же единица пересборки.
|
||||
|
||||
Источник — `dds.event_v`, публичный договор события. Витрина задания 1
|
||||
источником не служит: витрина слоя DM читает договор DDS, как и стендовые
|
||||
витрины, и не зависит от того, выполнена ли соседняя домашка.
|
||||
|
||||
## Состав колонок
|
||||
|
||||
| Колонка | Тип | Что это |
|
||||
|---|---|---|
|
||||
| `report_date` | `Date` | день события |
|
||||
| `page_path` | `LowCardinality(String)` | путь страницы |
|
||||
| `pageviews` | `UInt64` | число событий `pageview` на странице |
|
||||
| `visitors` | `UInt64` | число разных `client_id` на странице |
|
||||
| `cart_adds` | `UInt64` | число событий `add_to_cart` на странице |
|
||||
| `purchases` | `UInt64` | число событий `purchase` на странице |
|
||||
| `_load_id` | `String` | запуск, собравший день |
|
||||
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||
|
||||
Счетчики условные: строку дают все три типа событий, а различает их
|
||||
`countIf`. Отфильтровать источник по `event_type = 'pageview'`, как
|
||||
подсказывает имя витрины, — значит получить витрину из одних просмотров:
|
||||
страницы останутся на месте, а `cart_adds` и `purchases` у всех строк
|
||||
обнулятся. Ошибка тихая: витрина выглядит собранной, и главный ее вопрос —
|
||||
конверсия карточки — молча отвечает «ноль везде».
|
||||
|
||||
## Ритм пересборки
|
||||
|
||||
Источник — события: день замерзает сразу, как только прожит. Значит
|
||||
пересобирать нужно только то, что еще не собрано: хвост дней, которых в
|
||||
витрине нет, — от первого несобранного прожитого дня до последнего
|
||||
прожитого. Готовые дни трогать незачем, а вот дыру, оставленную частичным
|
||||
отказом, объем прогона обязан подобрать: ищи не последний собранный день, а
|
||||
первый несобранный.
|
||||
|
||||
Механика — сборка отрезка в донор одной вставкой и замена дневных партиций
|
||||
по одной, как у `revenue_daily`.
|
||||
|
||||
## Ключ шардирования
|
||||
|
||||
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||
шардам». Подумай, чем эта витрина отличается от `revenue_daily`: там кроме
|
||||
дня было одно измерение из шести близких по объему значений, здесь —
|
||||
страницы, которых много и которые по популярности очень неровные.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — `page_path` внутри дня. Собери прямой агрегат из
|
||||
`dds.event_v` за день, соедини с `dm.top_pages_daily_v` полным внешним
|
||||
соединением по ключу и посчитай строки, где ключа нет с одной из сторон или
|
||||
где расходится любой из четырех счетчиков. Ноль — зеленый.
|
||||
|
||||
Две проверки попроще, ими удобно ловить грубую ошибку до полного
|
||||
соединения:
|
||||
|
||||
- сумма `pageviews` за день равна числу событий `pageview` этого дня в
|
||||
`dds.event_v`;
|
||||
- сумма `purchases` за день равна числу событий `purchase` этого дня.
|
||||
|
||||
Проверки ловят разное. Фильтр `WHERE event_type = 'pageview'` первая из них
|
||||
пропускает: отбор строк по типу не меняет счет событий того же типа.
|
||||
Расхождение покажет только вторая — вместе с `cart_adds` обнулятся и
|
||||
`purchases`. Проверку выбирают по тому, умеет ли она покраснеть на твоей
|
||||
ошибке, а не по тому, как быстро она пишется: [проверка, которая не умеет
|
||||
краснеть, бесполезна](../architecture/testing.md).
|
||||
|
||||
`visitors` складывать по строкам нельзя: один посетитель ходит по разным
|
||||
страницам и в сумме посчитается несколько раз. Число посетителей дня
|
||||
спрашивают у `dm.daily_traffic_v`, а не у этой витрины.
|
||||
|
||||
## Приемка
|
||||
|
||||
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||
ключом цели;
|
||||
- [ ] `dm.top_pages_daily_v` перечисляет колонки явно, включая `_load_id` и
|
||||
`_load_ts`;
|
||||
- [ ] объем прогона выводится из состояния витрины, а не задается руками;
|
||||
- [ ] задача `top_pages_daily` в `homework_check` зеленая;
|
||||
- [ ] задача витрины добавлена в `dm_transform`, повторный прогон конвейера
|
||||
результат не меняет.
|
||||
@@ -1,150 +0,0 @@
|
||||
# Задание 5: `dm.utm_effectiveness`
|
||||
|
||||
**Чему учит:** приставка `declared_` в имени колонки — предупреждение, а не
|
||||
украшение. Она говорит: это заявка клиента, а не деньги отчетности. Урок
|
||||
здесь дает конфликт: суммы не сойдутся, и раскапывать почему предстоит
|
||||
самому.
|
||||
|
||||
Правило стенда: **поведение и атрибуцию считаем по трекеру, деньги — по
|
||||
бэкенду**. Эта витрина — единственное разрешенное исключение: атрибуции по
|
||||
UTM у бэкенда нет, метки живут только в кликстриме. Раз считать иначе
|
||||
нечем, клиентская сумма берется, но называется своим именем.
|
||||
|
||||
## Роль витрины
|
||||
|
||||
Эффективность рекламных каналов по дням: сколько трафика привел канал,
|
||||
сколько корзин и покупок в нем случилось и на какую сумму эти покупки, по
|
||||
словам клиента.
|
||||
|
||||
## Атрибуция: по событию, а не по визиту
|
||||
|
||||
Метки едут на каждом событии, и витрина группирует события по их
|
||||
собственным меткам. Модели «первое касание» или «последнее касание» здесь
|
||||
нет: их построение — отдельная тема, а это витрина трафика по меткам.
|
||||
|
||||
`dds.session_v` при этом несет UTM первого события визита, и ответ «какой
|
||||
канал привел» из сессии и из этой витрины будет разным. Это не
|
||||
ошибка ни одной из сторон — это два разных вопроса, и в отчете их надо
|
||||
различать.
|
||||
|
||||
## Прямой трафик из витрины не выбрасывается
|
||||
|
||||
Соблазн отфильтровать строки без меток велик: витрина же про рекламу.
|
||||
Отфильтровать нельзя по двум причинам. Первая деловая: прямой трафик —
|
||||
обычно самый крупный «канал» магазина, и отчет, где его нет, врет о доле
|
||||
рекламы. Вторая техническая: как только часть событий выпала, итог витрины
|
||||
перестал сходиться с итогом источника, и проверить сборку стало нечем.
|
||||
|
||||
Пустые метки остаются пустыми строками, как в источнике. Своего слова для
|
||||
«прямого захода» витрина не изобретает: чем меньше выдуманных значений, тем
|
||||
меньше расшифровывать читателю.
|
||||
|
||||
## Зерно и форма
|
||||
|
||||
Зерно — **день события × `utm_source` × `utm_medium` × `utm_campaign`**.
|
||||
Зерно источника — событие, значит витрина материализуется:
|
||||
`dm.utm_effectiveness_rep` / `_dist`, донор `_stage_rep` / `_stage_dist`,
|
||||
публичное `dm.utm_effectiveness_v`. Ключ партиции — день.
|
||||
|
||||
Источник — `dds.event_v`, публичный договор события: витрина слоя DM читает
|
||||
договор DDS, а не соседнюю витрину.
|
||||
|
||||
## Состав колонок
|
||||
|
||||
| Колонка | Тип | Что это |
|
||||
|---|---|---|
|
||||
| `report_date` | `Date` | день события |
|
||||
| `utm_source` | `LowCardinality(String)` | источник кампании |
|
||||
| `utm_medium` | `LowCardinality(String)` | канал кампании |
|
||||
| `utm_campaign` | `String` | имя кампании |
|
||||
| `visitors` | `UInt64` | число разных `client_id` |
|
||||
| `pageviews` | `UInt64` | просмотров страниц |
|
||||
| `add_to_carts` | `UInt64` | добавлений в корзину |
|
||||
| `purchases` | `UInt64` | покупок |
|
||||
| `declared_revenue` | `Decimal(18, 2)` | выручка покупок глазами клиента |
|
||||
| `_load_id` | `String` | запуск, собравший день |
|
||||
| `_load_ts` | `DateTime64(3, 'UTC')` | когда собран |
|
||||
|
||||
`declared_revenue` складывает `purchase_revenue[1]` событий `purchase`.
|
||||
Индекс не описка: блок `purchase*` едет параллельными массивами, и у события
|
||||
покупки в них ровно один элемент — контракт источника обещает один номер
|
||||
заказа и одну объявленную сумму на событие. Тем же индексом берет свою
|
||||
сторону стендовая сверка `purchase_vs_orders`.
|
||||
|
||||
Приводить тип не нужно: в договоре `purchase_revenue` уже
|
||||
`Array(Decimal(18, 2))`. `Float64` эта сумма была только в выгрузке
|
||||
источника, и ODS перевел ее в точные деньги на приеме — сложение Decimal
|
||||
точное, и где стоит `CAST`, до или после `sum`, на результат не влияет.
|
||||
Витрина просто хранит то, что сложила, в `Decimal(18, 2)`.
|
||||
|
||||
`utm_campaign` оставлена обычной строкой: имен кампаний со временем
|
||||
становится много, и словарь `LowCardinality` на растущем множестве
|
||||
перестает окупаться.
|
||||
|
||||
## Ритм пересборки
|
||||
|
||||
Источник — события: день замерзает сразу. Объем прогона и механика те же,
|
||||
что в задании 2: хвост несобранных дней, донор, замена дневных партиций.
|
||||
|
||||
## Ключ шардирования
|
||||
|
||||
Твое решение по [правилу донора](../architecture/storage.md), «Раскладка по
|
||||
шардам». Зерно здесь составное, из трех колонок, и решение «взять хеш от
|
||||
одной из них» надо защищать отдельно: назови, какая доля строк придется на
|
||||
самое частое значение выбранной колонки.
|
||||
|
||||
## Конфликт, ради которого витрина и написана
|
||||
|
||||
Собери за один и тот же день две суммы: `sum(declared_revenue)` из своей
|
||||
витрины и `sum(revenue)` из `dm.revenue_daily_v`. Они не сойдутся, и это
|
||||
верное поведение обеих витрин.
|
||||
|
||||
Базы у двух сумм при этом сравнимые: `revenue_daily` складывает цену позиций
|
||||
на количество и ни скидки, ни доставки в себя не берет — ровно то, что видит
|
||||
клиент. Значит все расхождение до копейки объясняется тем, что случилось с
|
||||
заказом, а не тем, как считали деньги. Ищи ответы среди таких причин:
|
||||
|
||||
- отмененный заказ из кликстрима никуда не девается: событие покупки
|
||||
случилось, а `revenue_daily` считает только оплаченные;
|
||||
- событие покупки может потеряться по дороге, а заказ при этом доехать —
|
||||
и наоборот, событие приехать дважды;
|
||||
- клиент назвал сумму, отличную от серверной суммы позиций: генератор
|
||||
делает это намеренно у одного-двух заказов из ста.
|
||||
|
||||
Как эти же расхождения выглядят на зерне заказа, показывает стендовая
|
||||
витрина `dm.purchase_vs_orders_v` с ее классами расхождений: это готовый
|
||||
ответ на вопрос «а сколько именно и из-за чего». Запиши свой вывод —
|
||||
в двух-трех фразах, своими словами.
|
||||
|
||||
Проверять сходимость этих двух сумм не нужно и нельзя: они и не должны
|
||||
сходиться.
|
||||
|
||||
## Как проверить себя
|
||||
|
||||
Деловой ключ — тройка `utm_source`, `utm_medium`, `utm_campaign` внутри
|
||||
дня. Собери прямой агрегат из `dds.event_v` за день, соедини с
|
||||
`dm.utm_effectiveness_v` полным внешним соединением и посчитай строки, где
|
||||
ключа нет с одной из сторон или где расходится любая из пяти величин. Ноль —
|
||||
зеленый.
|
||||
|
||||
Две проверки покрупнее ловят самые частые ошибки:
|
||||
|
||||
- сумма `pageviews`, `add_to_carts` и `purchases` за день равна числу
|
||||
событий соответствующего типа в `dds.event_v` за тот же день;
|
||||
- сумма `declared_revenue` за день равна сумме `purchase_revenue[1]` по всем
|
||||
событиям `purchase` этого дня.
|
||||
|
||||
Обе не сойдутся, если прямой трафик отфильтрован, и разойдутся ровно на его
|
||||
долю — по этому следу отфильтрованные строки и находят. `visitors`, как и в
|
||||
задании 3, по строкам не складывается: один посетитель приходит по разным
|
||||
меткам.
|
||||
|
||||
## Приемка
|
||||
|
||||
- [ ] пять объектов витрины созданы, донор объявлен через `AS` и шардируется
|
||||
ключом цели;
|
||||
- [ ] строки без UTM-меток в витрине остались;
|
||||
- [ ] `declared_revenue` хранится в `Decimal(18, 2)`;
|
||||
- [ ] расхождение с `dm.revenue_daily_v` объяснено письменно;
|
||||
- [ ] задача `utm_effectiveness` в `homework_check` зеленая;
|
||||
- [ ] задача витрины добавлена в `dm_transform`.
|
||||
@@ -116,7 +116,7 @@
|
||||
|
||||
**17. Свой дашборд в Superset (готовая необязательная лаба).**
|
||||
Урок: пользовательские объекты BI хранятся отдельно от стендовых и переживают перезапуск. Стендовые объекты при импорте перезаписываются.
|
||||
Материал: `docs/labs/superset-dashboard.md`, `docs/architecture/superset.md`.
|
||||
Материал: `docs/labs/optional/superset-dashboard.md`, `docs/architecture/superset.md`.\
|
||||
Форма: построить свое.
|
||||
Проверка: глазами — счетного инварианта у раскладки панелей нет.
|
||||
|
||||
|
||||
@@ -272,12 +272,13 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
|
||||
`duplicate_event` > `amount_delta` > `match`.
|
||||
|
||||
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
||||
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
||||
ориентир ~10%. Точные доли зафиксированы замером на каноническом мире
|
||||
(28 дней, 2026-08-30, #194) — запись в «Что проверено»
|
||||
[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
|
||||
только по дням с закрытым окном (раздел 8).
|
||||
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов впервые
|
||||
появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), ориентир
|
||||
~10%. Точные доли зафиксированы замером на каноническом мире (28 дней,
|
||||
2026-08-30, #194) — запись в «Что проверено» [карты
|
||||
проверок](../architecture/testing.md); паспорт хранит счётчики наблюдаемых
|
||||
классов после приоритета — в заказах и только по дням с закрытым окном (раздел
|
||||
8).
|
||||
|
||||
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
||||
механически дублирует B.
|
||||
@@ -311,14 +312,13 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
запуск пишет полный текущий набор различных пар; предыдущий результат не
|
||||
служит источником следующего.
|
||||
- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») —
|
||||
параметр мира; значение фиксирует эта спека: 15%, детерминировано
|
||||
от seed. Ядро лабы:
|
||||
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
|
||||
Константа мира: каждый двухкуковый покупатель делает минимум по одному
|
||||
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
|
||||
(она строится только из покупок) и лаба не воспроизводится. Число таких
|
||||
пар растёт, пока мир едет, и в опись не кладётся
|
||||
([что хранит опись](../architecture/orders/inventory.md)).
|
||||
параметр мира; значение фиксирует эта спека: 15%, детерминировано от seed.
|
||||
Ядро лабы: `uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
|
||||
Константа мира: каждый двухкуковый покупатель делает минимум по одному заказу
|
||||
с каждой куки — иначе вторая кука не попадает в карту соответствий (она
|
||||
строится только из покупок) и лаба не воспроизводится. Число таких пар растёт,
|
||||
пока мир едет, и в паспорт не кладётся ([что хранит
|
||||
паспорт](../architecture/orders/inventory.md)).
|
||||
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
||||
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
||||
|
||||
@@ -339,16 +339,14 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
|
||||
VIEW поверх локальной таблицы проявляются в дашборде сами.
|
||||
- **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна
|
||||
consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели.
|
||||
У заказов приём другого рода — пакетный забор без MV, чтец на одной ноде,
|
||||
топик `orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)).
|
||||
Раскладку в обоих случаях решает ключ:
|
||||
события — по `cityHash64(ClientID)` (см. 1.3), заказы —
|
||||
`cityHash64(order_id)`, сырьё STG — `cityHash64(сырой строки)`; полный
|
||||
список и доводы — в [доке хранилища](../architecture/storage.md). Урок
|
||||
«какая нода читала топик — меняется между прогонами, куда легли данные —
|
||||
нет» живёт на событиях: при пакетном заборе читает та нода, которую
|
||||
спросили.
|
||||
consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели. У
|
||||
заказов приём другого рода — пакетный забор без MV, чтец на одной ноде, топик
|
||||
`orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)).
|
||||
Раскладку в обоих случаях решает ключ: события — по `cityHash64(ClientID)`
|
||||
(см. 1.3), заказы — `cityHash64(order_id)`, STG — `cityHash64(сырой строки)`;
|
||||
полный список и доводы — в [доке хранилища](../architecture/storage.md). Урок
|
||||
«какая нода читала топик — меняется между прогонами, куда легли данные — нет»
|
||||
живёт на событиях: при пакетном заборе читает та нода, которую спросили.
|
||||
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
|
||||
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
|
||||
сообщения сверяется с контрактным; строка с NULL среди опорных колонок
|
||||
@@ -444,23 +442,24 @@ README.
|
||||
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
|
||||
|
||||
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
|
||||
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья —
|
||||
см. [доку хранилища](../architecture/storage.md).
|
||||
Потребительские запросы и витрины читают сущности DDS через `_v` с явным
|
||||
списком колонок; `_rep` и `_dist` служат загрузке, диагностике и обслуживанию.
|
||||
Договор держится конвенцией, а не правами: модель доступа описана в
|
||||
[ADR 0007](../adr/0007-clickhouse-access.md), договор чтения — в
|
||||
[доке хранилища](../architecture/storage.md), «Имена объектов».
|
||||
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырых данных
|
||||
— см. [доку хранилища](../architecture/storage.md). Потребительские запросы и
|
||||
витрины читают сущности DDS через `_v` с явным списком колонок; `_rep` и `_dist`
|
||||
служат загрузке, диагностике и обслуживанию. Договор держится конвенцией, а не
|
||||
правами: модель доступа описана в [ADR 0007](../adr/0007-clickhouse-access.md),
|
||||
договор чтения — в [доке хранилища](../architecture/storage.md), «Имена
|
||||
объектов».
|
||||
|
||||
Заказы принимаются **пакетным забором** ([ADR
|
||||
0008](../adr/0008-order-ingestion.md), [ADR
|
||||
0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у событий, но
|
||||
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
|
||||
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска.
|
||||
Один следующий `task_id` двумя последовательными запросами пишет годные строки
|
||||
и ошибки из того же среза. `ods.order` выбирает текущую версию по максимальной
|
||||
`snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Слой
|
||||
сырья остаётся точкой повтора и разбора одной принятой порции.
|
||||
0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у
|
||||
событий, но матвью к нему не привязана, и порцию в STG забирает шаг под
|
||||
управлением Airflow — одно прямое чтение вставляет её в `stg.orders_raw` с
|
||||
`_load_id` запуска. Один следующий `task_id` двумя последовательными запросами
|
||||
пишет годные строки и ошибки из того же среза. `ods.order` выбирает текущую
|
||||
версию по максимальной `snapshot_date` ([ADR
|
||||
0014](../adr/0014-order-version-snapshot-date.md)). Слой Сырой слой остаётся
|
||||
точкой повтора и разбора одной принятой порции.
|
||||
|
||||
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
||||
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию
|
||||
@@ -531,31 +530,28 @@ Kafka день переигрывается генератором заново:
|
||||
пачками), Postgres остаётся только служебной базой Airflow. Смешанность
|
||||
ландшафта выражена режимами и частотами, а не второй трубой.
|
||||
|
||||
## 8. Эталонный мир и опись
|
||||
## 8. Эталонный мир и паспорт
|
||||
|
||||
В git хранится только опись эталонного мира; сам снимок генерируется на
|
||||
месте — при `make up` и при проверках (решение развилки «Производительность»
|
||||
карты #26, подробности — [спека генератора](2026-08-01-generator.md),
|
||||
раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней
|
||||
снято на этапе 7. Опись несёт
|
||||
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
|
||||
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
||||
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
|
||||
карты #10) закрыта этим же ходом: версионируется опись.
|
||||
Контрольные числа описи:
|
||||
В git хранится только паспорт эталонного мира; сам снимок генерируется на месте
|
||||
— при `make up` и при проверках (решение развилки «Производительность» карты
|
||||
#26, подробности — [спека генератора](2026-08-01-generator.md), раздел 5).
|
||||
Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней снято на этапе
|
||||
7. Паспорт несёт шапку с каноническим зерном и версией генератора, контрольные
|
||||
счётчики и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
||||
сравни хеш» живут на нём. Политика версионирования артефакта (бывший туман карты
|
||||
#10) закрыта этим же ходом: версионируется паспорт. Контрольные числа паспорта:
|
||||
|
||||
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
|
||||
«слепок переснимается и даёт те же байты» опись сторожит наравне с днями;
|
||||
«слепок переснимается и даёт те же байты» паспорт сторожит наравне с днями;
|
||||
- заказная сторона: счётчики наблюдаемых классов расхождений после
|
||||
приоритета; каждый показывает число заказов и хранится только для дня с
|
||||
закрытым окном (читатель — техническая проверка `classes_vs_inventory`
|
||||
звена DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для
|
||||
общего числа заказов и выручки в описи пока нет: для них не назван
|
||||
читатель;
|
||||
закрытым окном (читатель — техническая проверка `classes_vs_inventory` звена
|
||||
DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для общего числа
|
||||
заказов и выручки в паспорте пока нет: для них не назван читатель;
|
||||
- контрольные числа идентичности не заводятся: uniq известных пользователей
|
||||
и число двухкуковых покупателей растут, пока мир едет, а счётчик без
|
||||
названного читателя в опись не кладётся
|
||||
([что хранит опись](../architecture/orders/inventory.md)).
|
||||
названного читателя в паспорт не кладётся ([что хранит
|
||||
паспорт](../architecture/orders/inventory.md)).
|
||||
|
||||
Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
|
||||
[карты проверок](../architecture/testing.md).
|
||||
@@ -572,7 +568,7 @@ v2 стартует пустым, поэтому объём ниже — это
|
||||
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
|
||||
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка), world_init/next_day, helpers; первый даг — переливка слепка — приходит раньше, этапом 3 | M — ~5–6 файлов |
|
||||
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
|
||||
| Эталонный мир | пересборка снимка на месте, счётчики описи, чек-скрипты | M–L |
|
||||
| Эталонный мир | пересборка снимка на месте, счётчики паспорта, чек-скрипты | M–L |
|
||||
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
|
||||
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
|
||||
|
||||
@@ -619,8 +615,8 @@ v2 стартует пустым, поэтому объём ниже — это
|
||||
4. Трансформации и витрины: модель DDS заказа, сессии, identity_map,
|
||||
выручка, сверка A+C.
|
||||
5. Airflow: `etl_pipeline` (партиционная переобработка).
|
||||
6. Расхождения B+D и опоздания; счётчики описи.
|
||||
7. Эталонный мир: опись и пересборка снимка, чек-скрипты. CI-генерация
|
||||
6. Расхождения B+D и опоздания; счётчики паспорта.
|
||||
7. Эталонный мир: паспорт и пересборка снимка, чек-скрипты. CI-генерация
|
||||
снята (спека генератора, раздел 5).
|
||||
8. Superset-дашборд v2.
|
||||
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
||||
@@ -658,15 +654,15 @@ ADR 0002 с приёмочными совпадают почти целиком.
|
||||
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
|
||||
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
|
||||
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
|
||||
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг
|
||||
самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и
|
||||
конвейер, в который она встает, проектируются вместе. Сюда же —
|
||||
необязательная лаба «свой дашборд в Superset» (#197): она стоит на
|
||||
витринах домашек DM и продумана до готового ТЗ в этапе 8, тогда как
|
||||
остальные лабы пока на уровне идей. Единый стиль блока
|
||||
домашек — за работой учебного пути. Спека даёт будущим лабам только
|
||||
опорные точки — контрольные числа описи (сверка, идентичность). Явное
|
||||
следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет.
|
||||
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг самопроверки
|
||||
забраны в этап 4 резолюцией #135, потому что витрина менти и конвейер, в
|
||||
который она встает, проектируются вместе. Сюда же — необязательная лаба «свой
|
||||
дашборд в Superset» (#197): она стоит на витринах домашек DM и продумана до
|
||||
готового ТЗ в этапе 8, тогда как остальные лабы пока на уровне идей. Единый
|
||||
стиль блока домашек — за работой учебного пути. Спека даёт будущим лабам
|
||||
только опорные точки — контрольные числа паспорта (сверка, идентичность).
|
||||
Явное следствие: после этапа 9 стенд работает, но учебного пути на нём ещё
|
||||
нет.
|
||||
- Инкрементальный ETL (#8) — свой issue.
|
||||
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
|
||||
- TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные
|
||||
|
||||
+132
-139
@@ -41,10 +41,10 @@
|
||||
конструкция внутри оправдана только наблюдаемым эффектом: без него это
|
||||
сложность, за которую никто не заплатил.
|
||||
- **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств
|
||||
мира сдвигает почти все числа разом — генератор случайных чисел выдаёт
|
||||
другую последовательность. Опираться на конкретные числа поэтому нечем.
|
||||
Хеши описи обещают не постоянство чисел, а то, что их сдвиг не пройдёт
|
||||
молча: пересобрал опись — увидел diff.
|
||||
мира сдвигает почти все числа разом — генератор случайных чисел выдаёт другую
|
||||
последовательность. Опираться на конкретные числа поэтому нечем. Хеши паспорта
|
||||
обещают не постоянство чисел, а то, что их сдвиг не пройдёт молча: пересобрал
|
||||
паспорт — увидел diff.
|
||||
- **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он
|
||||
правкой генератора после взгляда на получившиеся распределения. Оборона,
|
||||
которая дороже ошибки, не заводится.
|
||||
@@ -59,15 +59,15 @@
|
||||
модельный день D — функция (зерно, D). Между прогонами живут только зерно
|
||||
и позиция на оси времени.
|
||||
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
|
||||
хешами описи. Транспорт (офсеты Kafka, темп) — вне обещания.
|
||||
хешами паспорта. Транспорт (офсеты Kafka, темп) — вне обещания.
|
||||
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
|
||||
хранилище строится по рендеренной документации, границу сторожит строгий
|
||||
приём на стороне хранилища.
|
||||
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
|
||||
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
|
||||
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок совпадает со
|
||||
стартовым миром — 8 дней;
|
||||
в git — только опись мира; автоматических порогов по времени нет.
|
||||
стартовым миром — 8 дней; в git — только паспорт мира; автоматических порогов
|
||||
по времени нет.
|
||||
|
||||
## 1. Модель мира
|
||||
|
||||
@@ -109,23 +109,21 @@
|
||||
начинается в D0) — только дают, кому возвращаться в первые дни: дневная
|
||||
аудитория на полке с самого D0, разгона «пустого магазина» нет.
|
||||
- **Гарантия двухкуковых пар — назначенные заказы в плане.** Единица
|
||||
здесь — человек, не кука: план помечает часть людей покупателями
|
||||
(доля — раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают
|
||||
вторую куку. Обе куки пары принадлежат одному человеку одной когорты;
|
||||
вторая рождается в пределах его окна активности, без фиксированного
|
||||
зазора — по тому же затухающему профилю, что и возвраты (уточнение при
|
||||
исполнении #38, 2026-08-02): куку человек заводит, пока ещё ходит.
|
||||
Равные шансы по всему окну означали бы вторую куку у давно ушедшего
|
||||
человека — и вчетверо меньше пар, реализованных внутри снимка.
|
||||
Каждой паре план назначает дни гарантированных заказов: по
|
||||
одному на куку, из дней активности этой куки, на оси от D0 и позже;
|
||||
человеку предыстории, чьё окно активности таких дней не оставляет,
|
||||
пара не назначается. День-функция обязана назначенные заказы
|
||||
реализовать; остальные покупки — вольные, их решает генератор торговых
|
||||
событий (#40). План считает пары, реализованные в запрошенном горизонте;
|
||||
опись это число не хранит — оно растёт вместе с миром. Условность в данных
|
||||
не видна: дни назначены той же случайностью, просто брошенной планом один
|
||||
раз.
|
||||
здесь — человек, не кука: план помечает часть людей покупателями (доля —
|
||||
раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают вторую куку.
|
||||
Обе куки пары принадлежат одному человеку одной когорты; вторая рождается в
|
||||
пределах его окна активности, без фиксированного зазора — по тому же
|
||||
затухающему профилю, что и возвраты (уточнение при исполнении #38,
|
||||
2026-08-02): куку человек заводит, пока ещё ходит. Равные шансы по всему окну
|
||||
означали бы вторую куку у давно ушедшего человека — и вчетверо меньше пар,
|
||||
реализованных внутри снимка. Каждой паре план назначает дни гарантированных
|
||||
заказов: по одному на куку, из дней активности этой куки, на оси от D0 и
|
||||
позже; человеку предыстории, чьё окно активности таких дней не оставляет, пара
|
||||
не назначается. День-функция обязана назначенные заказы реализовать; остальные
|
||||
покупки — вольные, их решает генератор торговых событий (#40). План считает
|
||||
пары, реализованные в запрошенном горизонте; паспорт это число не хранит — оно
|
||||
растёт вместе с миром. Условность в данных не видна: дни назначены той же
|
||||
случайностью, просто брошенной планом один раз.
|
||||
- **Своя ось модельного времени.** Ось событий начинается в
|
||||
фиксированный день D0 (понедельник — см. раздел 5); реальный
|
||||
календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси
|
||||
@@ -133,10 +131,10 @@
|
||||
зависящая (значение — раздел 9). Между прогонами живут только зерно и
|
||||
позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
|
||||
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
||||
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
||||
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
||||
«дышат»; включается по требованию — постоянным фоном идёт, только пока
|
||||
включён выключатель пульта ([ADR 0009](../adr/0009-world-control.md)).
|
||||
с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой
|
||||
день — текущий день проигрывается с ускорением, дашборд и мониторинг «дышат»;
|
||||
включается по требованию — постоянным фоном идёт, только пока включён
|
||||
выключатель `world_live` ([ADR 0009](../adr/0009-world-control.md)).
|
||||
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
||||
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
||||
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
||||
@@ -148,14 +146,13 @@
|
||||
Отклонено с доводами:
|
||||
|
||||
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
|
||||
дням, требует чекпоинтов, счётчики описи узнаваемы только постфактум;
|
||||
ни один урок стенда на старении не стоит.
|
||||
дням, требует чекпоинтов, счётчики паспорта узнаваемы только постфактум; ни
|
||||
один урок стенда на старении не стоит.
|
||||
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
|
||||
выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
|
||||
- *Привязка модельного времени к реальному календарю* (T-1 с догоном):
|
||||
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает
|
||||
даты эталонного мира зависимыми от даты запуска, опись теряет
|
||||
воспроизводимость.
|
||||
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает даты
|
||||
эталонного мира зависимыми от даты запуска, паспорт теряет воспроизводимость.
|
||||
|
||||
Отклонено при исполнении #38 (2026-08-02):
|
||||
|
||||
@@ -170,9 +167,9 @@
|
||||
константы — потолка одновременно живущих кук, которого в жизни нет и
|
||||
который менти нечем объяснить.
|
||||
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
|
||||
гарантия — однажды опись покраснеет, а чинить нечем, кроме смены
|
||||
зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев,
|
||||
так что реализм этой лотереи невидим.
|
||||
гарантия — однажды паспорт покраснеет, а чинить нечем, кроме смены зерна; при
|
||||
этом несклеенная пара в данных неотличима от двух незнакомцев, так что реализм
|
||||
этой лотереи невидим.
|
||||
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
|
||||
«все пары покупают в первый день» виден в данных ровно там, куда лаба
|
||||
склейки смотрит пристальнее всего. Условность допустима, пока она не
|
||||
@@ -185,31 +182,29 @@
|
||||
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
|
||||
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
|
||||
Снимок при пересборке побайтово совпадает: канонический порядок ключей и
|
||||
строк; сверка — по хешам описи, а два локально пересобранных снимка
|
||||
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне
|
||||
обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала,
|
||||
`_load_ts`, темп живого дня.
|
||||
строк; сверка — по хешам паспорта, а два локально пересобранных снимка
|
||||
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне обещания —
|
||||
транспорт: офсеты и партиции Kafka, какая нода прочитала, `_load_ts`, темп
|
||||
живого дня.
|
||||
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
|
||||
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL
|
||||
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет
|
||||
скрипт «пересгенерируй день N — сравни хеш с описью». Расхождение на
|
||||
любой платформе — баг генератора, а не допуск.
|
||||
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL тоже;
|
||||
единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость
|
||||
любой машины проверяет скрипт «пересгенерируй день N — сравни хеш с
|
||||
паспортом». Расхождение на любой платформе — баг генератора, а не допуск.
|
||||
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
|
||||
состава мира, ветвящийся по номеру дня на когорты плана (состав
|
||||
спрашивается по дням — раздел 1; уточнение при исполнении #38).
|
||||
Дни предыстории отрицательны, а позиция в дереве — неотрицательное
|
||||
целое, поэтому у предыстории своя ветвь состава, отдельная от оси
|
||||
(уточнение при исполнении #38, 2026-08-02);
|
||||
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
||||
торговые события, заказная и событийная стороны (судьба заказов и порча
|
||||
событийного потока) — в фиксированном порядке (уточнение [судьбой
|
||||
заказа](../architecture/orders/fate.md): прежние «расхождения» и
|
||||
«опоздания» были названы по классам витрины, а компонент называет часть
|
||||
мира).
|
||||
По построению: параллельный прогон равен последовательному; продление
|
||||
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
|
||||
только его часть снимка — в описи меняются хеши только затронутых
|
||||
дней, дифф двух локальных пересборок читаем.
|
||||
состава мира, ветвящийся по номеру дня на когорты плана (состав спрашивается
|
||||
по дням — раздел 1; уточнение при исполнении #38). Дни предыстории
|
||||
отрицательны, а позиция в дереве — неотрицательное целое, поэтому у
|
||||
предыстории своя ветвь состава, отдельная от оси (уточнение при исполнении
|
||||
#38, 2026-08-02); (зерно, день) → подпоток дня → именованные подпотоки
|
||||
компонентов: трафик, торговые события, заказная и событийная стороны (судьба
|
||||
заказов и порча событийного потока) — в фиксированном порядке (уточнение
|
||||
[судьбой заказа](../architecture/orders/fate.md): прежние «расхождения» и
|
||||
«опоздания» были названы по классам витрины, а компонент называет часть мира).
|
||||
По построению: параллельный прогон равен последовательному; продление истории
|
||||
днём N+1 не трогает дни 1…N; правка одного компонента меняет только его часть
|
||||
снимка — в паспорте меняются хеши только затронутых дней, дифф двух локальных
|
||||
пересборок читаем.
|
||||
- **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через
|
||||
Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей
|
||||
расширением `spawn_key`, потомок полностью определяется парой
|
||||
@@ -223,8 +218,8 @@
|
||||
представление в клиентском `purchase` (урок мастер-спеки о расхождениях).
|
||||
- **Канонический seed и паспорт мира.** Эталонный мир собирается одним
|
||||
каноническим зерном — константой репозитория; свои зёрна менти крутит без
|
||||
гарантий описи. Опись хранит паспорт мира — зерно и версию
|
||||
генератора; чек-скрипты сверяют паспорт раньше счётчиков.
|
||||
гарантий паспорта. В шапке паспорта лежат зерно и версия генератора;
|
||||
чек-скрипты сверяют шапку раньше счётчиков.
|
||||
- **Суточный профиль интенсивности задаёт день-функция.** Форма — волны:
|
||||
ночной провал, обеденный и вечерний пики, различие будней и выходных;
|
||||
пики — до ~2× среднего. С детерминизмом профиль совместим: это часть
|
||||
@@ -241,8 +236,8 @@
|
||||
«параллельно равно последовательно» недостижимо.
|
||||
- *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно
|
||||
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
|
||||
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная
|
||||
пересборка описи одним PR.
|
||||
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная пересборка
|
||||
паспорта одним PR.
|
||||
|
||||
## 3. Контракт схемы
|
||||
|
||||
@@ -289,9 +284,9 @@
|
||||
|
||||
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
|
||||
упорядоченный поток канонических байтов — единственное место, где событие
|
||||
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш
|
||||
для пересборки и проверок описи), Kafka пачкой — пакетный режим,
|
||||
Kafka с темпом ×60 — живой день. Новых топиков нет.
|
||||
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш для
|
||||
пересборки и проверок паспорта), Kafka пачкой — пакетный режим, Kafka с темпом
|
||||
×60 — живой день. Новых топиков нет.
|
||||
- **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу
|
||||
отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое
|
||||
отдельным сообщением. Контракт транспорта, не деталь реализации — сторона
|
||||
@@ -299,9 +294,9 @@
|
||||
([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких
|
||||
событий в одно сообщение сломала бы разбор целиком.
|
||||
- **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`,
|
||||
`UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: весь
|
||||
смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте и
|
||||
разбирает событие глазами, а число эпохи этот урок убивает. Колонка
|
||||
`UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырых данных:
|
||||
весь смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте
|
||||
и разбирает событие глазами, а число эпохи этот урок убивает. Колонка
|
||||
`ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт
|
||||
Метрика.
|
||||
|
||||
@@ -321,9 +316,9 @@
|
||||
первым. Записана форма всё равно здесь — иначе каждый выберет своё, и
|
||||
разойдётся это уже после приёмки.
|
||||
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
|
||||
numpy-массивы и datetime сериализует нативно (заметка исследования #31).
|
||||
Смена библиотеки меняет канонические байты, поэтому проходит как
|
||||
обновление зависимости: осознанная пересборка описи одним PR.
|
||||
numpy-массивы и datetime сериализует нативно (заметка исследования #31). Смена
|
||||
библиотеки меняет канонические байты, поэтому проходит как обновление
|
||||
зависимости: осознанная пересборка паспорта одним PR.
|
||||
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
|
||||
потерял — пересчитал. В git снимок не попадает (раздел 5).
|
||||
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
|
||||
@@ -341,7 +336,7 @@
|
||||
|
||||
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
|
||||
обещания детерминизма, retention конечен, в git топик не положишь, хеш с
|
||||
описью не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
|
||||
паспортом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
|
||||
мастер-спеки).
|
||||
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
|
||||
через дедуп, канон держит единственный сериализатор, а не диск; файл
|
||||
@@ -360,27 +355,25 @@
|
||||
~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей —
|
||||
правдоподобный средний магазин.
|
||||
- **Эталонный снимок совпадает со стартовым миром — 8 дней**: понедельник
|
||||
по понедельник, полная неделя и один закрытый день за окном K = 7.
|
||||
Обещание в 14 дней снято на этапе 7 (решение владельца 2026-08-30):
|
||||
у дней 8–13 не нашлось читателя — лабы проигрывают мир дальше без описи,
|
||||
транспорт байты с описью не сверяет, а проверке «пересгенерируй день N —
|
||||
сравни хеш» день 3 годится так же, как день 13. Замеры и тесты, меряющие
|
||||
мир на окне в две недели, не меняются: мир — функция без края, окну
|
||||
замера опись не нужна. Удлинение описи остается дешевым ходом
|
||||
(пересборка) и делается, когда читатель появится — например, если
|
||||
растяжке `classes_vs_inventory` понадобится сверять больше одного
|
||||
закрытого дня.
|
||||
- **В git — только опись, снимок не хранится.** Снимок генерируется при
|
||||
по понедельник, полная неделя и один закрытый день за окном K = 7. Обещание в
|
||||
14 дней снято на этапе 7 (решение владельца 2026-08-30): у дней 8–13 не
|
||||
нашлось читателя — лабы проигрывают мир дальше без паспорта, транспорт байты с
|
||||
паспортом не сверяет, а проверке «пересгенерируй день N — сравни хеш» день 3
|
||||
годится так же, как день 13. Замеры и тесты, меряющие мир на окне в две
|
||||
недели, не меняются: мир — функция без края, окну замера паспорт не нужен.
|
||||
Удлинение паспорта остаётся дешёвым ходом (пересборка) и делается, когда
|
||||
читатель появится — например, если растяжке `classes_vs_inventory` понадобится
|
||||
сверять больше одного закрытого дня.
|
||||
- **В git — только паспорт, снимок не хранится.** Снимок генерируется при
|
||||
создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в
|
||||
git не хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки
|
||||
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на ней.
|
||||
Первый `make up` пустого стенда — живая демонстрация детерминизма; готовый мир
|
||||
повторный подъём не пересчитывает. Честная потеря —
|
||||
страховка на случай платформенного бага: раньше менти с расходящимися
|
||||
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
|
||||
описи; смягчение — дисциплина целочисленной случайности (раздел 2):
|
||||
расхождения amd64/arm64 сняты устройством, а не сторожем. CI ради одного
|
||||
прогона не заводится (решение владельца 2026-08-30).
|
||||
git не хранят. Паспорт несёт шапку, счётчики и хеши по дням; проверки «пустой
|
||||
git diff» и «пересгенерируй день N — сравни хеш» живут на нём. Первый `make
|
||||
up` пустого стенда — живая демонстрация детерминизма; готовый мир повторный
|
||||
подъём не пересчитывает. Честная потеря — страховка на случай платформенного
|
||||
бага: раньше менти с расходящимися байтами мог взять готовый снимок из git,
|
||||
теперь он упрётся в красный чек паспорта; смягчение — дисциплина целочисленной
|
||||
случайности (раздел 2): расхождения amd64/arm64 сняты устройством, а не
|
||||
сторожем. CI ради одного прогона не заводится (решение владельца 2026-08-30).
|
||||
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
|
||||
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
|
||||
в пиковые часы сильных дней — до ~100. Число —
|
||||
@@ -399,7 +392,7 @@
|
||||
| Операция | Ориентир |
|
||||
|---|---|
|
||||
| Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) |
|
||||
| Пересборка эталонного мира (8 дней + опись, без транспорта) | 20,5 с (замер 30 августа 2026 года) |
|
||||
| Пересборка эталонного мира (8 дней + паспорт, без транспорта) | 20,5 с (замер 30 августа 2026 года) |
|
||||
| Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) |
|
||||
| Лаг живого дня | секунды |
|
||||
|
||||
@@ -460,9 +453,9 @@
|
||||
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
|
||||
contract — хранилище пишется по документации, границу сторожит строгий
|
||||
приём (раздел 3 здесь).
|
||||
- **Раздел 8**: артефакт `data/startup_history/` в git заменён описью;
|
||||
снимок генерируется на месте (раздел 5 здесь). Туман «политика
|
||||
версионирования артефакта» закрыт этим же ходом: версионируется опись.
|
||||
- **Раздел 8**: артефакт `data/startup_history/` в git заменён паспортом;
|
||||
снимок генерируется на месте (раздел 5 здесь). Туман «политика версионирования
|
||||
артефакта» закрыт этим же ходом: версионируется паспорт.
|
||||
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования
|
||||
производительности» закрыт числами раздела 5.
|
||||
- Мелкие согласования там, где текст опирался на артефакт в git: источник
|
||||
@@ -491,24 +484,25 @@
|
||||
теперь есть.
|
||||
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
|
||||
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
||||
- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 —
|
||||
- **Позиция на оси времени** (хвост отдан дагам модельного времени раньше этапа
|
||||
5 —
|
||||
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
|
||||
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
|
||||
она переменной Airflow: отсутствие переменной означает, что стартовый мир
|
||||
ещё не создан. В обычной жизни её ставит даг пульта, который сыграл день, —
|
||||
она переменной Airflow: отсутствие переменной означает, что стартовый мир ещё
|
||||
не создан. В обычной жизни её ставит шаговый даг, который сыграл день, —
|
||||
значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла
|
||||
используют `-1` для незавершённой очистки, `0` для чистого мира до генерации
|
||||
и ставят первый несыгранный день только после приёма стартового мира
|
||||
([ADR 0013](../adr/0013-world-lifecycle.md)).
|
||||
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
||||
хранилищу незачем. Полный `make clean` сносит метаданные Airflow вместе с
|
||||
данными ClickHouse, а узкая пересборка явно сводит сохранённую позицию с
|
||||
заново залитым миром.
|
||||
Расхождение переменной с данными (менти почистил партицию руками) лечится
|
||||
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
|
||||
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
|
||||
- **Этап 7 (эталонный мир)**: пересборка — это опись, не артефакт.
|
||||
CI-генерация на amd64 и arm64 снята — резолюция в разделе 5.
|
||||
используют `-1` для незавершённой очистки, `0` для чистого мира до генерации и
|
||||
ставят первый несыгранный день только после приёма стартового мира ([ADR
|
||||
0013](../adr/0013-world-lifecycle.md)). Довод — генератор отдельная и
|
||||
заменяемая сущность, привязывать его к хранилищу незачем. Полный `make clean`
|
||||
сносит метаданные Airflow вместе с данными ClickHouse, а узкая пересборка явно
|
||||
сводит сохранённую позицию с заново залитым миром. Расхождение переменной с
|
||||
данными (менти почистил партицию руками) лечится документацией, а не сторожем:
|
||||
постоянная сверка была бы проверкой, красной в норме ([ADR
|
||||
0002](../adr/0002-monitoring-scope.md)).
|
||||
- **Этап 7 (эталонный мир)**: пересборка обновляет паспорт мира. Отдельного
|
||||
артефакта у неё нет. CI-генерация на amd64 и arm64 снята — резолюция в разделе
|
||||
5.
|
||||
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
||||
готовая демонстрация идемпотентности конвейера.
|
||||
|
||||
@@ -521,10 +515,10 @@
|
||||
[ADR 0017](../adr/0017-order-numbering.md). Новые решения генератора
|
||||
записывают в ADR и в спеке этапа, на котором их принимают.
|
||||
|
||||
Журнал собран по темам, но дата и тикет остались у каждого решения. По
|
||||
журналу видно, какие границы затрагивала каждая правка: иногда она меняла
|
||||
сразу план состава, воронку и каталог. При #40 это было дешево — опись еще
|
||||
не собрали; позже такой ход потребовал бы пересборки описи.
|
||||
Журнал собран по темам, но дата и тикет остались у каждого решения. По журналу
|
||||
видно, какие границы затрагивала каждая правка: иногда она меняла сразу план
|
||||
состава, воронку и каталог. При #40 это было дёшево — паспорт мира ещё не
|
||||
собрали; позже такой ход потребовал бы пересборки паспорта мира.
|
||||
|
||||
### План состава и числа мира
|
||||
|
||||
@@ -547,10 +541,10 @@
|
||||
покупателей ~190 в день, что дает конверсию ~2% на сессию.
|
||||
- **Конфигурация мира — модуль чистых данных** (#38, 2026-08-02) рядом с
|
||||
контрактом схемы: все числа мира собраны в одном месте, которое менти может
|
||||
крутить. Правка модуля меняет мир, и проверка описи честно краснеет: опись
|
||||
сторожит только канон. Вне модуля остается то, что мира не меняет, — свое
|
||||
зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и
|
||||
переопределения окружением — переменная мира, которой нет в паспорте описи;
|
||||
крутить. Правка модуля меняет мир, и проверка паспорта честно краснеет:
|
||||
паспорт сторожит только канон. Вне модуля остается то, что мира не меняет, —
|
||||
свое зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и
|
||||
переопределения окружением — переменная мира, которой нет в шапке паспорта;
|
||||
файл-конфиг в репозитории равен модулю по смыслу, но платит загрузчиком и
|
||||
валидацией (довод раздела 3 против YAML).
|
||||
- **Покупатель перестает быть словом — двумя рычагами сразу** (#40, решение
|
||||
@@ -749,8 +743,8 @@
|
||||
четвертям цены и по медиане, потому что одни только края списка обманываются
|
||||
четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом ещё
|
||||
не построен (#37/#43) — колонку надо взять в его описание сразу, а не догонять
|
||||
правкой; описи нужен хеш каталога (хвост #42) тем более, потому что теперь в
|
||||
файле живёт ещё и поведение.
|
||||
правкой; паспорту нужен хеш каталога (хвост #42) тем более, потому что теперь
|
||||
в файле живёт ещё и поведение.
|
||||
- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%**
|
||||
(#50, 2026-08-02) и подошла к потолку вилки. Визитам вне `/cart` нужны свои
|
||||
позиции, а визит с намерением должен класть почти все сравненное; обе границы
|
||||
@@ -764,7 +758,7 @@
|
||||
живых магазинов эта доля гуляет широко; пока она правдоподобна, сама по себе
|
||||
ничего не сторожит, и подгонять поведение под ее край не надо. Настоящий
|
||||
предел считается в другой валюте — дневной бюджет событий, на котором стоит
|
||||
опись #42. Если будущая правка упрется в него, двигать надо бюджет и его
|
||||
паспорт #42. Если будущая правка упрется в него, двигать надо бюджет и его
|
||||
причины, а не долю.
|
||||
|
||||
### Номер заказа, промокод и деньги
|
||||
@@ -933,28 +927,27 @@
|
||||
подъем указал бы внутрь `.venv`: каталог был бы в образе, но модуль его не
|
||||
нашел бы. Проверено в контейнере: день совпал побайтово с прогоном на машине.
|
||||
|
||||
### Опись мира и словарь
|
||||
### Паспорт мира и словарь
|
||||
|
||||
- **Опись мира одна** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7,
|
||||
2026-08-30). Стартовый мир этапа 2 — первые восемь дней,
|
||||
понедельник по понедельник: полная неделя и первый замкнутый цикл K = 7.
|
||||
`data/world-inventory.json` — та самая опись, которую обещает раздел 5.
|
||||
Этап 7 оставил ее восьмидневной: у хвоста в шесть дней не нашлось читателя
|
||||
(резолюция — в разделе 5); вырастет она продолжением этого же файла, а не
|
||||
вторым. В JSON лежат
|
||||
паспорт мира — зерно, версия генератора и хеш каталога — и строки дней с
|
||||
датой, числом событий и хешем. Опись собирает `make inventory`, свежесть
|
||||
сторожит `test_inventory.py` тем же способом, что свежесть описания выгрузки.
|
||||
Отклонено: *два файла, младший и полный* — две правды об одном мире и лишнее
|
||||
слово в словаре.
|
||||
- **Паспорт мира один** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7,
|
||||
2026-08-30). Стартовый мир этапа 2 — первые восемь дней, понедельник по
|
||||
понедельник: полная неделя и первый замкнутый цикл K = 7.
|
||||
`data/world-inventory.json` — тот самый паспорт, который обещает раздел 5.
|
||||
Этап 7 оставил его восьмидневным: у хвоста в шесть дней не нашлось читателя
|
||||
(резолюция — в разделе 5); вырастет он продолжением этого же файла, а не
|
||||
вторым. В JSON лежат шапка паспорта — зерно, версия генератора и хеш каталога
|
||||
— и строки дней с датой, числом событий и хешем. Паспорт собирает `make
|
||||
inventory`, свежесть сторожит `test_inventory.py` тем же способом, что
|
||||
свежесть описания выгрузки. Отклонено: *два файла, младший и полный* — две
|
||||
правды об одном мире и лишнее слово в словаре.
|
||||
- **Хеш каталога лежит в паспорте и объясняет расхождение** (#42, 2026-08-07).
|
||||
Правка цены меняет мир так же молча, как правка кода. Хеши дней и без
|
||||
каталожного хеша поймают смену мира; он отвечает на следующий вопрос — что
|
||||
менялось: разошлись дни и каталог — CSV, только дни — код.
|
||||
- **«Манифест» переименован в «опись мира», «зерновой мир» — в «стартовый мир»**
|
||||
(#42, решение владельца 2026-08-07). Обычные русские слова убирают пару
|
||||
«манифест / мини-манифест» и необходимость различать две сущности там, где
|
||||
вещь одна.
|
||||
(#42, решение владельца 2026-08-07; позднее тикет #214 сменил «опись мира» на
|
||||
«паспорт мира»). Обычные русские слова убирают пару «манифест / мини-манифест»
|
||||
и необходимость различать две сущности там, где вещь одна.
|
||||
|
||||
### Открытое на момент заморозки
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ DDL и двумя одноразовыми службами заливал во
|
||||
|
||||
Граница владения не совпадала с процессом, и это видно по следствиям. Успешная
|
||||
отправка в Kafka выдавалась за созданный мир: `world-init` завершался сразу
|
||||
после записи в топик, до приёма сообщений хранилищем.
|
||||
Повторный `make up` смешивал подъём служб с повторной заливкой данных. Узкая
|
||||
пересборка хранилища сохраняла в Kafka сообщения и офсеты прежнего мира, потому
|
||||
что чинила только ClickHouse. Позиция пульта правилась снаружи Airflow — тем,
|
||||
кто миром не управляет.
|
||||
после записи в топик, до приёма сообщений хранилищем. Повторный `make up`
|
||||
смешивал подъём служб с повторной заливкой данных. Узкая пересборка хранилища
|
||||
сохраняла в Kafka сообщения и офсеты прежнего мира, потому что чинила только
|
||||
ClickHouse. Позиция на оси правилась снаружи Airflow — тем, кто миром не
|
||||
управляет.
|
||||
|
||||
**Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат
|
||||
прикладная схема, данные мира и позиция модельного времени, — и Compose,
|
||||
@@ -63,7 +63,7 @@ Airflow держит прикладные топики Kafka, каноничес
|
||||
|
||||
Каталог `/opt/airflow/world` — то, что нужно для создания мира: `generator/`,
|
||||
`data/` и корневой `.dockerignore` смонтированы только для чтения. Из первого
|
||||
собирается канонический образ генератора, из второго читается опись мира, а
|
||||
собирается канонический образ генератора, из второго читается паспорт мира, а
|
||||
последний не пускает в контекст сборки локальные окружения и кэши. Запросы
|
||||
стенда приезжают прежним монтированием `sql/`.
|
||||
|
||||
@@ -101,7 +101,7 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
|
||||
кода: сборкой образа при подъёме больше никто не занимается.
|
||||
5. Играет стартовые дни одним прогоном `batch` того же образа.
|
||||
6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего
|
||||
сценария, и оно точное: сумма строк `ods.event_dist` за даты описи и строк
|
||||
сценария, и оно точное: сумма строк `ods.event_dist` за даты паспорта и строк
|
||||
`ods.event_errors_dist` не меньше суммы событий из
|
||||
[`data/world-inventory.json`](../../data/world-inventory.json). «Что-то
|
||||
приехало» условием не считается.
|
||||
@@ -136,21 +136,21 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
|
||||
третьим местом с той же последовательностью, а первое же расхождение между
|
||||
дагами пришлось бы прятать в его параметрах.
|
||||
|
||||
### Сторожа у работников мира
|
||||
### Сторожа у шаговых дагов
|
||||
|
||||
`world_next_day` и `world_live_day` краснеют, если позиции нет либо она меньше
|
||||
`WORLD_STARTING_DAYS`. Отказ называет прочитанное значение и следующий шаг:
|
||||
поднять стенд или пересобрать хранилище. Выключатель `world_live` своего
|
||||
сторожа не получает — он краснеет вслед за работником.
|
||||
поднять стенд или пересобрать хранилище. Выключатель `world_live` своего сторожа
|
||||
не получает — он краснеет вслед за шаговым дагом.
|
||||
|
||||
`orders_ingest` сторожа не получает вовсе: его зовёт сама инициализация, когда
|
||||
готового мира ещё нет.
|
||||
|
||||
## Состояния позиции
|
||||
|
||||
`world_position` остаётся единственным состоянием пульта.
|
||||
`world_position` остаётся единственным состоянием дагов модельного времени.
|
||||
|
||||
| Позиция | `world_initialize` | `world_recreate` | Работники |
|
||||
| Позиция | `world_initialize` | `world_recreate` | Шаговые даги |
|
||||
|---|---|---|---|
|
||||
| переменной нет | создаёт мир | очищает и создаёт | красный отказ |
|
||||
| `-1` | красный отказ: очистка не завершена, повторите `make rebuild-storage` | очищает и создаёт | красный отказ |
|
||||
@@ -187,9 +187,9 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
|
||||
ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо.
|
||||
Поэтому сначала удаляются базы, затем топики.
|
||||
|
||||
**Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: сырьё
|
||||
в STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на непустом
|
||||
ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта
|
||||
**Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: число
|
||||
строк STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на
|
||||
непустом ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта
|
||||
заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти
|
||||
делает себе сам и тут же видит.
|
||||
|
||||
@@ -197,10 +197,10 @@ ODS истинно сразу — этим оно и ограничено, об
|
||||
`world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом
|
||||
режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline`
|
||||
и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот.
|
||||
Одновременная пересборка добавляет ожидания и загоняет стенд в тупик.
|
||||
Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live`
|
||||
на паузу и даёт работникам доиграть. Предусловие остаётся в документации;
|
||||
механизм блокировок стоил бы дороже этой строки.
|
||||
Одновременная пересборка добавляет ожидания и загоняет стенд в тупик. Поэтому
|
||||
пересборка — явная операция обслуживания: оператор ставит `world_live` на паузу
|
||||
и даёт шаговым дагам доиграть. Предусловие остаётся в документации; механизм
|
||||
блокировок стоил бы дороже этой строки.
|
||||
|
||||
## Границы
|
||||
|
||||
@@ -226,9 +226,9 @@ ODS истинно сразу — этим оно и ограничено, об
|
||||
позиция та же, счётчики `check-clickhouse` те же, в журнале дага сказано, что
|
||||
мир уже есть.
|
||||
- **`make rebuild-storage`** возвращает канонический мир: позиция проходит
|
||||
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы
|
||||
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты паспорта, топики созданы
|
||||
заново, офсеты групп идут с нуля, DDS и DM собраны.
|
||||
- **Красные пути проверяются своими отказами.** Сторож работника при
|
||||
- **Красные пути проверяются своими отказами.** Сторож шагового дага при
|
||||
отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`;
|
||||
оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки,
|
||||
доказывает только то, что умеет печатать «ЗЕЛЁНО».
|
||||
|
||||
@@ -40,6 +40,20 @@
|
||||
- *расследование* — данные уже лежат на стенде или стенд намеренно ломается, а
|
||||
менти ищет причину и объясняет ее.
|
||||
|
||||
### Как писать задания
|
||||
|
||||
Пишите для учащегося: объясняйте, чему он научится, зачем нужны действия
|
||||
и как понять результат. Историю разработки оставляйте за пределами задания.
|
||||
|
||||
«Как проверить себя» описывает действия для проверки и поиска ошибок.
|
||||
«Что должно получиться» — результат работы и то, что учащийся теперь может
|
||||
объяснить. Если проверка уже встроена в шаги упражнения, повторять ее
|
||||
отдельным разделом не нужно.
|
||||
|
||||
Остальные заголовки и структуру выбирайте по содержанию. Образцы — задания
|
||||
про [дашборд Superset](../labs/optional/superset-dashboard.md) и
|
||||
[расследование подключения](../labs/optional/superset-missing-object.md).
|
||||
|
||||
## 3. Основная программа — семь ступеней
|
||||
|
||||
### Ступень 1. Построй пять витрин слоя DM
|
||||
@@ -64,7 +78,7 @@
|
||||
ступени предстоит написать при реализации программы. Полный собственный дашборд
|
||||
остается факультативом в разделе «Superset и BI».
|
||||
|
||||
### Ступень 3. Нарежь сессии сам
|
||||
### Ступень 3. Нарежьте сессии сами
|
||||
|
||||
До того как открыть стендовый SQL нарезки сессий в DDS, менти самостоятельно
|
||||
делит события на визиты. Сначала он сравнивает число своих сессий с эталоном
|
||||
@@ -75,7 +89,7 @@
|
||||
Форма — построить свое по ТЗ и провести расследование. Обратная связь состоит
|
||||
из счетной сверки с `VisitID` и ручного сопоставления границ.
|
||||
|
||||
### Ступень 4. Перепиши нарезку стенда
|
||||
### Ступень 4. Перепишите нарезку стенда
|
||||
|
||||
Менти удаляет и заново пишет SQL нарезки сессий DDS: расчет объема, пересборку
|
||||
и замену. Урок — изменить расчетную сущность внутри готового конвейера и
|
||||
@@ -86,7 +100,7 @@
|
||||
Форма — вычитание. Обратную связь дают `dq_check` и поштучная сверка,
|
||||
построенная на третьей ступени.
|
||||
|
||||
### Ступень 5. Сначала сторож, потом ломай: заказы
|
||||
### Ступень 5. Научите проверки замечать ошибки в заказах
|
||||
|
||||
Сначала менти строит две недостающие DQ-проверки. Первая утверждает равенство
|
||||
«цена × количество = items_total». Вторая сверяет слой с источником по дням. Их
|
||||
@@ -104,14 +118,14 @@
|
||||
|
||||
### Ступень 6. Разбери сырые события и сверь результат со стендом
|
||||
|
||||
Менти пакетно разбирает срез сырья событий в собственную таблицу и построчно
|
||||
Менти пакетно разбирает срез событий из STG в собственную таблицу и построчно
|
||||
сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока
|
||||
отдельно от живого приема и использовать стендовый результат как образец для
|
||||
сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет
|
||||
сообщения.
|
||||
|
||||
Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее
|
||||
границы называются прямо: срок работы ограничен окном хранения сырья, а
|
||||
границы называются прямо: срок работы ограничен окном хранения сырых данных, а
|
||||
содержимое неопорных колонок можно сравнить только со стендовым разбором, не с
|
||||
независимым эталоном.
|
||||
|
||||
@@ -150,7 +164,8 @@
|
||||
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
|
||||
разложить до конкретной вычеркнутой позиции и объяснить до копейки.
|
||||
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
|
||||
некоторые различия после истечения срока сырья уже не восстановить.
|
||||
некоторые различия после истечения срока хранения сырых данных уже не
|
||||
восстановить.
|
||||
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день
|
||||
становится окончательным лишь после того, как источник больше не может
|
||||
изменить этот день.
|
||||
@@ -170,11 +185,23 @@
|
||||
|
||||
Открывается после ступени 2 основной программы.
|
||||
|
||||
- **Свой дашборд.** Готовая лаба
|
||||
[`docs/labs/superset-dashboard.md`](../labs/superset-dashboard.md) учит
|
||||
хранить пользовательские объекты отдельно от стендовых.
|
||||
- **Почему дашборд не видит объект на второй ноде.** Забытый `ON CLUSTER` может
|
||||
остаться незаметным до тех пор, пока дашборд не обратится ко второй ноде.
|
||||
- **[Свой дашборд](../labs/optional/superset-dashboard.md).** Менти использует
|
||||
результат ступени 2, собирает отчет из пяти своих витрин и подтверждает
|
||||
выводы запросами. Пользовательские объекты остаются отдельно от стендовых.
|
||||
- **[В DBeaver работает, в Superset нет](../labs/optional/superset-missing-object.md).**
|
||||
Менти создает отдельное представление на первой ноде и расследует ошибку
|
||||
чтения из Superset, подключенного ко второй. Опыт заканчивается объяснением,
|
||||
исправлением DDL и удалением учебного объекта.
|
||||
|
||||
Задания самостоятельны: расследование использует готовую витрину стенда.
|
||||
Отсутствие объекта проверяется в SQL Lab, поскольку создание физического
|
||||
датасета может остановиться раньше, чем появится график. Чтение только одного
|
||||
шарда остается материалом инженерного раздела: это отдельная причина
|
||||
неполного результата, которую добавление `ON CLUSTER` само по себе не устраняет.
|
||||
|
||||
Технический опыт проверен 2026-09-13 на ClickHouse 26.3.17.56: локальное
|
||||
представление недоступно пользователю `bi` на второй ноде; после создания
|
||||
на кластере обе ноды возвращают тот же счет, что исходная витрина.
|
||||
|
||||
### Инженерия стенда
|
||||
|
||||
@@ -191,7 +218,8 @@
|
||||
потребителей создает еще одну полную копию потока.
|
||||
- **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах
|
||||
показывает потерю, которую трудно заметить по самим данным.
|
||||
- **Почему сырье удаляется по времени загрузки.** Очистка сырья живет по
|
||||
- **Почему сырые данные удаляются по времени загрузки.** Очистка сырых данных
|
||||
идёт по
|
||||
времени загрузки, а не по модельному дню.
|
||||
- **Куда попадают битые строки.** Годные строки и брак делят поток без
|
||||
пропусков и пересечений; менти подкладывает по строке каждого класса брака
|
||||
|
||||
+6
-4
@@ -64,8 +64,8 @@
|
||||
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
||||
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
||||
из контракта. Документ руками не правят — пересобирают.
|
||||
- `src/clickstream_generator/inventory.py` — сборка описи мира
|
||||
([`data/world-inventory.json`](../data/world-inventory.json)): паспорт мира
|
||||
- `src/clickstream_generator/inventory.py` — сборка паспорта мира
|
||||
([`data/world-inventory.json`](../data/world-inventory.json)): шапка паспорта
|
||||
и хеши восьми дней, которыми наполняется стенд, плюс счётчики классов
|
||||
расхождений по заказам для дней с закрытым окном. Руками не правят —
|
||||
пересобирают целью `make inventory`.
|
||||
@@ -77,7 +77,9 @@
|
||||
|
||||
## Команды
|
||||
|
||||
Проиграть день — [быстрый старт](../README.md#как-позвать-генератор) и `--help`.
|
||||
Проиграть день — раздел [«Как запустить
|
||||
генератор»](../docs/architecture/world.md#как-запустить-генератор)
|
||||
справочника мира и `--help`.
|
||||
|
||||
Из этого каталога — цели генератора живут здесь, а не в корне:
|
||||
|
||||
@@ -86,7 +88,7 @@
|
||||
- `make typecheck` — ty: проверка типов;
|
||||
- `make demo` — пройти стадии канонического D0 на одном экране;
|
||||
- `make docs` — пересобрать описание выгрузки;
|
||||
- `make inventory` — пересобрать опись мира.
|
||||
- `make inventory` — пересобрать паспорт мира.
|
||||
|
||||
В корне репозитория `make lint` тоже есть, но он про код стенда — даги и
|
||||
Superset. Одноимённые цели за разными дверями охватывают разное:
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"""Опись мира: чем стенд наполняется при подъёме и каким это обязано выйти.
|
||||
"""Паспорт мира: чем стенд наполняется при подъёме и каким это обязано выйти.
|
||||
|
||||
Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не
|
||||
он сам, а опись: паспорт мира, число событий и хеш байтов каждого дня, счётчики
|
||||
классов расхождений по заказам. Сам мир пересчитывается когда угодно, а опись
|
||||
он сам, а паспорт: шапка, число событий и хеш байтов каждого дня, счётчики
|
||||
классов расхождений по заказам. Сам мир пересчитывается когда угодно, а паспорт
|
||||
отвечает на единственный вопрос — **тот ли это мир, что был вчера**. Разошлись
|
||||
хеши — мир уехал, и дальше уже неважно, чего от него ждали проверки.
|
||||
|
||||
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
|
||||
Дней в паспорте восемь: столько заливается в стенд при `make up`. Понедельник по
|
||||
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
|
||||
Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 —
|
||||
у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот
|
||||
у хвоста не нашлось читателя. Понадобится длиннее — паспорт продолжит этот
|
||||
же файл, а не заведет второй.
|
||||
|
||||
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
|
||||
@@ -30,9 +30,9 @@
|
||||
Слепок заказов — второй артефакт мира, и хешами дней он не покрыт ни при каком
|
||||
раскладе подпотоков, поэтому у каждого отправленного слепка своя строка. Их на
|
||||
один меньше, чем дней: прогон дня 0 отправлять ещё нечего. Счёта строк у
|
||||
слепка нет — опись описывает мир, а не доставку.
|
||||
слепка нет — паспорт описывает мир, а не доставку.
|
||||
|
||||
Собирается опись из каталога генератора целью `make inventory`, а свежесть её
|
||||
Собирается паспорт из каталога генератора целью `make inventory`, а его свежесть
|
||||
сторожит тест — как и у «описания выгрузки».
|
||||
"""
|
||||
|
||||
@@ -57,7 +57,7 @@ STARTING_DAYS = 8
|
||||
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
"""Опись целиком: паспорт мира, строка на день и строка на слепок.
|
||||
"""Паспорт целиком: шапка, строка на день и строка на слепок.
|
||||
|
||||
Дни играются по одному и отпускаются: заказы дня остаются, потому что из
|
||||
них собираются слепки, а полсотни тысяч событий восьми дней сразу в память
|
||||
@@ -83,12 +83,12 @@ def build() -> dict[str, Any]:
|
||||
|
||||
|
||||
def render() -> str:
|
||||
"""Опись текстом файла: отступы в два пробела, кириллица как есть."""
|
||||
"""Паспорт текстом файла: отступы в два пробела, кириллица как есть."""
|
||||
return json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
def _day(number: int, today: day_module.Day) -> dict[str, Any]:
|
||||
"""Строка описи: номер дня, его дата, число событий и хеш байтов.
|
||||
"""Строка паспорта: номер дня, его дата, число событий и хеш байтов.
|
||||
|
||||
Дата считается от D0 арифметикой, а не берётся из событий: ось модельного
|
||||
времени так и определена (`world.ORIGIN`), и по этой же дате счётчики
|
||||
@@ -105,7 +105,7 @@ def _day(number: int, today: day_module.Day) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]:
|
||||
"""Строка описи слепка: какой день снят, его дата и хеш отправленных байтов.
|
||||
"""Строка паспорта слепка: какой день снят, его дата и хеш отправленных байтов.
|
||||
|
||||
Байты те же, что уезжают в топик `orders`, с переводом строки после
|
||||
каждого заказа: пересъёмка слепка обязана дать их снова.
|
||||
@@ -169,12 +169,12 @@ def _digest(payload: bytes) -> str:
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Собирает опись мира: паспорт, счётчики и хеши дней."
|
||||
description="Собирает паспорт мира: шапку, счётчики и хеши дней."
|
||||
)
|
||||
parser.add_argument("output", type=Path, help="путь к файлу описи")
|
||||
parser.add_argument("output", type=Path, help="путь к файлу паспорта")
|
||||
output = parser.parse_args().output
|
||||
output.write_text(render(), encoding="utf-8")
|
||||
print(f"Опись мира собрана: {output}")
|
||||
print(f"Паспорт мира собран: {output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
Тайминги печатаются раздельно — генерация и доставка, как требует спека
|
||||
(раздел 5): это разные машины разной природы, и сложенные в одно число они
|
||||
перестают что-либо говорить. Сериализация считается частью генерации: она
|
||||
рождает те самые байты, которые сторожит опись.
|
||||
рождает те самые байты, которые сторожит паспорт.
|
||||
|
||||
**Слепки заказов идут третьим ходом того же проигрывателя** и живут по правилу
|
||||
сдвига: прогон дня D отправляет слепок дня D−1 — ночная выгрузка бэкенда за
|
||||
|
||||
@@ -25,8 +25,8 @@ from enum import IntEnum
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Каноническое зерно эталонного мира — константа репозитория; опись хранит
|
||||
# его в паспорте мира. Свои зёрна менти крутит без гарантий описи.
|
||||
# Каноническое зерно эталонного мира — константа репозитория; паспорт хранит
|
||||
# его в своей шапке. Свои зёрна менти крутит без гарантий паспорта.
|
||||
CANONICAL_SEED = 20260601
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
строгий приём хранилища сверяет набор ключей (ADR 0005).
|
||||
- **Даты и время — ISO-8601** (спека, раздел 4): `EventDate` уезжает как
|
||||
`2026-06-01`, `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость
|
||||
сырья: менти открывает колонку `raw` обычным клиентом и разбирает событие
|
||||
сырых данных: менти открывает колонку `raw` обычным клиентом и разбирает событие
|
||||
глазами, а число эпохи этот урок убивает.
|
||||
- **Одно событие — один документ JSON**, без перевода строки внутри: приёмник
|
||||
сам решает, чем их разделить.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Модуль — приглашение крутить: поменяйте число, пересоберите снимок и
|
||||
посмотрите, что стало с данными. Правка любой константы здесь — смена мира,
|
||||
поэтому чек описи честно покраснеет: опись сторожит только канонический мир,
|
||||
свои миры менти собирает без её гарантий (спека генератора, раздел 9).
|
||||
поэтому чек паспорта честно покраснеет: паспорт сторожит только канонический мир,
|
||||
свои миры менти собирает без его гарантий (спека генератора, раздел 9).
|
||||
|
||||
Числа решены спекой и связаны между собой; связки сторожат тесты
|
||||
`test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего.
|
||||
@@ -28,7 +28,7 @@ COUNTER_TIMEZONE_MINUTES = 240 # Europe/Samara
|
||||
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
|
||||
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
|
||||
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
|
||||
# зависит — иначе опись перестала бы быть воспроизводимой.
|
||||
# зависит — иначе паспорт перестал бы быть воспроизводимым.
|
||||
ORIGIN = date(2026, 6, 1)
|
||||
|
||||
# Окно изменяемости заказа, модельные дни: слепок бэкенда несёт заказы,
|
||||
|
||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
|
||||
|
||||
def test_demo_runs_and_shows_the_whole_chain() -> None:
|
||||
"""Сторожит команду и стадии, а числа мира оставляет описи."""
|
||||
"""Сторожит команду и стадии, а проверку чисел мира оставляет паспорту."""
|
||||
finished = subprocess.run(
|
||||
["make", "--silent", "demo"],
|
||||
check=False,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Проверка описи мира: та ли она, что собирается из кода сегодня.
|
||||
"""Проверка паспорта мира: совпадает ли он с тем, что собирается из кода сегодня.
|
||||
|
||||
Опись собирается из кода, значит разойтись они могут только одним способом —
|
||||
код правили, опись не пересобрали. Ровно это здесь и сторожится, тем же
|
||||
Паспорт собирается из кода, значит разойтись они могут только одним способом —
|
||||
код правили, паспорт не пересобрали. Ровно это здесь и сторожится, тем же
|
||||
способом, что свежесть «описания выгрузки».
|
||||
|
||||
Проверка дорогая — она пересчитывает восемь модельных дней целиком, и это
|
||||
@@ -23,24 +23,24 @@ from clickstream_generator.seeds import CANONICAL_SEED
|
||||
|
||||
INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory.json"
|
||||
|
||||
# Прогон, слепок которого сверяется с описью байт в байт. День любой из
|
||||
# Прогон, слепок которого сверяется с паспортом байт в байт. День любой из
|
||||
# отправляемых; этот дешевле прочих — его окно короче.
|
||||
RUN_DAY = 2
|
||||
|
||||
# День любой: хеш дня не зависит ни от соседей, ни от края описи.
|
||||
# День любой: хеш дня не зависит ни от соседей, ни от края паспорта.
|
||||
REPLAY_DAY = 4
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def inventory() -> dict:
|
||||
"""Опись, собранная из кода: мир пересчитывается один раз на весь модуль."""
|
||||
"""Паспорт, собранный из кода: мир пересчитывается один раз на весь модуль."""
|
||||
return build()
|
||||
|
||||
|
||||
def test_inventory_is_up_to_date(inventory: dict):
|
||||
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
||||
assert stored == inventory, (
|
||||
"опись мира отстала от кода — пересоберите: make inventory."
|
||||
"паспорт мира отстал от кода — пересоберите: make inventory."
|
||||
" Разошлись хеши дней и каталога — правили data/catalog/products.csv;"
|
||||
" разошлись только дни — правили генератор"
|
||||
)
|
||||
@@ -68,7 +68,7 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm
|
||||
"""У каждого отправленного слепка — своя строка с хешем его байтов.
|
||||
|
||||
Байты слепка не покрыты хешами дней ни при каком раскладе подпотоков: это
|
||||
второй артефакт мира, и побайтовое обещание сторожит опись. Слепков на день
|
||||
второй артефакт мира, и побайтовое обещание сторожит паспорт. Слепков на день
|
||||
меньше, чем дней: прогон дня 0 не отправляет ничего.
|
||||
|
||||
Хеш сверяется с настоящей выгрузкой, а не с самим собой: в файл уезжают те
|
||||
@@ -86,7 +86,7 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm
|
||||
|
||||
|
||||
def test_a_replayed_day_matches_its_inventory_hash(tmp_path):
|
||||
"""Опись сверяется с байтами, вышедшими из CLI, а не с собранной тут же:
|
||||
"""Паспорт сверяется с байтами, вышедшими из CLI, а не с собранной тут же:
|
||||
пересчет сравнивался бы сам с собой.
|
||||
"""
|
||||
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_every_event_carries_every_column(events):
|
||||
|
||||
«Пусто» по контракту — пустое значение, а не отсутствие ключа: пропавший
|
||||
ключ уводит событие в брак целиком (ADR 0005). Порядок ключей — часть
|
||||
канона: от него зависят байты, а значит и хеши описи.
|
||||
канона: от него зависят байты, а значит и хеши паспорта.
|
||||
"""
|
||||
names = [column.name for column in schema.COLUMNS]
|
||||
for event in events:
|
||||
|
||||
@@ -167,7 +167,7 @@ def test_the_moments_carry_real_milliseconds(days: list[day_module.Day]):
|
||||
def test_the_rows_go_in_the_order_of_birth(days: list[day_module.Day]):
|
||||
"""Порядок строк слепка — порядок рождения заказов, он же рост номера.
|
||||
|
||||
Хешу слепка в описи нужен именно названный порядок: детерминизм даёт его
|
||||
Хешу слепка в паспорте нужен именно названный порядок: детерминизм даёт его
|
||||
даром, но обещание побайтового повтора держится на нём, а не на удаче.
|
||||
"""
|
||||
numbers = [record["order_id"] for record in snapshot_records(days, FULL_WINDOW_DAY)]
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Поток: сколько строк приезжает в слой сырья",
|
||||
"title": "Поток: сколько строк приезжает в сырой слой",
|
||||
"datasource": {
|
||||
"type": "grafana-clickhouse-datasource",
|
||||
"uid": "clickhouse"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
slice_name: Средний чек, ₽
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: big_number_total
|
||||
params:
|
||||
viz_type: big_number_total
|
||||
metric: avg_check
|
||||
adhoc_filters:
|
||||
- expressionType: SIMPLE
|
||||
subject: report_date
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: No filter
|
||||
clause: WHERE
|
||||
header_font_size: 0.3
|
||||
cache_timeout: null
|
||||
uuid: 19600000-0000-4000-8000-000000000106
|
||||
version: 1.0.0
|
||||
dataset_uuid: 19600000-0000-4000-8000-000000000001
|
||||
@@ -0,0 +1,19 @@
|
||||
slice_name: Заказы
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: big_number_total
|
||||
params:
|
||||
viz_type: big_number_total
|
||||
metric: orders
|
||||
adhoc_filters:
|
||||
- expressionType: SIMPLE
|
||||
subject: report_date
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: No filter
|
||||
clause: WHERE
|
||||
header_font_size: 0.3
|
||||
cache_timeout: null
|
||||
uuid: 19600000-0000-4000-8000-000000000105
|
||||
version: 1.0.0
|
||||
dataset_uuid: 19600000-0000-4000-8000-000000000001
|
||||
@@ -0,0 +1,19 @@
|
||||
slice_name: Выручка, ₽
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: big_number_total
|
||||
params:
|
||||
viz_type: big_number_total
|
||||
metric: revenue
|
||||
adhoc_filters:
|
||||
- expressionType: SIMPLE
|
||||
subject: report_date
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: No filter
|
||||
clause: WHERE
|
||||
header_font_size: 0.3
|
||||
cache_timeout: null
|
||||
uuid: 19600000-0000-4000-8000-000000000104
|
||||
version: 1.0.0
|
||||
dataset_uuid: 19600000-0000-4000-8000-000000000001
|
||||
@@ -0,0 +1,19 @@
|
||||
slice_name: Посетители
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: big_number_total
|
||||
params:
|
||||
viz_type: big_number_total
|
||||
metric: visitors
|
||||
adhoc_filters:
|
||||
- expressionType: SIMPLE
|
||||
subject: report_date
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: No filter
|
||||
clause: WHERE
|
||||
header_font_size: 0.3
|
||||
cache_timeout: null
|
||||
uuid: 19600000-0000-4000-8000-000000000107
|
||||
version: 1.0.0
|
||||
dataset_uuid: 19600000-0000-4000-8000-000000000003
|
||||
@@ -0,0 +1,31 @@
|
||||
slice_name: Доля известных пользователей
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: echarts_timeseries_line
|
||||
params:
|
||||
viz_type: echarts_timeseries_line
|
||||
x_axis: report_date
|
||||
metrics:
|
||||
- known_share
|
||||
groupby: []
|
||||
adhoc_filters:
|
||||
- expressionType: SIMPLE
|
||||
subject: report_date
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: No filter
|
||||
clause: WHERE
|
||||
row_limit: 10000
|
||||
seriesType: line
|
||||
show_legend: false
|
||||
rich_tooltip: true
|
||||
show_value: false
|
||||
markerEnabled: true
|
||||
markerSize: 6
|
||||
y_axis_format: .1%
|
||||
x_axis_time_format: '%d.%m'
|
||||
tooltipTimeFormat: '%d.%m.%Y'
|
||||
cache_timeout: null
|
||||
uuid: 19600000-0000-4000-8000-000000000109
|
||||
version: 1.0.0
|
||||
dataset_uuid: 19600000-0000-4000-8000-000000000003
|
||||
@@ -6,9 +6,9 @@ viz_type: pivot_table_v2
|
||||
params:
|
||||
viz_type: pivot_table_v2
|
||||
groupbyRows:
|
||||
- order_day
|
||||
groupbyColumns:
|
||||
- mismatch_class
|
||||
groupbyColumns:
|
||||
- order_day
|
||||
metrics:
|
||||
- order_count
|
||||
adhoc_filters:
|
||||
@@ -23,8 +23,7 @@ params:
|
||||
colOrder: key_a_to_z
|
||||
order_desc: true
|
||||
row_limit: 10000
|
||||
valueFormat: SMART_NUMBER
|
||||
date_format: smart_date
|
||||
date_format: '%d.%m.%Y'
|
||||
cache_timeout: null
|
||||
uuid: 19600000-0000-4000-8000-000000000102
|
||||
version: 1.0.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
slice_name: Выручка по дням и категориям
|
||||
slice_name: Выручка по дням и категориям, ₽
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
@@ -20,10 +20,13 @@ params:
|
||||
stack: Stack
|
||||
orientation: vertical
|
||||
show_legend: true
|
||||
legendType: plain
|
||||
legendOrientation: bottom
|
||||
rich_tooltip: true
|
||||
show_value: false
|
||||
y_axis_format: ',.2f'
|
||||
x_axis_time_format: smart_date
|
||||
y_axis_format: ',d'
|
||||
x_axis_time_format: '%d.%m'
|
||||
tooltipTimeFormat: '%d.%m.%Y'
|
||||
cache_timeout: null
|
||||
uuid: 19600000-0000-4000-8000-000000000101
|
||||
version: 1.0.0
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
slice_name: Доля категорий в выручке
|
||||
description: null
|
||||
certified_by: null
|
||||
certification_details: null
|
||||
viz_type: pie
|
||||
params:
|
||||
viz_type: pie
|
||||
groupby:
|
||||
- product_category
|
||||
metric: revenue
|
||||
adhoc_filters:
|
||||
- expressionType: SIMPLE
|
||||
subject: report_date
|
||||
operator: TEMPORAL_RANGE
|
||||
comparator: No filter
|
||||
clause: WHERE
|
||||
row_limit: 10000
|
||||
sort_by_metric: true
|
||||
donut: true
|
||||
innerRadius: 30
|
||||
outerRadius: 70
|
||||
show_labels: true
|
||||
labels_outside: false
|
||||
label_type: percent
|
||||
show_legend: false
|
||||
cache_timeout: null
|
||||
uuid: 19600000-0000-4000-8000-000000000108
|
||||
version: 1.0.0
|
||||
dataset_uuid: 19600000-0000-4000-8000-000000000001
|
||||
@@ -17,12 +17,16 @@ params:
|
||||
comparator: No filter
|
||||
clause: WHERE
|
||||
row_limit: 10000
|
||||
seriesType: echarts_timeseries_line
|
||||
seriesType: line
|
||||
show_legend: true
|
||||
legendOrientation: bottom
|
||||
rich_tooltip: true
|
||||
show_value: false
|
||||
markerEnabled: true
|
||||
markerSize: 6
|
||||
y_axis_format: ',d'
|
||||
x_axis_time_format: smart_date
|
||||
x_axis_time_format: '%d.%m'
|
||||
tooltipTimeFormat: '%d.%m.%Y'
|
||||
cache_timeout: null
|
||||
uuid: 19600000-0000-4000-8000-000000000103
|
||||
version: 1.0.0
|
||||
|
||||
@@ -19,9 +19,79 @@ position:
|
||||
parents:
|
||||
- ROOT_ID
|
||||
children:
|
||||
- ROW-kpi
|
||||
- ROW-revenue
|
||||
- ROW-pvo
|
||||
- ROW-traffic
|
||||
ROW-kpi:
|
||||
id: ROW-kpi
|
||||
type: ROW
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
children:
|
||||
- CHART-kpi-revenue
|
||||
- CHART-kpi-orders
|
||||
- CHART-kpi-avg-check
|
||||
- CHART-kpi-visitors
|
||||
meta:
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
CHART-kpi-revenue:
|
||||
id: CHART-kpi-revenue
|
||||
type: CHART
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
- ROW-kpi
|
||||
children: []
|
||||
meta:
|
||||
chartId: 4
|
||||
height: 20
|
||||
width: 3
|
||||
sliceName: Выручка, ₽
|
||||
uuid: 19600000-0000-4000-8000-000000000104
|
||||
CHART-kpi-orders:
|
||||
id: CHART-kpi-orders
|
||||
type: CHART
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
- ROW-kpi
|
||||
children: []
|
||||
meta:
|
||||
chartId: 5
|
||||
height: 20
|
||||
width: 3
|
||||
sliceName: Заказы
|
||||
uuid: 19600000-0000-4000-8000-000000000105
|
||||
CHART-kpi-avg-check:
|
||||
id: CHART-kpi-avg-check
|
||||
type: CHART
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
- ROW-kpi
|
||||
children: []
|
||||
meta:
|
||||
chartId: 6
|
||||
height: 20
|
||||
width: 3
|
||||
sliceName: Средний чек, ₽
|
||||
uuid: 19600000-0000-4000-8000-000000000106
|
||||
CHART-kpi-visitors:
|
||||
id: CHART-kpi-visitors
|
||||
type: CHART
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
- ROW-kpi
|
||||
children: []
|
||||
meta:
|
||||
chartId: 7
|
||||
height: 20
|
||||
width: 3
|
||||
sliceName: Посетители
|
||||
uuid: 19600000-0000-4000-8000-000000000107
|
||||
ROW-revenue:
|
||||
id: ROW-revenue
|
||||
type: ROW
|
||||
@@ -30,6 +100,7 @@ position:
|
||||
- GRID_ID
|
||||
children:
|
||||
- CHART-revenue
|
||||
- CHART-revenue-share
|
||||
meta:
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
CHART-revenue:
|
||||
@@ -43,9 +114,23 @@ position:
|
||||
meta:
|
||||
chartId: 1
|
||||
height: 50
|
||||
width: 12
|
||||
sliceName: Выручка по дням и категориям
|
||||
width: 8
|
||||
sliceName: Выручка по дням и категориям, ₽
|
||||
uuid: 19600000-0000-4000-8000-000000000101
|
||||
CHART-revenue-share:
|
||||
id: CHART-revenue-share
|
||||
type: CHART
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
- ROW-revenue
|
||||
children: []
|
||||
meta:
|
||||
chartId: 8
|
||||
height: 50
|
||||
width: 4
|
||||
sliceName: Доля категорий в выручке
|
||||
uuid: 19600000-0000-4000-8000-000000000108
|
||||
ROW-pvo:
|
||||
id: ROW-pvo
|
||||
type: ROW
|
||||
@@ -66,7 +151,7 @@ position:
|
||||
children: []
|
||||
meta:
|
||||
chartId: 2
|
||||
height: 50
|
||||
height: 44
|
||||
width: 12
|
||||
sliceName: Сверка purchase и заказов
|
||||
uuid: 19600000-0000-4000-8000-000000000102
|
||||
@@ -78,6 +163,7 @@ position:
|
||||
- GRID_ID
|
||||
children:
|
||||
- CHART-traffic
|
||||
- CHART-known-share
|
||||
meta:
|
||||
background: BACKGROUND_TRANSPARENT
|
||||
CHART-traffic:
|
||||
@@ -91,15 +177,57 @@ position:
|
||||
meta:
|
||||
chartId: 3
|
||||
height: 50
|
||||
width: 12
|
||||
width: 6
|
||||
sliceName: Посетители и известные пользователи
|
||||
uuid: 19600000-0000-4000-8000-000000000103
|
||||
CHART-known-share:
|
||||
id: CHART-known-share
|
||||
type: CHART
|
||||
parents:
|
||||
- ROOT_ID
|
||||
- GRID_ID
|
||||
- ROW-traffic
|
||||
children: []
|
||||
meta:
|
||||
chartId: 9
|
||||
height: 50
|
||||
width: 6
|
||||
sliceName: Доля известных пользователей
|
||||
uuid: 19600000-0000-4000-8000-000000000109
|
||||
metadata:
|
||||
color_scheme: supersetColors
|
||||
label_colors:
|
||||
Товары для дома: '#1FA8C9'
|
||||
Текстиль: '#454E7C'
|
||||
Посуда: '#5AC189'
|
||||
Бытовая техника: '#FF7F44'
|
||||
Детские товары: '#E04355'
|
||||
Одежда и обувь: '#A868B7'
|
||||
visitors: '#1FA8C9'
|
||||
known_users: '#454E7C'
|
||||
known_share: '#454E7C'
|
||||
cross_filters_enabled: false
|
||||
chart_configuration: {}
|
||||
expanded_slices: {}
|
||||
default_filters: '{}'
|
||||
native_filter_configuration: []
|
||||
native_filter_configuration:
|
||||
- id: NATIVE_FILTER-period
|
||||
name: Период
|
||||
filterType: filter_time
|
||||
type: NATIVE_FILTER
|
||||
targets:
|
||||
- {}
|
||||
controlValues:
|
||||
enableEmptyFilter: false
|
||||
defaultDataMask:
|
||||
extraFormData: {}
|
||||
filterState: {}
|
||||
ownState: {}
|
||||
cascadeParentIds: []
|
||||
scope:
|
||||
rootPath:
|
||||
- ROOT_ID
|
||||
excluded: []
|
||||
description: ''
|
||||
theme_uuid: null
|
||||
version: 1.0.0
|
||||
@@ -36,6 +36,15 @@ metrics:
|
||||
currency: null
|
||||
extra: null
|
||||
warning_text: null
|
||||
- metric_name: known_share
|
||||
verbose_name: Доля известных
|
||||
metric_type: null
|
||||
expression: SUM(known_users) / nullIf(SUM(visitors), 0)
|
||||
description: null
|
||||
d3format: .1%
|
||||
currency: null
|
||||
extra: null
|
||||
warning_text: null
|
||||
columns:
|
||||
- column_name: _load_ts
|
||||
verbose_name: Время загрузки
|
||||
|
||||
@@ -23,10 +23,26 @@ metrics:
|
||||
metric_type: null
|
||||
expression: SUM(revenue)
|
||||
description: null
|
||||
d3format: ',.2f'
|
||||
currency:
|
||||
symbol: ₽
|
||||
symbolPosition: suffix
|
||||
d3format: ',d'
|
||||
currency: null
|
||||
extra: null
|
||||
warning_text: null
|
||||
- metric_name: orders
|
||||
verbose_name: Заказы
|
||||
metric_type: null
|
||||
expression: SUM(orders)
|
||||
description: null
|
||||
d3format: ',d'
|
||||
currency: null
|
||||
extra: null
|
||||
warning_text: null
|
||||
- metric_name: avg_check
|
||||
verbose_name: Средний чек
|
||||
metric_type: null
|
||||
expression: SUM(revenue) / nullIf(SUM(orders), 0)
|
||||
description: null
|
||||
d3format: ',d'
|
||||
currency: null
|
||||
extra: null
|
||||
warning_text: null
|
||||
columns:
|
||||
@@ -44,7 +60,7 @@ columns:
|
||||
datetime_format: null
|
||||
extra: null
|
||||
- column_name: aov
|
||||
verbose_name: Средний чек
|
||||
verbose_name: Средний чек за день и категорию
|
||||
is_dttm: false
|
||||
is_active: true
|
||||
type: DECIMAL(18, 2)
|
||||
|
||||
@@ -102,12 +102,12 @@ on_signal() {
|
||||
# Единственный постоянный сторож цепочки Kafka → STG → ODS.
|
||||
#
|
||||
# Проверка спрашивает не «есть ли данные», а «те ли это данные»: подневный счёт
|
||||
# в `ods.event` против описи мира, лежащей в git. Поэтому она умеет краснеть на
|
||||
# в `ods.event` против паспорта мира, лежащего в git. Поэтому она умеет краснеть на
|
||||
# сломанном разборе — уехали события в брак, счёт разошёлся, — и на потере по
|
||||
# дороге, а не только на пустой таблице.
|
||||
#
|
||||
# **Счёт обрамлён датами стартового мира.** Мир растёт: менти переиграет день,
|
||||
# этап 5 добавит следующий, — и голый счёт по таблице разойдётся с описью
|
||||
# этап 5 добавит следующий, — и голый счёт по таблице разойдётся с паспортом
|
||||
# законно, без всякой поломки.
|
||||
#
|
||||
# **Счёт идёт через ods.event_v** — каноническую поверхность актуального
|
||||
@@ -138,7 +138,7 @@ check_starting_world() {
|
||||
FORMAT TSV")"
|
||||
|
||||
if [[ "$actual" == "$expected" ]]; then
|
||||
printf 'ЗЕЛЁНО: события всех дней стартового мира на месте, счёт сходится с описью.\n'
|
||||
printf 'ЗЕЛЁНО: события всех дней стартового мира на месте, счёт сходится с паспортом.\n'
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -148,13 +148,13 @@ check_starting_world() {
|
||||
GROUP BY error_class
|
||||
ORDER BY count() DESC
|
||||
FORMAT TSV")"
|
||||
printf 'Опись мира ожидает (дата, событий):\n%s\n' "$expected" >&2
|
||||
printf 'Паспорт мира ожидает (дата, событий):\n%s\n' "$expected" >&2
|
||||
printf 'В ods.event лежит:\n%s\n' "${actual:-— ничего —}" >&2
|
||||
if [[ -z "$broken" ]]; then
|
||||
fail 'счёт разошёлся с описью, а таблица брака пуста: события не доехали до ODS — начните с чтеца топика stg.hits_raw_kafka и матвью приёма stg.hits_raw_mv'
|
||||
fail 'счёт разошёлся с паспортом, а таблица брака пуста: события не доехали до ODS — начните с чтеца топика stg.hits_raw_kafka и матвью приёма stg.hits_raw_mv'
|
||||
fi
|
||||
printf 'В ods.event_errors по классам брака:\n%s\n' "$broken" >&2
|
||||
fail 'счёт разошёлся с описью, и в таблице брака есть строки. Сойдётся недостача с их числом — сломан разбор, смотрите матвью ods.event_mv; не сойдётся — брак остался от прежних опытов, а события не доехали: смотрите чтеца топика stg.hits_raw_kafka'
|
||||
fail 'счёт разошёлся с паспортом, и в таблице брака есть строки. Сойдётся недостача с их числом — сломан разбор, смотрите матвью ods.event_mv; не сойдётся — брак остался от прежних опытов, а события не доехали: смотрите чтеца топика stg.hits_raw_kafka'
|
||||
}
|
||||
|
||||
assert_ddl_queue_completed() {
|
||||
@@ -282,7 +282,7 @@ assert_equal "$expected_product" "$(query_as_etl clickhouse-01 "$product_sql")"
|
||||
assert_equal "$expected_product" "$(query_as_etl clickhouse-02 "$product_sql")" "словарь товаров не ответил на второй ноде"
|
||||
printf 'ЗЕЛЁНО: известный sku вернул имя, категорию и цену на обеих нодах.\n'
|
||||
|
||||
printf 'Проверка 10/10: стартовый мир в ods.event сходится с описью...\n'
|
||||
printf 'Проверка 10/10: стартовый мир в ods.event сходится с паспортом...\n'
|
||||
check_starting_world
|
||||
|
||||
printf 'ИТОГ: все 10 проверок кластера ClickHouse прошли.\n'
|
||||
@@ -1,6 +1,6 @@
|
||||
-- STG: чтецы топиков hits и orders, таблицы сырья обоих источников.
|
||||
-- STG: чтецы топиков hits и orders, таблицы STG обоих источников.
|
||||
--
|
||||
-- Слой сырья ничего не интерпретирует: сообщение ложится строкой, как пришло,
|
||||
-- Сырой слой ничего не интерпретирует: сообщение ложится строкой, как пришло,
|
||||
-- рядом с метаданными доставки. Довод целиком — ADR 0005, конвенции колонок и
|
||||
-- сроков — docs/architecture/storage.md.
|
||||
|
||||
@@ -30,9 +30,9 @@ SETTINGS
|
||||
kafka_group_name = 'clickstream_hits',
|
||||
kafka_format = 'RawBLOB';
|
||||
|
||||
-- Локальная таблица сырья.
|
||||
-- Локальная таблица STG.
|
||||
--
|
||||
-- Движок именно ReplicatedMergeTree, не Replacing: повтор доставки в сырье
|
||||
-- Движок именно ReplicatedMergeTree, не Replacing: повтор доставки в STG
|
||||
-- обязан быть виден — ради этого слой и заведён.
|
||||
--
|
||||
-- Служебные колонки не повторяют имён виртуальных (_topic, _partition,
|
||||
@@ -45,7 +45,7 @@ SETTINGS
|
||||
-- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа
|
||||
-- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms —
|
||||
-- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная,
|
||||
-- _load_ts рядом тоже миллисекундная, а слой сырья хранит то, что приехало, и
|
||||
-- _load_ts рядом тоже миллисекундная, а сырой слой хранит то, что приехало, и
|
||||
-- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не
|
||||
-- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год.
|
||||
--
|
||||
@@ -115,8 +115,8 @@ SETTINGS
|
||||
kafka_format = 'RawBLOB',
|
||||
kafka_commit_on_select = 1;
|
||||
|
||||
-- Локальная таблица сырья заказов. Колонки, типы, ключ, нарезка и срок жизни —
|
||||
-- те же, что у сырья событий, и по тем же доводам: docs/architecture/storage.md.
|
||||
-- Локальная таблица STG заказов. Колонки, типы, ключ, нарезка и срок жизни —
|
||||
-- те же, что у STG событий, и по тем же доводам: docs/architecture/storage.md.
|
||||
--
|
||||
-- Своя колонка здесь одна — _load_id, идентификатор пачки загрузки: он равен
|
||||
-- run_id прогона Airflow, который забрал порцию, и по нему разбор в ODS читает
|
||||
|
||||
@@ -106,7 +106,7 @@ SAMPLE BY intHash32(ClientID);
|
||||
-- События одной куки при этом остаются на одном шарде, и сессионизация со
|
||||
-- склейкой идентичностей ниже по течению живут локально.
|
||||
--
|
||||
-- У сырья ключ другой (хеш строки), поэтому сырая строка и разобранное из неё
|
||||
-- У STG ключ другой (хеш строки), поэтому сырая строка и разобранное из неё
|
||||
-- событие почти всегда лежат на разных шардах. Пошардовые счётчики слоёв
|
||||
-- сходиться не должны — сверять слои можно только через _dist.
|
||||
CREATE TABLE IF NOT EXISTS ods.event_dist ON CLUSTER clickstream_cluster
|
||||
@@ -125,10 +125,10 @@ ENGINE = Distributed('clickstream_cluster', 'ods', 'event_rep', cityHash64(Clien
|
||||
-- Ключи собственные. Шардирование — cityHash64(raw): ClientID у неразобранной
|
||||
-- строки взять неоткуда. ORDER BY (error_class, kafka_partition,
|
||||
-- kafka_offset): такую таблицу смотрят от класса брака, а внутри класса — по
|
||||
-- координатам доставки. Нарезка по дню загрузки, как у сырья: модельного дня
|
||||
-- координатам доставки. Нарезка по дню загрузки, как у STG: модельного дня
|
||||
-- у брака тоже нет.
|
||||
--
|
||||
-- Срок жизни — месяц, вдесятеро дольше сырья. Истеки брак вместе с сырьём —
|
||||
-- Срок жизни — месяц, вдесятеро дольше сырых данных. Истеки брак вместе с ними —
|
||||
-- к моменту разбирательства не осталось бы ни того, ни другого.
|
||||
CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster
|
||||
(
|
||||
|
||||
@@ -25,7 +25,7 @@ AS
|
||||
SELECT *
|
||||
FROM ods.order_dist FINAL;
|
||||
|
||||
-- ODS: разбор сырья в событие и в таблицу ошибок.
|
||||
-- ODS: разбор STG в событие и в таблицу ошибок.
|
||||
--
|
||||
-- Матвью две, и вместе они обязаны делить поток без зазора и без нахлёста:
|
||||
-- строка, подошедшая обеим, задвоится, а не подошедшая ни одной — исчезнет
|
||||
@@ -168,7 +168,7 @@ SELECT
|
||||
JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity,
|
||||
JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType,
|
||||
JSONExtract(raw, 'ecommerce', 'String') AS ecommerce,
|
||||
-- Метка загрузки переносится из сырья как есть, а не ставится заново:
|
||||
-- Метка загрузки переносится из STG как есть, а не ставится заново:
|
||||
-- она отвечает на вопрос «когда строка приехала в хранилище». Поставь
|
||||
-- здесь now64(3) — и колонка версии молча ответила бы на другой вопрос.
|
||||
_load_ts
|
||||
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user