Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46e82b5240 | ||
|
|
354b8abe56 | ||
|
|
7108cd4af3 | ||
|
|
e56b23816e | ||
|
|
59d8c83b17 | ||
|
|
86ad8afaf2 | ||
|
|
7a3542d933 | ||
|
|
bad5981904 | ||
|
|
2588515b9a | ||
|
|
ab35560ece | ||
|
|
d1dc0dd7bf |
No files matched your search
@@ -161,6 +161,10 @@ Airflow) и названия из кода. Если для понятия ес
|
|||||||
(`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных
|
(`top-pages-daily.md`), индекс папки — `README.md`. Здесь живут ТЗ учебных
|
||||||
заданий. Ни даты, ни номера: задание — не событие истории, а живой документ,
|
заданий. Ни даты, ни номера: задание — не событие истории, а живой документ,
|
||||||
и правится он вместе с тем, о чем говорит. Слаг называет предмет задания.
|
и правится он вместе с тем, о чем говорит. Слаг называет предмет задания.
|
||||||
|
- Имена файлов в `docs/handbook/` — слаг строчными латинскими буквами через
|
||||||
|
дефис, индекс папки — `README.md`. Здесь живет путеводитель по стенду:
|
||||||
|
учебные главы для менти. Ни дат, ни номеров: глава — живой документ и
|
||||||
|
правится вместе с тем, что описывает.
|
||||||
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
|
- Имена файлов в `docs/adr/` — `NNNN-краткое-имя.md`: сквозной номер из четырёх
|
||||||
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
|
цифр и слаг (`0001-stand-services.md`). Решения нумеруются подряд, дата
|
||||||
в имени не нужна.
|
в имени не нужна.
|
||||||
|
|||||||
+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 не выдаётся.
|
||||||
_Избегать_: DQ-проверка — та работает на наблюдаемых данных магазина.
|
_Избегать_: DQ-проверка — та работает на наблюдаемых данных магазина.
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
# Учебная дата-платформа кликстрима
|
# Учебная дата-платформа кликстрима
|
||||||
|
|
||||||
Стенд для работы с кликстримом. Преемник учебного стенда
|
> Этим стендом можно пользоваться самостоятельно. Он также входит в
|
||||||
|
> [план обучения Data Engineering Roadmap](https://de.dementev.space) как
|
||||||
|
> курсовая работа. Если вам нужна помощь, обратитесь к автору материалов —
|
||||||
|
> ментору [@dementev_dev](https://t.me/dementev_dev).
|
||||||
|
|
||||||
|
Этот стенд — для изучения и экспериментов с кликстримом и ClickHouse. Здесь
|
||||||
|
можно проследить весь путь данных: от кликстрима и заказов до витрин, проверок
|
||||||
|
и графиков. Это преемник учебного стенда
|
||||||
[clickstream-ch-kafka-superset-demo](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo).
|
[clickstream-ch-kafka-superset-demo](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo).
|
||||||
|
|
||||||
## Статус
|
В стенде подняты кластер ClickHouse из двух шардов, отдельный keeper, Kafka,
|
||||||
|
Airflow, Superset, Prometheus и Grafana; события кликстрима и заказы бэкенда
|
||||||
Репозиторий строится по спеке
|
приезжают из генератора и идут по слоям STG → ODS → DDS → DM. `make up`
|
||||||
[«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md).
|
наполняет стенд стартовым миром — первой неделей модельного времени. Чтобы
|
||||||
Сейчас работают кластер ClickHouse из двух шардов, отдельный
|
разобраться в устройстве, начните с [путеводителя](docs/handbook/README.md), а
|
||||||
clickhouse-keeper, односерверная Kafka в режиме KRaft, Airflow 3.3, Superset,
|
учиться — с [уроков](docs/labs/README.md).
|
||||||
Prometheus, Grafana, экспортёр метрик Kafka и общая база Postgres для
|
|
||||||
метаданных. Генератор кликстрима в [`generator/`](generator/) собран
|
|
||||||
целиком со стороны клиента: у него есть контракт схемы события, из которого
|
|
||||||
собрано [описание выгрузки](docs/formats/clickstream-event.md), модельный мир и
|
|
||||||
проигрыватель, отправляющий дни в Kafka или в файл. События доезжают до
|
|
||||||
типизированного `ods.event`, а `dds.event_v` отдаёт их на языке бизнес-модели
|
|
||||||
без копии данных. `make up` наполняет стенд стартовым миром — первой неделей
|
|
||||||
модельного времени.
|
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
@@ -28,33 +27,28 @@ Prometheus, Grafana, экспортёр метрик Kafka и общая баз
|
|||||||
пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не
|
пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не
|
||||||
хватает, стенд одинаково хорошо живёт на недорогом VPS.
|
хватает, стенд одинаково хорошо живёт на недорогом VPS.
|
||||||
|
|
||||||
Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`,
|
`make up` нужны `curl` и `jq`: первым он ходит в Airflow, вторым читает
|
||||||
`grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им
|
паспорт мира, по которому ждет заливки. По умолчанию должны быть свободны
|
||||||
читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны
|
|
||||||
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
|
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
|
||||||
и `29092`. Проверкам без стенда — `make config-test` и `make lint` в корне,
|
и `29092`. Всем целям генератора в `generator/` нужен `uv`.
|
||||||
всем целям генератора в `generator/` — нужен `uv`.
|
|
||||||
|
|
||||||
Сначала скопируйте настройки стенда:
|
Сначала скопируйте настройки стенда:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
make up
|
make up
|
||||||
make smoke
|
|
||||||
make check-clickhouse
|
|
||||||
make check-services
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Первому знакомству нужны все три проверки на стенде: `make smoke` говорит, что
|
`make up` сам ждет заливки стартового мира; что он делает по шагам и какие
|
||||||
стенд собран, `make check-clickhouse` — что кластер работает кластером, а
|
проверки есть у стенда, описано в [карте
|
||||||
`make check-services` — что Airflow запускает DAG, а Superset ходит в базу.
|
проверок](docs/architecture/testing.md).
|
||||||
Дальше, в рабочей петле, обычно хватает `make smoke`.
|
|
||||||
|
|
||||||
`.env.example` — образец обязательной локальной настройки. В нём живут имя
|
`.env.example` — образец обязательной локальной настройки. В нём живут имя
|
||||||
экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца
|
экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца
|
||||||
верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и
|
верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и
|
||||||
свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь,
|
свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь,
|
||||||
кроме пульта мира — о нём ниже. `compose.yaml` не
|
кроме [дагов модельного
|
||||||
|
времени](docs/architecture/world.md#даги-модельного-времени). `compose.yaml` не
|
||||||
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
|
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
|
||||||
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
|
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
|
||||||
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
|
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
|
||||||
@@ -64,233 +58,7 @@ make check-services
|
|||||||
все локальные данные стенда, а следующий `make up` создаст их с новыми
|
все локальные данные стенда, а следующий `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` порты доступны только с локальной машины:
|
С настройками из `.env.example` порты доступны только с локальной машины:
|
||||||
|
|
||||||
@@ -302,122 +70,64 @@ uv run --project generator python -m clickstream_generator batch \
|
|||||||
- Prometheus — `http://127.0.0.1:29090`;
|
- Prometheus — `http://127.0.0.1:29090`;
|
||||||
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
|
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
|
||||||
|
|
||||||
В ClickHouse семь пользователей и шесть ролей:
|
Службы, пользователи и роли, применение настроек и результаты сверки
|
||||||
|
конфигурации описаны в [справочнике стенда](docs/architecture/stand.md).
|
||||||
|
|
||||||
- `etl` подключает рабочие даги 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` остаётся служебным: им ходят проверки здоровья и скрипты внутри
|
|
||||||
контейнеров, но не приложения.
|
|
||||||
|
|
||||||
Почему пользователи объявлены файлом, а ноды доверяют общему секрету, — в
|
В Airflow запустите даг `world_next_day`: по умолчанию он сыграет один день, а
|
||||||
[ADR 0007](docs/adr/0007-clickhouse-access.md).
|
в форме запуска можно задать другое число дней. Снимите с паузы `world_live`,
|
||||||
Пользователи и роли объявлены в `infra/clickhouse/users.d/access.xml`. Пароли и
|
и мир поедет сам в темпе модельного времени. Подробности — в [справочнике
|
||||||
общий секрет нод живут в `.env` и передаются в конфигурацию через окружение;
|
мира](docs/architecture/world.md#даги-модельного-времени).
|
||||||
Superset получает пароль `bi`, а Grafana — пароль `grafana` тем же путём.
|
|
||||||
Значения для локального стенда есть в `.env.example`.
|
|
||||||
|
|
||||||
Изменения значений ClickHouse в `.env` и файлов настройки серверов в
|
## Вернуть мир к началу
|
||||||
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/` применяются
|
|
||||||
пересозданием нод:
|
|
||||||
|
|
||||||
```bash
|
`make rebuild-storage` запустит в Airflow явную пересборку: удалит прикладные
|
||||||
docker compose up --force-recreate --wait clickhouse-01 clickhouse-02
|
базы ClickHouse, топики и позиции потребителей Kafka, затем создаст схему
|
||||||
```
|
и данные заново. Службы и их тома останутся на месте. Перед пересборкой
|
||||||
|
поставьте `world_live` на паузу и дайте уже начатому шаговому дагу закончить
|
||||||
|
день.
|
||||||
|
|
||||||
Команда возвращает управление, когда обе ноды снова здоровы; именованные тома
|
## Выключить или сбросить целиком
|
||||||
при этом сохраняются. `docker compose restart` оставит прежнее окружение и
|
|
||||||
может оставить старую версию отдельно смонтированного файла.
|
|
||||||
|
|
||||||
Пароли ClickHouse и интерфейсов, пароли Postgres, ключи Airflow и Superset,
|
`make down` остановит контейнеры без удаления данных. `make clean` удалит весь
|
||||||
отсутствие проверки доступа у Kafka и Prometheus — намеренно простые и явно
|
стенд вместе с именованными томами и для обычной работы не нужен.
|
||||||
ненастоящие настройки локального учебного стенда. Это не пример настройки
|
|
||||||
защиты: не копируйте значения из `.env.example` в рабочую среду. Все
|
|
||||||
опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не
|
|
||||||
опубликован.
|
|
||||||
|
|
||||||
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
|
## Подключиться из DBeaver
|
||||||
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
|
|
||||||
2×2 намеренно не входят в стенд.
|
|
||||||
|
|
||||||
Конфигурация сверена 30 июля 2026 года с официальной документацией ClickHouse:
|
Драйвер ClickHouse в DBeaver подключается по JDBC через HTTP-порт. Укажите хост
|
||||||
[настройками сервера](https://clickhouse.com/docs/operations/server-configuration-parameters/settings),
|
`127.0.0.1`, порт `28123`, пользователя `analyst` и пароль из переменной
|
||||||
[Keeper](https://clickhouse.com/docs/guides/oss/deployment-and-scaling/keeper/),
|
`CLICKHOUSE_ANALYST_PASSWORD` в `.env`; в образце это `analyst-26-reader`.
|
||||||
[ReplicatedMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replication),
|
Ко второй ноде — порт `28124`.
|
||||||
[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,
|
Пользователь `analyst` читает все слои и справочники. Нативные порты `29000` и
|
||||||
публичный `airflow.sdk`, API здоровья и SimpleAuthManager сверены с
|
`29001` нужны `clickhouse-client`, но не DBeaver. Остальные пользователи и роли
|
||||||
[архитектурой 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)
|
стенда](docs/architecture/stand.md#пользователи-и-роли-clickhouse).
|
||||||
и [описанием здоровья](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/).
|
|
||||||
|
|
||||||
## Что здесь будет
|
|
||||||
|
|
||||||
- одно широкое событие кликстрима по образцу выгрузки Яндекс Метрики вместо
|
|
||||||
четырёх топиков;
|
|
||||||
- второй источник — заказы бэкенда, ежедневным слепком в ту же Kafka;
|
|
||||||
- сверка клиентской покупки с заказом бэкенда: деньги считаем по бэкенду,
|
|
||||||
поведение и атрибуцию — по трекеру;
|
|
||||||
- анонимный кликстрим и склейка кука↔пользователь через покупки;
|
|
||||||
- ClickHouse кластером как единственным режимом.
|
|
||||||
|
|
||||||
## Чем отличается от предшественника
|
|
||||||
|
|
||||||
Предшественник остаётся стабильным учебным стендом и заморожен для новых фич:
|
|
||||||
там событие разрезано на четыре топика, есть только просмотры страниц,
|
|
||||||
посетители опознаны по email, ClickHouse — одна нода. Развитие идёт здесь.
|
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
|
- [docs/handbook/](docs/handbook/) — путеводитель по стенду и порядок чтения.
|
||||||
|
- [docs/labs/](docs/labs/) — учебные задания с порядком прохождения и
|
||||||
|
самопроверкой.
|
||||||
- [docs/specs/](docs/specs/) — спеки: источник истины о задуманном.
|
- [docs/specs/](docs/specs/) — спеки: источник истины о задуманном.
|
||||||
- [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми
|
- [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми
|
||||||
вариантами: почему сделано так, а не иначе.
|
вариантами: почему сделано так, а не иначе.
|
||||||
- [docs/architecture/](docs/architecture/) — рабочие справочники по зонам
|
- [docs/architecture/](docs/architecture/) — рабочие справочники по зонам
|
||||||
ответственности: [хранилище](docs/architecture/storage.md) — конвенции имён,
|
ответственности:
|
||||||
раскладка по шардам, приём событий, карта таблиц;
|
- [хранилище](docs/architecture/storage.md) — конвенции имён, раскладка по
|
||||||
[проверки](docs/architecture/testing.md) — что утверждает каждая цель `make`
|
шардам, приём событий, карта таблиц.
|
||||||
и куда класть новую проверку.
|
- [конвейер](docs/architecture/etl.md) — движение данных между слоями и
|
||||||
|
работа дагов.
|
||||||
|
- [мир и модельное время](docs/architecture/world.md) — начальные данные,
|
||||||
|
ход дней и управление генератором.
|
||||||
|
- [стенд](docs/architecture/stand.md) — службы, доступ и настройка.
|
||||||
|
- [проверки](docs/architecture/testing.md) — что утверждает каждая цель
|
||||||
|
`make` и куда класть новую проверку.
|
||||||
|
- [мониторинг](docs/architecture/monitoring/README.md) — сбор метрик и
|
||||||
|
устройство дашборда.
|
||||||
|
- [Superset](docs/architecture/superset.md) — подключения, наборы данных и
|
||||||
|
граница автоматической настройки.
|
||||||
- [docs/research/](docs/research/) — исследования; сейчас это формат
|
- [docs/research/](docs/research/) — исследования; сейчас это формат
|
||||||
кликстрима Яндекса, по которому строится модель события.
|
кликстрима Яндекса, по которому строится модель события.
|
||||||
- [docs/formats/](docs/formats/) — описания форматов источников: по ним
|
- [docs/formats/](docs/formats/) — описания форматов источников: по ним
|
||||||
|
|||||||
+3
-3
@@ -34,7 +34,7 @@ x-clickhouse-common: &clickhouse-common
|
|||||||
retries: 30
|
retries: 30
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
# Факты генератора и пульта мира названы по одному разу — иначе они однажды
|
# Факты генератора и дагов модельного времени названы по одному разу — иначе они однажды
|
||||||
# разойдутся, и заметит это не проверка, а менти с пустым топиком.
|
# разойдутся, и заметит это не проверка, а менти с пустым топиком.
|
||||||
x-generator-image: &generator-image clickstream-generator:local
|
x-generator-image: &generator-image clickstream-generator:local
|
||||||
x-orders-topic: &orders-topic orders
|
x-orders-topic: &orders-topic orders
|
||||||
@@ -70,7 +70,7 @@ x-airflow-common: &airflow-common
|
|||||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
|
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
|
||||||
# Пульт мира поднимает генератор отдельным контейнером. Имя сети собирается
|
# Даги времени поднимают генератор отдельным контейнером. Имя сети собирается
|
||||||
# из имени проекта: у второй копии стенда оно другое.
|
# из имени проекта: у второй копии стенда оно другое.
|
||||||
<<: *kafka-target
|
<<: *kafka-target
|
||||||
GENERATOR_IMAGE: *generator-image
|
GENERATOR_IMAGE: *generator-image
|
||||||
@@ -291,7 +291,7 @@ services:
|
|||||||
airflow-init:
|
airflow-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
command: scheduler
|
command: scheduler
|
||||||
# Пульт мира зовёт генератор отдельным контейнером, а при LocalExecutor
|
# Даги времени зовут генератор отдельным контейнером, а при LocalExecutor
|
||||||
# задачи исполняет сам планировщик — значит сокет докера нужен ему одному.
|
# задачи исполняет сам планировщик — значит сокет докера нужен ему одному.
|
||||||
# Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root
|
# Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root
|
||||||
# на машине. Открывает дверь не монтирование, а группа: у сокета права 660
|
# на машине. Открывает дверь не монтирование, а группа: у сокета права 660
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ HITS_PARTITIONS = (0, 1)
|
|||||||
READINESS_TIMEOUT_SECONDS = 300
|
READINESS_TIMEOUT_SECONDS = 300
|
||||||
READINESS_POLL_SECONDS = 3
|
READINESS_POLL_SECONDS = 3
|
||||||
|
|
||||||
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставит
|
# Позиция на оси модельного времени: номер первого несыгранного дня. Её ставят
|
||||||
# пульт мира, и только она отличает прожитый день от живого — зачем это
|
# даги модельного времени, и только она отличает прожитый день от живого — зачем это
|
||||||
# сессиям, разобрано в sql/dds/session_scope.sql.
|
# сессиям, разобрано в sql/dds/session_scope.sql.
|
||||||
WORLD_POSITION = "world_position"
|
WORLD_POSITION = "world_position"
|
||||||
|
|
||||||
@@ -224,8 +224,8 @@ def _wait_for_phase(
|
|||||||
def _world_position() -> int:
|
def _world_position() -> int:
|
||||||
"""Позиция мира числом, с внятным отказом на непригодном значении.
|
"""Позиция мира числом, с внятным отказом на непригодном значении.
|
||||||
|
|
||||||
Ту же переменную читает пульт, но общего модуля у дагов нет намеренно:
|
Ту же переменную читают даги времени, но общего модуля у дагов нет намеренно:
|
||||||
связь между пультом и трансформацией держит сама переменная, а не импорт.
|
связь между дагами времени и трансформацией держит сама переменная, а не импорт.
|
||||||
"""
|
"""
|
||||||
raw_position = Variable.get(WORLD_POSITION, default=None)
|
raw_position = Variable.get(WORLD_POSITION, default=None)
|
||||||
if raw_position is 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()
|
for mismatch_class, count in day["orders"].items()
|
||||||
}
|
}
|
||||||
if not expected:
|
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")
|
query = (SQL_ROOT / "dq" / "classes_vs_inventory.sql").read_text(encoding="utf-8")
|
||||||
# GROUP BY закономерно не возвращает группу из нуля строк.
|
# GROUP BY закономерно не возвращает группу из нуля строк.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""Приём заказов: пакетный забор слепка из Kafka в хранилище.
|
"""Приём заказов: пакетный забор слепка из Kafka в хранилище.
|
||||||
|
|
||||||
Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор
|
Путь Kafka → STG → ODS у заказов один и живёт одним дагом: сначала забор
|
||||||
порции в сырьё, затем разбор того же среза в типизированный ODS и в таблицу
|
порции в `stg`, затем разбор того же среза в типизированный ODS и в таблицу
|
||||||
брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, —
|
брака. Расписания у дага нет: его дёргает тот, кто положил слепок в топик, —
|
||||||
работник пульта мира после проигрыша дня, — и ждёт конца.
|
шаговый даг модельного времени после проигрыша дня, — и ждёт конца.
|
||||||
|
|
||||||
Контраст с приёмом событий и есть урок. События тянет матвью, навсегда
|
Контраст с приёмом событий и есть урок. События тянет матвью, навсегда
|
||||||
подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов
|
подписанная на чтеца: приём идёт сам, пока идёт поток. Слепок заказов
|
||||||
|
|||||||
+15
-15
@@ -1,25 +1,25 @@
|
|||||||
"""Пульт мира: даги, которыми двигают ось модельного времени.
|
"""Даги модельного времени: ими двигают ось мира.
|
||||||
|
|
||||||
Их три, и они двух родов. **Работники** играют день и запускаются руками:
|
Их три, и они двух родов. **Шаговые даги** играют день и запускаются руками:
|
||||||
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
|
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
|
||||||
один день в темпе модельного времени. **Выключатель** `world_live` своей работы
|
один день в темпе модельного времени. **Выключатель** `world_live` своей работы
|
||||||
не делает: он тикает по расписанию и дёргает работника живого дня, дожидаясь
|
не делает: он тикает по расписанию и дёргает шаговый даг живого дня, дожидаясь
|
||||||
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
|
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
|
||||||
границе модельных суток.
|
границе модельных суток.
|
||||||
|
|
||||||
Сыграть день — половина работы. Вторая половина: отправить слепок заказов и
|
Сыграть день — половина работы. Вторая половина: отправить слепок заказов и
|
||||||
дождаться, пока хранилище его заберёт. Поэтому у обоих работников за проигрышем
|
дождаться, пока хранилище его заберёт. Поэтому у обоих шаговых дагов за проигрышем
|
||||||
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
|
идут отправка слепка тем же контейнером генератора и ждущий триггер дага приёма
|
||||||
`orders_ingest`.
|
`orders_ingest`.
|
||||||
|
|
||||||
Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка
|
Живой день после установки позиции дожидается еще и `etl_pipeline`. Пачка
|
||||||
только разгоняет источники: слои после нее менти догоняет ручным запуском.
|
только разгоняет источники: слои после нее менти догоняет ручным запуском.
|
||||||
|
|
||||||
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку
|
Разделение не косметическое. Расписание на самом шаговом даге заставило бы кнопку
|
||||||
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник
|
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а шаговый даг
|
||||||
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
|
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
|
||||||
|
|
||||||
Календарь Airflow к оси мира отношения не имеет: какой день играть, работник
|
Календарь Airflow к оси мира отношения не имеет: какой день играть, шаговый даг
|
||||||
спрашивает у переменной, а не у логической даты прогона.
|
спрашивает у переменной, а не у логической даты прогона.
|
||||||
|
|
||||||
Решения и доводы целиком — ADR 0009.
|
Решения и доводы целиком — ADR 0009.
|
||||||
@@ -51,7 +51,7 @@ ORDERS_TOPIC = os.environ["KAFKA_ORDERS_TOPIC"]
|
|||||||
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
|
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
|
||||||
|
|
||||||
# Позиция на оси: номер первого несыгранного дня. До конца инициализации
|
# Позиция на оси: номер первого несыгранного дня. До конца инициализации
|
||||||
# работники не имеют права угадывать её — состояние объяснено в ADR 0013.
|
# шаговые даги не имеют права угадывать её — состояние объяснено в ADR 0013.
|
||||||
#
|
#
|
||||||
# Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее
|
# Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее
|
||||||
# при таком правиле — сыгранный дважды день, а повтор схлопнет
|
# при таком правиле — сыгранный дважды день, а повтор схлопнет
|
||||||
@@ -63,12 +63,12 @@ WORLD_POSITION = "world_position"
|
|||||||
# двадцати четырёх минут: тик только спрашивает «не пора ли снова».
|
# двадцати четырёх минут: тик только спрашивает «не пора ли снова».
|
||||||
LIVE_TICK = datetime.timedelta(minutes=25)
|
LIVE_TICK = datetime.timedelta(minutes=25)
|
||||||
|
|
||||||
# Какой день играть, работник узнаёт у первой задачи. Спрашивают её все
|
# Какой день играть, шаговый даг узнаёт у первой задачи. Спрашивают её все
|
||||||
# генераторные шаги: слепок снимается с той же позиции, что и проигрыш.
|
# генераторные шаги: слепок снимается с той же позиции, что и проигрыш.
|
||||||
PLAYED_DAY = "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}"
|
PLAYED_DAY = "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}"
|
||||||
|
|
||||||
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||||
TAGS = ["пульт мира"]
|
TAGS = ["модельное время"]
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
@@ -120,7 +120,7 @@ def _generator(task_id: str, command: list[str]) -> DockerOperator:
|
|||||||
def _ingest_orders() -> TriggerDagRunOperator:
|
def _ingest_orders() -> TriggerDagRunOperator:
|
||||||
"""Задача забора: дёрнуть даг приёма и дождаться, чем он кончился.
|
"""Задача забора: дёрнуть даг приёма и дождаться, чем он кончился.
|
||||||
|
|
||||||
Ожидание здесь несущее. Без него работник позеленел бы, не узнав, доехал
|
Ожидание здесь несущее. Без него шаговый даг позеленел бы, не узнав, доехал
|
||||||
ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа
|
ли слепок, и позиция мира ушла бы вперёд хранилища — а зелёный конец графа
|
||||||
не должен переживать отказ выше (ADR 0003).
|
не должен переживать отказ выше (ADR 0003).
|
||||||
"""
|
"""
|
||||||
@@ -128,7 +128,7 @@ def _ingest_orders() -> TriggerDagRunOperator:
|
|||||||
task_id="trigger_orders_ingest",
|
task_id="trigger_orders_ingest",
|
||||||
trigger_dag_id="orders_ingest",
|
trigger_dag_id="orders_ingest",
|
||||||
wait_for_completion=True,
|
wait_for_completion=True,
|
||||||
# Умолчание — минута опроса, а весь прогон работника пачкой короче.
|
# Умолчание — минута опроса, а весь прогон шагового дага пачкой короче.
|
||||||
poke_interval=10,
|
poke_interval=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ def _ingest_orders() -> TriggerDagRunOperator:
|
|||||||
# (params.rst, Airflow 3.3), чтобы правило было видно сразу.
|
# (params.rst, Airflow 3.3), чтобы правило было видно сразу.
|
||||||
description_md=(
|
description_md=(
|
||||||
"Форма задает только число дней. Номер первого несыгранного "
|
"Форма задает только число дней. Номер первого несыгранного "
|
||||||
"дня работник берет из переменной `world_position` (Admin → "
|
"дня шаговый даг берёт из переменной `world_position` (Admin → "
|
||||||
"Variables). Поля Logical date и Run ID нужны самому Airflow "
|
"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: модельные сутки укладываются примерно в двадцать четыре
|
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
|
||||||
реальные минуты, и суточная волна разворачивается на глазах. Запускается
|
реальные минуты, и суточная волна разворачивается на глазах. Запускается
|
||||||
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
|
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
|
||||||
Следующий день работник берет из `world_position`. Логическая дата прогона
|
Следующий день шаговый даг берёт из `world_position`. Логическая дата прогона
|
||||||
на выбор дня не влияет.
|
на выбор дня не влияет.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ def world_initialize():
|
|||||||
|
|
||||||
@task
|
@task
|
||||||
def wait_for_events() -> None:
|
def wait_for_events() -> None:
|
||||||
"""Дождаться приёма всех событий описи, включая строки брака."""
|
"""Дождаться приёма всех событий паспорта, включая строки брака."""
|
||||||
inventory = json.loads(INVENTORY.read_text(encoding="utf-8"))
|
inventory = json.loads(INVENTORY.read_text(encoding="utf-8"))
|
||||||
expected = sum(day["events"] for day in inventory["days"])
|
expected = sum(day["events"] for day in inventory["days"])
|
||||||
first_date = inventory["days"][0]["date"]
|
first_date = inventory["days"][0]["date"]
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Дата: 3 августа 2026 года. Статус: принято.
|
Дата: 3 августа 2026 года. Статус: принято.
|
||||||
|
|
||||||
|
Позднее тикет #214 сменил имя слоя: вместо «сырья» — сырые данные, сырой слой,
|
||||||
|
STG. Текст ниже оставлен в словах своего времени, действующее имя — в
|
||||||
|
`CONTEXT.md`.
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение
|
Топик `hits` читает одна Kafka-таблица формата `RawBLOB`: непустое сообщение
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
Сетевых ограничений на `default` нет: порты стенда и так открыты только на
|
Сетевых ограничений на `default` нет: порты стенда и так открыты только на
|
||||||
`127.0.0.1`, а внутри сети Compose дверь заперта паролем.
|
`127.0.0.1`, а внутри сети Compose дверь заперта паролем.
|
||||||
|
|
||||||
Раздел README «Состав и доступ» описывает, кто каким пользователем ходит и где
|
Раздел README «Адреса и пароли» описывает, кто каким пользователем ходит и где
|
||||||
живут пароли; здесь остаются решение и доводы. Отдельного справочника по
|
живут пароли; здесь остаются решение и доводы. Отдельного справочника по
|
||||||
доступу в `docs/architecture/` нет: точные права видны в `access.xml`, конвенция
|
доступу в `docs/architecture/` нет: точные права видны в `access.xml`, конвенция
|
||||||
чтения через `_v` описана в доке хранилища.
|
чтения через `_v` описана в доке хранилища.
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
источник истины — файл в git. Стенд считает `access.xml` и переданные через
|
источник истины — файл в git. Стенд считает `access.xml` и переданные через
|
||||||
`.env` значения настройкой при создании контейнера; отдельного способа
|
`.env` значения настройкой при создании контейнера; отдельного способа
|
||||||
применять их на живом стенде нет, потому что уроки не меняют базовую модель
|
применять их на живом стенде нет, потому что уроки не меняют базовую модель
|
||||||
доступа. Порядок применения изменений — в разделе README «Состав и доступ».
|
доступа. Порядок применения изменений — в разделе README «Адреса и пароли».
|
||||||
Оба варианта с SQL — локальное хранилище доступа с `ON CLUSTER` и
|
Оба варианта с SQL — локальное хранилище доступа с `ON CLUSTER` и
|
||||||
реплицированное в keeper — делают пользователей состоянием тома: `.env` на
|
реплицированное в keeper — делают пользователей состоянием тома: `.env` на
|
||||||
них больше не влияет, и появляется расхождение того же сорта, которое README
|
них больше не влияет, и появляется расхождение того же сорта, которое README
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
Дата: 12 августа 2026 года. Статус: частично заменено
|
Дата: 12 августа 2026 года. Статус: частично заменено
|
||||||
[ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93.
|
[ADR 0010](0010-order-versions-in-ods.md); забор построен тикетом #93.
|
||||||
|
|
||||||
|
Позднее тикет #214 сменил имена: «сырьё» стало сырыми данными, «работник» —
|
||||||
|
шаговым дагом. Текст ниже оставлен в словах своего времени, действующие
|
||||||
|
имена — в `CONTEXT.md`.
|
||||||
|
|
||||||
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
|
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
|
||||||
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
|
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
|
||||||
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
|
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
|
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
|
||||||
|
|
||||||
|
Позднее тикет #214 сменил имена: «пульт мира» стал дагами модельного времени,
|
||||||
|
«работник» — шаговым дагом. Текст ниже оставлен в словах своего времени,
|
||||||
|
действующие имена — в `CONTEXT.md`.
|
||||||
|
|
||||||
## Решение
|
## Решение
|
||||||
|
|
||||||
Ось модельного времени двигают даги пульта, и они двух родов.
|
Ось модельного времени двигают даги пульта, и они двух родов.
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ ODS; точный тип и проверка описаны в разделе
|
|||||||
- **`Sign`** — колонка формата без механики: генератор исправлений не шлёт,
|
- **`Sign`** — колонка формата без механики: генератор исправлений не шлёт,
|
||||||
и в модели события колонке ответить нечего. Честный рассказ о ней живёт в
|
и в модели события колонке ответить нечего. Честный рассказ о ней живёт в
|
||||||
DDL ODS и лекции.
|
DDL ODS и лекции.
|
||||||
- **`ecommerce`** — сырой JSON: сырьё, а не атрибут. Разобранные массивы
|
- **`ecommerce`** — сырой JSON из STG. Это содержимое источника; отдельным
|
||||||
`purchase_*` и `product_*` в контракте есть; лаба про разбор JSON внутри
|
атрибутом модели оно не становится. Разобранные массивы `purchase_*` и
|
||||||
колонки читает ODS.
|
`product_*` в контракте есть; лаба про разбор JSON внутри колонки читает ODS.
|
||||||
- **`_load_ts`** — у представления нет собственной загрузки, а чужие
|
- **`_load_ts`** — у представления нет собственной загрузки, а чужие
|
||||||
координаты слой не переносит (правило слоя — [модель
|
координаты слой не переносит (правило слоя — [модель
|
||||||
заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по
|
заказа](order.md), «Служебные колонки»); расследование спрашивает ODS по
|
||||||
|
|||||||
@@ -75,12 +75,12 @@ ODS: это единственный устойчивый ключ сущнос
|
|||||||
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
|
**служебные колонки — координаты собственной загрузки слоя; чужие координаты
|
||||||
спрашиваются у их слоя по ключу сущности**.
|
спрашиваются у их слоя по ключу сущности**.
|
||||||
|
|
||||||
Происшествие качества данных расследуется цепочкой, где каждый шаг
|
Происшествие качества данных расследуется цепочкой, где каждый шаг спрашивает
|
||||||
спрашивает свой слой на его языке: по `_load_id` строки DDS видно, какой
|
свой слой на его языке: по `_load_id` строки DDS видно, какой запуск собрал
|
||||||
запуск собрал день; по `order_id` ODS показывает физические версии и брак —
|
день; по `order_id` ODS показывает физические версии и брак — там свой
|
||||||
там свой `_load_id` приёма связывает годные строки, ошибки и сырьё; дальше
|
`_load_id` приёма связывает годные строки, ошибки и данные STG; дальше читают
|
||||||
сырьё STG, пока живо. Окно расследования задано сроками хранения: сырьё —
|
STG, пока данные живы. Окно расследования задано сроками хранения: сырые данные
|
||||||
трое суток, брак — месяц ([дока хранилища](../storage.md)).
|
— трое суток, брак — месяц ([дока хранилища](../storage.md)).
|
||||||
|
|
||||||
## Читатели
|
## Читатели
|
||||||
|
|
||||||
|
|||||||
@@ -176,9 +176,10 @@ Distributed-таблица вернула бы каждый непарный з
|
|||||||
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк
|
`uniqExact(VisitID)` из `ods.event_v`, объект — число строк
|
||||||
`dds.session_v`;
|
`dds.session_v`;
|
||||||
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
|
- `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из
|
||||||
`orders` в описи мира, объект — число заказов класса в
|
`orders` в паспорте мира, объект — число заказов класса в
|
||||||
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
|
`dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`,
|
||||||
`duplicate_event` и `amount_delta` только для покрытых описью закрытых дней;
|
`duplicate_event` и `amount_delta` только для покрытых паспортом закрытых
|
||||||
|
дней;
|
||||||
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
|
- `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются
|
||||||
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
|
`orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`;
|
||||||
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
|
- `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые
|
||||||
@@ -194,14 +195,14 @@ Distributed-таблица вернула бы каждый непарный з
|
|||||||
сопоставления деловых ключей.
|
сопоставления деловых ключей.
|
||||||
|
|
||||||
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
|
`classes_vs_inventory` — техническая растяжка пути от генератора до DM. Она
|
||||||
подтверждает, что до витрины доехал записанный в описи мир, но не проверяет
|
подтверждает, что до витрины доехал записанный в паспорте мир, но не проверяет
|
||||||
сам генератор: у настоящего магазина такого оракула нет.
|
сам генератор: у настоящего магазина такого оракула нет.
|
||||||
|
|
||||||
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow
|
Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только
|
||||||
только сводку и малый срез диагностики. Техническая сверка с описью читает
|
сводку и малый срез диагностики. Техническая сверка с паспортом читает небольшой
|
||||||
небольшой файл JSON в Python. Все пять сборок пишут в общий донор. После их
|
файл JSON в Python. Все пять сборок пишут в общий донор. После их успеха даг
|
||||||
успеха даг заменяет проверенные дневные партиции. Затем именованные задачи
|
заменяет проверенные дневные партиции. Затем именованные задачи читают
|
||||||
читают опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
|
опубликованную сводку и падают при `failed_rows > 0`. Расхождение успевает
|
||||||
сохраниться до красного завершения, а ошибка сборки не затирает прежний
|
сохраниться до красного завершения, а ошибка сборки не затирает прежний
|
||||||
результат. Красная задача печатает не больше двадцати нарушенных ключей за
|
результат. Красная задача печатает не больше двадцати нарушенных ключей за
|
||||||
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
|
проверку со значениями сторон. Для полного списка итоговую агрегацию SQL-сверки
|
||||||
|
|||||||
+12
-12
@@ -37,13 +37,13 @@ world_next_day ─────> источники без ETL
|
|||||||
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
|
- **Живой день.** `world_live_day` дёргает конвейер с ожиданием — последним
|
||||||
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
|
шагом, после установки позиции. Порядок несёт смысл: день сыгран — факт,
|
||||||
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
|
упавшая трансформация его не отменяет, поэтому позиция ставится раньше; но
|
||||||
зеленеть, не узнав, разложился ли день, работник не должен
|
зеленеть, не узнав, разложился ли день, шаговый даг не должен ([ADR
|
||||||
([ADR 0003](../adr/0003-dag-run-state.md)).
|
0003](../adr/0003-dag-run-state.md)). Такой отказ чинится перезапуском
|
||||||
Такой отказ чинится перезапуском `etl_pipeline` рукой — перезапуску ничего
|
`etl_pipeline` рукой — перезапуску ничего не нужно помнить, см. «Объём
|
||||||
не нужно помнить, см. «Объём прогона».
|
прогона».
|
||||||
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: пульт управляет
|
- **Разгон пачкой.** `world_next_day` конвейер не дёргает: даги модельного
|
||||||
генератором и только им. После разгона менти запускает конвейер сам —
|
времени управляют генератором и только им. После разгона менти запускает
|
||||||
и видит, как слои догоняют уехавший вперёд мир.
|
конвейер сам — и видит, как слои догоняют уехавший вперёд мир.
|
||||||
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
|
- **Рождение мира.** `world_initialize` дёргает конвейер последним шагом:
|
||||||
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
|
`make up` отдаёт стенд с собранными слоями, а не пустые витрины. Уроки
|
||||||
ручного запуска начинаются со следующих дней.
|
ручного запуска начинаются со следующих дней.
|
||||||
@@ -145,9 +145,9 @@ ODS и ещё не собранный в DDS, затем при необходи
|
|||||||
|
|
||||||
## Отклонённые варианты
|
## Отклонённые варианты
|
||||||
|
|
||||||
- **Задачи трансформаций прямо в работниках пульта.** Один зелёный прогон —
|
- **Задачи трансформаций прямо в шаговых дагах.** Один зелёный прогон —
|
||||||
весь день, но пульт — про ось времени: работников два, шаги дублировались
|
весь день, но даги времени отвечают за ось времени: шаговых дагов два, шаги
|
||||||
бы, а этапу 5 пришлось бы выдирать их обратно.
|
дублировались бы, а этапу 5 пришлось бы выдирать их обратно.
|
||||||
- **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с
|
- **Make-цель или SQL рукой при приёмке.** Второй оркестратор рядом с
|
||||||
Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом
|
Airflow — против [ADR 0013](../adr/0013-world-lifecycle.md), — а в живом
|
||||||
режиме слои копили бы отставание молча.
|
режиме слои копили бы отставание молча.
|
||||||
@@ -163,8 +163,8 @@ ODS и ещё не собранный в DDS, затем при необходи
|
|||||||
|
|
||||||
- [ADR 0003](../adr/0003-dag-run-state.md) — зелёный конец графа не
|
- [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) — жизненный цикл мира: почему
|
- [ADR 0013](../adr/0013-world-lifecycle.md) — жизненный цикл мира: почему
|
||||||
прикладным процессом владеет Airflow и почему `world_initialize` кончается
|
прикладным процессом владеет Airflow и почему `world_initialize` кончается
|
||||||
собранными слоями.
|
собранными слоями.
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
Новый бросок в середине функции сдвигает все последующие значения этого
|
Новый бросок в середине функции сдвигает все последующие значения этого
|
||||||
подпотока. Код продолжает работать, но тем же свойствам достается другая
|
подпотока. Код продолжает работать, но тем же свойствам достается другая
|
||||||
случайность. Соседние подпотоки остаются прежними, а измененная часть
|
случайность. Соседние подпотоки остаются прежними, а измененная часть
|
||||||
канонического мира проявляется в описи и побайтовом сравнении.
|
канонического мира проявляется в паспорте и побайтовом сравнении.
|
||||||
|
|
||||||
Какое обещание воспроизводимости это защищает, записано в
|
Какое обещание воспроизводимости это защищает, записано в
|
||||||
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
|
[спеке генератора](../../specs/2026-08-01-generator.md), раздел 2. Здесь важно
|
||||||
|
|||||||
@@ -19,23 +19,23 @@
|
|||||||
## Почему слои у панелей разные
|
## Почему слои у панелей разные
|
||||||
|
|
||||||
Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него
|
Свежесть спрашивают у ODS. Это слой, который читают, и у заказов дорога до него
|
||||||
длиннее: сырьё привозит забор, а типизированную строку кладёт разбор. Встань
|
длиннее: в STG данные привозит забор, а типизированную строку кладёт разбор.
|
||||||
разбор — свежесть ODS это покажет, свежесть сырья нет.
|
Встань разбор — свежесть ODS это покажет, а свежесть STG — нет.
|
||||||
|
|
||||||
Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление
|
Читает свежесть физическую `_dist`, а не каноническое `ods.*_v`. Представление
|
||||||
прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос
|
прячет за собой `FINAL` и отвечает про текущее состояние сущностей, а вопрос
|
||||||
здесь про доставку. Местом диагностики доставленных версий физическая таблица
|
здесь про доставку. Местом диагностики доставленных версий физическая таблица
|
||||||
названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`).
|
названа там же, где заведено представление (`sql/ddl/30-ods-views.sql`).
|
||||||
|
|
||||||
Поток и брак считаются по слою сырья. Сырьё записывает каждую доставку и ничего
|
Поток и брак считаются по сырому слою. Он хранит каждую доставку и ничего не
|
||||||
не схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события,
|
схлопывает, а `ReplacingMergeTree` в ODS схлопнет и повтор доставки события, и
|
||||||
и все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
|
все версии одного заказа. Считай мы поток по ODS — он занизился бы, и тем
|
||||||
сильнее, чем дольше живёт стенд.
|
сильнее, чем дольше живёт стенд.
|
||||||
|
|
||||||
Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не
|
Доля брака берётся от приехавшего, а не от суммы «годные плюс брак»: брак не
|
||||||
отдельная куча, а часть сырья, которая не прошла разбор.
|
отдельная куча, а часть сырых данных, которая не прошла разбор.
|
||||||
|
|
||||||
Окно у панели брака — окно дашборда, и это не косметика. Сырьё живёт трое
|
Окно у панели брака — окно дашборда, и это не косметика. Сырые данные живут трое
|
||||||
суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток
|
суток, а брак — месяц: доля за всё время делила бы месяц брака на трое суток
|
||||||
приехавшего и однажды перевалила бы за сотню процентов.
|
приехавшего и однажды перевалила бы за сотню процентов.
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
укладываются в минуты реального времени, и соседние дни мира помещаются в одно
|
укладываются в минуты реального времени, и соседние дни мира помещаются в одно
|
||||||
окно панели сами.
|
окно панели сами.
|
||||||
|
|
||||||
## Приземлившееся считают по слою сырья
|
## Приземлившееся считают в сыром слое
|
||||||
|
|
||||||
Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам
|
Приземлившееся панели считают по `stg.*_raw_dist`, произведённое — по метрикам
|
||||||
экспортёра. Таблицы движка Kafka `stg.hits_raw_kafka` и `stg.orders_raw_kafka`
|
экспортёра. Таблицы движка Kafka `stg.hits_raw_kafka` и `stg.orders_raw_kafka`
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse.
|
Kafka» приходит из Prometheus, ряд «приземлилось в ClickHouse» — из ClickHouse.
|
||||||
Единицы общие: сообщения за интервал панели. Со стороны Kafka это разность
|
Единицы общие: сообщения за интервал панели. Со стороны Kafka это разность
|
||||||
офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со
|
офсетов топика, «сейчас минус интервал назад», с суммой по обеим партициям; со
|
||||||
стороны ClickHouse — `count()` строк сырья по тому же интервалу.
|
стороны ClickHouse — `count()` строк STG по тому же интервалу.
|
||||||
|
|
||||||
Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase`
|
Два соседних способа отвергнуты, и оба по замеру на живом стенде. `increase`
|
||||||
растягивает приращение на всё окно и на минутном интервале промахивается на
|
растягивает приращение на всё окно и на минутном интервале промахивается на
|
||||||
@@ -87,11 +87,11 @@ Kafka» приходит из Prometheus, ряд «приземлилось в C
|
|||||||
Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в
|
Kafka идёт чуть выше. Читать надо устойчивый разрыв между линиями; разница в
|
||||||
один столбец не значит ничего.
|
один столбец не значит ничего.
|
||||||
|
|
||||||
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден
|
Панель смотрит на топик событий. У него приём непрерывный, и разрыв виден сразу.
|
||||||
сразу. Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему
|
Слепок заказов уезжает пачкой раз в модельные сутки, и вопрос к нему другой — не
|
||||||
другой — не «сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него
|
«сходится ли поток», а «доехала ли выгрузка и вовремя ли». На него отвечает своя
|
||||||
отвечает своя панель, и модельный день она берёт из самого сообщения: в сырьё
|
панель, и модельный день она берёт из самого сообщения: в STG он приезжает полем
|
||||||
он приезжает полем `snapshot_date`.
|
`snapshot_date`.
|
||||||
|
|
||||||
## Что проверено
|
## Что проверено
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,9 @@
|
|||||||
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
|
`ORDERS` для заказной стороны и `BEACONS` для событийной; броски независимы,
|
||||||
пересечения выходят арифметикой, приоритет классов работает по-настоящему —
|
пересечения выходят арифметикой, приоритет классов работает по-настоящему —
|
||||||
[классы расхождений и опоздание](fate.md).
|
[классы расхождений и опоздание](fate.md).
|
||||||
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
|
- **Паспорт хранит только то, чего движение мира не меняет**: хеш байтов
|
||||||
каждого слепка и счётчики наблюдаемых классов —
|
каждого слепка и счётчики наблюдаемых классов — [что хранит
|
||||||
[что хранит опись](inventory.md).
|
паспорт](inventory.md).
|
||||||
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
|
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
|
||||||
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
|
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
|
||||||
[мост к склейке](identity.md).
|
[мост к склейке](identity.md).
|
||||||
|
|||||||
@@ -8,18 +8,18 @@
|
|||||||
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
|
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
|
||||||
решённая при его рождении и уложенная в окно K целиком.
|
решённая при его рождении и уложенная в окно K целиком.
|
||||||
|
|
||||||
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты,
|
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты, дельта,
|
||||||
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и
|
опоздание. Событийная сторона — порча событийного потока: потеря и дубль. Довод
|
||||||
дубль. Довод за разделение — различимость по описи: правка заказной механики
|
за разделение — различимость по паспорту: правка заказной механики не двигает
|
||||||
не двигает хеши событий, правка событийной не двигает байты слепка, и по
|
хеши событий, правка событийной не двигает байты слепка, и по покрасневшим хешам
|
||||||
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
|
видно, какую сторону трогали. Прежние имена `DISCREPANCIES` и `LATECOMERS`
|
||||||
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
решения не переживают — они названы по классам витрины, а компонент называет
|
||||||
компонент называет часть мира; новые стороны занимают те же позиции.
|
часть мира; новые стороны занимают те же позиции. Заказная сторона названа при
|
||||||
Заказная сторона названа при реализации — подпоток `ORDERS` на позиции 2;
|
реализации — подпоток `ORDERS` на позиции 2; событийная — `BEACONS` на позиции
|
||||||
событийная — `BEACONS` на позиции 3: маячок счетчика либо не долетает, либо
|
3: маячок счетчика либо не долетает, либо отправляется повторно. Отклонено:
|
||||||
отправляется повторно. Отклонено: *один компонент на всю судьбу* — правка
|
*один компонент на всю судьбу* — правка событийной механики молча меняла бы
|
||||||
событийной механики молча меняла бы байты слепка; *компонент на класс* —
|
байты слепка; *компонент на класс* — пять имён под ручки калибровки, которые
|
||||||
пять имён под ручки калибровки, которые крутятся разом.
|
крутятся разом.
|
||||||
|
|
||||||
**Правило формы, без которого разделение не работает: броски заказной стороны
|
**Правило формы, без которого разделение не работает: броски заказной стороны
|
||||||
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
||||||
@@ -62,15 +62,14 @@
|
|||||||
процентов» не заводим.
|
процентов» не заводим.
|
||||||
|
|
||||||
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
||||||
версия; различает их история версий: сырьё STG и физические версии
|
версия; различает их история версий: строки STG и физические версии `ods.order`
|
||||||
`ods.order` до фоновых слияний, а в витринах — выручка дня, которая
|
до фоновых слияний, а в витринах — выручка дня, которая сначала выросла, потом
|
||||||
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
|
убыла. Полное различение не обещано: слепок — состояние на границе суток, и
|
||||||
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
|
оплата с отменой в один день в сырых данных неразличимы; то же у сильно
|
||||||
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
|
опоздавших, приехавших уже терминальными. Точную форму этого урока решает этап
|
||||||
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда
|
4. Отклонено: *отмена только после оплаты* — неоплаченному некуда деться, кроме
|
||||||
деться, кроме как остаться брошенным; *мгновенная отмена при рождении* —
|
как остаться брошенным; *мгновенная отмена при рождении* — «дыхание» окна на
|
||||||
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1,
|
отменах исчезает; *отмен нет вовсе* — страховочный срез 1, он в резерве.
|
||||||
он в резерве.
|
|
||||||
|
|
||||||
## Классы расхождений и опоздание
|
## Классы расхождений и опоздание
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,10 @@
|
|||||||
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
|
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
|
||||||
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
|
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
|
||||||
отдельные прогоны одного мира дают то же соответствие; принятый мир
|
отдельные прогоны одного мира дают то же соответствие; принятый мир
|
||||||
воспроизводит эффект склейки без случайных ложных объединений — конкретные
|
воспроизводит эффект склейки без случайных ложных объединений. Паспорт не
|
||||||
числа пар контрактом описи не являются. Межкогортные столкновения принимаются
|
закрепляет конкретное число пар. Межкогортные столкновения принимаются по той же
|
||||||
по той же дисциплине, что у случайных `ClientID`: принимается конкретный
|
дисциплине, что у случайных `ClientID`: принимается конкретный канонический мир
|
||||||
канонический мир по внешнему результату.
|
по внешнему результату.
|
||||||
|
|
||||||
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
|
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
|
||||||
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
|
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
|
||||||
|
|||||||
@@ -35,10 +35,11 @@ PARTITION` могла бы заменить партицию дня неполн
|
|||||||
|
|
||||||
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
|
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
|
||||||
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
|
записывает полученную порцию в `stg.orders_raw`. Даг зовётся `orders_ingest`, а
|
||||||
дёргает его тот, кто положил слепок в топик, — работник пульта мира, — и ждёт
|
запускает его шаговый даг модельного времени, который положил слепок в топик и
|
||||||
конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
|
ждёт конца прогона. Все строки получают `_load_id`, равный `run_id` Airflow.
|
||||||
`_load_ts` вычисляется при этой записи и дальше переносится без пересчёта.
|
`_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` как параметр
|
`SQLExecuteQueryOperator` задаёт подключение и передаёт `run_id` как параметр
|
||||||
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
|
ClickHouse `load_id`, а преобразование остаётся в SQL своего слоя.
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ dbt snapshots — стратегия `check` вместо `timestamp`.
|
|||||||
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
|
Отказ — случай другой, и «хвост дождётся» на него не распространяется. Офсеты
|
||||||
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
|
порции коммитятся в момент чтения, поэтому упавшая вставка уносит прочитанное с
|
||||||
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
|
собой: повторное чтение вернёт ноль, а часть строк может уже лежать на шарде.
|
||||||
Позиция мира при этом не двигается, и тот же день уедет заново — в сырье он
|
Позиция мира при этом не двигается, и тот же день уедет заново — в STG он
|
||||||
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
|
окажется частичным дублем, а старый хвост, ушедший той же порцией, не вернётся
|
||||||
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»).
|
([ADR 0008](../../adr/0008-order-ingestion.md), «Следствия»).
|
||||||
|
|
||||||
@@ -191,10 +192,10 @@ dbt snapshots — стратегия `check` вместо `timestamp`.
|
|||||||
## Что проверено
|
## Что проверено
|
||||||
|
|
||||||
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94.
|
Переход STG → ODS снят на живом стенде 18 августа 2026 года при исполнении #94.
|
||||||
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение сырья на годные
|
Опыты этого раздела прогнаны и подтвердили обещанное: разбиение строк STG на
|
||||||
строки и брак полное и непересекающееся; две версии одного заказа легли в одну
|
годные строки и брак полное и непересекающееся; две версии одного заказа легли в
|
||||||
партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от фонового
|
одну партицию и на один шард, а `ods.order_v` вернуло позднюю независимо от
|
||||||
слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
|
фонового слияния; повтор задачи с тем же `_load_id` строку в `ods.order_v` и её
|
||||||
`_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки
|
`_load_ts` не изменил, а таблица ошибок записала тот же брак второй раз; строки
|
||||||
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
|
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
|
||||||
опирается, — [документ хранилища](../storage.md), «Что проверено».
|
опирается, — [документ хранилища](../storage.md), «Что проверено».
|
||||||
@@ -217,9 +218,9 @@ dbt snapshots — стратегия `check` вместо `timestamp`.
|
|||||||
|
|
||||||
Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
|
Текущее состояние проверено на пересобранном мире 26 августа 2026 года при
|
||||||
исполнении #165. После двух слепков физическая таблица содержала 3356 версий
|
исполнении #165. После двух слепков физическая таблица содержала 3356 версий
|
||||||
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним
|
1917 заказов. У всех строк `ods.order_v` дата слепка совпала с последним слепком
|
||||||
слепком заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из
|
заказа; деловые поля совпали с эталоном `argMax` по `updated_at` из сырых
|
||||||
сырья. В обеих сверках расхождений нет.
|
данных. В обеих сверках расхождений нет.
|
||||||
|
|
||||||
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
|
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
|
||||||
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
# Что хранит опись
|
# Что хранит паспорт мира
|
||||||
|
|
||||||
Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что
|
Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что
|
||||||
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72)
|
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72)
|
||||||
и [«Места заказов в стартовом
|
и [«Места заказов в стартовом
|
||||||
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
||||||
Словарь описи — растяжка-хеш против опоры-счётчика — задан «Чем меряется
|
Термины паспорта — «растяжка-хеш» и «опора-счётчик» — заданы разделом «Чем
|
||||||
генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
|
меряется генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
|
||||||
термином «Опись мира» в [CONTEXT.md](../../../CONTEXT.md).
|
статьёй «Паспорт мира» в [CONTEXT.md](../../../CONTEXT.md).
|
||||||
|
|
||||||
> Опись хранит только то, чего движение мира не меняет.
|
> Паспорт хранит только то, чего движение мира не меняет. Хеш кладём всегда,
|
||||||
> Хеш кладём всегда, счётчик — только когда назван его читатель.
|
> счётчик — только когда назван его читатель.
|
||||||
|
|
||||||
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
|
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
|
||||||
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
|
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
|
||||||
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же
|
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же байты»)
|
||||||
байты») опись начинает сторожить.
|
паспорт начинает сторожить.
|
||||||
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
|
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
|
||||||
итоговой судьбе заказов дня. В строке закрытого дня поле `orders` хранит
|
итоговой судьбе заказов дня. В строке закрытого дня поле `orders` хранит
|
||||||
число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных
|
число заказов каждого класса; пока окно открыто, поля нет. При N сыгранных
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
|
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
|
||||||
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
|
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
|
||||||
не выходит; генератор сторожит хеш, транспорт — счёт событий.
|
не выходит; генератор сторожит хеш, транспорт — счёт событий.
|
||||||
- **Опись описывает мир, а не доставку.** Работа генератора кончается на
|
- **Паспорт описывает мир, а не доставку.** Работа генератора кончается на
|
||||||
Kafka: дошли ли байты до `ods.order` — вопрос стенда и его
|
Kafka: дошли ли байты до `ods.order` — вопрос стенда и его проверок. Поэтому
|
||||||
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка
|
счёта строк у слепка в паспорте нет; понадобится проверка приёма заказов —
|
||||||
приёма заказов — число заведётся вместе с ней.
|
число заведётся вместе с ней.
|
||||||
@@ -112,5 +112,5 @@
|
|||||||
такого значения означает ошибку арифметики источника.
|
такого значения означает ошибку арифметики источника.
|
||||||
|
|
||||||
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
|
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
|
||||||
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
|
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт его
|
||||||
его даром, а хешу слепка в описи нужен именно названный порядок.
|
даром, а хешу слепка в паспорте нужен именно названный порядок.
|
||||||
@@ -25,11 +25,11 @@
|
|||||||
|
|
||||||
Цена принята с открытыми глазами: у части пар стартового мира поздний
|
Цена принята с открытыми глазами: у части пар стартового мира поздний
|
||||||
назначенный заказ падает на день 7, и до первого хода мира этих пар в
|
назначенный заказ падает на день 7, и до первого хода мира этих пар в
|
||||||
`dds.identity_map` нет. Опись пар не считает
|
`dds.identity_map` нет. Паспорт пар не считает ([что хранит
|
||||||
([что хранит опись](inventory.md)), поэтому красной проверки из этого не
|
паспорт](inventory.md)), поэтому красной проверки из этого не выходит.
|
||||||
выходит. Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на
|
Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на свежем
|
||||||
свежем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем
|
стенде, в CLI заводится рычаг, стартовый мир становится особым случаем ровно
|
||||||
ровно там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* —
|
там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* — число
|
||||||
число верно ровно до первого хода мира; *отменить сдвиг отправки* —
|
верно ровно до первого хода мира; *отменить сдвиг отправки* — `awaiting_order`
|
||||||
`awaiting_order` пропал бы навсегда; *подогнать план под горизонт* — мир
|
пропал бы навсегда; *подогнать план под горизонт* — мир перестал бы быть чистой
|
||||||
перестал бы быть чистой функцией зерна.
|
функцией зерна.
|
||||||
@@ -0,0 +1,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: кластер из двух шардов,
|
**Что здесь описано и чего ещё нет.** Собран этап 1: кластер из двух шардов,
|
||||||
keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка
|
keeper, Kafka, каркас сервисов. Этап 2 собран: в `sql/ddl/` лежит вся цепочка
|
||||||
`Kafka → STG → ODS` — чтец топика `hits`, таблицы сырья, типизированное
|
`Kafka → STG → ODS` — чтец топика `hits`, таблицы STG, типизированное событие с
|
||||||
событие с таблицей ошибок, поверхность актуального состояния и три матвью.
|
таблицей ошибок, поверхность актуального состояния и три матвью. Этап 3 добавил
|
||||||
Этап 3 добавил вход второго источника и довёл его до ODS: топик `orders`, свой
|
вход второго источника и довёл его до ODS: топик `orders`, свой чтец, свой сырой
|
||||||
чтец, своё сырьё, версии заказов с таблицей ошибок и поверхность текущего
|
слой, версии заказов с таблицей ошибок и поверхность текущего состояния.
|
||||||
состояния. Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью.
|
Наполняет всю цепочку даг `orders_ingest` двумя шагами, а не матвью. Тот же этап
|
||||||
Тот же этап принёс первый справочник — словарь товаров из общего с генератором
|
принёс первый справочник — словарь товаров из общего с генератором CSV-файла.
|
||||||
CSV-файла. Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а
|
Живёт он вне цепочки слоёв, в зоне `dic`, и читает не файл, а подложку над ним
|
||||||
подложку над ним ([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат
|
([ADR 0012](../adr/0012-dictionary-home.md)). Этап 4 начат публичным договором
|
||||||
публичным договором события `dds.event_v` над точным состоянием ODS, а следом
|
события `dds.event_v` над точным состоянием ODS, а следом получил две физические
|
||||||
получил две физические модели слоя — заказ и сессию: их собирает заменой
|
модели слоя — заказ и сессию: их собирает заменой партиций даг `dds_transform`.
|
||||||
партиций даг `dds_transform`. Сессия среди них особая: в потоке такой сущности
|
Сессия среди них особая: в потоке такой сущности нет, стенд режет её из событий
|
||||||
нет, стенд режет её из событий сам.
|
сам. Дальше по тексту устройство описано так, как оно проектируется; построенное
|
||||||
Дальше по тексту устройство описано так, как оно проектируется; построенное от
|
от заложенного отличает карта таблиц в конце.
|
||||||
заложенного отличает карта таблиц в конце.
|
|
||||||
|
|
||||||
Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
|
Зона ответственности у документа одна — хранилище. Генератор описан отдельно:
|
||||||
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
|
его замысел — в [спеке генератора](../specs/2026-08-01-generator.md), формат
|
||||||
@@ -97,7 +96,7 @@ ClickHouse и отклонённые варианты записаны в
|
|||||||
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
|
Ключи шардирования: `cityHash64(ClientID)` у событий, `cityHash64(order_id)` у
|
||||||
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
|
заказов, `cityHash64` сырой строки у STG и у таблицы ошибок. У первых двух хеш
|
||||||
выбран против перекоса: структурированный числовой идентификатор распределяется
|
выбран против перекоса: структурированный числовой идентификатор распределяется
|
||||||
по остатку от деления неравномерно. У сырья выбора нет — строку иначе не
|
по остатку от деления неравномерно. У сырого слоя выбора нет — строку иначе не
|
||||||
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
|
разложишь; там хеш даёт другое свойство, одинаковые сообщения ложатся на один
|
||||||
шард.
|
шард.
|
||||||
|
|
||||||
@@ -157,10 +156,10 @@ FROM источник_dist`. Заводское значение `parallel_distr
|
|||||||
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и
|
даёт две: `_timestamp` — `Nullable(DateTime)`, то есть секунды, и
|
||||||
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
|
`_timestamp_ms` — `Nullable(DateTime64(3))`, миллисекунды. Колонка объявлена
|
||||||
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
|
`Nullable(DateTime64(3, 'UTC'))` и заполняется из `_timestamp_ms`: у брокера
|
||||||
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а слой сырья
|
метка миллисекундная, соседняя `_load_ts` тоже миллисекундная, а сырой слой
|
||||||
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
|
хранит приехавшее, и округлять ему нечего. Обнуляемость нужна отдельно от
|
||||||
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы
|
разрядности: брокер метку заполняет не всегда, а необнуляемый тип значил бы либо
|
||||||
либо падение приёма на первом сообщении, либо тихие нули за 1970 год.
|
падение приёма на первом сообщении, либо тихие нули за 1970 год.
|
||||||
|
|
||||||
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
|
Заполняются все они выражением в `SELECT` матвью приёма, а не `DEFAULT` в
|
||||||
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
|
таблице. Для `consumer_host` это обязательно: `DEFAULT hostName()` вычисляется
|
||||||
@@ -173,10 +172,10 @@ FROM источник_dist`. Заводское значение `parallel_distr
|
|||||||
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
|
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
|
||||||
0010](../adr/0010-order-versions-in-ods.md)).
|
0010](../adr/0010-order-versions-in-ods.md)).
|
||||||
|
|
||||||
Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
Движок таблицы STG — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
||||||
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и
|
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у
|
||||||
нет. Замену версий сюда ставить нельзя — она отменила бы свойство слоя, ради
|
таблицы STG и нет. Замену версий сюда ставить нельзя — она отменила бы свойство
|
||||||
которого он заведён: повтор доставки в сырье обязан быть виден.
|
слоя, ради которого он заведён: повтор доставки в STG обязан быть виден.
|
||||||
|
|
||||||
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
|
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
|
||||||
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
|
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
|
||||||
@@ -192,7 +191,7 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
|
|||||||
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
|
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
|
||||||
|
|
||||||
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
|
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
|
||||||
Airflow. Переделать разобранное руками можно вставкой из сырья с фильтром по
|
Airflow. Переделать разобранное руками можно вставкой из STG с фильтром по
|
||||||
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
|
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
|
||||||
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
|
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
|
||||||
`_load_id`; дневные партиции ODS он не заменяет.
|
`_load_id`; дневные партиции ODS он не заменяет.
|
||||||
@@ -223,10 +222,10 @@ ODS и в таблицу ошибок. Нужен ли он выше ODS, реш
|
|||||||
|
|
||||||
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
|
Типы ODS обычно повторяют контракт выгрузки. Денежный `purchaseRevenue` —
|
||||||
исключение: источник объявляет `Array(Float64)`, а материализованное
|
исключение: источник объявляет `Array(Float64)`, а материализованное
|
||||||
представление ODS разбирает числа сырого JSON сразу в
|
представление ODS разбирает числа сырого JSON сразу в `Array(Decimal(18, 2))`.
|
||||||
`Array(Decimal(18, 2))`. DDS наследует этот тип без нового преобразования.
|
DDS наследует этот тип без нового преобразования. Прямой разбор подтвержден
|
||||||
Прямой разбор подтвержден документацией ClickHouse через Context7 и запросом
|
документацией ClickHouse через Context7 и запросом к ClickHouse 26.3; сравнение
|
||||||
к ClickHouse 26.3; сравнение с сырьем подтвердило: копейки всех покупок целы.
|
с сырыми данными подтвердило: копейки всех покупок целы.
|
||||||
|
|
||||||
## Версии заказов
|
## Версии заказов
|
||||||
|
|
||||||
@@ -257,16 +256,16 @@ DDS. `snapshot_date` в нём одновременно дата наблюде
|
|||||||
|
|
||||||
**Линза называется явно — в типе колонки либо в вызове функции.** Третий
|
**Линза называется явно — в типе колонки либо в вызове функции.** Третий
|
||||||
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там,
|
источник, умолчание сервера, в коде не виден и меняется снаружи, поэтому там,
|
||||||
где линза что-то решает — в объявлении хранимой колонки и в выражении,
|
где линза что-то решает — в объявлении хранимой колонки и в выражении, считающем
|
||||||
считающем дату, — его не остаётся. Прописать этот пояс своей рукой —
|
дату, — его не остаётся. Прописать этот пояс своей рукой — `<timezone>` в
|
||||||
`<timezone>` в конфигурации ноды или `TZ` контейнеру — было бы той же болезнью
|
конфигурации ноды или `TZ` контейнеру — было бы той же болезнью с другим
|
||||||
с другим умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах
|
умолчанием, и вдобавок отняло бы проверку: когда линза названа в типах и в
|
||||||
и в разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
|
разборе, пояс сервера на данные не влияет нигде, и в этом можно убедиться,
|
||||||
поменяв его. Правило стоит на источнике пояса, а не на функции:
|
поменяв его. Правило стоит на источнике пояса, а не на функции: `toDate` по
|
||||||
`toDate` по колонке, чей тип пояс несёт, законен и имени не требует — так и
|
колонке, чей тип пояс несёт, законен и имени не требует — так и работают ключи
|
||||||
работают ключи партиций `toDate(_load_ts)` у сырья и у таблицы ошибок. Имя
|
партиций `toDate(_load_ts)` у таблицы STG и у таблицы ошибок. Имя пишется тогда,
|
||||||
пишется тогда, когда нужна другая линза, чем у колонки:
|
когда нужна другая линза, чем у колонки: `toDate(UTCEventTime, 'Europe/Samara')`
|
||||||
`toDate(UTCEventTime, 'Europe/Samara')` — это «день по часам счётчика».
|
— это «день по часам счётчика».
|
||||||
|
|
||||||
**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок
|
**Какая линза, решает слой — по тому, кого он обслуживает.** ODS хранит снимок
|
||||||
выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна,
|
выгрузки и говорит на языке выгрузки: у Метрики `UTCEventTime` абсолютна,
|
||||||
@@ -314,8 +313,8 @@ UTC+4), и пересчёт идёт один раз при наполнении
|
|||||||
0008](../adr/0008-order-ingestion.md) и [спецификация приёма
|
0008](../adr/0008-order-ingestion.md) и [спецификация приёма
|
||||||
заказов](orders/ingestion.md).
|
заказов](orders/ingestion.md).
|
||||||
|
|
||||||
Цепочка одна: чтец топика → матвью → сырьё STG → матвью разбора → событие и
|
Цепочка одна: чтец топика → матвью → STG → матвью разбора → событие и таблица
|
||||||
таблица ошибок ODS.
|
ошибок ODS.
|
||||||
|
|
||||||
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
|
Чтец стоит на обеих нодах и читает одной группой потребителей — имя группы
|
||||||
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
|
`clickstream_hits`, и оно одинаково на обеих нодах по построению: DDL идёт
|
||||||
@@ -344,15 +343,15 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
|
|||||||
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
|
пользователя в конфигурации ноды. Синхронный режим связывает шарды: пока второй
|
||||||
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
|
недоступен, вставка падает, офсеты не коммитятся, и приём встаёт целиком — тогда
|
||||||
как при фоновом первая нода продолжает принимать и копит спул для соседа.
|
как при фоновом первая нода продолжает принимать и копит спул для соседа.
|
||||||
Платится при этом не одно ожидание на блок, а три распределённые вставки — сырьё,
|
Платится при этом не одно ожидание на блок, а три распределённые вставки — STG,
|
||||||
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
|
событие, ошибки, — и все внутри потока-потребителя, что само по себе повод для
|
||||||
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
|
ребаланса по таймауту сессии. Против всего этого — окно в сотню миллисекунд на
|
||||||
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
|
ноутбуке, где мир пересобирается одной командой. Размен не в пользу настройки, а
|
||||||
компромисс полезнее показать, чем спрятать за галочкой.
|
компромисс полезнее показать, чем спрятать за галочкой.
|
||||||
|
|
||||||
Ещё одно место, где сырьё хранит не всё приехавшее: запись с пустым значением и
|
Ещё одно место, где сырой слой хранит не всё приехавшее: запись с пустым
|
||||||
запись-надгробие проходят молча, не оставляя строки. Свойство измерено и принято
|
значением и запись-надгробие проходят молча, не оставляя строки. Свойство
|
||||||
осознанно — подробности в разделе «Что проверено».
|
измерено и принято осознанно — подробности в разделе «Что проверено».
|
||||||
|
|
||||||
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
|
**Гарантии нет ни в одну сторону — есть два узких окна.** Окно потери описано
|
||||||
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
|
выше: нода умерла между коммитом офсетов и сбросом спула. Окно дубля
|
||||||
@@ -361,14 +360,14 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
|
|||||||
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
|
один раз» нельзя — это обещало бы, что потерь не бывает, а они возможны.
|
||||||
|
|
||||||
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
|
Дубль ниже по течению ведёт себя по-разному. В ODS его схлопнет
|
||||||
`ReplacingMergeTree`, а сырьё дедупа не имеет вовсе: перезаливка модельного дня
|
`ReplacingMergeTree`, а таблицы STG вовсе не устраняют дубли: перезаливка
|
||||||
честно удваивает `count()` в STG, и живёт эта пара до истечения срока хранения.
|
модельного дня честно удваивает `count()` в STG, и живёт эта пара до истечения
|
||||||
Это свойство слоя, а не поломка, — но обещание идемпотентности конвейера к
|
срока хранения. Это свойство слоя, а не поломка, — но обещание идемпотентности
|
||||||
сырому слою не относится.
|
конвейера к сырому слою не относится.
|
||||||
|
|
||||||
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
|
Оговорка к последнему: у семейства `Replicated*` есть своя дедупликация — блок с
|
||||||
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
|
тем же хешем, вставленный повторно, отбрасывается (`insert_deduplicate`).
|
||||||
Удвоение сырья проходит мимо неё только потому, что при повторном чтении
|
Удвоение строк STG проходит мимо неё только потому, что при повторном чтении
|
||||||
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
|
`_load_ts` новый и хеш блока другой. Свойство слоя держится на этом, а не на
|
||||||
отсутствии механизма.
|
отсутствии механизма.
|
||||||
|
|
||||||
@@ -384,53 +383,54 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
|
|||||||
роняет вставку и останавливает потребление до починки
|
роняет вставку и останавливает потребление до починки
|
||||||
([ADR 0005](../adr/0005-event-ingestion.md)).
|
([ADR 0005](../adr/0005-event-ingestion.md)).
|
||||||
|
|
||||||
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырья
|
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырого
|
||||||
срок жизни трое суток, а ODS хранит всё, поэтому равенство «сырьё = события +
|
слоя срок жизни трое суток, а ODS хранит всё, поэтому равенство «строки STG =
|
||||||
ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня
|
события + ошибки» разъедется само: строки STG истекут раньше, а перезаливка
|
||||||
задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не
|
модельного дня задвоит их, тогда как в ODS тот же повтор схлопнется. Правило,
|
||||||
смотреть на оповещения
|
красное в норме, учит не смотреть на оповещения ([ADR
|
||||||
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым
|
0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым опытом при
|
||||||
опытом при исполнении #43, на управляемой пачке: отправили N сообщений —
|
исполнении #43, на управляемой пачке: отправили N сообщений — получили N строк
|
||||||
получили N строк сырья и N в сумме событий и ошибок. Постоянной целью такой
|
STG и N в сумме событий и ошибок. Постоянной целью такой опыт не становится, и
|
||||||
опыт не становится, и почему — в [карте
|
почему — в [карте проверок](testing.md), раздел «Интеграционная проверка
|
||||||
проверок](testing.md), раздел «Интеграционная проверка постоянной целью не
|
постоянной целью не становится». Счёт актуальных событий идёт через
|
||||||
становится». Счёт актуальных событий идёт через `ods.event_v`, которое скрывает
|
`ods.event_v`, которое скрывает `FINAL`: голый `count()` по `ReplacingMergeTree`
|
||||||
`FINAL`: голый `count()` по `ReplacingMergeTree` зависит от того, сколько
|
зависит от того, сколько мержей успело пройти, и спека это прямо запрещает
|
||||||
мержей успело пройти, и спека это прямо запрещает (раздел 6).
|
(раздел 6).
|
||||||
|
|
||||||
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
|
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
|
||||||
против [описи мира](../../data/world-inventory.json), лежащей в git
|
против [паспорта мира](../../data/world-inventory.json), лежащего в git (`make
|
||||||
(`make check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в
|
check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в этом вся
|
||||||
этом вся разница: опись описывает восемь дней стартового мира, счёт обрамлён
|
разница: паспорт описывает восемь дней стартового мира, счёт обрамлён их датами,
|
||||||
их датами, повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не
|
повтор заливки схлопывается в `ods.event_v`, а срок хранения ей не помеха — ODS
|
||||||
помеха — ODS хранит всё. Обе стороны равенства зафиксированы: одна кодом
|
хранит всё. Обе стороны равенства зафиксированы: одна кодом генератора, другая
|
||||||
генератора, другая файлом в git. У межслойной сверки такой опоры нет ни с
|
файлом в git. У межслойной сверки такой опоры нет ни с одной стороны.
|
||||||
одной стороны.
|
|
||||||
|
|
||||||
## Срок жизни сырья
|
## Срок жизни сырых данных
|
||||||
|
|
||||||
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно
|
Сырые данные в STG живут трое суток реального времени и уходят сами. Трое — это
|
||||||
отладки: столько сырьё лежит на ноутбуке, чтобы менти успел разобрать полёты,
|
окно отладки: столько сырые данные хранятся на ноутбуке, чтобы менти успел
|
||||||
после чего перестаёт занимать место. Нарезка — по дню загрузки, срок — по той же
|
разобрать полёты, после чего перестаёт занимать место. Нарезка — по дню
|
||||||
колонке `_load_ts`, снятие — целыми кусками (`ttl_only_drop_parts`). В DDL
|
загрузки, срок — по той же колонке `_load_ts`, снятие — целыми кусками
|
||||||
значение проставлено явно, чтобы поведение не зависело от умолчания версии.
|
(`ttl_only_drop_parts`). В DDL значение проставлено явно, чтобы поведение не
|
||||||
|
зависело от умолчания версии.
|
||||||
|
|
||||||
Нарезать сырьё по модельному дню события было бы соблазнительно — он единица
|
Нарезать сырые данные по модельному дню события было бы соблазнительно — он
|
||||||
переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
|
единица переобработки и он же ключ партиции в ODS, — но чистку это ломает. Кусок
|
||||||
снимается целиком, только когда в нём истекли все строки, а в партиции модельного
|
снимается целиком, только когда в нём истекли все строки, а в партиции
|
||||||
дня лежит приехавшее в разное реальное время: мерж склеит куски разного возраста,
|
модельного дня лежит приехавшее в разное реальное время: мерж склеит куски
|
||||||
самая свежая строка удержит весь кусок, и данные переживут срок неограниченно.
|
разного возраста, самая свежая строка удержит весь кусок, и данные переживут
|
||||||
|
срок неограниченно.
|
||||||
|
|
||||||
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
|
В партиции дня загрузки склейка идёт точно так же, и строки в ней тоже разного
|
||||||
возраста — но не более чем на сутки, потому что партицию закрывает календарный
|
возраста — но не более чем на сутки, потому что партицию закрывает календарный
|
||||||
день. Отсюда и оценка: сырьё живёт трое суток плюс хвост до суток, а не ровно
|
день. Отсюда и оценка: сырые данные живут трое суток плюс хвост до суток, а не
|
||||||
трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику, так что
|
ровно трое. Оговорка про сроки: TTL исполняется на мержах, а не по будильнику,
|
||||||
«уходит само» здесь обещано, а «уходит вовремя» — нет.
|
так что «уходит само» здесь обещано, а «уходит вовремя» — нет.
|
||||||
|
|
||||||
Модельного дня среди колонок сырья нет вовсе. Он свойство содержимого, а
|
Модельного дня среди колонок STG нет вовсе. Он свойство содержимого, а
|
||||||
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырьё
|
содержимое разбирает ODS — там `EventDate` и живёт, ключом партиции. Сырые
|
||||||
режется своими координатами: и разбор полётов, и переобработка фильтруют по
|
данные режутся по своим координатам: и разбор полётов, и переобработка фильтруют
|
||||||
`_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
|
по `_load_ts`, попадая в ключ партиции, а не идя сплошным проходом. Фильтр по
|
||||||
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
|
модельному дню вдобавок пропускал бы битые строки — у них дата не извлекается.
|
||||||
|
|
||||||
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
|
Две оси времени тут не совпадают намеренно. Ось модельного времени начинается в
|
||||||
@@ -440,21 +440,21 @@ D0 и к реальному календарю не привязана; паке
|
|||||||
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
|
дате был бы просто сломан: он отсчитывает срок от реального «сейчас» и удалял бы
|
||||||
эталонные дни прямо на входе.
|
эталонные дни прямо на входе.
|
||||||
|
|
||||||
В бою слой сырья иногда собирают на движке `Null` — тогда он не хранится вовсе.
|
В бою сырой слой иногда собирают на движке `Null` — тогда он не хранится вовсе.
|
||||||
Такой вариант отвергнут: на стенде сырьё нужно для отладки, поэтому окно, а не
|
Такой вариант отвергнут: на стенде сырые данные нужны для отладки, поэтому окно,
|
||||||
ноль.
|
а не ноль.
|
||||||
|
|
||||||
## Таблица ошибок
|
## Таблица ошибок
|
||||||
|
|
||||||
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
|
`ods.event_errors` держит строки, не прошедшие строгий приём, вместе с их сырым
|
||||||
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что у
|
текстом, метаданными доставки и классом брака. Ключи её собственные, потому что
|
||||||
брака нет разобранных полей: шардируется `cityHash64` сырой строки — `ClientID` у
|
у брака нет разобранных полей: шардируется `cityHash64` сырой строки —
|
||||||
строки, которая не разобралась, взять неоткуда; нарезается по дню загрузки, как и
|
`ClientID` у строки, которая не разобралась, взять неоткуда; нарезается по дню
|
||||||
сырьё; живёт месяц. Дольше сырья — намеренно: если брак истекает вместе с ним,
|
загрузки, как и сырые данные; живёт месяц. Дольше сырых данных — намеренно: если
|
||||||
разбираться к моменту разбирательства будет уже нечем. Снятие — целыми кусками
|
брак истекает вместе с ними, разбираться к моменту разбирательства будет уже
|
||||||
(`ttl_only_drop_parts`), как у сырья и по той же причине: строки в партиции дня
|
нечем. Снятие — целыми кусками (`ttl_only_drop_parts`), как у STG и по той же
|
||||||
загрузки разного возраста не более чем на сутки, и куску незачем переживать
|
причине: строки в партиции дня загрузки разного возраста не более чем на сутки,
|
||||||
срок из-за самой свежей строки.
|
и куску незачем переживать срок из-за самой свежей строки.
|
||||||
|
|
||||||
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
|
Класс брака лежит в колонке `error_class` типа `LowCardinality(String)`. Без неё
|
||||||
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
|
в таблице копятся строки «что-то не так» без ответа на «что именно», а витрине
|
||||||
@@ -500,13 +500,12 @@ kafka_offset)`: смотрят такую таблицу от класса, а
|
|||||||
|
|
||||||
## Раскладка SQL
|
## Раскладка SQL
|
||||||
|
|
||||||
Исполняемые дагами запросы лежат по правилу
|
Исполняемые дагами запросы лежат по правилу `sql/<слой>/<объект>_<роль>.sql`.
|
||||||
`sql/<слой>/<объект>_<роль>.sql`. Слой — слой цели запроса: забор заказов в
|
Слой — слой цели запроса: забор заказов в STG живёт в
|
||||||
сырьё живёт в `sql/stg/orders_raw_load.sql`, разбор — в
|
`sql/stg/orders_raw_load.sql`, разбор — в `sql/ods/order_parse.sql`. Даг
|
||||||
`sql/ods/order_parse.sql`. Даг называет файлы, передаёт параметры и задаёт
|
называет файлы, передаёт параметры и задаёт порядок выполнения, но не хранит
|
||||||
порядок выполнения, но не хранит текст преобразований. Два выражения разбора
|
текст преобразований. Два выражения разбора делят локальный макрос Jinja в том
|
||||||
делят локальный макрос Jinja в том же файле; подключаемого SQL-фрагмента между
|
же файле; подключаемого SQL-фрагмента между ними нет.
|
||||||
ними нет.
|
|
||||||
|
|
||||||
SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода
|
SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. При разборе Python-кода
|
||||||
обработчик DAG читает список DDL-файлов, загружает содержимое каждого
|
обработчик DAG читает список DDL-файлов, загружает содержимое каждого
|
||||||
@@ -524,10 +523,10 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `00-databases.sql` | базы слоёв и зона справочников |
|
| `00-databases.sql` | базы слоёв и зона справочников |
|
||||||
| `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё |
|
| `05-dic-catalog.sql` | подложка над CSV-каталогом и словарь товаров поверх неё |
|
||||||
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы сырья обоих источников |
|
| `10-stg-tables.sql` | чтецы топиков `hits` и `orders`, локальные и распределённые таблицы STG обоих источников |
|
||||||
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
|
| `20-ods-tables.sql` | типизированное событие, версии заказа и обе таблицы ошибок |
|
||||||
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
|
| `30-ods-views.sql` | актуальные события, текущие заказы и матвью разбора в ODS |
|
||||||
| `40-stg-views.sql` | матвью приёма: чтец в сырьё |
|
| `40-stg-views.sql` | матвью приёма: чтец в STG |
|
||||||
| `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций |
|
| `45-dds-tables.sql` | физические модели заказа, сессии и карты идентичностей, доноры партиций |
|
||||||
| `50-dds-views.sql` | публичные договоры сущностей DDS |
|
| `50-dds-views.sql` | публичные договоры сущностей DDS |
|
||||||
| `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций |
|
| `55-dm-tables.sql` | физические витрины выручки, сверки и трафика, доноры дневных партиций |
|
||||||
@@ -537,14 +536,14 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
|||||||
правила порядка слоёв к ней не применяются.
|
правила порядка слоёв к ней не применяются.
|
||||||
|
|
||||||
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
|
Порядок остальных задают два правила. Первое: матвью принадлежит слою своей
|
||||||
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому
|
цели, а не источника, — разбор из STG в ODS лежит среди файлов ODS, потому что
|
||||||
что наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся
|
наполняет ODS. Второе: последней среди матвью цепочки Kafka создаётся матвью
|
||||||
матвью приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает
|
приёма, и потому она нарушает нумерацию слоёв. Kafka-движок начинает читать
|
||||||
читать топик, когда к нему привязывают первую матвью; создай её раньше
|
топик, когда к нему привязывают первую матвью; создай её раньше разбора — и всё,
|
||||||
разбора — и всё, что доедет в зазоре, ляжет в сырьё и не попадёт в ODS
|
что доедет в зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в
|
||||||
никуда, ни в событие, ни в ошибки. На пустом топике зазор безвреден,
|
ошибки. На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет.
|
||||||
поэтому первый прогон о нём не скажет. Проснётся он, когда тома ClickHouse
|
Проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, — то есть на
|
||||||
снесены, а данные Kafka целы, — то есть на обычной отладке.
|
обычной отладке.
|
||||||
|
|
||||||
Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
|
Файлы применяет даг `world_initialize` с ноды 1 отдельной учётной записью
|
||||||
`lifecycle`: один файл — одна последовательная задача
|
`lifecycle`: один файл — одна последовательная задача
|
||||||
@@ -579,13 +578,13 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
|
| `stg` | `stg.hits_raw_kafka` | чтец топика `hits`, формат `RawBLOB` |
|
||||||
| `stg` | `stg.hits_raw_rep` / `_dist` | сырая строка сообщения плюс метаданные доставки |
|
| `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_kafka` | чтец топика `orders`, формат `RawBLOB`, только на ноде 1 и без матвью |
|
||||||
| `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` |
|
| `stg` | `stg.orders_raw_rep` / `_dist` | сырое сообщение слепка, метаданные доставки и `_load_id` |
|
||||||
| `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие |
|
| `ods` | `ods.event_rep` / `_dist` | типизированное широкое событие |
|
||||||
| `ods` | `ods.event_v` | актуальная версия события с полями источника |
|
| `ods` | `ods.event_v` | актуальная версия события с полями источника |
|
||||||
| `ods` | `ods.event_errors_rep` / `_dist` | строки, не прошедшие строгий приём |
|
| `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_rep` / `_dist` | типизированные версии заказа |
|
||||||
| `ods` | `ods.order_v` | текущая версия заказа на языке источника |
|
| `ods` | `ods.order_v` | текущая версия заказа на языке источника |
|
||||||
| `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
|
| `ods` | `ods.order_errors_rep` / `_dist` | строки слепка, не прошедшие строгий приём |
|
||||||
@@ -612,9 +611,8 @@ SQL-файлы исполняет штатный `SQLExecuteQueryOperator`. Пр
|
|||||||
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
|
| `dic` | `dic.products_file` | чтец CSV-каталога, общего с генератором |
|
||||||
| `dic` | `dic.products` | словарь товаров поверх подложки |
|
| `dic` | `dic.products` | словарь товаров поверх подложки |
|
||||||
|
|
||||||
Матвью разбора у заказов нет: срез сырья раскладывают по этим двум целям два
|
Матвью разбора у заказов нет: срез STG раскладывают по этим двум целям два
|
||||||
`INSERT SELECT` одной задачи `parse_batch` из файла
|
`INSERT SELECT` одной задачи `parse_batch` из файла `sql/ods/order_parse.sql`.
|
||||||
`sql/ods/order_parse.sql`.
|
|
||||||
|
|
||||||
В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие
|
В слое DDS построены событие, заказ, сессия и карта идентичностей. Событие
|
||||||
остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции;
|
остаётся представлением над ODS; заказ и сессия гасят повтор заменой партиции;
|
||||||
@@ -655,11 +653,11 @@ DDL-словарь с файловым источником внутри `user_f
|
|||||||
**Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и
|
**Проверка ко-локации заказов 22 августа 2026 года (#137).** Context7 и
|
||||||
`system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку
|
`system.settings` закреплённого ClickHouse 26.3 назвали одну и ту же тройку
|
||||||
значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT`
|
значений `parallel_distributed_insert_select`: `0` — выключено, `1` — `SELECT`
|
||||||
на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`.
|
на каждом шарде, `2` — `SELECT` и `INSERT` на каждом шарде; заводское — `2`. На
|
||||||
На живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов
|
живых данных видны и дефект, и починка: до правки 1189 из 1662 заказов лежали на
|
||||||
лежали на обоих шардах, после явного нуля и повторного разбора того же
|
обоих шардах, после явного нуля и повторного разбора того же `_load_id` — ни
|
||||||
`_load_id` — ни одного. Вершина версий уцелела: деловые поля `ods.order_v`
|
одного. Вершина версий уцелела: деловые поля `ods.order_v` сошлись с эталоном,
|
||||||
сошлись с эталоном, посчитанным прямо из сырья.
|
посчитанным прямо из STG.
|
||||||
|
|
||||||
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
|
**Проверка версий заказов 18 августа 2026 года (#94).** MCP Context7 подтвердил,
|
||||||
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
|
что `JSONType` возвращает имя типа значения JSON, — на нём стоит проверка типов
|
||||||
@@ -683,19 +681,19 @@ DDL-словарь с файловым источником внутри `user_f
|
|||||||
потолком разбор отдаёт как `NULL`. Это ловит опыт, который берёт «заведомо
|
потолком разбор отдаёт как `NULL`. Это ловит опыт, который берёт «заведомо
|
||||||
далёкий» год: 2999-й не разбирается вовсе.
|
далёкий» год: 2999-й не разбирается вовсе.
|
||||||
|
|
||||||
Про сам приём заказов снято следующее. Предикат формы провода разложил все
|
Про сам приём заказов снято следующее. Предикат формы провода разложил все 13
|
||||||
13 661 строку сырья, приехавшую при исполнении #93, без исключений и без
|
661 строку STG, приехавшую при исполнении #93, без исключений и без `NULL`: 13
|
||||||
`NULL`: 13 653 годных и 8 брака, сумма сошлась с общим счётом по каждому
|
653 годных и 8 брака, сумма сошлась с общим счётом по каждому `_load_id`,
|
||||||
`_load_id`, пересечение ветвей — ноль. Все восемь строк брака — версии одного
|
пересечение ветвей — ноль. Все восемь строк брака — версии одного заказа с
|
||||||
заказа с отрицательным `total`; это дефект генератора, разобранный в
|
отрицательным `total`; это дефект генератора, разобранный в [спецификации
|
||||||
[спецификации приёма](orders/ingestion.md), «Что проверено». На управляемой
|
приёма](orders/ingestion.md), «Что проверено». На управляемой порции из пяти
|
||||||
порции из пяти строк (две версии одного заказа плюс по одной строке каждого
|
строк (две версии одного заказа плюс по одной строке каждого класса брака)
|
||||||
класса брака) годные ушли в `ods.order`, брак — в `ods.order_errors` с
|
годные ушли в `ods.order`, брак — в `ods.order_errors` с ожидаемыми классами.
|
||||||
ожидаемыми классами. Обе версии легли в одну партицию и на один шард; `FINAL`
|
Обе версии легли в одну партицию и на один шард; `FINAL` через распределённую
|
||||||
через распределённую таблицу вернул одну строку — ту, что несла позднейшую
|
таблицу вернул одну строку — ту, что несла позднейшую версию, — тогда как
|
||||||
версию, — тогда как физический счёт показывал две. Повтор задачи с тем же
|
физический счёт показывал две. Повтор задачи с тем же `_load_id` строку в
|
||||||
`_load_id` строку в `ods.order_v` и её `_load_ts` не изменил, а таблица ошибок
|
`ods.order_v` и её `_load_ts` не изменил, а таблица ошибок записала тот же брак
|
||||||
записала тот же брак второй раз, как и обещано спецификацией приёма.
|
второй раз, как и обещано спецификацией приёма.
|
||||||
|
|
||||||
Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у
|
Отдельно измерено, что **сводка вставки не годится в счётчики строк**: у
|
||||||
запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, —
|
запроса с `WHERE` `read_rows` считает прочитанное с диска, а не подошедшее, —
|
||||||
@@ -758,27 +756,26 @@ DDL-словарь с файловым источником внутри `user_f
|
|||||||
вовсе, до 7 августа утверждение держалось на опыте владельца.
|
вовсе, до 7 августа утверждение держалось на опыте владельца.
|
||||||
- Упавшая матвью роняет вставку и останавливает потребление до починки.
|
- Упавшая матвью роняет вставку и останавливает потребление до починки.
|
||||||
Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за
|
Проверено сносом цели — распределённой `ods.event_dist` — при живом чтеце: за
|
||||||
двадцать секунд (сброс блока идёт за 7,5) в сырьё не приехало ничего, а
|
двадцать секунд (сброс блока идёт за 7,5) в STG не приехало ничего, а офсет
|
||||||
офсет группы застыл с отставанием в одно сообщение. Цель вернули — сообщение
|
группы застыл с отставанием в одно сообщение. Цель вернули — сообщение доехало
|
||||||
доехало само, без повторной отправки, отставание ушло в ноль, событие
|
само, без повторной отправки, отставание ушло в ноль, событие разобралось.
|
||||||
разобралось. Сносить надо именно распределённую таблицу: вставка в
|
Сносить надо именно распределённую таблицу: вставка в `Distributed` кладёт
|
||||||
`Distributed` кладёт блок в спул и сразу возвращает управление, так что на
|
блок в спул и сразу возвращает управление, так что на сносе локальной ошибка
|
||||||
сносе локальной ошибка всплыла бы фоном и утверждение показалось бы
|
всплыла бы фоном и утверждение показалось бы опровергнутым.
|
||||||
опровергнутым.
|
- `_load_ts` в `ods.event` — это метка исходной строки STG, а не время
|
||||||
- `_load_ts` в `ods.event` — это метка исходной строки сырья, а не время
|
|
||||||
разбора. Сверено по `WatchID` на двух тысячах событий модельного дня,
|
разбора. Сверено по `WatchID` на двух тысячах событий модельного дня,
|
||||||
залитого дважды: у каждого события метка совпала с меткой одной из двух его
|
залитого дважды: у каждого события метка совпала с меткой одной из двух его
|
||||||
доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди
|
доставок, а после `FINAL` — с меткой поздней. Случаев «метки нет среди
|
||||||
доставок» ноль, то есть `now64()` в матвью разбора нет.
|
доставок» ноль, то есть `now64()` в матвью разбора нет.
|
||||||
- Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново
|
- Пересозданная матвью пропускает ближайшие сообщения. Снятые и заново
|
||||||
созданные матвью разбора при живом чтеце: сообщение, отправленное сразу
|
созданные матвью разбора при живом чтеце: сообщение, отправленное сразу после,
|
||||||
после, легло в сырьё и не попало в ODS никуда — ни в событие, ни в ошибки;
|
легло в STG и не попало в ODS никуда — ни в событие, ни в ошибки; то же
|
||||||
то же сообщение через минуту разобралось штатно. Воспроизведено дважды
|
сообщение через минуту разобралось штатно. Воспроизведено дважды 7 августа
|
||||||
7 августа 2026 года. Это тот же зазор, о котором предупреждает нумерация
|
2026 года. Это тот же зазор, о котором предупреждает нумерация файлов DDL,
|
||||||
файлов DDL, только приходит он с другой стороны — не при первом создании, а
|
только приходит он с другой стороны — не при первом создании, а при замене
|
||||||
при замене матвью на работающем стенде. Практический вывод один: правишь
|
матвью на работающем стенде. Практический вывод один: правишь матвью — не верь
|
||||||
матвью — не верь ближайшей отправке, повтори её. Чем именно держится
|
ближайшей отправке, повтори её. Чем именно держится задержка, не измерено;
|
||||||
задержка, не измерено; наблюдение записано как наблюдение.
|
наблюдение записано как наблюдение.
|
||||||
- Форма ключа `ods.event` принимается такой, как её задумала спека: выражение
|
- Форма ключа `ods.event` принимается такой, как её задумала спека: выражение
|
||||||
`intHash32(ClientID)` стоит в ключе сортировки `ReplacingMergeTree`, а
|
`intHash32(ClientID)` стоит в ключе сортировки `ReplacingMergeTree`, а
|
||||||
`SAMPLE BY` — по тому же выражению. Вопрос стоял открытым в разделе 11
|
`SAMPLE BY` — по тому же выражению. Вопрос стоял открытым в разделе 11
|
||||||
@@ -811,18 +808,19 @@ DDL-словарь с файловым источником внутри `user_f
|
|||||||
|
|
||||||
Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись
|
Оговорка к первому опыту, и она измеренная: граница проходит по пустоте. Запись
|
||||||
Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`)
|
Kafka с пустым значением (ноль байт) и запись-надгробие (значение `null`)
|
||||||
читаются, двигают офсет потребителя и не дают строки вовсе — ни в сырьё, ни в
|
читаются, двигают офсет потребителя и не дают строки вовсе — ни в STG, ни в
|
||||||
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа
|
таблицу ошибок; приём при этом не останавливается. Проверено 5 августа 2026
|
||||||
2026 года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, —
|
года: в топик ушли четыре записи — пустая, обычная, надгробие, обычная, — в STG
|
||||||
в сырьё приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при
|
приехали две, а офсет группы сдвинулся на все четыре. Формат тут ни при чём:
|
||||||
чём: `LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на
|
`LineAsString`, запасной по [ADR 0005](../adr/0005-event-ingestion.md), на том
|
||||||
том же наборе даёт ровно те же две строки и тот же офсет.
|
же наборе даёт ровно те же две строки и тот же офсет.
|
||||||
|
|
||||||
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
|
Свойство принято осознанно и переделкой не закрывается. Генератор стенда пустых
|
||||||
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
|
сообщений не шлёт, приём от них не встаёт, а менять решённый формат из-за
|
||||||
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
|
случая, которого стенд не производит, — размен не в ту сторону. Знать о нём
|
||||||
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
|
стоит ровно затем, чтобы не искать пропавшую строку глазами: пропуск виден в
|
||||||
самом сырье, `kafka_offset` лежит там колонкой, и дырка в офсетах — это он.
|
самой таблице STG, `kafka_offset` лежит там колонкой, и дырка в офсетах — это
|
||||||
|
он.
|
||||||
|
|
||||||
Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS
|
Оговорка к третьему опыту, и она сама непроверенная: `CREATE ... Distributed AS
|
||||||
<локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
|
<локальная>` копирует умолчания колонок, а значит, умолчание, оказавшееся заодно
|
||||||
@@ -865,7 +863,7 @@ Kafka с пустым значением (ноль байт) и запись-н
|
|||||||
- **`JSONExtract` читает `Decimal` и из числа JSON.** Массив
|
- **`JSONExtract` читает `Decimal` и из числа JSON.** Массив
|
||||||
`purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается
|
`purchaseRevenue`, объявленный источником как `Array(Float64)`, разбирается
|
||||||
сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на
|
сразу в `Array(Decimal(18, 2))`. Синтаксис подтвержден через Context7 и на
|
||||||
ClickHouse 26.3; все суммы после ODS совпали с сырьем до копейки.
|
ClickHouse 26.3; все суммы после ODS совпали с сырыми данными до копейки.
|
||||||
|
|
||||||
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
|
**Сказано по памяти, проверки нет.** Группа пуста: оба утверждения, ждавшие
|
||||||
матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
|
матвью разбора, закрыты опытами при исполнении #43 и переехали выше.
|
||||||
@@ -23,9 +23,9 @@
|
|||||||
заметит потерянного подключения Superset: он про ClickHouse и только.
|
заметит потерянного подключения Superset: он про ClickHouse и только.
|
||||||
|
|
||||||
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
|
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
|
||||||
против описи — вопрос к ClickHouse: строки в `ods.event` считает сам
|
против паспорта — вопрос к ClickHouse: строки в `ods.event` считает сам сервер и
|
||||||
сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене
|
отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене они
|
||||||
они подошли бы смоуку.
|
подошли бы смоуку.
|
||||||
|
|
||||||
## Карта целей
|
## Карта целей
|
||||||
|
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
||||||
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
|
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||||
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
|
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
|
||||||
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с |
|
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против паспорта | нужен | 24 с |
|
||||||
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
|
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
|
||||||
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||||
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
||||||
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
|
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [паспорт мира](../../data/world-inventory.json) — свежими | не нужен | 176,5 с |
|
||||||
|
|
||||||
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
|
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
|
||||||
разными дверями охватывают разное: корневая не видит ни одного файла
|
разными дверями охватывают разное: корневая не видит ни одного файла
|
||||||
@@ -76,9 +76,18 @@
|
|||||||
Airflow. В цену целей она не входит. На готовом мире подъём больше не
|
Airflow. В цену целей она не входит. На готовом мире подъём больше не
|
||||||
пересчитывает данные: `world_initialize` зеленеет без изменений.
|
пересчитывает данные: `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 удаляет
|
`make rebuild-storage` тоже не проверка. Команда через Airflow удаляет
|
||||||
прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL,
|
прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL,
|
||||||
заливает стартовый мир и возвращает его пульт к концу стартовых дней. Службы и
|
заливает стартовый мир и возвращает его позицию к концу стартовых дней. Службы и
|
||||||
их тома она сохраняет, поэтому возврат к каноническим данным не требует
|
их тома она сохраняет, поэтому возврат к каноническим данным не требует
|
||||||
холодного запуска всего стенда.
|
холодного запуска всего стенда.
|
||||||
|
|
||||||
@@ -122,10 +131,9 @@ ClickHouse отвечает сразу.
|
|||||||
дорогая. Правки дагов и Superset — корневой `make lint`.
|
дорогая. Правки дагов и Superset — корневой `make lint`.
|
||||||
|
|
||||||
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
|
Двадцать секунд из ее ста семидесяти шести — пересборка восьми модельных дней
|
||||||
для сверки с описью мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
|
для сверки с паспортом мира (20,5 с из 176,5). Дешевле хеши не сравнить: чтобы
|
||||||
узнать, тот ли получается мир, его надо получить. Место выбрано по той же
|
узнать, тот ли получается мир, его надо получить. Место выбрано по той же оси
|
||||||
оси «кого спрашивают» — вопрос обращён к коду генератора, стенд ему не
|
«кого спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и
|
||||||
нужен, — и
|
|
||||||
краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
|
краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
|
||||||
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
|
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
|
||||||
поломкой хранилища, а не забытой пересборкой.
|
поломкой хранилища, а не забытой пересборкой.
|
||||||
@@ -163,10 +171,10 @@ ClickHouse отвечает сразу.
|
|||||||
жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он
|
жизнь с менти, а интеграционный прогон в неё не входит; самое большее, он
|
||||||
становится темой урока.
|
становится темой урока.
|
||||||
|
|
||||||
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырья срок
|
Довод не про цену прогона, а про данные. Вложенное остаётся: у сырых данных срок
|
||||||
жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в
|
жизни трое суток, а ODS хранит всё, и события, которых мир не рождал, лягут в
|
||||||
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и
|
лабы менти неотличимо от настоящих — считая конверсию, он молча посчитает и их.
|
||||||
их. Хуже явного мусора: явный видно.
|
Хуже явного мусора: явный видно.
|
||||||
|
|
||||||
Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.**
|
Отсюда правило для того, что всё-таки вкладывает: **прибираться за собой.**
|
||||||
Дешевле всего это выходит партицией — опыт играет модельный день за границей
|
Дешевле всего это выходит партицией — опыт играет модельный день за границей
|
||||||
@@ -175,10 +183,10 @@ ClickHouse отвечает сразу.
|
|||||||
же механизм, на котором стоит переобработка дня X.
|
же механизм, на котором стоит переобработка дня X.
|
||||||
|
|
||||||
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
|
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
|
||||||
что стенд произвёл сам: подневный счёт событий против описи мира ловит и
|
что стенд произвёл сам: подневный счёт событий против паспорта мира ловит и
|
||||||
сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге,
|
сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге,
|
||||||
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки
|
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки Kafka →
|
||||||
Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
STG → ODS, и живёт он в `make check-clickhouse`.
|
||||||
|
|
||||||
Правило, которое такая проверка обязана выдержать и которое стоит держать в
|
Правило, которое такая проверка обязана выдержать и которое стоит держать в
|
||||||
голове для следующей: **на растущих данных утверждать можно только про
|
голове для следующей: **на растущих данных утверждать можно только про
|
||||||
@@ -237,6 +245,45 @@ 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 и очистку временных таблиц. Девятая из
|
||||||
|
десяти проверок спрашивает словарь товаров на обеих нодах. Последняя — подневный
|
||||||
|
счёт событий против паспорта, о нем сказано выше.
|
||||||
|
|
||||||
## Что проверено
|
## Что проверено
|
||||||
|
|
||||||
**Калибровка заказной стороны 30 августа 2026 года при исполнении #194.**
|
**Калибровка заказной стороны 30 августа 2026 года при исполнении #194.**
|
||||||
@@ -304,11 +351,11 @@ wc -c /tmp/stage7-events.jsonl /tmp/stage7-snapshot-{1,2,3,4,5,6,7}.jsonl
|
|||||||
time make test
|
time make test
|
||||||
```
|
```
|
||||||
|
|
||||||
Опись пересобралась за 20,515 с. Файловый приемник записал 460 731 812 байт:
|
Паспорт пересобрался за 20,515 с. Файловый приемник записал 460 731 812 байт:
|
||||||
458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0
|
458 478 771 байт событий и 2 253 041 байт слепков. `make test` прошел 437/0 за
|
||||||
за 176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов
|
176,476 с. Прежние 71 с (7 августа, #42) выросли по двум причинам: тестов стало
|
||||||
стало 437 против 407, и потяжелел сам мир — та же пересборка восьми дней
|
437 против 407, и потяжелел сам мир — та же пересборка восьми дней стоила 13 с,
|
||||||
стоила 13 с, теперь 20,5. Эта правка добавила один тест — проигрыш дня.
|
теперь 20,5. Эта правка добавила один тест — проигрыш дня.
|
||||||
|
|
||||||
**Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
|
**Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
|
||||||
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
|
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
|
||||||
@@ -416,11 +463,11 @@ shell-версия узкой пересборки за 2 м 25 с пересо
|
|||||||
`make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за
|
`make up` после `make clean` прошёл за 8 м 18 с, повтор на живом стенде — за
|
||||||
3 м 35 с. Отправка семи слепков заняла около 28 секунд.
|
3 м 35 с. Отправка семи слепков заняла около 28 секунд.
|
||||||
|
|
||||||
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и пульта
|
Прежний замер ниже — 2 м 50 с и 1 м 5 с до появления приёма заказов и дагов
|
||||||
мира. Весь рост приписывать слепкам нельзя: на холодном прогоне основное время
|
модельного времени. Весь рост приписывать слепкам нельзя: на холодном прогоне
|
||||||
ушло ещё до сценариев ожидания: `airflow-init` завершился примерно на 320-й
|
основное время ушло ещё до сценариев ожидания: `airflow-init` завершился
|
||||||
секунде, обработчик DAG стал здоров примерно на 458-й. Числа здесь называют
|
примерно на 320-й секунде, обработчик DAG стал здоров примерно на 458-й. Числа
|
||||||
цену нынешнего стенда, а не выносят вердикт о регрессии.
|
здесь называют цену нынешнего стенда, а не выносят вердикт о регрессии.
|
||||||
|
|
||||||
**Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой
|
**Перезамер 19 августа 2026 года при исполнении #95.** После добавления десятой
|
||||||
проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за
|
проверки словаря `make check-clickhouse` на чисто поднятом стенде прошёл за
|
||||||
@@ -436,25 +483,25 @@ shell-версия узкой пересборки за 2 м 25 с пересо
|
|||||||
от файла, и ближний выигрывает.
|
от файла, и ближний выигрывает.
|
||||||
|
|
||||||
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
|
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
|
||||||
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка:
|
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка: 18,7
|
||||||
18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka.
|
с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka. Повторный
|
||||||
Повторный `make up` на живом стенде — 1 м 5 с: мир заливается заново, и это
|
`make up` на живом стенде — 1 м 5 с: мир заливается заново, и это не оплошность
|
||||||
не оплошность — `WatchID` те же, повтор схлопнет `ReplacingMergeTree`.
|
— `WatchID` те же, повтор схлопнет `ReplacingMergeTree`. Смоук — 9 с,
|
||||||
Смоук — 9 с, `check-clickhouse` — 8 с с новой девятой проверкой. `make test`
|
`check-clickhouse` — 8 с с новой девятой проверкой. `make test` вырос до 71 с:
|
||||||
вырос до 71 с: 58 с прежних тестов плюс 13 с на пересборку восьми дней для
|
58 с прежних тестов плюс 13 с на пересборку восьми дней для сверки с паспортом.
|
||||||
сверки с описью. Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
|
Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
|
||||||
#41 и #43.
|
#41 и #43.
|
||||||
|
|
||||||
Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и
|
Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и
|
||||||
проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в
|
проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в
|
||||||
`ods.event_rep` — проверка покраснела, показала недостающий день и назвала
|
`ods.event_rep` — проверка покраснела, показала недостающий день и назвала
|
||||||
адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца
|
адрес: «таблица брака пуста, события не доехали до ODS, начните с чтеца топика».
|
||||||
топика». Затем положили в сырьё заведомо негодную строку — брак появился, и
|
Затем положили в STG заведомо негодную строку — брак появился, и диагноз
|
||||||
диагноз сменился на второй, с признаком для различения: «сойдётся недостача с
|
сменился на второй, с признаком для различения: «сойдётся недостача с числом
|
||||||
числом брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки
|
брака — сломан разбор, не сойдётся — брак от прежних опытов». Строки опыта
|
||||||
опыта убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт
|
убраны, день переигран `make generate-batch GENERATOR_DAY=2`; счёт вернулся к
|
||||||
вернулся к 401 185, и это заодно показало дедупликацию: повтор дня не удвоил
|
401 185, и это заодно показало дедупликацию: повтор дня не удвоил счёт под
|
||||||
счёт под `FINAL`.
|
`FINAL`.
|
||||||
|
|
||||||
Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и
|
Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и
|
||||||
совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону:
|
совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону:
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Мир и модельное время
|
||||||
|
|
||||||
|
Оператор управляет модельным временем стенда. Здесь разобрано, как рождается
|
||||||
|
мир, что двигает его ось, какие даги играют дни и где хранится позиция. Доводы
|
||||||
|
об устройстве дагов модельного времени — в [ADR
|
||||||
|
0009](../adr/0009-world-control.md), о безопасном повторном подъёме и жизненном
|
||||||
|
цикле мира — в [ADR 0013](../adr/0013-world-lifecycle.md). Почему последний день
|
||||||
|
стартового мира неполон и что хранит паспорт, разобрано в [справочнике
|
||||||
|
стартового мира](orders/start-world.md) и [справочнике
|
||||||
|
паспорта](orders/inventory.md).
|
||||||
|
|
||||||
|
## Стартовый мир
|
||||||
|
|
||||||
|
Стенд поднимается не пустым: `make up` через REST API запускает даг Airflow
|
||||||
|
`world_initialize`. Тот создаёт прикладные топики и схему, собирает образ
|
||||||
|
генератора и играет в `hits` первые восемь дней модельного времени — понедельник
|
||||||
|
по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в
|
||||||
|
`orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только
|
||||||
|
тогда ставит позицию мира в `8`. Так у каждой лабы есть данные, и всегда одни и
|
||||||
|
те же.
|
||||||
|
|
||||||
|
Последний день намеренно неполон: события дня 7 уже приехали, а его слепок
|
||||||
|
уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика
|
||||||
|
выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так
|
||||||
|
виден разный темп потокового трекера и ночной выгрузки магазина.
|
||||||
|
|
||||||
|
Повторный `make up` безопасен: `world_initialize` видит готовую позицию,
|
||||||
|
сообщает в журнале, что мир уже существует, и не применяет DDL, не собирает
|
||||||
|
образ и не заливает данные повторно. Изменённую схему применяют адресно либо
|
||||||
|
явной командой `make rebuild-storage`.
|
||||||
|
|
||||||
|
## Паспорт мира
|
||||||
|
|
||||||
|
Сам мир в git не хранится: он вычисляется из зерна, поэтому готовая копия была
|
||||||
|
бы лишь кэшем. Вместо него лежит **паспорт мира**,
|
||||||
|
[`data/world-inventory.json`](../../data/world-inventory.json): зерно, версия
|
||||||
|
генератора, хеш каталога товаров и по строке на каждый день — дата, число
|
||||||
|
событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики классов
|
||||||
|
расхождений по заказам. Паспорт отвечает на единственный вопрос: тот ли это мир,
|
||||||
|
что был вчера.
|
||||||
|
|
||||||
|
Спрашивают его двое. `make test` в `generator/` сверяет паспорт с тем, что
|
||||||
|
собирается из кода сегодня: правка генератора меняет мир, и паспорт надо
|
||||||
|
пересобрать — `make inventory` там же. `make check-clickhouse` в корне по дням
|
||||||
|
сверяет паспорт с данными, поступившими в `ods.event`. Хеш дня можно пересчитать
|
||||||
|
и руками — это обычный `sha256sum` файла, который пишет файловый приёмник:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --project generator python -m clickstream_generator batch \
|
||||||
|
--day 0 --file tmp/day0.jsonl
|
||||||
|
sha256sum tmp/day0.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как запустить генератор
|
||||||
|
|
||||||
|
События производит генератор из [`generator/`](../../generator/). Модельный день
|
||||||
|
— функция зерна и номера дня, поэтому один и тот же день всегда даёт те же
|
||||||
|
события: обрыв лечится повтором. Позицию на оси генератор не помнит — номер дня
|
||||||
|
передаёт вызывающая команда.
|
||||||
|
|
||||||
|
Режима два, и различаются они темпом. В **пакетном** режиме события дня
|
||||||
|
отправляются подряд, без пауз: так заливается мир и так переигрывается день
|
||||||
|
после обрыва. **Живой** держит темп модельного времени — по умолчанию ×60:
|
||||||
|
модельные сутки за 24 реальные минуты, суточная волна разворачивается на глазах,
|
||||||
|
отставание видно в журнале.
|
||||||
|
|
||||||
|
На стенде генератор запускается из отдельного образа как разовая служба. После
|
||||||
|
прогона её убирают. Для каждого режима есть своя цель:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# день D0 целиком в топик hits
|
||||||
|
make generate-batch
|
||||||
|
# первая сотня событий дня D3
|
||||||
|
make generate-batch GENERATOR_DAY=3 GENERATOR_LIMIT=100
|
||||||
|
# день D3 живьём, ускорение ×1000
|
||||||
|
make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000
|
||||||
|
```
|
||||||
|
|
||||||
|
| Переменная | Что задаёт | Умолчание |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GENERATOR_DAY` | номер дня на оси мира (D0 — первый) | `0`, задано в `Makefile` |
|
||||||
|
| `GENERATOR_LIMIT` | потолок событий на прогон; только `generate-batch` | нет: день целиком |
|
||||||
|
| `GENERATOR_SPEED` | ускорение модельного времени; только `generate-live` | ×60, задано в генераторе |
|
||||||
|
|
||||||
|
Адрес назначения для обеих целей задаёт стенд: внутри сети Compose это брокер
|
||||||
|
`kafka:9092` и топик `hits`.
|
||||||
|
|
||||||
|
При обычном запуске образ генератора не пересобирается. Если образа нет, Compose
|
||||||
|
соберёт его; если есть, использует готовый. После правки `generator/Dockerfile`
|
||||||
|
или зависимостей образ пересобирают отдельной командой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile generator build generator
|
||||||
|
```
|
||||||
|
|
||||||
|
Для заливки стартового мира нужен тот же образ. Перед такой заливкой
|
||||||
|
`world_initialize` собирает его из текущего дерева; на готовом мире повторный
|
||||||
|
`make up` до сборки не доходит.
|
||||||
|
|
||||||
|
Сборка образа и запуск контейнера разделены намеренно. Если цель запуска сама
|
||||||
|
пересобирает образ, различие между этими действиями исчезает. Следующий запуск
|
||||||
|
покажет, что образ устарел.
|
||||||
|
|
||||||
|
Посмотреть на события, не поднимая стенд, помогает файловый приёмник: одно
|
||||||
|
событие — одна строка. Флаг `--limit` берёт начало дня вместо целого дня —
|
||||||
|
тому, кто смотрит на конвейер, ждать полсотни тысяч событий незачем.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --project generator python -m clickstream_generator batch \
|
||||||
|
--day 0 --limit 100 --file tmp/day0.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
За один запуск можно проиграть несколько дней подряд: `--days 8`. Всё, что
|
||||||
|
принимается флагами, принимается и переменными окружения — так генератор
|
||||||
|
запускают даги модельного времени; полный список у `--help`.
|
||||||
|
|
||||||
|
**Значения по умолчанию есть не у всех параметров, и это сделано специально.**
|
||||||
|
Зерно, число дней и темп живого дня описывают модель мира — их генератор знает
|
||||||
|
сам. День на оси, приёмник и имя топика относятся к конкретному запуску
|
||||||
|
генератора на стенде: если их не задать, он откажется работать и вернёт
|
||||||
|
ненулевой код ещё до первого события. Поэтому значение дня по умолчанию живёт в
|
||||||
|
`Makefile`: день задаёт тот, кто запускает, а не тот, кого запускают.
|
||||||
|
|
||||||
|
## Даги модельного времени
|
||||||
|
|
||||||
|
Считать дни самому необязательно. В Airflow живут **даги модельного времени**:
|
||||||
|
два дага создают начальное состояние мира, а три двигают ось дальше.
|
||||||
|
|
||||||
|
| Даг | Что делает |
|
||||||
|
| --- | --- |
|
||||||
|
| `world_initialize` | создаёт стартовый мир, если его ещё нет; повторный запуск ничего не меняет |
|
||||||
|
| `world_recreate` | удаляет только прикладной мир и запускает его создание заново |
|
||||||
|
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
|
||||||
|
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
|
||||||
|
| `world_live` | выключатель: пока снят с паузы, запускает `world_live_day` день за днём |
|
||||||
|
|
||||||
|
Сыграв день, шаговый даг отправляет слепок заказов и запускает даг
|
||||||
|
`orders_ingest`. Тот забирает слепок из топика `orders` в `stg`; шаговый даг
|
||||||
|
ждёт его завершения. Так две половины стенда идут в ногу: упал приём — падает и
|
||||||
|
шаговый даг, а позиция остаётся на месте ([ADR
|
||||||
|
0008](../adr/0008-order-ingestion.md)).
|
||||||
|
|
||||||
|
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
|
||||||
|
дня. Шаговый даг записывает позицию только после успешного завершения дня:
|
||||||
|
оборванный прогон позицию не двигает, и следующий запуск играет тот же день
|
||||||
|
заново. Отсутствующая переменная означает, что мир ещё не создавали; `-1` —
|
||||||
|
очистка не закончена, `0` — очистка закончена, а начальная генерация ещё не
|
||||||
|
завершена. Шаговые даги начинают играть только с позиции `8` и больше.
|
||||||
|
|
||||||
|
В форме запуска можно выбрать только число дней. Первый день шаговый даг берёт
|
||||||
|
из `world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced
|
||||||
|
Options». Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и
|
||||||
|
новую позицию шаговый даг пишет в журнал шага `remember_played`.
|
||||||
|
|
||||||
|
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
|
||||||
|
встанет на границе модельных суток, доиграв начатый день. Форма дагов времени и
|
||||||
|
доводы целиком — [ADR 0009](../adr/0009-world-control.md).
|
||||||
|
|
||||||
|
**У этого решения есть цена: планировщик Airflow подключён к сокету Docker.**
|
||||||
|
Без него планировщик не сможет запустить контейнер генератора. Доступ к сокету
|
||||||
|
равен правам root на машине; для локального учебного стенда это приемлемо, но
|
||||||
|
знать об этом нужно. Доступ даёт не само монтирование сокета, а членство в
|
||||||
|
группе `docker`: у неё на каждой машине свой GID. Он записан в `.env`, а узнать
|
||||||
|
его можно командой `getent group docker`.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Путеводитель по стенду
|
||||||
|
|
||||||
|
Если вы впервые видите стенд, начните отсюда. Путеводитель подскажет, на что
|
||||||
|
смотреть и в каком порядке, а за подробностями отправит в справочники. Так вы
|
||||||
|
разберетесь в устройстве, прежде чем браться за уроки.
|
||||||
|
|
||||||
|
## Как читать
|
||||||
|
|
||||||
|
Тропа такая: [словарь понятий](../../CONTEXT.md) → главы путеводителя →
|
||||||
|
[справочники `docs/architecture/`](../architecture/) →
|
||||||
|
[уроки](../labs/README.md).
|
||||||
|
|
||||||
|
Словарь не надо читать подряд. Возвращайтесь к нему за незнакомым словом.
|
||||||
|
Первая статья объясняет хранилище. Следом идет несколько статей об устройстве
|
||||||
|
генератора; они понадобятся нескоро.
|
||||||
|
|
||||||
|
## Главы
|
||||||
|
|
||||||
|
### [Хранилище и его слои](storage-layers.md)
|
||||||
|
|
||||||
|
От события в Kafka до витрины: глава разбирает путь через STG, ODS, DDS и DM
|
||||||
|
и показывает, за что отвечает каждый слой.
|
||||||
|
|
||||||
|
### Потоки Airflow и даги модельного времени
|
||||||
|
|
||||||
|
Шаговый даг играет модельный день, а дневной конвейер раскладывает данные по
|
||||||
|
слоям. Глава поможет увидеть их связь и роль каждого дага. Пока главы нет,
|
||||||
|
смотрите [справочник конвейера](../architecture/etl.md) и [справочник мира и
|
||||||
|
дагов времени](../architecture/world.md).
|
||||||
|
|
||||||
|
### Мониторинг: куда смотреть
|
||||||
|
|
||||||
|
Свежесть и поток данных, брак и отставание собраны на дашборде «Данные».
|
||||||
|
Глава научит находить нужную панель и разбирать ее показания. Пока главы
|
||||||
|
нет, смотрите [устройство мониторинга](../architecture/monitoring/README.md).
|
||||||
|
|
||||||
|
### Немного Superset
|
||||||
|
|
||||||
|
Глава покажет, как витрина становится графиком и где проходит граница между
|
||||||
|
своими объектами и теми, которые поднимает стенд. Пока главы нет, смотрите
|
||||||
|
[справочник Superset](../architecture/superset.md).
|
||||||
|
|
||||||
|
### Генератор: откуда берутся данные
|
||||||
|
|
||||||
|
Здесь генератор — источник данных, а не предмет изучения. Глава в общих
|
||||||
|
чертах объяснит, откуда берутся события и заказы и в каком виде их получает
|
||||||
|
хранилище. Пока главы нет, смотрите [`generator/`](../../generator/) и
|
||||||
|
[описание события кликстрима](../formats/clickstream-event.md).
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Хранилище и его слои
|
||||||
|
|
||||||
|
В хранилище приходят два потока из Kafka: в топике `hits` — события
|
||||||
|
кликстрима, в `orders` — заказы магазина. Оба проходят один путь: 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 можно увидеть, что именно приехало из Kafka, даже если сообщение
|
||||||
|
испорчено.
|
||||||
|
|
||||||
|
Срок хранения — трое суток реального времени. Этого хватает для расследования
|
||||||
|
и повторного разбора, но старые строки не занимают диск вечно. Точное правило
|
||||||
|
— в [справочнике
|
||||||
|
хранилища](../architecture/storage.md#срок-жизни-сырых-данных).
|
||||||
|
|
||||||
|
## ODS: язык источника
|
||||||
|
|
||||||
|
ODS — слой разобранных данных. Строка получает типы: дата становится датой,
|
||||||
|
число — числом. Имена полей остаются на языке источника. Например, событие
|
||||||
|
сохраняет колонки `WatchID`, `ClientID` и `EventDate`.
|
||||||
|
|
||||||
|
Испорченное сообщение не останавливает загрузку. Оно попадает в таблицу
|
||||||
|
`*_errors` вместе с сырой строкой и классом ошибки. Так брак остается виден, а
|
||||||
|
годные строки продолжают движение.
|
||||||
|
|
||||||
|
## DDS: язык бизнеса
|
||||||
|
|
||||||
|
DDS — детальный слой на языке бизнеса. Здесь есть событие, заказ, сессия и
|
||||||
|
карта идентичностей. Карта связывает посетителя, то есть куку `ClientID`, с
|
||||||
|
пользователем магазина из заказа.
|
||||||
|
|
||||||
|
Сессия не приходит готовой из Kafka. Стенд сам выделяет ее из последовательных
|
||||||
|
событий одного посетителя. Здесь собраны сущности, с которыми дальше работают
|
||||||
|
аналитики.
|
||||||
|
|
||||||
|
## DM: данные для дашбордов
|
||||||
|
|
||||||
|
DM — слой витрин. Витрина — готовый набор показателей для отчета или
|
||||||
|
дашборда. Здесь уже посчитаны, например, дневная выручка по категориям и
|
||||||
|
дневные показатели трафика.
|
||||||
|
|
||||||
|
К каждой витрине можно обращаться коротким запросом, не повторяя в нем весь
|
||||||
|
путь расчета. Состав готовых витрин смотрите в [карте
|
||||||
|
таблиц](../architecture/storage.md#карта-таблиц).
|
||||||
|
|
||||||
|
## Сбоку цепочки: зона `dic`
|
||||||
|
|
||||||
|
Зона `dic` стоит вне пути STG → ODS → DDS → DM. В ней живут справочные данные,
|
||||||
|
которые хранилище получает готовыми. Ни один слой их не производит, а читать
|
||||||
|
может любой.
|
||||||
|
|
||||||
|
Первый справочник стенда — каталог товаров `dic.products`. Его ключ `sku` —
|
||||||
|
артикул товара. По нему можно узнать название, бренд и категорию товара. Как
|
||||||
|
устроена зона, смотрите в [разделе о
|
||||||
|
справочниках](../architecture/storage.md#справочники).
|
||||||
|
|
||||||
|
## Сквозные ключи
|
||||||
|
|
||||||
|
Один и тот же смысл проходит через слои под разными именами: имя меняется
|
||||||
|
вместе с языком слоя. Таблица показывает исходное имя и то, где искать тот же
|
||||||
|
смысл дальше.
|
||||||
|
|
||||||
|
| Колонка | Что означает | Что соединяет |
|
||||||
|
|---|---|---|
|
||||||
|
| `WatchID` | Идентификатор события | Событие ODS с событием DDS, где колонка называется `event_id` |
|
||||||
|
| `ClientID` | Посетитель, то есть кука | События 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). Чтобы посмотреть другой день, замените
|
||||||
|
эту дату в условии. Первый запрос покажет
|
||||||
|
события одного модельного дня:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
WatchID,
|
||||||
|
ClientID,
|
||||||
|
EventDate,
|
||||||
|
EventType,
|
||||||
|
URL
|
||||||
|
FROM ods.event_v
|
||||||
|
WHERE EventDate = '2026-06-01'
|
||||||
|
ORDER BY UTCEventTime
|
||||||
|
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/storage.md). Как данные переносят между слоями — в
|
||||||
|
следующей главе: [потоки Airflow и даги модельного
|
||||||
|
времени](README.md#потоки-airflow-и-даги-модельного-времени).
|
||||||
@@ -62,9 +62,9 @@
|
|||||||
|
|
||||||
## Ритм пересборки: витрина переживает источник
|
## Ритм пересборки: витрина переживает источник
|
||||||
|
|
||||||
Таблицы брака живут месяц (`TTL`), сырье — трое суток. Витрина живет без
|
Таблицы брака живут месяц (`TTL`), сырые данные — трое суток. Витрина живет без
|
||||||
срока: сводка «сколько брака было в марте» нужна и после того, как сами
|
срока: сводка «сколько брака было в марте» нужна и после того, как сами строки
|
||||||
строки марта удалены.
|
марта удалены.
|
||||||
|
|
||||||
Отсюда правило прогона: пересобираются **только те дни загрузки, которые
|
Отсюда правило прогона: пересобираются **только те дни загрузки, которые
|
||||||
еще есть в источнике**. Спроси у источника список живых дней и замени
|
еще есть в источнике**. Спроси у источника список живых дней и замени
|
||||||
|
|||||||
@@ -272,12 +272,13 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
|
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
|
||||||
`duplicate_event` > `amount_delta` > `match`.
|
`duplicate_event` > `amount_delta` > `match`.
|
||||||
|
|
||||||
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов впервые
|
||||||
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»), ориентир
|
||||||
ориентир ~10%. Точные доли зафиксированы замером на каноническом мире
|
~10%. Точные доли зафиксированы замером на каноническом мире (28 дней,
|
||||||
(28 дней, 2026-08-30, #194) — запись в «Что проверено»
|
2026-08-30, #194) — запись в «Что проверено» [карты
|
||||||
[карты проверок](../architecture/testing.md); опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
|
проверок](../architecture/testing.md); паспорт хранит счётчики наблюдаемых
|
||||||
только по дням с закрытым окном (раздел 8).
|
классов после приоритета — в заказах и только по дням с закрытым окном (раздел
|
||||||
|
8).
|
||||||
|
|
||||||
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
||||||
механически дублирует B.
|
механически дублирует B.
|
||||||
@@ -311,14 +312,13 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
запуск пишет полный текущий набор различных пар; предыдущий результат не
|
запуск пишет полный текущий набор различных пар; предыдущий результат не
|
||||||
служит источником следующего.
|
служит источником следующего.
|
||||||
- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») —
|
- **N:1**: часть покупателей покупает с двух кук («телефон и ноутбук») —
|
||||||
параметр мира; значение фиксирует эта спека: 15%, детерминировано
|
параметр мира; значение фиксирует эта спека: 15%, детерминировано от seed.
|
||||||
от seed. Ядро лабы:
|
Ядро лабы: `uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
|
||||||
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
|
Константа мира: каждый двухкуковый покупатель делает минимум по одному заказу
|
||||||
Константа мира: каждый двухкуковый покупатель делает минимум по одному
|
с каждой куки — иначе вторая кука не попадает в карту соответствий (она
|
||||||
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
|
строится только из покупок) и лаба не воспроизводится. Число таких пар растёт,
|
||||||
(она строится только из покупок) и лаба не воспроизводится. Число таких
|
пока мир едет, и в паспорт не кладётся ([что хранит
|
||||||
пар растёт, пока мир едет, и в опись не кладётся
|
паспорт](../architecture/orders/inventory.md)).
|
||||||
([что хранит опись](../architecture/orders/inventory.md)).
|
|
||||||
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
||||||
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
||||||
|
|
||||||
@@ -339,16 +339,14 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
|
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
|
||||||
VIEW поверх локальной таблицы проявляются в дашборде сами.
|
VIEW поверх локальной таблицы проявляются в дашборде сами.
|
||||||
- **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна
|
- **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна
|
||||||
consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели.
|
consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели. У
|
||||||
У заказов приём другого рода — пакетный забор без MV, чтец на одной ноде,
|
заказов приём другого рода — пакетный забор без MV, чтец на одной ноде, топик
|
||||||
топик `orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)).
|
`orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)).
|
||||||
Раскладку в обоих случаях решает ключ:
|
Раскладку в обоих случаях решает ключ: события — по `cityHash64(ClientID)`
|
||||||
события — по `cityHash64(ClientID)` (см. 1.3), заказы —
|
(см. 1.3), заказы — `cityHash64(order_id)`, STG — `cityHash64(сырой строки)`;
|
||||||
`cityHash64(order_id)`, сырьё STG — `cityHash64(сырой строки)`; полный
|
полный список и доводы — в [доке хранилища](../architecture/storage.md). Урок
|
||||||
список и доводы — в [доке хранилища](../architecture/storage.md). Урок
|
«какая нода читала топик — меняется между прогонами, куда легли данные — нет»
|
||||||
«какая нода читала топик — меняется между прогонами, куда легли данные —
|
живёт на событиях: при пакетном заборе читает та нода, которую спросили.
|
||||||
нет» живёт на событиях: при пакетном заборе читает та нода, которую
|
|
||||||
спросили.
|
|
||||||
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
|
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
|
||||||
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
|
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
|
||||||
сообщения сверяется с контрактным; строка с NULL среди опорных колонок
|
сообщения сверяется с контрактным; строка с NULL среди опорных колонок
|
||||||
@@ -444,23 +442,24 @@ README.
|
|||||||
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
|
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
|
||||||
|
|
||||||
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
|
У каждой таблицы слоя — пара из локальной и распределённой, имена по конвенции
|
||||||
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья —
|
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырых данных
|
||||||
см. [доку хранилища](../architecture/storage.md).
|
— см. [доку хранилища](../architecture/storage.md). Потребительские запросы и
|
||||||
Потребительские запросы и витрины читают сущности DDS через `_v` с явным
|
витрины читают сущности DDS через `_v` с явным списком колонок; `_rep` и `_dist`
|
||||||
списком колонок; `_rep` и `_dist` служат загрузке, диагностике и обслуживанию.
|
служат загрузке, диагностике и обслуживанию. Договор держится конвенцией, а не
|
||||||
Договор держится конвенцией, а не правами: модель доступа описана в
|
правами: модель доступа описана в [ADR 0007](../adr/0007-clickhouse-access.md),
|
||||||
[ADR 0007](../adr/0007-clickhouse-access.md), договор чтения — в
|
договор чтения — в [доке хранилища](../architecture/storage.md), «Имена
|
||||||
[доке хранилища](../architecture/storage.md), «Имена объектов».
|
объектов».
|
||||||
|
|
||||||
Заказы принимаются **пакетным забором** ([ADR
|
Заказы принимаются **пакетным забором** ([ADR
|
||||||
0008](../adr/0008-order-ingestion.md), [ADR
|
0008](../adr/0008-order-ingestion.md), [ADR
|
||||||
0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у событий, но
|
0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у
|
||||||
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
|
событий, но матвью к нему не привязана, и порцию в STG забирает шаг под
|
||||||
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска.
|
управлением Airflow — одно прямое чтение вставляет её в `stg.orders_raw` с
|
||||||
Один следующий `task_id` двумя последовательными запросами пишет годные строки
|
`_load_id` запуска. Один следующий `task_id` двумя последовательными запросами
|
||||||
и ошибки из того же среза. `ods.order` выбирает текущую версию по максимальной
|
пишет годные строки и ошибки из того же среза. `ods.order` выбирает текущую
|
||||||
`snapshot_date` ([ADR 0014](../adr/0014-order-version-snapshot-date.md)). Слой
|
версию по максимальной `snapshot_date` ([ADR
|
||||||
сырья остаётся точкой повтора и разбора одной принятой порции.
|
0014](../adr/0014-order-version-snapshot-date.md)). Слой Сырой слой остаётся
|
||||||
|
точкой повтора и разбора одной принятой порции.
|
||||||
|
|
||||||
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
||||||
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию
|
`ods.event` и `ods.order` получают метку загрузки `_load_ts`, но версию
|
||||||
@@ -531,31 +530,28 @@ Kafka день переигрывается генератором заново:
|
|||||||
пачками), Postgres остаётся только служебной базой Airflow. Смешанность
|
пачками), Postgres остаётся только служебной базой Airflow. Смешанность
|
||||||
ландшафта выражена режимами и частотами, а не второй трубой.
|
ландшафта выражена режимами и частотами, а не второй трубой.
|
||||||
|
|
||||||
## 8. Эталонный мир и опись
|
## 8. Эталонный мир и паспорт
|
||||||
|
|
||||||
В git хранится только опись эталонного мира; сам снимок генерируется на
|
В git хранится только паспорт эталонного мира; сам снимок генерируется на месте
|
||||||
месте — при `make up` и при проверках (решение развилки «Производительность»
|
— при `make up` и при проверках (решение развилки «Производительность» карты
|
||||||
карты #26, подробности — [спека генератора](2026-08-01-generator.md),
|
#26, подробности — [спека генератора](2026-08-01-generator.md), раздел 5).
|
||||||
раздел 5). Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней
|
Снимок — 8 дней, совпадает со стартовым миром: обещание в 14 дней снято на этапе
|
||||||
снято на этапе 7. Опись несёт
|
7. Паспорт несёт шапку с каноническим зерном и версией генератора, контрольные
|
||||||
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
|
счётчики и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
||||||
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
сравни хеш» живут на нём. Политика версионирования артефакта (бывший туман карты
|
||||||
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
|
#10) закрыта этим же ходом: версионируется паспорт. Контрольные числа паспорта:
|
||||||
карты #10) закрыта этим же ходом: версионируется опись.
|
|
||||||
Контрольные числа описи:
|
|
||||||
|
|
||||||
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
|
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
|
||||||
«слепок переснимается и даёт те же байты» опись сторожит наравне с днями;
|
«слепок переснимается и даёт те же байты» паспорт сторожит наравне с днями;
|
||||||
- заказная сторона: счётчики наблюдаемых классов расхождений после
|
- заказная сторона: счётчики наблюдаемых классов расхождений после
|
||||||
приоритета; каждый показывает число заказов и хранится только для дня с
|
приоритета; каждый показывает число заказов и хранится только для дня с
|
||||||
закрытым окном (читатель — техническая проверка `classes_vs_inventory`
|
закрытым окном (читатель — техническая проверка `classes_vs_inventory` звена
|
||||||
звена DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для
|
DQ; при N сыгранных днях таких дней N − 7). Отдельных полей для общего числа
|
||||||
общего числа заказов и выручки в описи пока нет: для них не назван
|
заказов и выручки в паспорте пока нет: для них не назван читатель;
|
||||||
читатель;
|
|
||||||
- контрольные числа идентичности не заводятся: uniq известных пользователей
|
- контрольные числа идентичности не заводятся: uniq известных пользователей
|
||||||
и число двухкуковых покупателей растут, пока мир едет, а счётчик без
|
и число двухкуковых покупателей растут, пока мир едет, а счётчик без
|
||||||
названного читателя в опись не кладётся
|
названного читателя в паспорт не кладётся ([что хранит
|
||||||
([что хранит опись](../architecture/orders/inventory.md)).
|
паспорт](../architecture/orders/inventory.md)).
|
||||||
|
|
||||||
Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
|
Размер снимка замерен на этапе 7 — запись в разделе «Что проверено»
|
||||||
[карты проверок](../architecture/testing.md).
|
[карты проверок](../architecture/testing.md).
|
||||||
@@ -572,7 +568,7 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
|
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
|
||||||
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка), world_init/next_day, helpers; первый даг — переливка слепка — приходит раньше, этапом 3 | M — ~5–6 файлов |
|
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка), world_init/next_day, helpers; первый даг — переливка слепка — приходит раньше, этапом 3 | M — ~5–6 файлов |
|
||||||
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
|
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
|
||||||
| Эталонный мир | пересборка снимка на месте, счётчики описи, чек-скрипты | M–L |
|
| Эталонный мир | пересборка снимка на месте, счётчики паспорта, чек-скрипты | M–L |
|
||||||
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
|
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
|
||||||
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
|
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
|
||||||
|
|
||||||
@@ -619,8 +615,8 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
4. Трансформации и витрины: модель DDS заказа, сессии, identity_map,
|
4. Трансформации и витрины: модель DDS заказа, сессии, identity_map,
|
||||||
выручка, сверка A+C.
|
выручка, сверка A+C.
|
||||||
5. Airflow: `etl_pipeline` (партиционная переобработка).
|
5. Airflow: `etl_pipeline` (партиционная переобработка).
|
||||||
6. Расхождения B+D и опоздания; счётчики описи.
|
6. Расхождения B+D и опоздания; счётчики паспорта.
|
||||||
7. Эталонный мир: опись и пересборка снимка, чек-скрипты. CI-генерация
|
7. Эталонный мир: паспорт и пересборка снимка, чек-скрипты. CI-генерация
|
||||||
снята (спека генератора, раздел 5).
|
снята (спека генератора, раздел 5).
|
||||||
8. Superset-дашборд v2.
|
8. Superset-дашборд v2.
|
||||||
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
||||||
@@ -658,15 +654,15 @@ ADR 0002 с приёмочными совпадают почти целиком.
|
|||||||
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
|
расхождение часов клиент/коллектор — туман карты, вернётся своим тикетом.
|
||||||
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
|
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
|
||||||
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
|
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
|
||||||
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг
|
переносится. Исключение — домашки слоя DM: ТЗ витрин менти и даг самопроверки
|
||||||
самопроверки забраны в этап 4 резолюцией #135, потому что витрина менти и
|
забраны в этап 4 резолюцией #135, потому что витрина менти и конвейер, в
|
||||||
конвейер, в который она встает, проектируются вместе. Сюда же —
|
который она встает, проектируются вместе. Сюда же — необязательная лаба «свой
|
||||||
необязательная лаба «свой дашборд в Superset» (#197): она стоит на
|
дашборд в Superset» (#197): она стоит на витринах домашек DM и продумана до
|
||||||
витринах домашек DM и продумана до готового ТЗ в этапе 8, тогда как
|
готового ТЗ в этапе 8, тогда как остальные лабы пока на уровне идей. Единый
|
||||||
остальные лабы пока на уровне идей. Единый стиль блока
|
стиль блока домашек — за работой учебного пути. Спека даёт будущим лабам
|
||||||
домашек — за работой учебного пути. Спека даёт будущим лабам только
|
только опорные точки — контрольные числа паспорта (сверка, идентичность).
|
||||||
опорные точки — контрольные числа описи (сверка, идентичность). Явное
|
Явное следствие: после этапа 9 стенд работает, но учебного пути на нём ещё
|
||||||
следствие: после этапа 9 стенд работает, но учебного пути на нём ещё нет.
|
нет.
|
||||||
- Инкрементальный ETL (#8) — свой issue.
|
- Инкрементальный ETL (#8) — свой issue.
|
||||||
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
|
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
|
||||||
- TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные
|
- TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные
|
||||||
|
|||||||
+132
-139
@@ -41,10 +41,10 @@
|
|||||||
конструкция внутри оправдана только наблюдаемым эффектом: без него это
|
конструкция внутри оправдана только наблюдаемым эффектом: без него это
|
||||||
сложность, за которую никто не заплатил.
|
сложность, за которую никто не заплатил.
|
||||||
- **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств
|
- **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств
|
||||||
мира сдвигает почти все числа разом — генератор случайных чисел выдаёт
|
мира сдвигает почти все числа разом — генератор случайных чисел выдаёт другую
|
||||||
другую последовательность. Опираться на конкретные числа поэтому нечем.
|
последовательность. Опираться на конкретные числа поэтому нечем. Хеши паспорта
|
||||||
Хеши описи обещают не постоянство чисел, а то, что их сдвиг не пройдёт
|
обещают не постоянство чисел, а то, что их сдвиг не пройдёт молча: пересобрал
|
||||||
молча: пересобрал опись — увидел diff.
|
паспорт — увидел diff.
|
||||||
- **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он
|
- **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он
|
||||||
правкой генератора после взгляда на получившиеся распределения. Оборона,
|
правкой генератора после взгляда на получившиеся распределения. Оборона,
|
||||||
которая дороже ошибки, не заводится.
|
которая дороже ошибки, не заводится.
|
||||||
@@ -59,15 +59,15 @@
|
|||||||
модельный день D — функция (зерно, D). Между прогонами живут только зерно
|
модельный день D — функция (зерно, D). Между прогонами живут только зерно
|
||||||
и позиция на оси времени.
|
и позиция на оси времени.
|
||||||
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
|
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
|
||||||
хешами описи. Транспорт (офсеты Kafka, темп) — вне обещания.
|
хешами паспорта. Транспорт (офсеты Kafka, темп) — вне обещания.
|
||||||
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
|
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
|
||||||
хранилище строится по рендеренной документации, границу сторожит строгий
|
хранилище строится по рендеренной документации, границу сторожит строгий
|
||||||
приём на стороне хранилища.
|
приём на стороне хранилища.
|
||||||
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
|
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
|
||||||
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
|
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
|
||||||
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок совпадает со
|
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок совпадает со
|
||||||
стартовым миром — 8 дней;
|
стартовым миром — 8 дней; в git — только паспорт мира; автоматических порогов
|
||||||
в git — только опись мира; автоматических порогов по времени нет.
|
по времени нет.
|
||||||
|
|
||||||
## 1. Модель мира
|
## 1. Модель мира
|
||||||
|
|
||||||
@@ -109,23 +109,21 @@
|
|||||||
начинается в D0) — только дают, кому возвращаться в первые дни: дневная
|
начинается в D0) — только дают, кому возвращаться в первые дни: дневная
|
||||||
аудитория на полке с самого D0, разгона «пустого магазина» нет.
|
аудитория на полке с самого D0, разгона «пустого магазина» нет.
|
||||||
- **Гарантия двухкуковых пар — назначенные заказы в плане.** Единица
|
- **Гарантия двухкуковых пар — назначенные заказы в плане.** Единица
|
||||||
здесь — человек, не кука: план помечает часть людей покупателями
|
здесь — человек, не кука: план помечает часть людей покупателями (доля —
|
||||||
(доля — раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают
|
раздел 9), и 15% покупателей (мастер-спека, раздел 5) получают вторую куку.
|
||||||
вторую куку. Обе куки пары принадлежат одному человеку одной когорты;
|
Обе куки пары принадлежат одному человеку одной когорты; вторая рождается в
|
||||||
вторая рождается в пределах его окна активности, без фиксированного
|
пределах его окна активности, без фиксированного зазора — по тому же
|
||||||
зазора — по тому же затухающему профилю, что и возвраты (уточнение при
|
затухающему профилю, что и возвраты (уточнение при исполнении #38,
|
||||||
исполнении #38, 2026-08-02): куку человек заводит, пока ещё ходит.
|
2026-08-02): куку человек заводит, пока ещё ходит. Равные шансы по всему окну
|
||||||
Равные шансы по всему окну означали бы вторую куку у давно ушедшего
|
означали бы вторую куку у давно ушедшего человека — и вчетверо меньше пар,
|
||||||
человека — и вчетверо меньше пар, реализованных внутри снимка.
|
реализованных внутри снимка. Каждой паре план назначает дни гарантированных
|
||||||
Каждой паре план назначает дни гарантированных заказов: по
|
заказов: по одному на куку, из дней активности этой куки, на оси от D0 и
|
||||||
одному на куку, из дней активности этой куки, на оси от D0 и позже;
|
позже; человеку предыстории, чьё окно активности таких дней не оставляет, пара
|
||||||
человеку предыстории, чьё окно активности таких дней не оставляет,
|
не назначается. День-функция обязана назначенные заказы реализовать; остальные
|
||||||
пара не назначается. День-функция обязана назначенные заказы
|
покупки — вольные, их решает генератор торговых событий (#40). План считает
|
||||||
реализовать; остальные покупки — вольные, их решает генератор торговых
|
пары, реализованные в запрошенном горизонте; паспорт это число не хранит — оно
|
||||||
событий (#40). План считает пары, реализованные в запрошенном горизонте;
|
растёт вместе с миром. Условность в данных не видна: дни назначены той же
|
||||||
опись это число не хранит — оно растёт вместе с миром. Условность в данных
|
случайностью, просто брошенной планом один раз.
|
||||||
не видна: дни назначены той же случайностью, просто брошенной планом один
|
|
||||||
раз.
|
|
||||||
- **Своя ось модельного времени.** Ось событий начинается в
|
- **Своя ось модельного времени.** Ось событий начинается в
|
||||||
фиксированный день D0 (понедельник — см. раздел 5); реальный
|
фиксированный день D0 (понедельник — см. раздел 5); реальный
|
||||||
календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси
|
календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси
|
||||||
@@ -133,10 +131,10 @@
|
|||||||
зависящая (значение — раздел 9). Между прогонами живут только зерно и
|
зависящая (значение — раздел 9). Между прогонами живут только зерно и
|
||||||
позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
|
позиция на оси: выключенный ноутбук — мир замер, потом продолжил.
|
||||||
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
||||||
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
с эталонного снимка, дальше «прожить следующий день» — явное действие. Живой
|
||||||
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
день — текущий день проигрывается с ускорением, дашборд и мониторинг «дышат»;
|
||||||
«дышат»; включается по требованию — постоянным фоном идёт, только пока
|
включается по требованию — постоянным фоном идёт, только пока включён
|
||||||
включён выключатель пульта ([ADR 0009](../adr/0009-world-control.md)).
|
выключатель `world_live` ([ADR 0009](../adr/0009-world-control.md)).
|
||||||
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
||||||
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
||||||
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
||||||
@@ -148,14 +146,13 @@
|
|||||||
Отклонено с доводами:
|
Отклонено с доводами:
|
||||||
|
|
||||||
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
|
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
|
||||||
дням, требует чекпоинтов, счётчики описи узнаваемы только постфактум;
|
дням, требует чекпоинтов, счётчики паспорта узнаваемы только постфактум; ни
|
||||||
ни один урок стенда на старении не стоит.
|
один урок стенда на старении не стоит.
|
||||||
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
|
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
|
||||||
выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
|
выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
|
||||||
- *Привязка модельного времени к реальному календарю* (T-1 с догоном):
|
- *Привязка модельного времени к реальному календарю* (T-1 с догоном):
|
||||||
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает
|
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает даты
|
||||||
даты эталонного мира зависимыми от даты запуска, опись теряет
|
эталонного мира зависимыми от даты запуска, паспорт теряет воспроизводимость.
|
||||||
воспроизводимость.
|
|
||||||
|
|
||||||
Отклонено при исполнении #38 (2026-08-02):
|
Отклонено при исполнении #38 (2026-08-02):
|
||||||
|
|
||||||
@@ -170,9 +167,9 @@
|
|||||||
константы — потолка одновременно живущих кук, которого в жизни нет и
|
константы — потолка одновременно живущих кук, которого в жизни нет и
|
||||||
который менти нечем объяснить.
|
который менти нечем объяснить.
|
||||||
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
|
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
|
||||||
гарантия — однажды опись покраснеет, а чинить нечем, кроме смены
|
гарантия — однажды паспорт покраснеет, а чинить нечем, кроме смены зерна; при
|
||||||
зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев,
|
этом несклеенная пара в данных неотличима от двух незнакомцев, так что реализм
|
||||||
так что реализм этой лотереи невидим.
|
этой лотереи невидим.
|
||||||
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
|
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
|
||||||
«все пары покупают в первый день» виден в данных ровно там, куда лаба
|
«все пары покупают в первый день» виден в данных ровно там, куда лаба
|
||||||
склейки смотрит пристальнее всего. Условность допустима, пока она не
|
склейки смотрит пристальнее всего. Условность допустима, пока она не
|
||||||
@@ -185,31 +182,29 @@
|
|||||||
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
|
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
|
||||||
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
|
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
|
||||||
Снимок при пересборке побайтово совпадает: канонический порядок ключей и
|
Снимок при пересборке побайтово совпадает: канонический порядок ключей и
|
||||||
строк; сверка — по хешам описи, а два локально пересобранных снимка
|
строк; сверка — по хешам паспорта, а два локально пересобранных снимка
|
||||||
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне
|
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне обещания —
|
||||||
обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала,
|
транспорт: офсеты и партиции Kafka, какая нода прочитала, `_load_ts`, темп
|
||||||
`_load_ts`, темп живого дня.
|
живого дня.
|
||||||
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
|
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
|
||||||
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL
|
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL тоже;
|
||||||
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет
|
единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость
|
||||||
скрипт «пересгенерируй день N — сравни хеш с описью». Расхождение на
|
любой машины проверяет скрипт «пересгенерируй день N — сравни хеш с
|
||||||
любой платформе — баг генератора, а не допуск.
|
паспортом». Расхождение на любой платформе — баг генератора, а не допуск.
|
||||||
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
|
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
|
||||||
состава мира, ветвящийся по номеру дня на когорты плана (состав
|
состава мира, ветвящийся по номеру дня на когорты плана (состав спрашивается
|
||||||
спрашивается по дням — раздел 1; уточнение при исполнении #38).
|
по дням — раздел 1; уточнение при исполнении #38). Дни предыстории
|
||||||
Дни предыстории отрицательны, а позиция в дереве — неотрицательное
|
отрицательны, а позиция в дереве — неотрицательное целое, поэтому у
|
||||||
целое, поэтому у предыстории своя ветвь состава, отдельная от оси
|
предыстории своя ветвь состава, отдельная от оси (уточнение при исполнении
|
||||||
(уточнение при исполнении #38, 2026-08-02);
|
#38, 2026-08-02); (зерно, день) → подпоток дня → именованные подпотоки
|
||||||
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
компонентов: трафик, торговые события, заказная и событийная стороны (судьба
|
||||||
торговые события, заказная и событийная стороны (судьба заказов и порча
|
заказов и порча событийного потока) — в фиксированном порядке (уточнение
|
||||||
событийного потока) — в фиксированном порядке (уточнение [судьбой
|
[судьбой заказа](../architecture/orders/fate.md): прежние «расхождения» и
|
||||||
заказа](../architecture/orders/fate.md): прежние «расхождения» и
|
«опоздания» были названы по классам витрины, а компонент называет часть мира).
|
||||||
«опоздания» были названы по классам витрины, а компонент называет часть
|
По построению: параллельный прогон равен последовательному; продление истории
|
||||||
мира).
|
днём N+1 не трогает дни 1…N; правка одного компонента меняет только его часть
|
||||||
По построению: параллельный прогон равен последовательному; продление
|
снимка — в паспорте меняются хеши только затронутых дней, дифф двух локальных
|
||||||
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
|
пересборок читаем.
|
||||||
только его часть снимка — в описи меняются хеши только затронутых
|
|
||||||
дней, дифф двух локальных пересборок читаем.
|
|
||||||
- **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через
|
- **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через
|
||||||
Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей
|
Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей
|
||||||
расширением `spawn_key`, потомок полностью определяется парой
|
расширением `spawn_key`, потомок полностью определяется парой
|
||||||
@@ -223,8 +218,8 @@
|
|||||||
представление в клиентском `purchase` (урок мастер-спеки о расхождениях).
|
представление в клиентском `purchase` (урок мастер-спеки о расхождениях).
|
||||||
- **Канонический seed и паспорт мира.** Эталонный мир собирается одним
|
- **Канонический seed и паспорт мира.** Эталонный мир собирается одним
|
||||||
каноническим зерном — константой репозитория; свои зёрна менти крутит без
|
каноническим зерном — константой репозитория; свои зёрна менти крутит без
|
||||||
гарантий описи. Опись хранит паспорт мира — зерно и версию
|
гарантий паспорта. В шапке паспорта лежат зерно и версия генератора;
|
||||||
генератора; чек-скрипты сверяют паспорт раньше счётчиков.
|
чек-скрипты сверяют шапку раньше счётчиков.
|
||||||
- **Суточный профиль интенсивности задаёт день-функция.** Форма — волны:
|
- **Суточный профиль интенсивности задаёт день-функция.** Форма — волны:
|
||||||
ночной провал, обеденный и вечерний пики, различие будней и выходных;
|
ночной провал, обеденный и вечерний пики, различие будней и выходных;
|
||||||
пики — до ~2× среднего. С детерминизмом профиль совместим: это часть
|
пики — до ~2× среднего. С детерминизмом профиль совместим: это часть
|
||||||
@@ -241,8 +236,8 @@
|
|||||||
«параллельно равно последовательно» недостижимо.
|
«параллельно равно последовательно» недостижимо.
|
||||||
- *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно
|
- *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно
|
||||||
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
|
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
|
||||||
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная
|
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная пересборка
|
||||||
пересборка описи одним PR.
|
паспорта одним PR.
|
||||||
|
|
||||||
## 3. Контракт схемы
|
## 3. Контракт схемы
|
||||||
|
|
||||||
@@ -289,9 +284,9 @@
|
|||||||
|
|
||||||
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
|
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
|
||||||
упорядоченный поток канонических байтов — единственное место, где событие
|
упорядоченный поток канонических байтов — единственное место, где событие
|
||||||
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш
|
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш для
|
||||||
для пересборки и проверок описи), Kafka пачкой — пакетный режим,
|
пересборки и проверок паспорта), Kafka пачкой — пакетный режим, Kafka с темпом
|
||||||
Kafka с темпом ×60 — живой день. Новых топиков нет.
|
×60 — живой день. Новых топиков нет.
|
||||||
- **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу
|
- **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу
|
||||||
отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое
|
отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое
|
||||||
отдельным сообщением. Контракт транспорта, не деталь реализации — сторона
|
отдельным сообщением. Контракт транспорта, не деталь реализации — сторона
|
||||||
@@ -299,9 +294,9 @@
|
|||||||
([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких
|
([ADR 0005](../adr/0005-event-ingestion.md)), поэтому склейка нескольких
|
||||||
событий в одно сообщение сломала бы разбор целиком.
|
событий в одно сообщение сломала бы разбор целиком.
|
||||||
- **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`,
|
- **Даты и время на проводе — ISO-8601.** `EventDate` уезжает как `2026-06-01`,
|
||||||
`UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырья: весь
|
`UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость сырых данных:
|
||||||
смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте и
|
весь смысл слоя STG в том, что менти открывает колонку `raw` в обычном клиенте
|
||||||
разбирает событие глазами, а число эпохи этот урок убивает. Колонка
|
и разбирает событие глазами, а число эпохи этот урок убивает. Колонка
|
||||||
`ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт
|
`ecommerce` — строка, внутри которой лежит экранированный JSON, как отдаёт
|
||||||
Метрика.
|
Метрика.
|
||||||
|
|
||||||
@@ -321,9 +316,9 @@
|
|||||||
первым. Записана форма всё равно здесь — иначе каждый выберет своё, и
|
первым. Записана форма всё равно здесь — иначе каждый выберет своё, и
|
||||||
разойдётся это уже после приёмки.
|
разойдётся это уже после приёмки.
|
||||||
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
|
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
|
||||||
numpy-массивы и datetime сериализует нативно (заметка исследования #31).
|
numpy-массивы и datetime сериализует нативно (заметка исследования #31). Смена
|
||||||
Смена библиотеки меняет канонические байты, поэтому проходит как
|
библиотеки меняет канонические байты, поэтому проходит как обновление
|
||||||
обновление зависимости: осознанная пересборка описи одним PR.
|
зависимости: осознанная пересборка паспорта одним PR.
|
||||||
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
|
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
|
||||||
потерял — пересчитал. В git снимок не попадает (раздел 5).
|
потерял — пересчитал. В git снимок не попадает (раздел 5).
|
||||||
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
|
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
|
||||||
@@ -341,7 +336,7 @@
|
|||||||
|
|
||||||
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
|
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
|
||||||
обещания детерминизма, retention конечен, в git топик не положишь, хеш с
|
обещания детерминизма, retention конечен, в git топик не положишь, хеш с
|
||||||
описью не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
|
паспортом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
|
||||||
мастер-спеки).
|
мастер-спеки).
|
||||||
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
|
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
|
||||||
через дедуп, канон держит единственный сериализатор, а не диск; файл
|
через дедуп, канон держит единственный сериализатор, а не диск; файл
|
||||||
@@ -360,27 +355,25 @@
|
|||||||
~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей —
|
~2× среднего, будни/выходные; ≈8–12 тыс. сессий, 6–8 тыс. посетителей —
|
||||||
правдоподобный средний магазин.
|
правдоподобный средний магазин.
|
||||||
- **Эталонный снимок совпадает со стартовым миром — 8 дней**: понедельник
|
- **Эталонный снимок совпадает со стартовым миром — 8 дней**: понедельник
|
||||||
по понедельник, полная неделя и один закрытый день за окном K = 7.
|
по понедельник, полная неделя и один закрытый день за окном K = 7. Обещание в
|
||||||
Обещание в 14 дней снято на этапе 7 (решение владельца 2026-08-30):
|
14 дней снято на этапе 7 (решение владельца 2026-08-30): у дней 8–13 не
|
||||||
у дней 8–13 не нашлось читателя — лабы проигрывают мир дальше без описи,
|
нашлось читателя — лабы проигрывают мир дальше без паспорта, транспорт байты с
|
||||||
транспорт байты с описью не сверяет, а проверке «пересгенерируй день N —
|
паспортом не сверяет, а проверке «пересгенерируй день N — сравни хеш» день 3
|
||||||
сравни хеш» день 3 годится так же, как день 13. Замеры и тесты, меряющие
|
годится так же, как день 13. Замеры и тесты, меряющие мир на окне в две
|
||||||
мир на окне в две недели, не меняются: мир — функция без края, окну
|
недели, не меняются: мир — функция без края, окну замера паспорт не нужен.
|
||||||
замера опись не нужна. Удлинение описи остается дешевым ходом
|
Удлинение паспорта остаётся дешёвым ходом (пересборка) и делается, когда
|
||||||
(пересборка) и делается, когда читатель появится — например, если
|
читатель появится — например, если растяжке `classes_vs_inventory` понадобится
|
||||||
растяжке `classes_vs_inventory` понадобится сверять больше одного
|
сверять больше одного закрытого дня.
|
||||||
закрытого дня.
|
- **В git — только паспорт, снимок не хранится.** Снимок генерируется при
|
||||||
- **В git — только опись, снимок не хранится.** Снимок генерируется при
|
|
||||||
создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в
|
создании мира и в проверках генератора: артефакт — кэш чистой функции, кэш в
|
||||||
git не хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки
|
git не хранят. Паспорт несёт шапку, счётчики и хеши по дням; проверки «пустой
|
||||||
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на ней.
|
git diff» и «пересгенерируй день N — сравни хеш» живут на нём. Первый `make
|
||||||
Первый `make up` пустого стенда — живая демонстрация детерминизма; готовый мир
|
up` пустого стенда — живая демонстрация детерминизма; готовый мир повторный
|
||||||
повторный подъём не пересчитывает. Честная потеря —
|
подъём не пересчитывает. Честная потеря — страховка на случай платформенного
|
||||||
страховка на случай платформенного бага: раньше менти с расходящимися
|
бага: раньше менти с расходящимися байтами мог взять готовый снимок из git,
|
||||||
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
|
теперь он упрётся в красный чек паспорта; смягчение — дисциплина целочисленной
|
||||||
описи; смягчение — дисциплина целочисленной случайности (раздел 2):
|
случайности (раздел 2): расхождения amd64/arm64 сняты устройством, а не
|
||||||
расхождения amd64/arm64 сняты устройством, а не сторожем. CI ради одного
|
сторожем. CI ради одного прогона не заводится (решение владельца 2026-08-30).
|
||||||
прогона не заводится (решение владельца 2026-08-30).
|
|
||||||
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
|
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
|
||||||
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
|
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
|
||||||
в пиковые часы сильных дней — до ~100. Число —
|
в пиковые часы сильных дней — до ~100. Число —
|
||||||
@@ -399,7 +392,7 @@
|
|||||||
| Операция | Ориентир |
|
| Операция | Ориентир |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) |
|
| Генерация одного дня | 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 года) |
|
| Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) |
|
||||||
| Лаг живого дня | секунды |
|
| Лаг живого дня | секунды |
|
||||||
|
|
||||||
@@ -460,9 +453,9 @@
|
|||||||
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
|
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
|
||||||
contract — хранилище пишется по документации, границу сторожит строгий
|
contract — хранилище пишется по документации, границу сторожит строгий
|
||||||
приём (раздел 3 здесь).
|
приём (раздел 3 здесь).
|
||||||
- **Раздел 8**: артефакт `data/startup_history/` в git заменён описью;
|
- **Раздел 8**: артефакт `data/startup_history/` в git заменён паспортом;
|
||||||
снимок генерируется на месте (раздел 5 здесь). Туман «политика
|
снимок генерируется на месте (раздел 5 здесь). Туман «политика версионирования
|
||||||
версионирования артефакта» закрыт этим же ходом: версионируется опись.
|
артефакта» закрыт этим же ходом: версионируется паспорт.
|
||||||
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования
|
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования
|
||||||
производительности» закрыт числами раздела 5.
|
производительности» закрыт числами раздела 5.
|
||||||
- Мелкие согласования там, где текст опирался на артефакт в git: источник
|
- Мелкие согласования там, где текст опирался на артефакт в git: источник
|
||||||
@@ -491,24 +484,25 @@
|
|||||||
теперь есть.
|
теперь есть.
|
||||||
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
|
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
|
||||||
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
||||||
- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 —
|
- **Позиция на оси времени** (хвост отдан дагам модельного времени раньше этапа
|
||||||
|
5 —
|
||||||
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
|
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
|
||||||
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
|
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
|
||||||
она переменной Airflow: отсутствие переменной означает, что стартовый мир
|
она переменной Airflow: отсутствие переменной означает, что стартовый мир ещё
|
||||||
ещё не создан. В обычной жизни её ставит даг пульта, который сыграл день, —
|
не создан. В обычной жизни её ставит шаговый даг, который сыграл день, —
|
||||||
значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла
|
значением «последний сыгранный + 1» и только по успеху. Даги жизненного цикла
|
||||||
используют `-1` для незавершённой очистки, `0` для чистого мира до генерации
|
используют `-1` для незавершённой очистки, `0` для чистого мира до генерации и
|
||||||
и ставят первый несыгранный день только после приёма стартового мира
|
ставят первый несыгранный день только после приёма стартового мира ([ADR
|
||||||
([ADR 0013](../adr/0013-world-lifecycle.md)).
|
0013](../adr/0013-world-lifecycle.md)). Довод — генератор отдельная и
|
||||||
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
заменяемая сущность, привязывать его к хранилищу незачем. Полный `make clean`
|
||||||
хранилищу незачем. Полный `make clean` сносит метаданные Airflow вместе с
|
сносит метаданные Airflow вместе с данными ClickHouse, а узкая пересборка явно
|
||||||
данными ClickHouse, а узкая пересборка явно сводит сохранённую позицию с
|
сводит сохранённую позицию с заново залитым миром. Расхождение переменной с
|
||||||
заново залитым миром.
|
данными (менти почистил партицию руками) лечится документацией, а не сторожем:
|
||||||
Расхождение переменной с данными (менти почистил партицию руками) лечится
|
постоянная сверка была бы проверкой, красной в норме ([ADR
|
||||||
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
|
0002](../adr/0002-monitoring-scope.md)).
|
||||||
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
|
- **Этап 7 (эталонный мир)**: пересборка обновляет паспорт мира. Отдельного
|
||||||
- **Этап 7 (эталонный мир)**: пересборка — это опись, не артефакт.
|
артефакта у неё нет. CI-генерация на amd64 и arm64 снята — резолюция в разделе
|
||||||
CI-генерация на amd64 и arm64 снята — резолюция в разделе 5.
|
5.
|
||||||
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
||||||
готовая демонстрация идемпотентности конвейера.
|
готовая демонстрация идемпотентности конвейера.
|
||||||
|
|
||||||
@@ -521,10 +515,10 @@
|
|||||||
[ADR 0017](../adr/0017-order-numbering.md). Новые решения генератора
|
[ADR 0017](../adr/0017-order-numbering.md). Новые решения генератора
|
||||||
записывают в ADR и в спеке этапа, на котором их принимают.
|
записывают в ADR и в спеке этапа, на котором их принимают.
|
||||||
|
|
||||||
Журнал собран по темам, но дата и тикет остались у каждого решения. По
|
Журнал собран по темам, но дата и тикет остались у каждого решения. По журналу
|
||||||
журналу видно, какие границы затрагивала каждая правка: иногда она меняла
|
видно, какие границы затрагивала каждая правка: иногда она меняла сразу план
|
||||||
сразу план состава, воронку и каталог. При #40 это было дешево — опись еще
|
состава, воронку и каталог. При #40 это было дёшево — паспорт мира ещё не
|
||||||
не собрали; позже такой ход потребовал бы пересборки описи.
|
собрали; позже такой ход потребовал бы пересборки паспорта мира.
|
||||||
|
|
||||||
### План состава и числа мира
|
### План состава и числа мира
|
||||||
|
|
||||||
@@ -547,10 +541,10 @@
|
|||||||
покупателей ~190 в день, что дает конверсию ~2% на сессию.
|
покупателей ~190 в день, что дает конверсию ~2% на сессию.
|
||||||
- **Конфигурация мира — модуль чистых данных** (#38, 2026-08-02) рядом с
|
- **Конфигурация мира — модуль чистых данных** (#38, 2026-08-02) рядом с
|
||||||
контрактом схемы: все числа мира собраны в одном месте, которое менти может
|
контрактом схемы: все числа мира собраны в одном месте, которое менти может
|
||||||
крутить. Правка модуля меняет мир, и проверка описи честно краснеет: опись
|
крутить. Правка модуля меняет мир, и проверка паспорта честно краснеет:
|
||||||
сторожит только канон. Вне модуля остается то, что мира не меняет, — свое
|
паспорт сторожит только канон. Вне модуля остается то, что мира не меняет, —
|
||||||
зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и
|
свое зерно и транспортные флаги проигрывателя. Отклонено: внешний конфиг и
|
||||||
переопределения окружением — переменная мира, которой нет в паспорте описи;
|
переопределения окружением — переменная мира, которой нет в шапке паспорта;
|
||||||
файл-конфиг в репозитории равен модулю по смыслу, но платит загрузчиком и
|
файл-конфиг в репозитории равен модулю по смыслу, но платит загрузчиком и
|
||||||
валидацией (довод раздела 3 против YAML).
|
валидацией (довод раздела 3 против YAML).
|
||||||
- **Покупатель перестает быть словом — двумя рычагами сразу** (#40, решение
|
- **Покупатель перестает быть словом — двумя рычагами сразу** (#40, решение
|
||||||
@@ -749,8 +743,8 @@
|
|||||||
четвертям цены и по медиане, потому что одни только края списка обманываются
|
четвертям цены и по медиане, потому что одни только края списка обманываются
|
||||||
четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом ещё
|
четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом ещё
|
||||||
не построен (#37/#43) — колонку надо взять в его описание сразу, а не догонять
|
не построен (#37/#43) — колонку надо взять в его описание сразу, а не догонять
|
||||||
правкой; описи нужен хеш каталога (хвост #42) тем более, потому что теперь в
|
правкой; паспорту нужен хеш каталога (хвост #42) тем более, потому что теперь
|
||||||
файле живёт ещё и поведение.
|
в файле живёт ещё и поведение.
|
||||||
- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%**
|
- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%**
|
||||||
(#50, 2026-08-02) и подошла к потолку вилки. Визитам вне `/cart` нужны свои
|
(#50, 2026-08-02) и подошла к потолку вилки. Визитам вне `/cart` нужны свои
|
||||||
позиции, а визит с намерением должен класть почти все сравненное; обе границы
|
позиции, а визит с намерением должен класть почти все сравненное; обе границы
|
||||||
@@ -764,7 +758,7 @@
|
|||||||
живых магазинов эта доля гуляет широко; пока она правдоподобна, сама по себе
|
живых магазинов эта доля гуляет широко; пока она правдоподобна, сама по себе
|
||||||
ничего не сторожит, и подгонять поведение под ее край не надо. Настоящий
|
ничего не сторожит, и подгонять поведение под ее край не надо. Настоящий
|
||||||
предел считается в другой валюте — дневной бюджет событий, на котором стоит
|
предел считается в другой валюте — дневной бюджет событий, на котором стоит
|
||||||
опись #42. Если будущая правка упрется в него, двигать надо бюджет и его
|
паспорт #42. Если будущая правка упрется в него, двигать надо бюджет и его
|
||||||
причины, а не долю.
|
причины, а не долю.
|
||||||
|
|
||||||
### Номер заказа, промокод и деньги
|
### Номер заказа, промокод и деньги
|
||||||
@@ -933,28 +927,27 @@
|
|||||||
подъем указал бы внутрь `.venv`: каталог был бы в образе, но модуль его не
|
подъем указал бы внутрь `.venv`: каталог был бы в образе, но модуль его не
|
||||||
нашел бы. Проверено в контейнере: день совпал побайтово с прогоном на машине.
|
нашел бы. Проверено в контейнере: день совпал побайтово с прогоном на машине.
|
||||||
|
|
||||||
### Опись мира и словарь
|
### Паспорт мира и словарь
|
||||||
|
|
||||||
- **Опись мира одна** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7,
|
- **Паспорт мира один** (#42, 2026-08-07; эталонная длина — 8 дней, этап 7,
|
||||||
2026-08-30). Стартовый мир этапа 2 — первые восемь дней,
|
2026-08-30). Стартовый мир этапа 2 — первые восемь дней, понедельник по
|
||||||
понедельник по понедельник: полная неделя и первый замкнутый цикл K = 7.
|
понедельник: полная неделя и первый замкнутый цикл K = 7.
|
||||||
`data/world-inventory.json` — та самая опись, которую обещает раздел 5.
|
`data/world-inventory.json` — тот самый паспорт, который обещает раздел 5.
|
||||||
Этап 7 оставил ее восьмидневной: у хвоста в шесть дней не нашлось читателя
|
Этап 7 оставил его восьмидневным: у хвоста в шесть дней не нашлось читателя
|
||||||
(резолюция — в разделе 5); вырастет она продолжением этого же файла, а не
|
(резолюция — в разделе 5); вырастет он продолжением этого же файла, а не
|
||||||
вторым. В JSON лежат
|
вторым. В JSON лежат шапка паспорта — зерно, версия генератора и хеш каталога
|
||||||
паспорт мира — зерно, версия генератора и хеш каталога — и строки дней с
|
— и строки дней с датой, числом событий и хешем. Паспорт собирает `make
|
||||||
датой, числом событий и хешем. Опись собирает `make inventory`, свежесть
|
inventory`, свежесть сторожит `test_inventory.py` тем же способом, что
|
||||||
сторожит `test_inventory.py` тем же способом, что свежесть описания выгрузки.
|
свежесть описания выгрузки. Отклонено: *два файла, младший и полный* — две
|
||||||
Отклонено: *два файла, младший и полный* — две правды об одном мире и лишнее
|
правды об одном мире и лишнее слово в словаре.
|
||||||
слово в словаре.
|
|
||||||
- **Хеш каталога лежит в паспорте и объясняет расхождение** (#42, 2026-08-07).
|
- **Хеш каталога лежит в паспорте и объясняет расхождение** (#42, 2026-08-07).
|
||||||
Правка цены меняет мир так же молча, как правка кода. Хеши дней и без
|
Правка цены меняет мир так же молча, как правка кода. Хеши дней и без
|
||||||
каталожного хеша поймают смену мира; он отвечает на следующий вопрос — что
|
каталожного хеша поймают смену мира; он отвечает на следующий вопрос — что
|
||||||
менялось: разошлись дни и каталог — CSV, только дни — код.
|
менялось: разошлись дни и каталог — CSV, только дни — код.
|
||||||
- **«Манифест» переименован в «опись мира», «зерновой мир» — в «стартовый мир»**
|
- **«Манифест» переименован в «опись мира», «зерновой мир» — в «стартовый мир»**
|
||||||
(#42, решение владельца 2026-08-07). Обычные русские слова убирают пару
|
(#42, решение владельца 2026-08-07; позднее тикет #214 сменил «опись мира» на
|
||||||
«манифест / мини-манифест» и необходимость различать две сущности там, где
|
«паспорт мира»). Обычные русские слова убирают пару «манифест / мини-манифест»
|
||||||
вещь одна.
|
и необходимость различать две сущности там, где вещь одна.
|
||||||
|
|
||||||
### Открытое на момент заморозки
|
### Открытое на момент заморозки
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ DDL и двумя одноразовыми службами заливал во
|
|||||||
|
|
||||||
Граница владения не совпадала с процессом, и это видно по следствиям. Успешная
|
Граница владения не совпадала с процессом, и это видно по следствиям. Успешная
|
||||||
отправка в Kafka выдавалась за созданный мир: `world-init` завершался сразу
|
отправка в Kafka выдавалась за созданный мир: `world-init` завершался сразу
|
||||||
после записи в топик, до приёма сообщений хранилищем.
|
после записи в топик, до приёма сообщений хранилищем. Повторный `make up`
|
||||||
Повторный `make up` смешивал подъём служб с повторной заливкой данных. Узкая
|
смешивал подъём служб с повторной заливкой данных. Узкая пересборка хранилища
|
||||||
пересборка хранилища сохраняла в Kafka сообщения и офсеты прежнего мира, потому
|
сохраняла в Kafka сообщения и офсеты прежнего мира, потому что чинила только
|
||||||
что чинила только ClickHouse. Позиция пульта правилась снаружи Airflow — тем,
|
ClickHouse. Позиция на оси правилась снаружи Airflow — тем, кто миром не
|
||||||
кто миром не управляет.
|
управляет.
|
||||||
|
|
||||||
**Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат
|
**Чему учит переезд.** Менти видит один наблюдаемый граф, которому принадлежат
|
||||||
прикладная схема, данные мира и позиция модельного времени, — и Compose,
|
прикладная схема, данные мира и позиция модельного времени, — и Compose,
|
||||||
@@ -63,7 +63,7 @@ Airflow держит прикладные топики Kafka, каноничес
|
|||||||
|
|
||||||
Каталог `/opt/airflow/world` — то, что нужно для создания мира: `generator/`,
|
Каталог `/opt/airflow/world` — то, что нужно для создания мира: `generator/`,
|
||||||
`data/` и корневой `.dockerignore` смонтированы только для чтения. Из первого
|
`data/` и корневой `.dockerignore` смонтированы только для чтения. Из первого
|
||||||
собирается канонический образ генератора, из второго читается опись мира, а
|
собирается канонический образ генератора, из второго читается паспорт мира, а
|
||||||
последний не пускает в контекст сборки локальные окружения и кэши. Запросы
|
последний не пускает в контекст сборки локальные окружения и кэши. Запросы
|
||||||
стенда приезжают прежним монтированием `sql/`.
|
стенда приезжают прежним монтированием `sql/`.
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
|
|||||||
кода: сборкой образа при подъёме больше никто не занимается.
|
кода: сборкой образа при подъёме больше никто не занимается.
|
||||||
5. Играет стартовые дни одним прогоном `batch` того же образа.
|
5. Играет стартовые дни одним прогоном `batch` того же образа.
|
||||||
6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего
|
6. Ждёт, пока события доедут до ODS. Условие ожидания то же, что у прежнего
|
||||||
сценария, и оно точное: сумма строк `ods.event_dist` за даты описи и строк
|
сценария, и оно точное: сумма строк `ods.event_dist` за даты паспорта и строк
|
||||||
`ods.event_errors_dist` не меньше суммы событий из
|
`ods.event_errors_dist` не меньше суммы событий из
|
||||||
[`data/world-inventory.json`](../../data/world-inventory.json). «Что-то
|
[`data/world-inventory.json`](../../data/world-inventory.json). «Что-то
|
||||||
приехало» условием не считается.
|
приехало» условием не считается.
|
||||||
@@ -136,21 +136,21 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
|
|||||||
третьим местом с той же последовательностью, а первое же расхождение между
|
третьим местом с той же последовательностью, а первое же расхождение между
|
||||||
дагами пришлось бы прятать в его параметрах.
|
дагами пришлось бы прятать в его параметрах.
|
||||||
|
|
||||||
### Сторожа у работников мира
|
### Сторожа у шаговых дагов
|
||||||
|
|
||||||
`world_next_day` и `world_live_day` краснеют, если позиции нет либо она меньше
|
`world_next_day` и `world_live_day` краснеют, если позиции нет либо она меньше
|
||||||
`WORLD_STARTING_DAYS`. Отказ называет прочитанное значение и следующий шаг:
|
`WORLD_STARTING_DAYS`. Отказ называет прочитанное значение и следующий шаг:
|
||||||
поднять стенд или пересобрать хранилище. Выключатель `world_live` своего
|
поднять стенд или пересобрать хранилище. Выключатель `world_live` своего сторожа
|
||||||
сторожа не получает — он краснеет вслед за работником.
|
не получает — он краснеет вслед за шаговым дагом.
|
||||||
|
|
||||||
`orders_ingest` сторожа не получает вовсе: его зовёт сама инициализация, когда
|
`orders_ingest` сторожа не получает вовсе: его зовёт сама инициализация, когда
|
||||||
готового мира ещё нет.
|
готового мира ещё нет.
|
||||||
|
|
||||||
## Состояния позиции
|
## Состояния позиции
|
||||||
|
|
||||||
`world_position` остаётся единственным состоянием пульта.
|
`world_position` остаётся единственным состоянием дагов модельного времени.
|
||||||
|
|
||||||
| Позиция | `world_initialize` | `world_recreate` | Работники |
|
| Позиция | `world_initialize` | `world_recreate` | Шаговые даги |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| переменной нет | создаёт мир | очищает и создаёт | красный отказ |
|
| переменной нет | создаёт мир | очищает и создаёт | красный отказ |
|
||||||
| `-1` | красный отказ: очистка не завершена, повторите `make rebuild-storage` | очищает и создаёт | красный отказ |
|
| `-1` | красный отказ: очистка не завершена, повторите `make rebuild-storage` | очищает и создаёт | красный отказ |
|
||||||
@@ -187,9 +187,9 @@ Kafka, `SQLExecuteQueryOperator` для исполняемых SQL-файлов
|
|||||||
ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо.
|
ClickHouse, они состоят в группах потребителей, и удаление группы недопустимо.
|
||||||
Поэтому сначала удаляются базы, затем топики.
|
Поэтому сначала удаляются базы, затем топики.
|
||||||
|
|
||||||
**Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: сырьё
|
**Позицию удалили руками на живом мире.** Даг честно зальёт мир заново: число
|
||||||
в STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на непустом
|
строк STG удвоится, ODS схлопнет повтор дедупликацией. Условие ожидания на
|
||||||
ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта
|
непустом ODS истинно сразу — этим оно и ограничено, обещание «доехала именно эта
|
||||||
заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти
|
заливка» ни один счёт дать не может. Сторожа на этот случай нет: ошибку менти
|
||||||
делает себе сам и тут же видит.
|
делает себе сам и тут же видит.
|
||||||
|
|
||||||
@@ -197,10 +197,10 @@ ODS истинно сразу — этим оно и ограничено, об
|
|||||||
`world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом
|
`world_initialize`, тот ждет `etl_pipeline`, а конвейер ждет слой. В живом
|
||||||
режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline`
|
режиме такую же цепочку образуют `world_live`, `world_live_day`, `etl_pipeline`
|
||||||
и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот.
|
и слой. В обоих случаях три ожидающие задачи оставляют слою последний слот.
|
||||||
Одновременная пересборка добавляет ожидания и загоняет стенд в тупик.
|
Одновременная пересборка добавляет ожидания и загоняет стенд в тупик. Поэтому
|
||||||
Поэтому пересборка — явная операция обслуживания: оператор ставит `world_live`
|
пересборка — явная операция обслуживания: оператор ставит `world_live` на паузу
|
||||||
на паузу и даёт работникам доиграть. Предусловие остаётся в документации;
|
и даёт шаговым дагам доиграть. Предусловие остаётся в документации; механизм
|
||||||
механизм блокировок стоил бы дороже этой строки.
|
блокировок стоил бы дороже этой строки.
|
||||||
|
|
||||||
## Границы
|
## Границы
|
||||||
|
|
||||||
@@ -226,9 +226,9 @@ ODS истинно сразу — этим оно и ограничено, об
|
|||||||
позиция та же, счётчики `check-clickhouse` те же, в журнале дага сказано, что
|
позиция та же, счётчики `check-clickhouse` те же, в журнале дага сказано, что
|
||||||
мир уже есть.
|
мир уже есть.
|
||||||
- **`make rebuild-storage`** возвращает канонический мир: позиция проходит
|
- **`make rebuild-storage`** возвращает канонический мир: позиция проходит
|
||||||
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты описи, топики созданы
|
`-1` → `0` → `WORLD_STARTING_DAYS`, в ODS ровно даты паспорта, топики созданы
|
||||||
заново, офсеты групп идут с нуля, DDS и DM собраны.
|
заново, офсеты групп идут с нуля, DDS и DM собраны.
|
||||||
- **Красные пути проверяются своими отказами.** Сторож работника при
|
- **Красные пути проверяются своими отказами.** Сторож шагового дага при
|
||||||
отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`;
|
отсутствующей, нулевой и отрицательной позиции; `world_initialize` на `-1`;
|
||||||
оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки,
|
оборванная очистка, оставляющая `-1`. Проверка, не видевшая своей поломки,
|
||||||
доказывает только то, что умеет печатать «ЗЕЛЁНО».
|
доказывает только то, что умеет печатать «ЗЕЛЁНО».
|
||||||
|
|||||||
@@ -104,14 +104,14 @@
|
|||||||
|
|
||||||
### Ступень 6. Разбери сырые события и сверь результат со стендом
|
### Ступень 6. Разбери сырые события и сверь результат со стендом
|
||||||
|
|
||||||
Менти пакетно разбирает срез сырья событий в собственную таблицу и построчно
|
Менти пакетно разбирает срез событий из STG в собственную таблицу и построчно
|
||||||
сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока
|
сверяет результат с нетронутым стендовым ODS. Урок — изучить разбор потока
|
||||||
отдельно от живого приема и использовать стендовый результат как образец для
|
отдельно от живого приема и использовать стендовый результат как образец для
|
||||||
сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет
|
сверки. Матвью разбора остаются несущей стеной: их пересоздание молча теряет
|
||||||
сообщения.
|
сообщения.
|
||||||
|
|
||||||
Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее
|
Форма — «собери рядом». Обратная связь — построчная сверка двух разборов. Ее
|
||||||
границы называются прямо: срок работы ограничен окном хранения сырья, а
|
границы называются прямо: срок работы ограничен окном хранения сырых данных, а
|
||||||
содержимое неопорных колонок можно сравнить только со стендовым разбором, не с
|
содержимое неопорных колонок можно сравнить только со стендовым разбором, не с
|
||||||
независимым эталоном.
|
независимым эталоном.
|
||||||
|
|
||||||
@@ -150,7 +150,8 @@
|
|||||||
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
|
- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
|
||||||
разложить до конкретной вычеркнутой позиции и объяснить до копейки.
|
разложить до конкретной вычеркнутой позиции и объяснить до копейки.
|
||||||
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
|
- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
|
||||||
некоторые различия после истечения срока сырья уже не восстановить.
|
некоторые различия после истечения срока хранения сырых данных уже не
|
||||||
|
восстановить.
|
||||||
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день
|
- **Когда отчет за день можно считать окончательным (#188).** Отчет за день
|
||||||
становится окончательным лишь после того, как источник больше не может
|
становится окончательным лишь после того, как источник больше не может
|
||||||
изменить этот день.
|
изменить этот день.
|
||||||
@@ -191,7 +192,8 @@
|
|||||||
потребителей создает еще одну полную копию потока.
|
потребителей создает еще одну полную копию потока.
|
||||||
- **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах
|
- **Как заметить пропущенные сообщения по офсетам.** Разрыв в офсетах
|
||||||
показывает потерю, которую трудно заметить по самим данным.
|
показывает потерю, которую трудно заметить по самим данным.
|
||||||
- **Почему сырье удаляется по времени загрузки.** Очистка сырья живет по
|
- **Почему сырые данные удаляются по времени загрузки.** Очистка сырых данных
|
||||||
|
идёт по
|
||||||
времени загрузки, а не по модельному дню.
|
времени загрузки, а не по модельному дню.
|
||||||
- **Куда попадают битые строки.** Годные строки и брак делят поток без
|
- **Куда попадают битые строки.** Годные строки и брак делят поток без
|
||||||
пропусков и пересечений; менти подкладывает по строке каждого класса брака
|
пропусков и пересечений; менти подкладывает по строке каждого класса брака
|
||||||
|
|||||||
+6
-4
@@ -64,8 +64,8 @@
|
|||||||
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
||||||
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
||||||
из контракта. Документ руками не правят — пересобирают.
|
из контракта. Документ руками не правят — пересобирают.
|
||||||
- `src/clickstream_generator/inventory.py` — сборка описи мира
|
- `src/clickstream_generator/inventory.py` — сборка паспорта мира
|
||||||
([`data/world-inventory.json`](../data/world-inventory.json)): паспорт мира
|
([`data/world-inventory.json`](../data/world-inventory.json)): шапка паспорта
|
||||||
и хеши восьми дней, которыми наполняется стенд, плюс счётчики классов
|
и хеши восьми дней, которыми наполняется стенд, плюс счётчики классов
|
||||||
расхождений по заказам для дней с закрытым окном. Руками не правят —
|
расхождений по заказам для дней с закрытым окном. Руками не правят —
|
||||||
пересобирают целью `make inventory`.
|
пересобирают целью `make inventory`.
|
||||||
@@ -77,7 +77,9 @@
|
|||||||
|
|
||||||
## Команды
|
## Команды
|
||||||
|
|
||||||
Проиграть день — [быстрый старт](../README.md#как-позвать-генератор) и `--help`.
|
Проиграть день — раздел [«Как запустить
|
||||||
|
генератор»](../docs/architecture/world.md#как-запустить-генератор)
|
||||||
|
справочника мира и `--help`.
|
||||||
|
|
||||||
Из этого каталога — цели генератора живут здесь, а не в корне:
|
Из этого каталога — цели генератора живут здесь, а не в корне:
|
||||||
|
|
||||||
@@ -86,7 +88,7 @@
|
|||||||
- `make typecheck` — ty: проверка типов;
|
- `make typecheck` — ty: проверка типов;
|
||||||
- `make demo` — пройти стадии канонического D0 на одном экране;
|
- `make demo` — пройти стадии канонического D0 на одном экране;
|
||||||
- `make docs` — пересобрать описание выгрузки;
|
- `make docs` — пересобрать описание выгрузки;
|
||||||
- `make inventory` — пересобрать опись мира.
|
- `make inventory` — пересобрать паспорт мира.
|
||||||
|
|
||||||
В корне репозитория `make lint` тоже есть, но он про код стенда — даги и
|
В корне репозитория `make lint` тоже есть, но он про код стенда — даги и
|
||||||
Superset. Одноимённые цели за разными дверями охватывают разное:
|
Superset. Одноимённые цели за разными дверями охватывают разное:
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"""Опись мира: чем стенд наполняется при подъёме и каким это обязано выйти.
|
"""Паспорт мира: чем стенд наполняется при подъёме и каким это обязано выйти.
|
||||||
|
|
||||||
Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не
|
Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не
|
||||||
он сам, а опись: паспорт мира, число событий и хеш байтов каждого дня, счётчики
|
он сам, а паспорт: шапка, число событий и хеш байтов каждого дня, счётчики
|
||||||
классов расхождений по заказам. Сам мир пересчитывается когда угодно, а опись
|
классов расхождений по заказам. Сам мир пересчитывается когда угодно, а паспорт
|
||||||
отвечает на единственный вопрос — **тот ли это мир, что был вчера**. Разошлись
|
отвечает на единственный вопрос — **тот ли это мир, что был вчера**. Разошлись
|
||||||
хеши — мир уехал, и дальше уже неважно, чего от него ждали проверки.
|
хеши — мир уехал, и дальше уже неважно, чего от него ждали проверки.
|
||||||
|
|
||||||
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
|
Дней в паспорте восемь: столько заливается в стенд при `make up`. Понедельник по
|
||||||
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
|
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
|
||||||
Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 —
|
Это и есть эталонный мир: обещание в четырнадцать дней снято на этапе 7 —
|
||||||
у хвоста не нашлось читателя. Понадобится длиннее — опись продолжит этот
|
у хвоста не нашлось читателя. Понадобится длиннее — паспорт продолжит этот
|
||||||
же файл, а не заведет второй.
|
же файл, а не заведет второй.
|
||||||
|
|
||||||
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
|
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
Слепок заказов — второй артефакт мира, и хешами дней он не покрыт ни при каком
|
Слепок заказов — второй артефакт мира, и хешами дней он не покрыт ни при каком
|
||||||
раскладе подпотоков, поэтому у каждого отправленного слепка своя строка. Их на
|
раскладе подпотоков, поэтому у каждого отправленного слепка своя строка. Их на
|
||||||
один меньше, чем дней: прогон дня 0 отправлять ещё нечего. Счёта строк у
|
один меньше, чем дней: прогон дня 0 отправлять ещё нечего. Счёта строк у
|
||||||
слепка нет — опись описывает мир, а не доставку.
|
слепка нет — паспорт описывает мир, а не доставку.
|
||||||
|
|
||||||
Собирается опись из каталога генератора целью `make inventory`, а свежесть её
|
Собирается паспорт из каталога генератора целью `make inventory`, а его свежесть
|
||||||
сторожит тест — как и у «описания выгрузки».
|
сторожит тест — как и у «описания выгрузки».
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ STARTING_DAYS = 8
|
|||||||
|
|
||||||
|
|
||||||
def build() -> dict[str, Any]:
|
def build() -> dict[str, Any]:
|
||||||
"""Опись целиком: паспорт мира, строка на день и строка на слепок.
|
"""Паспорт целиком: шапка, строка на день и строка на слепок.
|
||||||
|
|
||||||
Дни играются по одному и отпускаются: заказы дня остаются, потому что из
|
Дни играются по одному и отпускаются: заказы дня остаются, потому что из
|
||||||
них собираются слепки, а полсотни тысяч событий восьми дней сразу в память
|
них собираются слепки, а полсотни тысяч событий восьми дней сразу в память
|
||||||
@@ -83,12 +83,12 @@ def build() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def render() -> str:
|
def render() -> str:
|
||||||
"""Опись текстом файла: отступы в два пробела, кириллица как есть."""
|
"""Паспорт текстом файла: отступы в два пробела, кириллица как есть."""
|
||||||
return json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
|
return json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def _day(number: int, today: day_module.Day) -> dict[str, Any]:
|
def _day(number: int, today: day_module.Day) -> dict[str, Any]:
|
||||||
"""Строка описи: номер дня, его дата, число событий и хеш байтов.
|
"""Строка паспорта: номер дня, его дата, число событий и хеш байтов.
|
||||||
|
|
||||||
Дата считается от D0 арифметикой, а не берётся из событий: ось модельного
|
Дата считается от D0 арифметикой, а не берётся из событий: ось модельного
|
||||||
времени так и определена (`world.ORIGIN`), и по этой же дате счётчики
|
времени так и определена (`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]:
|
def _snapshot(number: int, orders: list[orders_module.Orders]) -> dict[str, Any]:
|
||||||
"""Строка описи слепка: какой день снят, его дата и хеш отправленных байтов.
|
"""Строка паспорта слепка: какой день снят, его дата и хеш отправленных байтов.
|
||||||
|
|
||||||
Байты те же, что уезжают в топик `orders`, с переводом строки после
|
Байты те же, что уезжают в топик `orders`, с переводом строки после
|
||||||
каждого заказа: пересъёмка слепка обязана дать их снова.
|
каждого заказа: пересъёмка слепка обязана дать их снова.
|
||||||
@@ -169,12 +169,12 @@ def _digest(payload: bytes) -> str:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
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 = parser.parse_args().output
|
||||||
output.write_text(render(), encoding="utf-8")
|
output.write_text(render(), encoding="utf-8")
|
||||||
print(f"Опись мира собрана: {output}")
|
print(f"Паспорт мира собран: {output}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
Тайминги печатаются раздельно — генерация и доставка, как требует спека
|
Тайминги печатаются раздельно — генерация и доставка, как требует спека
|
||||||
(раздел 5): это разные машины разной природы, и сложенные в одно число они
|
(раздел 5): это разные машины разной природы, и сложенные в одно число они
|
||||||
перестают что-либо говорить. Сериализация считается частью генерации: она
|
перестают что-либо говорить. Сериализация считается частью генерации: она
|
||||||
рождает те самые байты, которые сторожит опись.
|
рождает те самые байты, которые сторожит паспорт.
|
||||||
|
|
||||||
**Слепки заказов идут третьим ходом того же проигрывателя** и живут по правилу
|
**Слепки заказов идут третьим ходом того же проигрывателя** и живут по правилу
|
||||||
сдвига: прогон дня D отправляет слепок дня D−1 — ночная выгрузка бэкенда за
|
сдвига: прогон дня D отправляет слепок дня D−1 — ночная выгрузка бэкенда за
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ from enum import IntEnum
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
# Каноническое зерно эталонного мира — константа репозитория; опись хранит
|
# Каноническое зерно эталонного мира — константа репозитория; паспорт хранит
|
||||||
# его в паспорте мира. Свои зёрна менти крутит без гарантий описи.
|
# его в своей шапке. Свои зёрна менти крутит без гарантий паспорта.
|
||||||
CANONICAL_SEED = 20260601
|
CANONICAL_SEED = 20260601
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
строгий приём хранилища сверяет набор ключей (ADR 0005).
|
строгий приём хранилища сверяет набор ключей (ADR 0005).
|
||||||
- **Даты и время — ISO-8601** (спека, раздел 4): `EventDate` уезжает как
|
- **Даты и время — ISO-8601** (спека, раздел 4): `EventDate` уезжает как
|
||||||
`2026-06-01`, `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость
|
`2026-06-01`, `UTCEventTime` — как `2026-06-01T12:34:56Z`. Довод — читаемость
|
||||||
сырья: менти открывает колонку `raw` обычным клиентом и разбирает событие
|
сырых данных: менти открывает колонку `raw` обычным клиентом и разбирает событие
|
||||||
глазами, а число эпохи этот урок убивает.
|
глазами, а число эпохи этот урок убивает.
|
||||||
- **Одно событие — один документ JSON**, без перевода строки внутри: приёмник
|
- **Одно событие — один документ JSON**, без перевода строки внутри: приёмник
|
||||||
сам решает, чем их разделить.
|
сам решает, чем их разделить.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Модуль — приглашение крутить: поменяйте число, пересоберите снимок и
|
Модуль — приглашение крутить: поменяйте число, пересоберите снимок и
|
||||||
посмотрите, что стало с данными. Правка любой константы здесь — смена мира,
|
посмотрите, что стало с данными. Правка любой константы здесь — смена мира,
|
||||||
поэтому чек описи честно покраснеет: опись сторожит только канонический мир,
|
поэтому чек паспорта честно покраснеет: паспорт сторожит только канонический мир,
|
||||||
свои миры менти собирает без её гарантий (спека генератора, раздел 9).
|
свои миры менти собирает без его гарантий (спека генератора, раздел 9).
|
||||||
|
|
||||||
Числа решены спекой и связаны между собой; связки сторожат тесты
|
Числа решены спекой и связаны между собой; связки сторожат тесты
|
||||||
`test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего.
|
`test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего.
|
||||||
@@ -28,7 +28,7 @@ COUNTER_TIMEZONE_MINUTES = 240 # Europe/Samara
|
|||||||
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
|
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
|
||||||
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
|
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
|
||||||
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
|
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
|
||||||
# зависит — иначе опись перестала бы быть воспроизводимой.
|
# зависит — иначе паспорт перестал бы быть воспроизводимым.
|
||||||
ORIGIN = date(2026, 6, 1)
|
ORIGIN = date(2026, 6, 1)
|
||||||
|
|
||||||
# Окно изменяемости заказа, модельные дни: слепок бэкенда несёт заказы,
|
# Окно изменяемости заказа, модельные дни: слепок бэкенда несёт заказы,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
def test_demo_runs_and_shows_the_whole_chain() -> None:
|
def test_demo_runs_and_shows_the_whole_chain() -> None:
|
||||||
"""Сторожит команду и стадии, а числа мира оставляет описи."""
|
"""Сторожит команду и стадии, а проверку чисел мира оставляет паспорту."""
|
||||||
finished = subprocess.run(
|
finished = subprocess.run(
|
||||||
["make", "--silent", "demo"],
|
["make", "--silent", "demo"],
|
||||||
check=False,
|
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"
|
INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory.json"
|
||||||
|
|
||||||
# Прогон, слепок которого сверяется с описью байт в байт. День любой из
|
# Прогон, слепок которого сверяется с паспортом байт в байт. День любой из
|
||||||
# отправляемых; этот дешевле прочих — его окно короче.
|
# отправляемых; этот дешевле прочих — его окно короче.
|
||||||
RUN_DAY = 2
|
RUN_DAY = 2
|
||||||
|
|
||||||
# День любой: хеш дня не зависит ни от соседей, ни от края описи.
|
# День любой: хеш дня не зависит ни от соседей, ни от края паспорта.
|
||||||
REPLAY_DAY = 4
|
REPLAY_DAY = 4
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def inventory() -> dict:
|
def inventory() -> dict:
|
||||||
"""Опись, собранная из кода: мир пересчитывается один раз на весь модуль."""
|
"""Паспорт, собранный из кода: мир пересчитывается один раз на весь модуль."""
|
||||||
return build()
|
return build()
|
||||||
|
|
||||||
|
|
||||||
def test_inventory_is_up_to_date(inventory: dict):
|
def test_inventory_is_up_to_date(inventory: dict):
|
||||||
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
||||||
assert stored == inventory, (
|
assert stored == inventory, (
|
||||||
"опись мира отстала от кода — пересоберите: make inventory."
|
"паспорт мира отстал от кода — пересоберите: make inventory."
|
||||||
" Разошлись хеши дней и каталога — правили data/catalog/products.csv;"
|
" Разошлись хеши дней и каталога — правили data/catalog/products.csv;"
|
||||||
" разошлись только дни — правили генератор"
|
" разошлись только дни — правили генератор"
|
||||||
)
|
)
|
||||||
@@ -68,7 +68,7 @@ def test_the_inventory_holds_the_hash_of_every_sent_snapshot(inventory: dict, tm
|
|||||||
"""У каждого отправленного слепка — своя строка с хешем его байтов.
|
"""У каждого отправленного слепка — своя строка с хешем его байтов.
|
||||||
|
|
||||||
Байты слепка не покрыты хешами дней ни при каком раскладе подпотоков: это
|
Байты слепка не покрыты хешами дней ни при каком раскладе подпотоков: это
|
||||||
второй артефакт мира, и побайтовое обещание сторожит опись. Слепков на день
|
второй артефакт мира, и побайтовое обещание сторожит паспорт. Слепков на день
|
||||||
меньше, чем дней: прогон дня 0 не отправляет ничего.
|
меньше, чем дней: прогон дня 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):
|
def test_a_replayed_day_matches_its_inventory_hash(tmp_path):
|
||||||
"""Опись сверяется с байтами, вышедшими из CLI, а не с собранной тут же:
|
"""Паспорт сверяется с байтами, вышедшими из CLI, а не с собранной тут же:
|
||||||
пересчет сравнивался бы сам с собой.
|
пересчет сравнивался бы сам с собой.
|
||||||
"""
|
"""
|
||||||
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def test_every_event_carries_every_column(events):
|
|||||||
|
|
||||||
«Пусто» по контракту — пустое значение, а не отсутствие ключа: пропавший
|
«Пусто» по контракту — пустое значение, а не отсутствие ключа: пропавший
|
||||||
ключ уводит событие в брак целиком (ADR 0005). Порядок ключей — часть
|
ключ уводит событие в брак целиком (ADR 0005). Порядок ключей — часть
|
||||||
канона: от него зависят байты, а значит и хеши описи.
|
канона: от него зависят байты, а значит и хеши паспорта.
|
||||||
"""
|
"""
|
||||||
names = [column.name for column in schema.COLUMNS]
|
names = [column.name for column in schema.COLUMNS]
|
||||||
for event in events:
|
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]):
|
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)]
|
numbers = [record["order_id"] for record in snapshot_records(days, FULL_WINDOW_DAY)]
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "timeseries",
|
"type": "timeseries",
|
||||||
"title": "Поток: сколько строк приезжает в слой сырья",
|
"title": "Поток: сколько строк приезжает в сырой слой",
|
||||||
"datasource": {
|
"datasource": {
|
||||||
"type": "grafana-clickhouse-datasource",
|
"type": "grafana-clickhouse-datasource",
|
||||||
"uid": "clickhouse"
|
"uid": "clickhouse"
|
||||||
|
|||||||
@@ -102,12 +102,12 @@ on_signal() {
|
|||||||
# Единственный постоянный сторож цепочки Kafka → STG → ODS.
|
# Единственный постоянный сторож цепочки Kafka → STG → ODS.
|
||||||
#
|
#
|
||||||
# Проверка спрашивает не «есть ли данные», а «те ли это данные»: подневный счёт
|
# Проверка спрашивает не «есть ли данные», а «те ли это данные»: подневный счёт
|
||||||
# в `ods.event` против описи мира, лежащей в git. Поэтому она умеет краснеть на
|
# в `ods.event` против паспорта мира, лежащего в git. Поэтому она умеет краснеть на
|
||||||
# сломанном разборе — уехали события в брак, счёт разошёлся, — и на потере по
|
# сломанном разборе — уехали события в брак, счёт разошёлся, — и на потере по
|
||||||
# дороге, а не только на пустой таблице.
|
# дороге, а не только на пустой таблице.
|
||||||
#
|
#
|
||||||
# **Счёт обрамлён датами стартового мира.** Мир растёт: менти переиграет день,
|
# **Счёт обрамлён датами стартового мира.** Мир растёт: менти переиграет день,
|
||||||
# этап 5 добавит следующий, — и голый счёт по таблице разойдётся с описью
|
# этап 5 добавит следующий, — и голый счёт по таблице разойдётся с паспортом
|
||||||
# законно, без всякой поломки.
|
# законно, без всякой поломки.
|
||||||
#
|
#
|
||||||
# **Счёт идёт через ods.event_v** — каноническую поверхность актуального
|
# **Счёт идёт через ods.event_v** — каноническую поверхность актуального
|
||||||
@@ -138,7 +138,7 @@ check_starting_world() {
|
|||||||
FORMAT TSV")"
|
FORMAT TSV")"
|
||||||
|
|
||||||
if [[ "$actual" == "$expected" ]]; then
|
if [[ "$actual" == "$expected" ]]; then
|
||||||
printf 'ЗЕЛЁНО: события всех дней стартового мира на месте, счёт сходится с описью.\n'
|
printf 'ЗЕЛЁНО: события всех дней стартового мира на месте, счёт сходится с паспортом.\n'
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -148,13 +148,13 @@ check_starting_world() {
|
|||||||
GROUP BY error_class
|
GROUP BY error_class
|
||||||
ORDER BY count() DESC
|
ORDER BY count() DESC
|
||||||
FORMAT TSV")"
|
FORMAT TSV")"
|
||||||
printf 'Опись мира ожидает (дата, событий):\n%s\n' "$expected" >&2
|
printf 'Паспорт мира ожидает (дата, событий):\n%s\n' "$expected" >&2
|
||||||
printf 'В ods.event лежит:\n%s\n' "${actual:-— ничего —}" >&2
|
printf 'В ods.event лежит:\n%s\n' "${actual:-— ничего —}" >&2
|
||||||
if [[ -z "$broken" ]]; then
|
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
|
fi
|
||||||
printf 'В ods.event_errors по классам брака:\n%s\n' "$broken" >&2
|
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() {
|
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")" "словарь товаров не ответил на второй ноде"
|
assert_equal "$expected_product" "$(query_as_etl clickhouse-02 "$product_sql")" "словарь товаров не ответил на второй ноде"
|
||||||
printf 'ЗЕЛЁНО: известный sku вернул имя, категорию и цену на обеих нодах.\n'
|
printf 'ЗЕЛЁНО: известный sku вернул имя, категорию и цену на обеих нодах.\n'
|
||||||
|
|
||||||
printf 'Проверка 10/10: стартовый мир в ods.event сходится с описью...\n'
|
printf 'Проверка 10/10: стартовый мир в ods.event сходится с паспортом...\n'
|
||||||
check_starting_world
|
check_starting_world
|
||||||
|
|
||||||
printf 'ИТОГ: все 10 проверок кластера ClickHouse прошли.\n'
|
printf 'ИТОГ: все 10 проверок кластера ClickHouse прошли.\n'
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
-- STG: чтецы топиков hits и orders, таблицы сырья обоих источников.
|
-- STG: чтецы топиков hits и orders, таблицы STG обоих источников.
|
||||||
--
|
--
|
||||||
-- Слой сырья ничего не интерпретирует: сообщение ложится строкой, как пришло,
|
-- Сырой слой ничего не интерпретирует: сообщение ложится строкой, как пришло,
|
||||||
-- рядом с метаданными доставки. Довод целиком — ADR 0005, конвенции колонок и
|
-- рядом с метаданными доставки. Довод целиком — ADR 0005, конвенции колонок и
|
||||||
-- сроков — docs/architecture/storage.md.
|
-- сроков — docs/architecture/storage.md.
|
||||||
|
|
||||||
@@ -30,9 +30,9 @@ SETTINGS
|
|||||||
kafka_group_name = 'clickstream_hits',
|
kafka_group_name = 'clickstream_hits',
|
||||||
kafka_format = 'RawBLOB';
|
kafka_format = 'RawBLOB';
|
||||||
|
|
||||||
-- Локальная таблица сырья.
|
-- Локальная таблица STG.
|
||||||
--
|
--
|
||||||
-- Движок именно ReplicatedMergeTree, не Replacing: повтор доставки в сырье
|
-- Движок именно ReplicatedMergeTree, не Replacing: повтор доставки в STG
|
||||||
-- обязан быть виден — ради этого слой и заведён.
|
-- обязан быть виден — ради этого слой и заведён.
|
||||||
--
|
--
|
||||||
-- Служебные колонки не повторяют имён виртуальных (_topic, _partition,
|
-- Служебные колонки не повторяют имён виртуальных (_topic, _partition,
|
||||||
@@ -45,7 +45,7 @@ SETTINGS
|
|||||||
-- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа
|
-- колонки _timestamp_ms, а не из _timestamp. Измерено на стенде 5 августа
|
||||||
-- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms —
|
-- 2026 года: _timestamp — Nullable(DateTime), то есть секунды; _timestamp_ms —
|
||||||
-- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная,
|
-- Nullable(DateTime64(3)). Взяты миллисекунды: у брокера метка миллисекундная,
|
||||||
-- _load_ts рядом тоже миллисекундная, а слой сырья хранит то, что приехало, и
|
-- _load_ts рядом тоже миллисекундная, а сырой слой хранит то, что приехало, и
|
||||||
-- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не
|
-- округлять ему нечего. Обнуляемость обязательна: метку брокер заполняет не
|
||||||
-- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год.
|
-- всегда, а необнуляемый тип дал бы либо падение приёма, либо тихий 1970 год.
|
||||||
--
|
--
|
||||||
@@ -115,8 +115,8 @@ SETTINGS
|
|||||||
kafka_format = 'RawBLOB',
|
kafka_format = 'RawBLOB',
|
||||||
kafka_commit_on_select = 1;
|
kafka_commit_on_select = 1;
|
||||||
|
|
||||||
-- Локальная таблица сырья заказов. Колонки, типы, ключ, нарезка и срок жизни —
|
-- Локальная таблица STG заказов. Колонки, типы, ключ, нарезка и срок жизни —
|
||||||
-- те же, что у сырья событий, и по тем же доводам: docs/architecture/storage.md.
|
-- те же, что у STG событий, и по тем же доводам: docs/architecture/storage.md.
|
||||||
--
|
--
|
||||||
-- Своя колонка здесь одна — _load_id, идентификатор пачки загрузки: он равен
|
-- Своя колонка здесь одна — _load_id, идентификатор пачки загрузки: он равен
|
||||||
-- run_id прогона Airflow, который забрал порцию, и по нему разбор в ODS читает
|
-- run_id прогона Airflow, который забрал порцию, и по нему разбор в ODS читает
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ SAMPLE BY intHash32(ClientID);
|
|||||||
-- События одной куки при этом остаются на одном шарде, и сессионизация со
|
-- События одной куки при этом остаются на одном шарде, и сессионизация со
|
||||||
-- склейкой идентичностей ниже по течению живут локально.
|
-- склейкой идентичностей ниже по течению живут локально.
|
||||||
--
|
--
|
||||||
-- У сырья ключ другой (хеш строки), поэтому сырая строка и разобранное из неё
|
-- У STG ключ другой (хеш строки), поэтому сырая строка и разобранное из неё
|
||||||
-- событие почти всегда лежат на разных шардах. Пошардовые счётчики слоёв
|
-- событие почти всегда лежат на разных шардах. Пошардовые счётчики слоёв
|
||||||
-- сходиться не должны — сверять слои можно только через _dist.
|
-- сходиться не должны — сверять слои можно только через _dist.
|
||||||
CREATE TABLE IF NOT EXISTS ods.event_dist ON CLUSTER clickstream_cluster
|
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 у неразобранной
|
-- Ключи собственные. Шардирование — cityHash64(raw): ClientID у неразобранной
|
||||||
-- строки взять неоткуда. ORDER BY (error_class, kafka_partition,
|
-- строки взять неоткуда. ORDER BY (error_class, kafka_partition,
|
||||||
-- kafka_offset): такую таблицу смотрят от класса брака, а внутри класса — по
|
-- kafka_offset): такую таблицу смотрят от класса брака, а внутри класса — по
|
||||||
-- координатам доставки. Нарезка по дню загрузки, как у сырья: модельного дня
|
-- координатам доставки. Нарезка по дню загрузки, как у STG: модельного дня
|
||||||
-- у брака тоже нет.
|
-- у брака тоже нет.
|
||||||
--
|
--
|
||||||
-- Срок жизни — месяц, вдесятеро дольше сырья. Истеки брак вместе с сырьём —
|
-- Срок жизни — месяц, вдесятеро дольше сырых данных. Истеки брак вместе с ними —
|
||||||
-- к моменту разбирательства не осталось бы ни того, ни другого.
|
-- к моменту разбирательства не осталось бы ни того, ни другого.
|
||||||
CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster
|
CREATE TABLE IF NOT EXISTS ods.event_errors_rep ON CLUSTER clickstream_cluster
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ AS
|
|||||||
SELECT *
|
SELECT *
|
||||||
FROM ods.order_dist FINAL;
|
FROM ods.order_dist FINAL;
|
||||||
|
|
||||||
-- ODS: разбор сырья в событие и в таблицу ошибок.
|
-- ODS: разбор STG в событие и в таблицу ошибок.
|
||||||
--
|
--
|
||||||
-- Матвью две, и вместе они обязаны делить поток без зазора и без нахлёста:
|
-- Матвью две, и вместе они обязаны делить поток без зазора и без нахлёста:
|
||||||
-- строка, подошедшая обеим, задвоится, а не подошедшая ни одной — исчезнет
|
-- строка, подошедшая обеим, задвоится, а не подошедшая ни одной — исчезнет
|
||||||
@@ -168,7 +168,7 @@ SELECT
|
|||||||
JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity,
|
JSONExtract(raw, 'productQuantity', 'Array(UInt64)') AS productQuantity,
|
||||||
JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType,
|
JSONExtract(raw, 'productEventType', 'Array(String)') AS productEventType,
|
||||||
JSONExtract(raw, 'ecommerce', 'String') AS ecommerce,
|
JSONExtract(raw, 'ecommerce', 'String') AS ecommerce,
|
||||||
-- Метка загрузки переносится из сырья как есть, а не ставится заново:
|
-- Метка загрузки переносится из STG как есть, а не ставится заново:
|
||||||
-- она отвечает на вопрос «когда строка приехала в хранилище». Поставь
|
-- она отвечает на вопрос «когда строка приехала в хранилище». Поставь
|
||||||
-- здесь now64(3) — и колонка версии молча ответила бы на другой вопрос.
|
-- здесь now64(3) — и колонка версии молча ответила бы на другой вопрос.
|
||||||
_load_ts
|
_load_ts
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
-- STG: матвью приёма — из чтеца в сырьё.
|
-- STG: матвью приёма — из чтеца в таблицу STG.
|
||||||
--
|
--
|
||||||
-- Номер 40, а не 11, и пропуск в нумерации намеренный. Файлы 20-ods-tables.sql
|
-- Номер 40, а не 11, и пропуск в нумерации намеренный. Файлы 20-ods-tables.sql
|
||||||
-- и 30-ods-views.sql приносит #43, и матвью приёма обязана создаваться после
|
-- и 30-ods-views.sql приносит #43, и матвью приёма обязана создаваться после
|
||||||
-- матвью разбора: Kafka-движок начинает читать топик ровно тогда, когда к нему
|
-- матвью разбора: Kafka-движок начинает читать топик ровно тогда, когда к нему
|
||||||
-- привязывают первую матвью. Создай её раньше разбора — и всё, что доедет в
|
-- привязывают первую матвью. Создай её раньше разбора — и всё, что доедет в
|
||||||
-- зазоре, ляжет в сырьё и не попадёт в ODS никуда, ни в событие, ни в ошибки.
|
-- зазоре, ляжет в STG и не попадёт в ODS никуда, ни в событие, ни в ошибки.
|
||||||
-- На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет:
|
-- На пустом топике зазор безвреден, поэтому первый прогон о нём не скажет:
|
||||||
-- проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, то есть на
|
-- проснётся он, когда тома ClickHouse снесены, а данные Kafka целы, то есть на
|
||||||
-- обычной отладке. Нумерацию здесь не «приводить в порядок».
|
-- обычной отладке. Нумерацию здесь не «приводить в порядок».
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
-- Позиция — номер первого несыгранного дня, счётчик прожитого. Своей копии
|
-- Позиция — номер первого несыгранного дня, счётчик прожитого. Своей копии
|
||||||
-- начала оси стенд из-за неё не заводит: начало он спрашивает у данных, и
|
-- начала оси стенд из-за неё не заводит: начало он спрашивает у данных, и
|
||||||
-- дата дня N в них — константа, начало оси плюс N. Держится якорь на том, что
|
-- дата дня N в них — константа, начало оси плюс N. Держится якорь на том, что
|
||||||
-- ранние дни из ods.event не исчезают: срок жизни назначен сырью и таблицам
|
-- ранние дни из ods.event не исчезают: срок жизни назначен сырым данным и таблицам
|
||||||
-- ошибок, самому событию — нет (sql/ddl/20-ods-tables.sql).
|
-- ошибок, самому событию — нет (sql/ddl/20-ods-tables.sql).
|
||||||
--
|
--
|
||||||
-- За «доехал» позиция не отвечает — только за «прожит». Перед расчётом объёма
|
-- За «доехал» позиция не отвечает — только за «прожит». Перед расчётом объёма
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
-- DQ: стендовая сторона технической растяжки от описи мира.
|
-- DQ: стендовая сторона технической растяжки от паспорта мира.
|
||||||
--
|
--
|
||||||
-- Эталонные счётчики читает Airflow из world-inventory.json: у настоящего
|
-- Эталонные счётчики читает Airflow из world-inventory.json: у настоящего
|
||||||
-- магазина такого оракула нет. Запрос возвращает только наблюдаемую сторону;
|
-- магазина такого оракула нет. Запрос возвращает только наблюдаемую сторону;
|
||||||
-- Python-задача сопоставляет ее с пятью счетчиками закрытого дня из описи.
|
-- Python-задача сопоставляет ее с пятью счетчиками закрытого дня из паспорта.
|
||||||
SELECT
|
SELECT
|
||||||
order_day AS data_date,
|
order_day AS data_date,
|
||||||
mismatch_class,
|
mismatch_class,
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ SELECT
|
|||||||
_load_ts
|
_load_ts
|
||||||
FROM stg.orders_raw_dist
|
FROM stg.orders_raw_dist
|
||||||
WHERE _load_id = {load_id:String} AND NOT row_is_valid
|
WHERE _load_id = {load_id:String} AND NOT row_is_valid
|
||||||
-- Тот же ноль: ключ брака сегодня совпадает с ключом сырья, и локальная
|
-- Тот же ноль: ключ брака сегодня совпадает с ключом STG, и локальная
|
||||||
-- запись легла бы верно — но по совпадению, а не по контракту.
|
-- запись легла бы верно — но по совпадению, а не по контракту.
|
||||||
SETTINGS distributed_foreground_insert = 1,
|
SETTINGS distributed_foreground_insert = 1,
|
||||||
parallel_distributed_insert_select = 0;
|
parallel_distributed_insert_select = 0;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
-- Забор порции слепка заказов из Kafka в сырьё STG.
|
-- Забор порции слепка заказов из Kafka в STG.
|
||||||
--
|
--
|
||||||
-- Забор — один прямой SELECT, без цикла до пустоты: одна порция ClickHouse
|
-- Забор — один прямой SELECT, без цикла до пустоты: одна порция ClickHouse
|
||||||
-- берёт десятки тысяч сообщений, а слепок дня — порядка полутора тысяч строк.
|
-- берёт десятки тысяч сообщений, а слепок дня — порядка полутора тысяч строк.
|
||||||
|
|||||||
Reference in new issue
Block a user