Compare commits
31
Commits
a02eba56c2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f72e63b14 | ||
|
|
cf1c6c4636 | ||
|
|
3b4827d819 | ||
|
|
905ca5bbaf | ||
|
|
ede1df765c | ||
|
|
8d54a3caba | ||
|
|
c17c5ef995 | ||
|
|
86e2d6dbb2 | ||
|
|
34267e8494 | ||
|
|
c656ca198f | ||
|
|
f51048dd23 | ||
|
|
292302e161 | ||
|
|
35dc933d29 | ||
|
|
61bc156d6a | ||
|
|
9d43ff5489 | ||
|
|
d0f15bea02 | ||
|
|
5627f6c7aa | ||
|
|
780b2d0ef9 | ||
|
|
62dc618252 | ||
|
|
790f7c1bb7 | ||
|
|
41d057252e | ||
|
|
1fb219f836 | ||
|
|
a3d852cad9 | ||
|
|
d5c5e14cd5 | ||
|
|
c9b11224d2 | ||
|
|
e4e5688775 | ||
|
|
6a708cd5aa | ||
|
|
368fb1a66b | ||
|
|
704c139d5a | ||
|
|
5d012cc222 | ||
|
|
76405a06ee |
+15
-15
@@ -1,39 +1,39 @@
|
||||
# Имя и внешние порты меняют для второй копии стенда или при конфликте.
|
||||
COMPOSE_PROJECT_NAME=clickstream-data-platform
|
||||
CLICKHOUSE_IMAGE=clickhouse/clickhouse-server:26.3.17.56
|
||||
CLICKHOUSE_01_HTTP_PORT=28123
|
||||
CLICKHOUSE_01_TCP_PORT=29000
|
||||
CLICKHOUSE_02_HTTP_PORT=28124
|
||||
CLICKHOUSE_02_TCP_PORT=29001
|
||||
KAFKA_IMAGE=apache/kafka:4.3.1
|
||||
CLICKHOUSE_CLUSTER_SECRET=cluster-26-secret
|
||||
CLICKHOUSE_DEFAULT_PASSWORD=default-26-local
|
||||
CLICKHOUSE_ETL_PASSWORD=etl-26-writer
|
||||
CLICKHOUSE_BI_PASSWORD=bi-26-reader
|
||||
CLICKHOUSE_ANALYST_PASSWORD=analyst-26-reader
|
||||
KAFKA_EXTERNAL_PORT=29092
|
||||
|
||||
# Разовая служба генератора. Адрес брокера здесь внутренний: команда идёт
|
||||
# из сети Compose.
|
||||
KAFKA_BOOTSTRAP_SERVERS=kafka:9092
|
||||
KAFKA_TOPIC=hits
|
||||
|
||||
PROMETHEUS_IMAGE=prom/prometheus:v3.13.2
|
||||
PROMETHEUS_PORT=29090
|
||||
GRAFANA_IMAGE=grafana/grafana:13.1.1
|
||||
GRAFANA_PORT=23000
|
||||
AIRFLOW_PORT=28080
|
||||
SUPERSET_PORT=28088
|
||||
|
||||
# GID группы `docker` на этой машине: без него планировщик Airflow не достучится
|
||||
# до сокета докера, и пульт мира не поднимет контейнер генератора.
|
||||
# Подсмотреть свой — `getent group docker`.
|
||||
DOCKER_GID=127
|
||||
|
||||
# Учётные данные и ключи учебного стенда. Для VPS замените значения образца.
|
||||
GRAFANA_ADMIN_USER=admin
|
||||
GRAFANA_ADMIN_PASSWORD=admin
|
||||
POSTGRES_IMAGE=postgres:16-alpine
|
||||
POSTGRES_ADMIN_USER=postgres
|
||||
POSTGRES_ADMIN_PASSWORD=postgres-local
|
||||
AIRFLOW_METADATA_USER=airflow
|
||||
AIRFLOW_METADATA_PASSWORD=airflow-local
|
||||
SUPERSET_METADATA_USER=superset
|
||||
SUPERSET_METADATA_PASSWORD=superset-local
|
||||
AIRFLOW_IMAGE=apache/airflow:3.3.0
|
||||
AIRFLOW_PORT=28080
|
||||
AIRFLOW_ADMIN_USER=admin
|
||||
AIRFLOW_ADMIN_PASSWORD=airflow
|
||||
AIRFLOW_JWT_SECRET=local-only-airflow-jwt-secret-do-not-use
|
||||
AIRFLOW_API_SECRET_KEY=local-only-airflow-api-secret-do-not-use
|
||||
AIRFLOW_FERNET_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
||||
SUPERSET_IMAGE=apache/superset:6.1.0
|
||||
SUPERSET_PORT=28088
|
||||
SUPERSET_ADMIN_USER=admin
|
||||
SUPERSET_ADMIN_PASSWORD=superset
|
||||
SUPERSET_SECRET_KEY=local-only-superset-secret-do-not-use-outside-this-stand
|
||||
|
||||
@@ -44,3 +44,6 @@ Thumbs.db
|
||||
# Локальные настройки Claude Code
|
||||
.claude/*
|
||||
!.claude/agents/
|
||||
|
||||
# Локальные рабочие файлы агентов: хэндоффы, обменные артефакты ревью
|
||||
.scratch/
|
||||
|
||||
@@ -77,7 +77,10 @@ Airflow) и названия из кода. Если для понятия ес
|
||||
|
||||
## Код и данные
|
||||
|
||||
- Python — только через `uv`; проверка и формат — `ruff` (`make lint`).
|
||||
- Python — только через `uv`; проверка и формат — `ruff`. Цель `make lint`
|
||||
есть за двумя дверями и охватывает разное: в корне — код стенда, в
|
||||
`generator/` — код генератора. Правишь даги или Superset — зови корневую,
|
||||
правишь генератор — зови из `generator/`.
|
||||
- Изменения держать минимальными и в границах задания.
|
||||
- Куда класть новую проверку, что утверждает каждая цель `make` и почему смоук
|
||||
обязан оставаться быстрым — [`docs/architecture/testing.md`](docs/architecture/testing.md).
|
||||
@@ -155,4 +158,7 @@ Airflow) и названия из кода. Если для понятия ес
|
||||
- Имена файлов в `docs/architecture/` — слаг строчными латинскими буквами через
|
||||
дефис (`storage.md`). Здесь живут рабочие справочники по зонам
|
||||
ответственности: не событие истории и не решение, а текущее устройство —
|
||||
один файл на зону, правится по мере постройки.
|
||||
один файл на зону, правится по мере постройки. Крупный компонент живёт
|
||||
подпапкой: индекс `README.md` и файл на каждую часть устройства
|
||||
(`architecture/orders/`), имена по тому же правилу
|
||||
([ADR 0011](docs/adr/0011-component-docs.md)).
|
||||
|
||||
+62
-7
@@ -34,6 +34,20 @@ _Избегать_: состояние мира
|
||||
Люди, впервые пришедшие в мир в один день, со всеми их куками и днями
|
||||
активности. Единица плана состава: когорта — функция зерна и номера дня.
|
||||
|
||||
**Человек**:
|
||||
Устойчивая личность в составе мира, которой принадлежат одна или две куки.
|
||||
Не поле источника: кликстрим видит куку, а заказ — пользователя магазина.
|
||||
|
||||
**Посетитель**:
|
||||
Кука, наблюдаемая в кликстриме и обозначенная `ClientID`; единица
|
||||
`uniq(ClientID)`. Один человек может быть представлен несколькими посетителями.
|
||||
_Избегать_: «посетитель» про человека после склейки
|
||||
|
||||
**Пользователь магазина**:
|
||||
Представление человека со стороны заказов; в записи заказа обозначается
|
||||
`user_id`. Через заказы связывает разные куки человека, в кликстрим напрямую
|
||||
не попадает.
|
||||
|
||||
**Приток**:
|
||||
Появление новых кук на всём протяжении оси модельного времени; единица —
|
||||
кука (`ClientID`). Из-за притока накопленная аудитория растёт с
|
||||
@@ -102,10 +116,11 @@ ClickHouse. Форма файла решена, длина — нет: стро
|
||||
_Избегать_: зерновой мир
|
||||
|
||||
**Опись мира**:
|
||||
`data/world-inventory.json` — единственное, что о мире хранится в git:
|
||||
паспорт (зерно, версия генератора, хеш каталога) и по строке на день с
|
||||
датой, числом событий и хешем его байтов. Сам мир в git не лежит — он
|
||||
пересчитывается. Опись отвечает на один вопрос: тот ли это мир.
|
||||
`data/world-inventory.json` — единственное, что о мире хранится в git: паспорт
|
||||
мира (зерно, версия генератора, хеш каталога), строка на каждый день событий
|
||||
(число и хеш байтов) и строка на каждый отправленный слепок (хеш байтов).
|
||||
Счётчик сверх этого заводится только с названным читателем. Сам мир не
|
||||
хранится, а пересчитывается; опись отвечает на один вопрос: тот ли это мир.
|
||||
_Избегать_: манифест, мини-манифест
|
||||
|
||||
**Пошаговый режим**:
|
||||
@@ -114,12 +129,18 @@ _Избегать_: манифест, мини-манифест
|
||||
|
||||
**Живой день**:
|
||||
Проигрывание текущего модельного дня в реальном времени с ускорением;
|
||||
включается по требованию, не постоянный фон.
|
||||
включается по требованию — постоянным фоном идёт, только пока включён
|
||||
выключатель пульта.
|
||||
|
||||
**Пакетный режим**:
|
||||
Проигрывание готового дня пачкой, без темпа: заливка снимка при старте
|
||||
стенда, пересборки и проверки.
|
||||
|
||||
**Пульт мира**:
|
||||
Даги Airflow, которыми двигают ось модельного времени: работники играют день —
|
||||
пачкой или в темпе, — а выключатель держит стенд живущим, пока включён.
|
||||
_Избегать_: управляющие даги
|
||||
|
||||
**Граница суток**:
|
||||
Единственный структурный шов модели: сессии режутся по ней, слепок заказов
|
||||
снимается на ней, день проживается только целиком.
|
||||
@@ -141,9 +162,43 @@ Python-модуль с описателями колонок события —
|
||||
_Избегать_: описание схемы, документация контракта
|
||||
|
||||
**Канонический сериализатор**:
|
||||
Единственное место, где событие превращается в байты. Фиксированный порядок
|
||||
ключей и строк — основа побайтовой воспроизводимости.
|
||||
Единственная граница, где запись генератора превращается в байты. Фиксированный
|
||||
порядок ключей и строк — основа побайтовой воспроизводимости.
|
||||
|
||||
**Проигрыватель**:
|
||||
Компонент доставки готового потока дня в приёмник. Два режима: пакетный
|
||||
(пачкой, без темпа) и живой день.
|
||||
|
||||
**Слепок**:
|
||||
Полная выгрузка заказов окна изменяемости, снятая бэкендом на границе суток:
|
||||
состояние заказов на этот момент, а не поток их изменений. Опоздавший заказ
|
||||
может отсутствовать в ранних слепках; после первого появления ездит до конца
|
||||
своего окна.
|
||||
|
||||
**Дата слепка**:
|
||||
Дата завершившегося модельного дня, состояние которого снято на исходящей
|
||||
границе суток. Одна для всех записей выгрузки; не дата заказа и не время
|
||||
отправки сообщения.
|
||||
|
||||
**Время аудита источника**:
|
||||
Время создания или последнего изменения строки заказа по часам базы источника.
|
||||
В записи слепка это `created_at` и `updated_at`; бизнес-время покупки живёт в
|
||||
событии `purchase` отдельно.
|
||||
|
||||
**Судьба заказа**:
|
||||
Исход, моменты изменений, вычеркнутая позиция и опоздание заказа — всё
|
||||
решается бросками при его рождении и укладывается в окно изменяемости целиком.
|
||||
Слепок любого дня — чтение готовой судьбы, а не накопление состояния.
|
||||
|
||||
**Окно изменяемости**:
|
||||
Сколько модельных дней заказ ещё может измениться и потому продолжает ездить
|
||||
в слепках. Константа мира: семь дней. За окном заказ замёрз, выручка дня
|
||||
перестала «дышать».
|
||||
|
||||
**Пакетный забор**:
|
||||
Способ приёма топика, при котором данные тянет запрос по команде, а не
|
||||
матвью непрерывно. Так стенд принимает заказы: у слепка есть начало и конец,
|
||||
и забирать его уместно тогда, когда он собран целиком. Противоположность —
|
||||
потоковый приём событий.
|
||||
_Избегать_: путать с приёмником и пакетным режимом — те про сторону
|
||||
генератора, про отправку; забор — про сторону хранилища, про чтение.
|
||||
|
||||
@@ -3,7 +3,10 @@ GENERATOR_DAY ?= 0
|
||||
GENERATOR_LIMIT ?=
|
||||
GENERATOR_SPEED ?=
|
||||
|
||||
.PHONY: up down clean ps logs generate-batch generate-live config-test lint typecheck test docs inventory smoke check-clickhouse check-services
|
||||
# Цели корня — про стенд; проверки генератора — в `generator/Makefile`.
|
||||
.PHONY: up down clean ps logs smoke check-clickhouse check-services config-test lint generate-batch generate-live
|
||||
|
||||
# --- Жизнь стенда ---
|
||||
|
||||
# Второй шаг: `--wait` дожидается служб, а приём событий асинхронный — довод
|
||||
# целиком в шапке скрипта.
|
||||
@@ -23,33 +26,7 @@ ps:
|
||||
logs:
|
||||
$(COMPOSE) logs --follow
|
||||
|
||||
generate-batch:
|
||||
$(COMPOSE) --profile generator run --rm generator batch --day "$(GENERATOR_DAY)" \
|
||||
$(if $(GENERATOR_LIMIT),--limit "$(GENERATOR_LIMIT)")
|
||||
|
||||
generate-live:
|
||||
$(COMPOSE) --profile generator run --rm generator live --day "$(GENERATOR_DAY)" \
|
||||
$(if $(GENERATOR_SPEED),--speed "$(GENERATOR_SPEED)")
|
||||
|
||||
config-test:
|
||||
COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh
|
||||
|
||||
lint:
|
||||
cd generator && uv run ruff check && uv run ruff format --check
|
||||
|
||||
typecheck:
|
||||
cd generator && uv run ty check
|
||||
|
||||
test:
|
||||
cd generator && uv run pytest
|
||||
|
||||
docs:
|
||||
cd generator && uv run python -m clickstream_generator.schema_doc \
|
||||
../docs/formats/clickstream-event.md
|
||||
|
||||
inventory:
|
||||
cd generator && uv run python -m clickstream_generator.inventory \
|
||||
../data/world-inventory.json
|
||||
# --- Проверки, которым нужен поднятый стенд ---
|
||||
|
||||
smoke:
|
||||
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh
|
||||
@@ -59,3 +36,26 @@ check-clickhouse:
|
||||
|
||||
check-services:
|
||||
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-services.sh
|
||||
|
||||
# --- Проверки, которым стенд не нужен ---
|
||||
|
||||
config-test:
|
||||
COMPOSE_BIN="$(COMPOSE)" ./scripts/config-test.sh
|
||||
|
||||
# Пути названы вслух: без них ruff из корня прошёлся бы и по генератору, а у
|
||||
# того своя дверь и свой конфиг. Версия закреплена, потому что лока в корне
|
||||
# нет, а форматтер между версиями меняет вывод — иначе проверка однажды
|
||||
# покраснела бы сама, без единой правки в репозитории.
|
||||
lint:
|
||||
uvx ruff@0.16.1 check dags infra/superset
|
||||
uvx ruff@0.16.1 format --check dags infra/superset
|
||||
|
||||
# --- Наполнение миром ---
|
||||
|
||||
generate-batch:
|
||||
$(COMPOSE) --profile generator run --rm generator batch --day "$(GENERATOR_DAY)" \
|
||||
$(if $(GENERATOR_LIMIT),--limit "$(GENERATOR_LIMIT)")
|
||||
|
||||
generate-live:
|
||||
$(COMPOSE) --profile generator run --rm generator live --day "$(GENERATOR_DAY)" \
|
||||
$(if $(GENERATOR_SPEED),--speed "$(GENERATOR_SPEED)")
|
||||
|
||||
@@ -31,13 +31,13 @@ Prometheus, Grafana и общая база Postgres для метаданных.
|
||||
`grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им
|
||||
читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны
|
||||
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
|
||||
и `29092`. Проверкам без стенда — `make config-test`, `make lint`,
|
||||
`make typecheck`, `make test` — и сборкам `make docs` и `make inventory` нужен
|
||||
`uv`.
|
||||
и `29092`. Проверкам без стенда — `make config-test` и `make lint` в корне,
|
||||
всем целям генератора в `generator/` — нужен `uv`.
|
||||
|
||||
Стенд запускается без `.env`:
|
||||
Сначала скопируйте настройки стенда:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make up
|
||||
make smoke
|
||||
make check-clickhouse
|
||||
@@ -49,19 +49,14 @@ make check-services
|
||||
`make check-services` — что Airflow запускает DAG, а Superset ходит в базу.
|
||||
Дальше, в рабочей петле, обычно хватает `make smoke`.
|
||||
|
||||
Чтобы изменить образы, порты или учебные учётные данные, скопируйте образец:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make up
|
||||
make smoke
|
||||
```
|
||||
|
||||
`.env.example` — справочник, а не настройка: в нём перечислены все переменные,
|
||||
которые читает `compose.yaml`, с теми же значениями по умолчанию. Стенд его не
|
||||
читает и на согласованность не проверяет, поэтому расхождение с `compose.yaml`
|
||||
обнаружит только читатель. Меняя подстановку `${VAR:-значение}` в
|
||||
`compose.yaml`, поправьте образец тем же коммитом.
|
||||
`.env.example` — образец обязательной локальной настройки. В нём живут имя
|
||||
экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца
|
||||
верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и
|
||||
свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь,
|
||||
кроме пульта мира — о нём ниже. `compose.yaml` не
|
||||
дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит
|
||||
скопировать образец. Внутренние адреса, имена топиков и версии образов описывают
|
||||
сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile.
|
||||
|
||||
Учётные данные Postgres и Grafana применяются при создании их томов.
|
||||
После первого запуска меняйте их только вместе с `make clean`: команда удалит
|
||||
@@ -147,11 +142,11 @@ ClickHouse, пока мир не доедет. Повторный `make up` за
|
||||
событий и хеш его байтов. Опись отвечает на единственный вопрос: тот ли это
|
||||
мир, что был вчера.
|
||||
|
||||
Спрашивают её двое. `make test` сверяет опись с тем, что собирается из кода
|
||||
сегодня: правка генератора меняет мир, и опись надо пересобрать —
|
||||
`make inventory`. `make check-clickhouse` сверяет с описью то, что доехало до
|
||||
`ods.event`, подневно. Хеш дня можно пересчитать и руками — это обычный
|
||||
`sha256sum` файла, который пишет файловый приёмник:
|
||||
Спрашивают её двое. `make test` в `generator/` сверяет опись с тем, что
|
||||
собирается из кода сегодня: правка генератора меняет мир, и опись надо
|
||||
пересобрать — `make inventory` там же. `make check-clickhouse` в корне сверяет
|
||||
с описью то, что доехало до `ods.event`, подневно. Хеш дня можно пересчитать и
|
||||
руками — это обычный `sha256sum` файла, который пишет файловый приёмник:
|
||||
|
||||
```bash
|
||||
uv run --project generator python -m clickstream_generator batch \
|
||||
@@ -189,8 +184,8 @@ make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000
|
||||
| `GENERATOR_LIMIT` | потолок событий на прогон; только `generate-batch` | нет: день целиком |
|
||||
| `GENERATOR_SPEED` | ускорение модельного времени; только `generate-live` | ×60, задано в генераторе |
|
||||
|
||||
Куда отправлять, обе цели берут из `.env`: `KAFKA_BOOTSTRAP_SERVERS` (внутри
|
||||
сети Compose это `kafka:9092`) и `KAFKA_TOPIC` (`hits`).
|
||||
Куда отправлять, обеим целям задаёт сам стенд: внутри сети Compose это брокер
|
||||
`kafka:9092` и топик `hits`.
|
||||
|
||||
Образ цели не пересобирают: нет образа — Compose соберёт его сам, есть —
|
||||
возьмёт как есть. Пересобрать намеренно, после правки `generator/Dockerfile`
|
||||
@@ -227,6 +222,33 @@ uv run --project generator python -m clickstream_generator batch \
|
||||
код возврата, ещё до первого события. Поэтому умолчание дня и живёт в
|
||||
`Makefile`: день называет тот, кто запускает, а не тот, кого запускают.
|
||||
|
||||
### Пульт мира
|
||||
|
||||
Считать дни самому необязательно. В Airflow живёт **пульт мира** — три дага,
|
||||
которые зовут тот же образ генератора и ведут позицию на оси за вас.
|
||||
|
||||
| Даг | Что делает |
|
||||
| --- | --- |
|
||||
| `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один |
|
||||
| `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут |
|
||||
| `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём |
|
||||
|
||||
Позицию хранит переменная Airflow `world_position` — номер первого несыгранного
|
||||
дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон
|
||||
позицию не двигает, и следующий запуск играет тот же день заново. Нет
|
||||
переменной — мир в стартовом состоянии.
|
||||
|
||||
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
|
||||
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
|
||||
целиком — [ADR 0009](docs/adr/0009-world-control.md).
|
||||
|
||||
**Плата названа вслух: планировщику Airflow отдан сокет докера** — иначе
|
||||
контейнер генератора ему не поднять. Доступ к сокету равен праву root на
|
||||
машине; для локального учебного стенда размен принят, но знать о нём надо.
|
||||
Открывает дверь не монтирование, а членство в группе: GID группы `docker` у
|
||||
каждой машины свой, живёт в `.env` и подсматривается командой
|
||||
`getent group docker`.
|
||||
|
||||
## Состав и доступ
|
||||
|
||||
- `clickhouse-01` — инициатор DDL и точка подключения Airflow;
|
||||
@@ -240,7 +262,7 @@ uv run --project generator python -m clickstream_generator batch \
|
||||
- `superset` — интерфейс и подготовленное подключение ClickHouse;
|
||||
- `prometheus` и `grafana` — сбор и просмотр встроенных метрик ClickHouse.
|
||||
|
||||
Порты доступны только с локальной машины:
|
||||
С настройками из `.env.example` порты доступны только с локальной машины:
|
||||
|
||||
- нода 1 — `http://127.0.0.1:28123`, нативный порт `29000`;
|
||||
- нода 2 — `http://127.0.0.1:28124`, нативный порт `29001`;
|
||||
@@ -250,16 +272,27 @@ uv run --project generator python -m clickstream_generator batch \
|
||||
- Prometheus — `http://127.0.0.1:29090`;
|
||||
- Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`.
|
||||
|
||||
У локального учебного кластера нет пароля: ноды используют общего пользователя
|
||||
`default` для запросов `Distributed`. Порты поэтому привязаны к `127.0.0.1` и
|
||||
не открыты во внешнюю сеть.
|
||||
В ClickHouse четыре пользователя:
|
||||
|
||||
Пароли интерфейсов, пароли Postgres, ключи Airflow и Superset, отсутствие
|
||||
пароля ClickHouse и отсутствие проверки доступа у Kafka и Prometheus —
|
||||
намеренно простые и явно ненастоящие настройки локального учебного стенда. Это
|
||||
не пример настройки защиты: не копируйте значения из `.env.example` в рабочую
|
||||
среду. Все опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу
|
||||
не опубликован.
|
||||
- `etl` применяет DDL и подключает Airflow; роль `etl_writer` читает и пишет
|
||||
слои хранилища;
|
||||
- `bi` подключает Superset; роль `bi_reader` читает будущие слои DDS и DM;
|
||||
- `analyst` предназначен для подключения человека и читает все слои;
|
||||
- `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри
|
||||
контейнеров, но не приложения.
|
||||
|
||||
Решение и его доводы — в [ADR 0007](docs/adr/0007-clickhouse-access.md).
|
||||
Пользователи и роли объявлены в `infra/clickhouse/users.d/access.xml`. Пароли и
|
||||
общий секрет нод живут в `.env` и передаются в конфигурацию через окружение;
|
||||
Superset получает пароль `bi` тем же путём через штатную функцию настройки.
|
||||
Значения для локального стенда есть в `.env.example`.
|
||||
|
||||
Пароли ClickHouse и интерфейсов, пароли Postgres, ключи Airflow и Superset,
|
||||
отсутствие проверки доступа у Kafka и Prometheus — намеренно простые и явно
|
||||
ненастоящие настройки локального учебного стенда. Это не пример настройки
|
||||
защиты: не копируйте значения из `.env.example` в рабочую среду. Все
|
||||
опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не
|
||||
опубликован.
|
||||
|
||||
В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом
|
||||
шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология
|
||||
@@ -333,6 +366,6 @@ Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, ф
|
||||
- [docs/research/](docs/research/) — исследования; сейчас это формат
|
||||
кликстрима Яндекса, по которому строится модель события.
|
||||
- [docs/formats/](docs/formats/) — описания форматов источников: по ним
|
||||
пишется сторона хранилища. Собираются из кода командой `make docs`, руками
|
||||
не правятся.
|
||||
пишется сторона хранилища. Собираются из кода командой `make docs` в
|
||||
`generator/`, руками не правятся.
|
||||
- [AGENTS.md](AGENTS.md) — контракт работы в репозитории.
|
||||
|
||||
+92
-51
@@ -1,4 +1,4 @@
|
||||
name: ${COMPOSE_PROJECT_NAME:-clickstream-data-platform}
|
||||
name: ${COMPOSE_PROJECT_NAME:?Скопируйте .env.example в .env}
|
||||
|
||||
x-clickhouse-nofile: &clickhouse-nofile
|
||||
nofile:
|
||||
@@ -6,11 +6,16 @@ x-clickhouse-nofile: &clickhouse-nofile
|
||||
hard: 262144
|
||||
|
||||
x-clickhouse-common: &clickhouse-common
|
||||
image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
|
||||
image: clickhouse/clickhouse-server:26.3.17.56
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Стенд локальный: нодам нужен общий пользователь без пароля для Distributed.
|
||||
# Пользователей объявляет access.xml; настройка учётки образом не нужна.
|
||||
CLICKHOUSE_SKIP_USER_SETUP: "1"
|
||||
CLICKHOUSE_CLUSTER_SECRET: ${CLICKHOUSE_CLUSTER_SECRET:?Скопируйте .env.example в .env}
|
||||
CLICKHOUSE_DEFAULT_PASSWORD: ${CLICKHOUSE_DEFAULT_PASSWORD:?Скопируйте .env.example в .env}
|
||||
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
||||
CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env}
|
||||
CLICKHOUSE_ANALYST_PASSWORD: ${CLICKHOUSE_ANALYST_PASSWORD:?Скопируйте .env.example в .env}
|
||||
depends_on:
|
||||
clickhouse-keeper:
|
||||
condition: service_healthy
|
||||
@@ -18,34 +23,51 @@ x-clickhouse-common: &clickhouse-common
|
||||
# Гигабайта не хватало: половину съедали страницы самого бинарника (ADR 0004).
|
||||
mem_limit: 4g
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "clickhouse-client --host 127.0.0.1 --query 'SELECT 1' >/dev/null 2>&1"]
|
||||
test: ["CMD-SHELL", "clickhouse-client --host 127.0.0.1 --user default --password \"$$CLICKHOUSE_DEFAULT_PASSWORD\" --query 'SELECT 1' >/dev/null 2>&1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
|
||||
# Факты стенда, у которых стало по два потребителя: разовая служба генератора и
|
||||
# пульт мира. Названы по одному разу — иначе однажды разойдутся, и заметит это
|
||||
# не проверка, а менти с пустым топиком.
|
||||
x-generator-image: &generator-image clickstream-generator:local
|
||||
|
||||
x-kafka-target: &kafka-target
|
||||
KAFKA_BOOTSTRAP_SERVERS: kafka:9092
|
||||
KAFKA_TOPIC: hits
|
||||
|
||||
x-world-starting-days: &world-starting-days "8"
|
||||
|
||||
x-airflow-common: &airflow-common
|
||||
image: clickstream-airflow:local
|
||||
build:
|
||||
context: ./infra/airflow
|
||||
args:
|
||||
AIRFLOW_BASE_IMAGE: ${AIRFLOW_IMAGE:-apache/airflow:3.3.0}
|
||||
environment:
|
||||
AIRFLOW__CORE__EXECUTOR: LocalExecutor
|
||||
AIRFLOW__CORE__PARALLELISM: "4"
|
||||
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
|
||||
AIRFLOW__CORE__AUTH_MANAGER: airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:-admin}:admin"
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}:admin"
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE: /opt/airflow/auth/simple_auth_manager_passwords.json
|
||||
AIRFLOW__CORE__EXECUTION_API_SERVER_URL: http://airflow-apiserver:8080/execution/
|
||||
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=}
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:-local-only-airflow-jwt-secret-do-not-use}
|
||||
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:?Скопируйте .env.example в .env}
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: ${AIRFLOW_JWT_SECRET:?Скопируйте .env.example в .env}
|
||||
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||
AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:-local-only-airflow-api-secret-do-not-use}
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: "postgresql+psycopg2://${AIRFLOW_METADATA_USER:-airflow}:${AIRFLOW_METADATA_PASSWORD:-airflow-local}@postgres-metadata:5432/airflow"
|
||||
AIRFLOW__API__SECRET_KEY: ${AIRFLOW_API_SECRET_KEY:?Скопируйте .env.example в .env}
|
||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: "postgresql+psycopg2://${AIRFLOW_METADATA_USER:?Скопируйте .env.example в .env}:${AIRFLOW_METADATA_PASSWORD:?Скопируйте .env.example в .env}@postgres-metadata:5432/airflow"
|
||||
AIRFLOW__SCHEDULER__ENABLE_HEALTH_CHECK: "true"
|
||||
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:-admin}
|
||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-airflow}
|
||||
AIRFLOW_ADMIN_USER: ${AIRFLOW_ADMIN_USER:?Скопируйте .env.example в .env}
|
||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
||||
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
||||
# Пульт мира поднимает генератор сам, отдельным контейнером, — значит те же
|
||||
# факты стенда, что compose даёт разовой службе генератора, нужны и дагам.
|
||||
# Имя сети собирается из имени проекта: у второй копии стенда оно другое.
|
||||
<<: *kafka-target
|
||||
GENERATOR_IMAGE: *generator-image
|
||||
STAND_NETWORK: ${COMPOSE_PROJECT_NAME}_default
|
||||
WORLD_STARTING_DAYS: *world-starting-days
|
||||
volumes:
|
||||
- ./dags:/opt/airflow/dags:ro
|
||||
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
|
||||
@@ -53,7 +75,7 @@ x-airflow-common: &airflow-common
|
||||
- airflow_auth:/opt/airflow/auth
|
||||
|
||||
x-generator-common: &generator-common
|
||||
image: clickstream-generator:local
|
||||
image: *generator-image
|
||||
build:
|
||||
context: .
|
||||
dockerfile: generator/Dockerfile
|
||||
@@ -71,21 +93,19 @@ x-generator-common: &generator-common
|
||||
# Адрес брокера и имя топика — факты стенда, и называет их стенд.
|
||||
# Остальное (день, зерно, число дней, предел пачки) приходит аргументами
|
||||
# от того, кто запускает: у службы нет позиции на оси мира.
|
||||
KAFKA_BOOTSTRAP_SERVERS: ${KAFKA_BOOTSTRAP_SERVERS:-kafka:9092}
|
||||
KAFKA_TOPIC: ${KAFKA_TOPIC:-hits}
|
||||
<<: *kafka-target
|
||||
|
||||
x-superset-common: &superset-common
|
||||
image: clickstream-superset:local
|
||||
build:
|
||||
context: ./infra/superset
|
||||
args:
|
||||
SUPERSET_BASE_IMAGE: ${SUPERSET_IMAGE:-apache/superset:6.1.0}
|
||||
environment:
|
||||
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
|
||||
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:-local-only-superset-secret-do-not-use-outside-this-stand}
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: "postgresql+psycopg2://${SUPERSET_METADATA_USER:-superset}:${SUPERSET_METADATA_PASSWORD:-superset-local}@postgres-metadata:5432/superset"
|
||||
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:-admin}
|
||||
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD:-superset}
|
||||
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env}
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: "postgresql+psycopg2://${SUPERSET_METADATA_USER:?Скопируйте .env.example в .env}:${SUPERSET_METADATA_PASSWORD:?Скопируйте .env.example в .env}@postgres-metadata:5432/superset"
|
||||
SUPERSET_ADMIN_USER: ${SUPERSET_ADMIN_USER:?Скопируйте .env.example в .env}
|
||||
SUPERSET_ADMIN_PASSWORD: ${SUPERSET_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
||||
CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env}
|
||||
volumes:
|
||||
- ./infra/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
||||
- ./infra/superset/init.sh:/app/infra/init.sh:ro
|
||||
@@ -94,7 +114,7 @@ x-superset-common: &superset-common
|
||||
|
||||
services:
|
||||
clickhouse-keeper:
|
||||
image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
|
||||
image: clickhouse/clickhouse-server:26.3.17.56
|
||||
restart: unless-stopped
|
||||
user: clickhouse
|
||||
ulimits: *clickhouse-nofile
|
||||
@@ -113,44 +133,46 @@ services:
|
||||
retries: 30
|
||||
start_period: 10s
|
||||
|
||||
# Инициатор DDL и будущее подключение Airflow.
|
||||
# Инициатор DDL и точка подключения Airflow.
|
||||
clickhouse-01:
|
||||
<<: *clickhouse-common
|
||||
hostname: clickhouse-01
|
||||
ports:
|
||||
- "127.0.0.1:${CLICKHOUSE_01_HTTP_PORT:-28123}:8123"
|
||||
- "127.0.0.1:${CLICKHOUSE_01_TCP_PORT:-29000}:9000"
|
||||
- "127.0.0.1:${CLICKHOUSE_01_HTTP_PORT:?Скопируйте .env.example в .env}:8123"
|
||||
- "127.0.0.1:${CLICKHOUSE_01_TCP_PORT:?Скопируйте .env.example в .env}:9000"
|
||||
volumes:
|
||||
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
|
||||
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
|
||||
- ./infra/clickhouse/config.d/macros-01.xml:/etc/clickhouse-server/config.d/macros.xml:ro
|
||||
- ./infra/clickhouse/users.d/access.xml:/etc/clickhouse-server/users.d/access.xml:ro
|
||||
- clickhouse_01_data:/var/lib/clickhouse
|
||||
|
||||
# Будущее подключение Superset.
|
||||
# Точка подключения Superset.
|
||||
clickhouse-02:
|
||||
<<: *clickhouse-common
|
||||
hostname: clickhouse-02
|
||||
ports:
|
||||
- "127.0.0.1:${CLICKHOUSE_02_HTTP_PORT:-28124}:8123"
|
||||
- "127.0.0.1:${CLICKHOUSE_02_TCP_PORT:-29001}:9000"
|
||||
- "127.0.0.1:${CLICKHOUSE_02_HTTP_PORT:?Скопируйте .env.example в .env}:8123"
|
||||
- "127.0.0.1:${CLICKHOUSE_02_TCP_PORT:?Скопируйте .env.example в .env}:9000"
|
||||
volumes:
|
||||
- ./infra/clickhouse/config.d/cluster.xml:/etc/clickhouse-server/config.d/cluster.xml:ro
|
||||
- ./infra/clickhouse/config.d/prometheus.xml:/etc/clickhouse-server/config.d/prometheus.xml:ro
|
||||
- ./infra/clickhouse/config.d/macros-02.xml:/etc/clickhouse-server/config.d/macros.xml:ro
|
||||
- ./infra/clickhouse/users.d/access.xml:/etc/clickhouse-server/users.d/access.xml:ro
|
||||
- clickhouse_02_data:/var/lib/clickhouse
|
||||
|
||||
kafka:
|
||||
image: ${KAFKA_IMAGE:-apache/kafka:4.3.1}
|
||||
image: apache/kafka:4.3.1
|
||||
hostname: kafka
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${KAFKA_EXTERNAL_PORT:-29092}:29092"
|
||||
- "127.0.0.1:${KAFKA_EXTERNAL_PORT:?Скопируйте .env.example в .env}:29092"
|
||||
environment:
|
||||
KAFKA_NODE_ID: "1"
|
||||
KAFKA_PROCESS_ROLES: "broker,controller"
|
||||
KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093"
|
||||
KAFKA_LISTENERS: "INTERNAL://:9092,CONTROLLER://:9093,EXTERNAL://:29092"
|
||||
KAFKA_ADVERTISED_LISTENERS: "INTERNAL://kafka:9092,EXTERNAL://localhost:${KAFKA_EXTERNAL_PORT:-29092}"
|
||||
KAFKA_ADVERTISED_LISTENERS: "INTERNAL://kafka:9092,EXTERNAL://localhost:${KAFKA_EXTERNAL_PORT:?Скопируйте .env.example в .env}"
|
||||
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "INTERNAL:PLAINTEXT,CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT"
|
||||
KAFKA_INTER_BROKER_LISTENER_NAME: "INTERNAL"
|
||||
KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER"
|
||||
@@ -175,7 +197,7 @@ services:
|
||||
start_period: 30s
|
||||
|
||||
kafka-init:
|
||||
image: ${KAFKA_IMAGE:-apache/kafka:4.3.1}
|
||||
image: apache/kafka:4.3.1
|
||||
restart: "no"
|
||||
depends_on:
|
||||
kafka:
|
||||
@@ -218,7 +240,7 @@ services:
|
||||
|
||||
# DDL применяется с ноды 1 по порядку имён файлов после готовности всего кластера.
|
||||
clickhouse-init:
|
||||
image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
|
||||
image: clickhouse/clickhouse-server:26.3.17.56
|
||||
restart: "no"
|
||||
depends_on:
|
||||
kafka-init:
|
||||
@@ -229,6 +251,7 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
LC_ALL: C
|
||||
CLICKHOUSE_ETL_PASSWORD: ${CLICKHOUSE_ETL_PASSWORD:?Скопируйте .env.example в .env}
|
||||
entrypoint: ["/bin/bash", "-ec"]
|
||||
command:
|
||||
- |
|
||||
@@ -241,6 +264,8 @@ services:
|
||||
printf 'Применяем %s.\n' "$${ddl_file##*/}"
|
||||
clickhouse-client \
|
||||
--host clickhouse-01 \
|
||||
--user etl \
|
||||
--password "$$CLICKHOUSE_ETL_PASSWORD" \
|
||||
--multiquery \
|
||||
--queries-file "$$ddl_file"
|
||||
done
|
||||
@@ -253,14 +278,15 @@ services:
|
||||
# Число дней стоит здесь числом: YAML не читает Python, и одно из двух мест
|
||||
# (второе — `STARTING_DAYS` в inventory.py) лишнее по построению. Правя одно,
|
||||
# правьте второе — на страже тут никто не стоит: залей эта служба лишний
|
||||
# день, он лёг бы за рамкой дат описи и остался бы незамеченным.
|
||||
# день, он лёг бы за рамкой дат описи и остался бы незамеченным. Внутри YAML
|
||||
# число одно на всех: то же говорит дагам пульта, где кончается стартовый мир.
|
||||
#
|
||||
# Повторный `make up` заливает мир заново, и это не оплошность: `WatchID` у
|
||||
# событий те же, ReplacingMergeTree схлопнет повтор в ODS. Сырьё в STG при
|
||||
# этом честно удвоится — свойство слоя, описанное в storage.md.
|
||||
world-init:
|
||||
<<: *generator-common
|
||||
command: ["batch", "--day", "0", "--days", "8"]
|
||||
command: ["batch", "--day", "0", "--days", *world-starting-days]
|
||||
|
||||
# Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`.
|
||||
# Под профилем — чтобы обычный подъём стенда её не трогал.
|
||||
@@ -270,7 +296,7 @@ services:
|
||||
command: ["batch"]
|
||||
|
||||
postgres-metadata:
|
||||
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- postgres
|
||||
@@ -279,13 +305,13 @@ services:
|
||||
- -c
|
||||
- max_connections=50
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_ADMIN_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:-postgres-local}
|
||||
POSTGRES_USER: ${POSTGRES_ADMIN_USER:?Скопируйте .env.example в .env}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
||||
POSTGRES_DB: postgres
|
||||
AIRFLOW_METADATA_USER: ${AIRFLOW_METADATA_USER:-airflow}
|
||||
AIRFLOW_METADATA_PASSWORD: ${AIRFLOW_METADATA_PASSWORD:-airflow-local}
|
||||
SUPERSET_METADATA_USER: ${SUPERSET_METADATA_USER:-superset}
|
||||
SUPERSET_METADATA_PASSWORD: ${SUPERSET_METADATA_PASSWORD:-superset-local}
|
||||
AIRFLOW_METADATA_USER: ${AIRFLOW_METADATA_USER:?Скопируйте .env.example в .env}
|
||||
AIRFLOW_METADATA_PASSWORD: ${AIRFLOW_METADATA_PASSWORD:?Скопируйте .env.example в .env}
|
||||
SUPERSET_METADATA_USER: ${SUPERSET_METADATA_USER:?Скопируйте .env.example в .env}
|
||||
SUPERSET_METADATA_PASSWORD: ${SUPERSET_METADATA_PASSWORD:?Скопируйте .env.example в .env}
|
||||
volumes:
|
||||
- ./infra/postgres/init/10-metadata-databases.sh:/docker-entrypoint-initdb.d/10-metadata-databases.sh:ro
|
||||
- postgres_metadata_data:/var/lib/postgresql/data
|
||||
@@ -326,7 +352,7 @@ services:
|
||||
condition: service_completed_successfully
|
||||
command: api-server
|
||||
ports:
|
||||
- "127.0.0.1:${AIRFLOW_PORT:-28080}:8080"
|
||||
- "127.0.0.1:${AIRFLOW_PORT:?Скопируйте .env.example в .env}:8080"
|
||||
mem_limit: 512m
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/api/v2/monitor/health | python -c 'import json,sys; sys.exit(0 if json.load(sys.stdin)[\"metadatabase\"][\"status\"] == \"healthy\" else 1)'"]
|
||||
@@ -342,6 +368,21 @@ services:
|
||||
airflow-init:
|
||||
condition: service_completed_successfully
|
||||
command: scheduler
|
||||
# Пульт мира зовёт генератор отдельным контейнером, а при LocalExecutor
|
||||
# задачи исполняет сам планировщик — значит сокет докера нужен ему одному.
|
||||
# Плата названа вслух в README и ADR 0009: доступ к сокету равен праву root
|
||||
# на машине. Открывает дверь не монтирование, а группа: у сокета права 660
|
||||
# и группа `docker`, чей GID на каждой машине свой и живёт в `.env`.
|
||||
group_add:
|
||||
- ${DOCKER_GID:?Скопируйте .env.example в .env}
|
||||
# Тома перечислены заново: список службы общий не дополняет, а заменяет.
|
||||
# Появится новый том у остальных служб Airflow — добавьте и сюда.
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./dags:/opt/airflow/dags:ro
|
||||
- ./infra/airflow/init.sh:/opt/airflow/init.sh:ro
|
||||
- airflow_logs:/opt/airflow/logs
|
||||
- airflow_auth:/opt/airflow/auth
|
||||
mem_limit: 640m
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8974/health >/dev/null"]
|
||||
@@ -387,7 +428,7 @@ services:
|
||||
superset-init:
|
||||
condition: service_completed_successfully
|
||||
ports:
|
||||
- "127.0.0.1:${SUPERSET_PORT:-28088}:8088"
|
||||
- "127.0.0.1:${SUPERSET_PORT:?Скопируйте .env.example в .env}:8088"
|
||||
mem_limit: 1g
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8088/health >/dev/null"]
|
||||
@@ -397,7 +438,7 @@ services:
|
||||
start_period: 30s
|
||||
|
||||
prometheus:
|
||||
image: ${PROMETHEUS_IMAGE:-prom/prometheus:v3.13.2}
|
||||
image: prom/prometheus:v3.13.2
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
clickhouse-keeper:
|
||||
@@ -415,7 +456,7 @@ services:
|
||||
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
ports:
|
||||
- "127.0.0.1:${PROMETHEUS_PORT:-29090}:9090"
|
||||
- "127.0.0.1:${PROMETHEUS_PORT:?Скопируйте .env.example в .env}:9090"
|
||||
mem_limit: 512m
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9090/-/ready >/dev/null 2>&1"]
|
||||
@@ -425,14 +466,14 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
grafana:
|
||||
image: ${GRAFANA_IMAGE:-grafana/grafana:13.1.1}
|
||||
image: grafana/grafana:13.1.1
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
prometheus:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin}
|
||||
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:?Скопируйте .env.example в .env}
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?Скопируйте .env.example в .env}
|
||||
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||
GF_NEWS_NEWS_FEED_ENABLED: "false"
|
||||
GF_ANALYTICS_REPORTING_ENABLED: "false"
|
||||
@@ -441,7 +482,7 @@ services:
|
||||
- ./infra/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
|
||||
- grafana_data:/var/lib/grafana
|
||||
ports:
|
||||
- "127.0.0.1:${GRAFANA_PORT:-23000}:3000"
|
||||
- "127.0.0.1:${GRAFANA_PORT:?Скопируйте .env.example в .env}:3000"
|
||||
mem_limit: 512m
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:3000/api/health >/dev/null"]
|
||||
|
||||
+40
-20
@@ -29,18 +29,31 @@ EXPECTED_TABLES = [
|
||||
(LOCAL_TABLE, "ReplicatedMergeTree"),
|
||||
]
|
||||
|
||||
# Ноду 2 пробник читает не своим подключением, а запросом remote() с ноды 1: у
|
||||
# Airflow подготовлено одно подключение — к clickhouse-01, и второго ради
|
||||
# пробника не заводят. При этом remote('clickhouse-02:9000', ...) делает
|
||||
# инициатором распределённого запроса саму ноду 2 — проверяется именно это, а
|
||||
# не доступность ноды 2 по сети. Порт 9000 — межсерверный, тогда как
|
||||
# подключение Airflow ходит по HTTP на 8123.
|
||||
# У Airflow одно подключение — к ноде 1; второго ради пробника не заводят.
|
||||
# Ноду 2 он читает через remote(), который не использует секрет из описания
|
||||
# кластера, поэтому учётные данные etl передаются явно. Порт 9000 — нативный,
|
||||
# тогда как подключение Airflow ходит по HTTP на 8123. Trace-журнал сервера
|
||||
# видит пароль: это допустимо только для локального учебного стенда.
|
||||
NODES = (
|
||||
("ноде 1", "system.tables"),
|
||||
("ноде 2", "remote('clickhouse-02:9000', system.tables)"),
|
||||
(
|
||||
"ноде 2",
|
||||
"""remote(
|
||||
'clickhouse-02:9000', 'system', 'tables',
|
||||
{remote_user:String}, {remote_password:String}
|
||||
)""",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _remote_parameters() -> dict[str, str]:
|
||||
connection = Connection.get("clickhouse_default")
|
||||
return {
|
||||
"remote_user": connection.login,
|
||||
"remote_password": connection.password,
|
||||
}
|
||||
|
||||
|
||||
def _clickhouse_client():
|
||||
# clickhouse_connect импортируется внутри функции, а не наверху файла:
|
||||
# обработчик DAG разбирает этот файл снова и снова, и импорт наверху
|
||||
@@ -52,8 +65,8 @@ def _clickhouse_client():
|
||||
return clickhouse_connect.get_client(
|
||||
host=connection.host,
|
||||
port=connection.port,
|
||||
username=connection.login or "default",
|
||||
password=connection.password or "",
|
||||
username=connection.login,
|
||||
password=connection.password,
|
||||
database=connection.schema or "default",
|
||||
connect_timeout=5,
|
||||
send_receive_timeout=30,
|
||||
@@ -68,15 +81,15 @@ def _table_engines(client, source: str) -> list[tuple[str, str]]:
|
||||
WHERE database = 'default'
|
||||
AND name IN ('{LOCAL_TABLE}', '{DISTRIBUTED_TABLE}')
|
||||
ORDER BY name
|
||||
"""
|
||||
""",
|
||||
parameters=_remote_parameters(),
|
||||
)
|
||||
return result.result_rows
|
||||
|
||||
|
||||
def _drop_tables(client) -> None:
|
||||
client.command(
|
||||
f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} "
|
||||
f"ON CLUSTER {CLUSTER} SYNC"
|
||||
f"DROP TABLE IF EXISTS default.{DISTRIBUTED_TABLE} ON CLUSTER {CLUSTER} SYNC"
|
||||
)
|
||||
client.command(
|
||||
f"DROP TABLE IF EXISTS default.{LOCAL_TABLE} ON CLUSTER {CLUSTER} SYNC"
|
||||
@@ -97,7 +110,7 @@ def _assert_tables_absent(client) -> None:
|
||||
@dag(
|
||||
dag_id="test_clickhouse",
|
||||
schedule=None,
|
||||
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
|
||||
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
|
||||
catchup=False,
|
||||
tags=["проверка"],
|
||||
doc_md=__doc__,
|
||||
@@ -174,24 +187,31 @@ def test_clickhouse():
|
||||
node_2_rows = client.query(
|
||||
"""
|
||||
SELECT hostName()
|
||||
FROM remote('clickhouse-02:9000', system.one)
|
||||
"""
|
||||
FROM remote(
|
||||
'clickhouse-02:9000', 'system', 'one',
|
||||
{remote_user:String}, {remote_password:String}
|
||||
)
|
||||
""",
|
||||
parameters=_remote_parameters(),
|
||||
).result_rows
|
||||
if len(node_2_rows) != 1:
|
||||
raise RuntimeError(
|
||||
f"не удалось определить имя ноды 2: {node_2_rows}"
|
||||
)
|
||||
raise RuntimeError(f"не удалось определить имя ноды 2: {node_2_rows}")
|
||||
distributed_rows = client.query(
|
||||
f"""
|
||||
SELECT _shard_num, hostName(), marker
|
||||
FROM remote(
|
||||
'clickhouse-02:9000',
|
||||
'default',
|
||||
'{DISTRIBUTED_TABLE}'
|
||||
'{DISTRIBUTED_TABLE}',
|
||||
{{remote_user:String}},
|
||||
{{remote_password:String}}
|
||||
)
|
||||
WHERE marker = {{marker:String}}
|
||||
""",
|
||||
parameters={"marker": written["marker"]},
|
||||
parameters={
|
||||
"marker": written["marker"],
|
||||
**_remote_parameters(),
|
||||
},
|
||||
).result_rows
|
||||
if written["hostname"] == node_2_rows[0][0]:
|
||||
raise RuntimeError(
|
||||
|
||||
+2
-4
@@ -77,7 +77,7 @@ class RecordAddress(NamedTuple):
|
||||
@dag(
|
||||
dag_id="test_kafka",
|
||||
schedule=None,
|
||||
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
|
||||
start_date=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
|
||||
catchup=False,
|
||||
tags=["проверка"],
|
||||
doc_md=__doc__,
|
||||
@@ -100,9 +100,7 @@ def test_kafka():
|
||||
if error is not None:
|
||||
delivery_errors.append(str(error))
|
||||
else:
|
||||
addresses.append(
|
||||
RecordAddress(message.partition(), message.offset())
|
||||
)
|
||||
addresses.append(RecordAddress(message.partition(), message.offset()))
|
||||
|
||||
producer = Producer(PRODUCER_CONFIG)
|
||||
# produce() не пишет, а ставит сообщение в очередь: о судьбе записи
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Пульт мира: даги, которыми двигают ось модельного времени.
|
||||
|
||||
Их три, и они двух родов. **Работники** играют день и запускаются руками:
|
||||
`world_next_day` — пачкой, без пауз, сколько дней попросили; `world_live_day` —
|
||||
один день в темпе модельного времени. **Выключатель** `world_live` своей работы
|
||||
не делает: он тикает по расписанию и дёргает работника живого дня, дожидаясь
|
||||
конца. Снят с паузы — мир едет день за днём; поставлен на паузу — встал на
|
||||
границе модельных суток.
|
||||
|
||||
Разделение не косметическое. Расписание на самом работнике заставило бы кнопку
|
||||
паузы значить две вещи разом — «мир не едет сам» и «даг выключен», — а работник
|
||||
при этом выглядел бы в списке выключенным, хотя нажимают его каждый день.
|
||||
|
||||
Календарь Airflow к оси мира отношения не имеет: какой день играть, работник
|
||||
спрашивает у переменной, а не у логической даты прогона.
|
||||
|
||||
Решения и доводы целиком — ADR 0009.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import os
|
||||
|
||||
from airflow.providers.docker.operators.docker import DockerOperator
|
||||
from airflow.providers.standard.operators.trigger_dagrun import TriggerDagRunOperator
|
||||
from airflow.sdk import Param, Variable, dag, get_current_context, task
|
||||
|
||||
# Факты стенда — образ генератора, сеть, адрес брокера, топик и размер
|
||||
# стартового мира — приходят окружением, и называет их compose: тот же, что
|
||||
# называет их разовой службе генератора.
|
||||
GENERATOR_IMAGE = os.environ["GENERATOR_IMAGE"]
|
||||
STAND_NETWORK = os.environ["STAND_NETWORK"]
|
||||
GENERATOR_ENVIRONMENT = {
|
||||
"KAFKA_BOOTSTRAP_SERVERS": os.environ["KAFKA_BOOTSTRAP_SERVERS"],
|
||||
"KAFKA_TOPIC": os.environ["KAFKA_TOPIC"],
|
||||
}
|
||||
STARTING_DAYS = int(os.environ["WORLD_STARTING_DAYS"])
|
||||
|
||||
# Позиция на оси: номер первого несыгранного дня. Переменной нет — мир в
|
||||
# стартовом состоянии, и играть надо сразу за ним.
|
||||
#
|
||||
# Позиция ставится, а не увеличивается. Наложись один прогон на другой, худшее
|
||||
# при таком правиле — сыгранный дважды день, а повтор схлопнет
|
||||
# ReplacingMergeTree. Увеличение молча съело бы день, и в мире осталась бы
|
||||
# дыра, которой никто не заметит.
|
||||
WORLD_POSITION = "world_position"
|
||||
|
||||
# Тик выключателя. Каденцию задаёт не он, а сама длина живого дня — около
|
||||
# двадцати четырёх минут: тик только спрашивает «не пора ли снова».
|
||||
LIVE_TICK = datetime.timedelta(minutes=25)
|
||||
|
||||
START_DATE = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
|
||||
TAGS = ["пульт мира"]
|
||||
|
||||
|
||||
@task
|
||||
def first_unplayed_day() -> int:
|
||||
"""Номер дня, с которого играть."""
|
||||
return int(Variable.get(WORLD_POSITION, default=STARTING_DAYS))
|
||||
|
||||
|
||||
def _play(task_id: str, command: list[str]) -> DockerOperator:
|
||||
"""Задача, играющая дни в каноническом контейнере генератора.
|
||||
|
||||
Внутрь образа Airflow генератор не поставить: он требует Python 3.14, а
|
||||
образ несёт 3.13. Да и обещание побайтовой воспроизводимости дано для
|
||||
зафиксированного образа генератора — держится оно только там.
|
||||
"""
|
||||
return DockerOperator(
|
||||
task_id=task_id,
|
||||
image=GENERATOR_IMAGE,
|
||||
command=command,
|
||||
network_mode=STAND_NETWORK,
|
||||
environment=GENERATOR_ENVIRONMENT,
|
||||
# Контейнер убирается в любом исходе: вывод генератора оператор уже
|
||||
# перелил в журнал задачи, а мёртвые контейнеры копить незачем.
|
||||
auto_remove="force",
|
||||
# По умолчанию оператор монтирует контейнеру временный каталог. Здесь
|
||||
# это ловушка: путь он заводит внутри Airflow, а монтирует демон с
|
||||
# хоста, где такого пути нет. Генератору временный каталог не нужен.
|
||||
mount_tmp_dir=False,
|
||||
)
|
||||
|
||||
|
||||
@dag(
|
||||
dag_id="world_next_day",
|
||||
schedule=None,
|
||||
start_date=START_DATE,
|
||||
is_paused_upon_creation=False,
|
||||
max_active_runs=1,
|
||||
tags=TAGS,
|
||||
params={"days": Param(1, type="integer", minimum=1, title="Сколько дней прожить")},
|
||||
)
|
||||
def world_next_day():
|
||||
"""Прожить следующие дни пачкой, без пауз.
|
||||
|
||||
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
|
||||
нажимом, а не десятью: сколько дней играть — параметр запуска.
|
||||
"""
|
||||
|
||||
@task
|
||||
def remember_played(first_day: int) -> None:
|
||||
"""Позиция ставится по сыгранным дням и только по успеху."""
|
||||
days = get_current_context()["params"]["days"]
|
||||
Variable.set(WORLD_POSITION, str(first_day + days))
|
||||
|
||||
first_day = first_unplayed_day()
|
||||
played = _play(
|
||||
"play_days",
|
||||
[
|
||||
"batch",
|
||||
"--day",
|
||||
"{{ ti.xcom_pull(task_ids='first_unplayed_day') }}",
|
||||
"--days",
|
||||
"{{ params.days }}",
|
||||
],
|
||||
)
|
||||
|
||||
first_day >> played >> remember_played(first_day)
|
||||
|
||||
|
||||
@dag(
|
||||
dag_id="world_live_day",
|
||||
schedule=None,
|
||||
start_date=START_DATE,
|
||||
is_paused_upon_creation=False,
|
||||
max_active_runs=1,
|
||||
tags=TAGS,
|
||||
)
|
||||
def world_live_day():
|
||||
"""Прожить следующий день в темпе модельного времени.
|
||||
|
||||
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
|
||||
реальные минуты, и суточная волна разворачивается на глазах. Запускается
|
||||
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
|
||||
"""
|
||||
|
||||
@task
|
||||
def remember_played(first_day: int) -> None:
|
||||
"""Позиция ставится по сыгранному дню и только по успеху."""
|
||||
Variable.set(WORLD_POSITION, str(first_day + 1))
|
||||
|
||||
first_day = first_unplayed_day()
|
||||
played = _play(
|
||||
"play_day",
|
||||
["live", "--day", "{{ ti.xcom_pull(task_ids='first_unplayed_day') }}"],
|
||||
)
|
||||
|
||||
first_day >> played >> remember_played(first_day)
|
||||
|
||||
|
||||
@dag(
|
||||
dag_id="world_live",
|
||||
schedule=LIVE_TICK,
|
||||
start_date=START_DATE,
|
||||
catchup=False,
|
||||
is_paused_upon_creation=True,
|
||||
max_active_runs=1,
|
||||
tags=TAGS,
|
||||
)
|
||||
def world_live():
|
||||
"""Выключатель: пока включён, мир живёт день за днём.
|
||||
|
||||
Своей работы у выключателя нет — он дёргает `world_live_day` и ждёт конца.
|
||||
Ожидание тут несущая конструкция, а не вежливость: без него тик шёл бы
|
||||
независимо от хода дня, лишние прогоны скопились бы очередью, и мир потом
|
||||
промчался бы по ней без всякого темпа.
|
||||
|
||||
Ждём триггером, а не сенсором: оператор опрашивает тот прогон, который сам
|
||||
и создал, и ссылка на дочерний прогон видна прямо отсюда. Упал день —
|
||||
краснеет и выключатель.
|
||||
"""
|
||||
TriggerDagRunOperator(
|
||||
task_id="trigger_live_day",
|
||||
trigger_dag_id="world_live_day",
|
||||
wait_for_completion=True,
|
||||
)
|
||||
|
||||
|
||||
world_next_day()
|
||||
world_live_day()
|
||||
world_live()
|
||||
@@ -0,0 +1,145 @@
|
||||
# ADR 0007. Доступ к ClickHouse: пользователи файлом, доверие нод секретом
|
||||
|
||||
Дата: 9 августа 2026 года. Статус: принято. Реализация — отдельным тикетом.
|
||||
|
||||
## Решение
|
||||
|
||||
У кластера четыре пользователя и три роли.
|
||||
|
||||
- `default` — с паролем, только служебный: проверки здоровья нод и работа
|
||||
изнутри контейнеров. Приложения им не ходят.
|
||||
- `etl` с ролью `etl_writer` — чтение и запись во всех слоях. Им ходит
|
||||
Airflow и применяется DDL при подъёме стенда.
|
||||
- `bi` с ролью `bi_reader` — чтение витрин DM и слоя DDS. Им ходит Superset.
|
||||
- `analyst` с ролью `analyst_reader` — чтение всех слоёв. Им человек
|
||||
подключается снаружи, из своего клиента.
|
||||
|
||||
Роль здесь шире прав на слои: образ ClickHouse требует отдельного разрешения
|
||||
на запросы `ON CLUSTER` и на чтение системных таблиц, а без второго клиент
|
||||
не открывает соединение вовсе. Состав прав каждой роли — работа тикета
|
||||
реализации.
|
||||
|
||||
Пользователи, роли и права объявлены файлами настройки сервера, а не
|
||||
запросами SQL. Файл монтируется на обе ноды и лежит в git. Пароли живут
|
||||
в `.env` и попадают в настройку подстановкой по имени переменной; значения
|
||||
собраны по конвенции «слово-число-слово», потому что пароль `analyst`
|
||||
человек набирает руками.
|
||||
|
||||
Ноды доверяют друг другу по общему секрету, объявленному в описании
|
||||
кластера. Учётные данные по репликам не расписываются.
|
||||
|
||||
Сетевых ограничений на `default` нет: порты стенда и так открыты только на
|
||||
`127.0.0.1`, а внутри сети Compose дверь заперта паролем.
|
||||
|
||||
Устройство модели — кто каким пользователем ходит и где живут пароли —
|
||||
опишет раздел README «Состав и доступ»; здесь только решение и доводы.
|
||||
Отдельного справочника по доступу в `docs/architecture/` не заводим: своего
|
||||
содержания сверх этих двух документов у него сегодня нет, а дом ему
|
||||
понадобится, когда появятся слои DDS и DM и гранты размножатся. Абзац README
|
||||
«У локального учебного кластера нет пароля» этим решением отменяется.
|
||||
|
||||
## Почему
|
||||
|
||||
Стенд учит не администрированию доступа, а чтению отказа в правах: человек
|
||||
должен узнать в бою картину, которую видел здесь, и понимать, чего просить.
|
||||
Отсюда критерий отбора — модель должна читаться из текста ошибки, а не из
|
||||
описания. Поэтому пользователей мало, имена у них говорящие, а границы
|
||||
проходят там, где их обычно проводят в компаниях: `bi` видит витрины и слой
|
||||
DDS под ними, потому что так чаще всего и бывает; заодно расхождение витрины
|
||||
со слоем ниже остаётся проверяемым одним и тем же пользователем.
|
||||
|
||||
**Секрет, а не учётные данные по репликам.** У каждой реплики в описании
|
||||
кластера своя учётка — вписанная явно или подразумеваемая, и тогда это
|
||||
беспарольный `default`. Подключение к соседней ноде идёт от неё, а не от
|
||||
того, кто задал вопрос. Значит, стоит `default` получить пароль — и без
|
||||
правки описания кластера межнодовые запросы встают. Починить это можно двумя
|
||||
способами, и они различаются не удобством. С учётными данными по репликам вся
|
||||
межшардовая работа идёт от одной общей учётки: на своей ноде права
|
||||
применяются, на соседней — нет. Ролевая модель тогда наполовину декорация, а
|
||||
это хуже её отсутствия: она утверждает разграничение, которого не существует.
|
||||
Общий секрет передаёт на соседа личность инициатора, стоит одной строки в
|
||||
описании кластера и не размножает пароль по репликам. Шифрование тут ни при
|
||||
чём; в соседнем `clickhouse-learning-cluster` выбран как раз путь с учётными
|
||||
данными по репликам, и там пароль вписан в каждую реплику каждого из трёх
|
||||
кластеров.
|
||||
|
||||
Довод подтверждён замером 13 августа 2026 года: на соседнем шарде сохранился
|
||||
пользователь-инициатор, поэтому там применились его права. Результат замера —
|
||||
в разделе «Что проверено».
|
||||
|
||||
**Файлы, а не SQL.** Объявленное файлом не оставляет состояния в томах:
|
||||
повторный `make up` на живом стенде ничего не сдвигает, правка пароля
|
||||
применяется перезапуском, источник истины — файл в git. Оба варианта с SQL —
|
||||
локальное хранилище доступа с `ON CLUSTER` и реплицированное в keeper —
|
||||
делают пользователей состоянием тома: `.env` на них больше не влияет, и
|
||||
появляется расхождение того же сорта, которое README уже описывает для
|
||||
Postgres и Grafana. Цена выбора известна: объявленных файлом пользователей
|
||||
нельзя менять запросами. Она принята как упрощение — сломать модель случайно
|
||||
труднее.
|
||||
|
||||
**Что ещё отвергнуто.** Оставить всё на беспарольном `default`
|
||||
— цена нулевая, но и рассказать по итогам нечего. Раздать всем пароль без
|
||||
ролей — дёшево и показывает ровно тот антипаттерн, за который ругают в бою:
|
||||
все под одним админом. Вынести доступ в лекцию, как репликационную
|
||||
эксплуатацию, — довод «отдельный операционный домен» здесь не работает:
|
||||
у репликации это живые процессы, учения и runbook, а тут несколько десятков
|
||||
строк настройки и ноль эксплуатации. Запереть `default` ещё и по сети —
|
||||
второй замок на ту же дверь.
|
||||
|
||||
**Отложено явно**: TLS и защищённые межсерверные соединения, настоящее
|
||||
хранилище секретов, внешние поставщики учётных записей, квоты и профили под
|
||||
пользователя, политики строк, аудит запросов.
|
||||
|
||||
## Следствия
|
||||
|
||||
До появления слоёв DDS и DM читать `bi` нечего, и это намеренно. Дашборды
|
||||
рисуются поверх модели данных, а не поверх типизированных событий, поэтому
|
||||
доступ Superset к ODS не планировался и правами не выдаётся. Пока витрин нет,
|
||||
подключение Superset проверяется связью, а не запросом к таблице.
|
||||
|
||||
Владелец матвью приёма определяется тем, кто применил DDL. Весь DDL стенда
|
||||
идёт через `IF NOT EXISTS`, поэтому на существующих томах три матвью
|
||||
сохранят нынешнего владельца, а на собранном заново стенде им станет `etl`.
|
||||
Работают оба — права есть у обоих, — но два стенда в этом расходятся.
|
||||
|
||||
Лаба «сломать права» вариантом с файлами не закрывается: создать через SQL
|
||||
новую роль и нового пользователя ничто не мешает, и новую роль разрешено
|
||||
выдавать даже пользователю, объявленному файлом. Недоступно обратное —
|
||||
править объявленных файлом. Всё, что лаба создаст, живёт в томе и убирается
|
||||
`make clean`.
|
||||
|
||||
## Что проверено
|
||||
|
||||
Сверено 9 августа 2026 года по документации ClickHouse через MCP Context7 и
|
||||
на живом стенде 26.3.17.56.
|
||||
|
||||
- В `system.clusters` у каждой реплики своя колонка `user`; на стенде там
|
||||
`default`. Отсюда весь довод про общий секрет.
|
||||
- Секрет кластера аутентифицирует серверы хешем от соли, нонса, секрета и
|
||||
запроса и позволяет назначить пользователя, от которого запрос выполняется
|
||||
на соседней ноде.
|
||||
- Роли и права объявляются в файле настройки пользователей наравне с самими
|
||||
пользователями; SQL-хранилище доступа для этого не требуется.
|
||||
- Хранилище пользователей из файла — только для чтения: попытка изменить
|
||||
такого пользователя запросом даёт `ACCESS_STORAGE_READONLY`.
|
||||
- Роль, созданную через SQL, разрешено выдавать пользователю из файла —
|
||||
отдельное улучшение ClickHouse 2025 года, в 26.3 оно есть.
|
||||
- Настройку пользователей можно перечитать на живом стенде, в том числе на
|
||||
всём кластере сразу.
|
||||
- Подстановка значений из переменных окружения — общий механизм файлов
|
||||
настройки ClickHouse.
|
||||
- Весь DDL репозитория идёт через `IF NOT EXISTS` (файлы `sql/ddl/`).
|
||||
|
||||
Проверено на живом стенде 13 августа 2026 года, ClickHouse 26.3.17.56.
|
||||
|
||||
- Секрет кластера принимается внутри `remote_servers` в виде
|
||||
`<secret from_env="CLICKHOUSE_CLUSTER_SECRET"/>`. Запрос от `etl` через обе
|
||||
ноды выполнился на каждой под пользователем `etl`.
|
||||
- `from_env` работает у элемента `password`: `etl`, `bi` и `analyst` вошли с
|
||||
паролями из окружения и получили свои роли.
|
||||
- При запросе от `bi` через Distributed на второй ноде `system.query_log`
|
||||
показал `user = bi` и `initial_user = bi`. Значит, на соседнем шарде
|
||||
применяются права инициатора, а не общей учётной записи.
|
||||
- Права на отсутствующие базы `dds` и `dm` приняты. До создания баз они видны
|
||||
в `SHOW GRANTS FOR etl_writer`; временная таблица в `dm` подтвердила право
|
||||
`bi` на чтение после появления базы.
|
||||
@@ -0,0 +1,167 @@
|
||||
# ADR 0008. Приём заказов: пакетный забор слепка, инициируемый Airflow
|
||||
|
||||
Дата: 12 августа 2026 года. Статус: частично заменено
|
||||
[ADR 0010](0010-order-versions-in-ods.md). Реализация — отдельным тикетом.
|
||||
|
||||
Сохраняются пакетный забор, `RawBLOB`, один чтец на `clickhouse-01`, одна
|
||||
партиция топика и отсутствие матвью. Отменены граница «одно чтение — полный
|
||||
слепок», замена партиции `snapshot_date`, ODS без дедупликации и готовая схема
|
||||
`dds.order` с `argMax`. Действующая форма STG → ODS описана в
|
||||
[спецификации приёма заказов](../architecture/orders/ingestion.md).
|
||||
|
||||
## Решение
|
||||
|
||||
Топик `orders` читает Kafka-таблица формата `RawBLOB` — та же форма чтеца, что
|
||||
у событий ([ADR 0005](0005-event-ingestion.md)). Матвью к ней не привязана.
|
||||
Сырьё забирает пакетный шаг, которым управляет Airflow: он вставляет
|
||||
прочитанное в `stg.orders_raw_dist`, разбирает его в типизированный слепок и
|
||||
заменяет партицию дня в `ods.order_snapshot`. Тот же даг проигрывает модельный
|
||||
день генератором, поэтому переливается ровно то, что он положил в топик.
|
||||
Уточнение со сдвигом отправки, решённым позже (#71, [слепок и его
|
||||
доставка](../architecture/orders/snapshot.md)): даг, играющий день D, в
|
||||
штатном прогоне кладёт и забирает слепок дня D−1 — слепок предыдущего дня, а
|
||||
не сыгранного. После падения между шагами в топике может ждать и хвост
|
||||
прежних слепков; забор принимает всё приехавшее.
|
||||
|
||||
Прямое чтение из Kafka-движка требует двух настроек, и вторая не очевидна:
|
||||
`stream_like_engine_allow_direct_select = 1` разрешает читать чтеца запросом,
|
||||
а `kafka_commit_on_select = 1` на самой таблице заставляет запрос коммитить
|
||||
офсеты. По умолчанию прямое чтение их не коммитит — без этой настройки каждый
|
||||
прогон забирает один и тот же слепок заново.
|
||||
|
||||
Матвью к чтецу не привязана не по вкусу, а по устройству: при привязанной
|
||||
матвью прямое чтение остаётся запрещённым независимо от первой настройки. Два
|
||||
способа приёма взаимоисключающи по построению, и выбрать половину каждого
|
||||
нельзя.
|
||||
|
||||
У топика **одна партиция**, а чтец объявлен **только на `clickhouse-01` и без
|
||||
`ON CLUSTER`**. Пишет пакетный шаг по-прежнему в распределённую таблицу, так
|
||||
что раскладка по шардам не меняется: ключ у сырья прежний, `cityHash64` сырой
|
||||
строки.
|
||||
|
||||
Слои от этого получают разные роли, и каждая своя:
|
||||
|
||||
- `stg.orders_raw` — байты как приехали, срок жизни и метаданные доставки как
|
||||
у сырья событий; повторная заливка дня честно удваивает строки, как и там;
|
||||
- `ods.order_snapshot` — типизированный слепок дня, идемпотентный **заменой
|
||||
партиции** `snapshot_date`, ровно как обещает раздел 2 мастер-спеки;
|
||||
- `dds.order` — дедуп до последней версии через `argMax`, без изменений.
|
||||
|
||||
Сенсора дневного батча нет. Ждать нечего: производитель и потребитель слепка
|
||||
живут в одном даге.
|
||||
|
||||
## Почему
|
||||
|
||||
**Слепок — не поток, и приём обязан это признать.** События приезжают
|
||||
непрерывно, и матвью, тянущая их на лету, — честная форма для непрерывного.
|
||||
Заказы приезжают раз в модельный день целой выгрузкой окна изменяемости; у
|
||||
такой доставки есть начало и конец, и забирать её уместно по команде, а не
|
||||
подписью на бесконечность. Стенд от этого получает не два оттенка одного
|
||||
приёма, а два разных режима — push и pull, — и каждый стоит там, где ему место
|
||||
по природе источника. Именно это сравнение раздел 12 мастер-спеки и заказывал
|
||||
опорной точкой; прежняя его формулировка противопоставляла байтового чтеца
|
||||
типизированному, то есть две формы одного и того же приёма, и переписана.
|
||||
|
||||
**Почему не типизированный чтец прямо в `ods.order_snapshot`.** Он давал бы
|
||||
строгий приём средствами движка и живое сравнение двух чтецов даром, но ODS
|
||||
перестал бы быть надстройкой над STG и стал бы вторым входом с шины. Ровно эту
|
||||
схему ADR 0005 отверг для событий, и повод здесь тот же: на стенде слои и есть
|
||||
предмет изучения.
|
||||
|
||||
**Почему не матвью из сырья в ODS, как у событий.** Тогда `ods.order_snapshot`
|
||||
получил бы те же свойства, что `stg.orders_raw`: «как приехало», без дедупа,
|
||||
дубли при повторной заливке законны. Два слоя подряд с одной ролью — один
|
||||
лишний. Хуже того, обещание идемпотентности из раздела 2 повисло бы ни на чём:
|
||||
матвью партиций не заменяет, а у ODS, в отличие от сырья, срока жизни нет, и
|
||||
удвоение слепка жило бы вечно. При пакетном шаге каждый слой отрабатывает
|
||||
своё, а замену партиции делает тот, кто данные и принёс.
|
||||
|
||||
**Почему чтец остался байтовым.** Пулл снял возражение про второй вход с шины,
|
||||
и типизированный чтец снова стал допустим — но тогда между двумя приёмами
|
||||
стенда менялись бы сразу две переменные, и сравнение перестало бы читаться.
|
||||
Меняется одна: push против pull. Вдобавок `RawBLOB` сохраняет за слепком то же,
|
||||
что даёт событиям, — колонку, которую менти открывает в обычном клиенте и
|
||||
читает глазами, и возможность переразобрать сырьё, не переигрывая день.
|
||||
Свойства этого формата уже сняты живыми запросами при исполнении #37 и #43;
|
||||
менять его здесь значило бы платить второй раз за уже купленное.
|
||||
|
||||
**Почему одна партиция и один чтец.** Вторая партиция ничего не покупает:
|
||||
слепок дня — порядка полутора тысяч строк, параллелизм не нужен. А урок «какая
|
||||
нода читала топик» при пулле мёртв в любом случае — читает та нода, которую
|
||||
спросили. То есть вторая партиция продаёт единственный настоящий риск схемы:
|
||||
половина слепка застревает у ноды, к которой запрос не пришёл. Одна партиция
|
||||
риск смягчает, но не снимает — брокер отдаст её любому из двух потребителей;
|
||||
снимает его единственный чтец. У Airflow подготовлено одно подключение, к
|
||||
`clickhouse-01`, — там чтецу и место.
|
||||
|
||||
Объявление без `ON CLUSTER` — не оговорка к конвенции, а её первое осознанное
|
||||
исключение: у пулла один тянущий по определению. Заодно это контрпример к
|
||||
рефлексу «везде `ON CLUSTER`»: приставка не ритуал, а решение.
|
||||
|
||||
**Почему не `Distributed` поверх Kafka.** Один запрос к распределённой таблице
|
||||
над двумя чтецами осушил бы обе ноды разом и снял бы вопрос о партициях.
|
||||
Механически это, скорее всего, работает — `Distributed` просто просит каждый
|
||||
шард выполнить локальное чтение по имени таблицы, — но документация о такой
|
||||
связке молчит: ни поддержки, ни запрета. Цена молчания высока. Офсеты
|
||||
коммитятся на каждом шарде в момент чтения, а вставка идёт следом на
|
||||
инициаторе: упала вставка — потеряны обе половины, а не одна. Недоступный шард
|
||||
даёт худший из возможных исходов — тихо приехавшую половину слепка. Чинить
|
||||
такое пришлось бы чтением исходников. И учебная цена своя: менти обязан
|
||||
расшифровать конструкцию, которой нет ни в документации, ни в бою, и получает
|
||||
за это трюк. При одной партиции и одном чтеце она не нужна вовсе.
|
||||
|
||||
**Что отвергнуто ещё.** Сенсор дневного батча из раздела 9 мастер-спеки: у
|
||||
топика нет сигнала «всё», и сенсор ловил бы момент, которого не существует, —
|
||||
а раз генератор и переливка в одном даге, ждать нечего по построению. Лаба,
|
||||
поднимающая типизированного чтеца во второй группе потребителей ради того же
|
||||
сравнения: она понадобилась бы, останься сравнение невыполненным, но push
|
||||
против pull даёт его живым и постоянным.
|
||||
|
||||
## Следствия
|
||||
|
||||
Урок про виртуальные колонки — «какая нода читала топик, меняется между
|
||||
прогонами» — остаётся целиком за событиями. У заказов `consumer_host` всегда
|
||||
один и тот же, и это честная разница двух режимов, а не потеря: при пулле
|
||||
читает тот, кого спросили.
|
||||
|
||||
Гарантий приёма у заказов не больше, чем у событий, но последствия мягче.
|
||||
Офсеты коммитятся при чтении, вставка идёт следом — упавшая вставка теряет
|
||||
пачку. У потока такая потеря невосстановима, у слепка её лечит следующий день:
|
||||
окно изменяемости K = 7 привезёт те же заказы заново.
|
||||
|
||||
Этап 3 забирает у этапа 5 первый настоящий даг. Раздел 9 мастер-спеки отдавал
|
||||
даги этапу 5 целиком; приём заказов без дага не существует, поэтому порядок
|
||||
меняется. `etl_pipeline` остаётся за этапом 5.
|
||||
|
||||
Расхождения с мастер-спекой, внесённые тем же коммитом: раздел 6 (чтец заказов
|
||||
живёт на одной ноде и без матвью), раздел 7 (развилка закрыта, у `orders` одна
|
||||
партиция), раздел 9 (сенсор снят, даг переехал на этап 3), раздел 12
|
||||
(сравнение приёмов переформулировано).
|
||||
|
||||
## Что проверено
|
||||
|
||||
Документация ClickHouse проверена через MCP Context7 12 августа 2026 года.
|
||||
Предел порции одного опроса Kafka дополнительно снят 16 августа на локальном
|
||||
ClickHouse `26.3.17.56`.
|
||||
|
||||
- Прямое чтение из движков-очередей (Kafka, RabbitMQ, FileLog) запрещено
|
||||
начиная с версии 21.12 и открывается настройкой
|
||||
`stream_like_engine_allow_direct_select`.
|
||||
- При привязанной матвью прямое чтение остаётся запрещённым и с этой
|
||||
настройкой. Отсюда вывод, что два способа приёма взаимоисключающи.
|
||||
- Прямое чтение офсеты по умолчанию **не** коммитит; коммит включается
|
||||
настройкой `kafka_commit_on_select` на самой таблице. Это тот подводный
|
||||
камень, который молчит на первом прогоне и вылезает на втором.
|
||||
- Прямое чтение возвращает одну порцию, полученную одним опросом Kafka. При
|
||||
настройках стенда её предел — 65 409 сообщений, поэтому слепок порядка
|
||||
полутора тысяч строк помещается с запасом.
|
||||
- Про `Distributed` поверх Kafka документация не говорит ничего — ни
|
||||
поддержки, ни запрета.
|
||||
|
||||
Осталось проверить при исполнении, и это работа тикета реализации: что второй
|
||||
прогон подряд возвращает пусто, то есть офсеты действительно закоммичены; что
|
||||
одного чтения хватает на весь слепок дня; что виртуальные колонки доставки
|
||||
(`_topic`, `_partition`, `_offset`, `_timestamp_ms`) доступны при прямом чтении
|
||||
— на них стоят служебные колонки сырья, см. [доку
|
||||
хранилища](../architecture/storage.md); что повторная заливка дня даёт в
|
||||
`ods.order_snapshot` тот же счёт, а не удвоенный.
|
||||
@@ -0,0 +1,162 @@
|
||||
# ADR 0009. Пульт мира: работники, выключатель и канонический контейнер
|
||||
|
||||
Дата: 13 августа 2026 года. Статус: принято. Реализация — тикет #79.
|
||||
|
||||
## Решение
|
||||
|
||||
Ось модельного времени двигают даги пульта, и они двух родов.
|
||||
|
||||
**Работники** — без расписания и без паузы, запускаются руками:
|
||||
|
||||
- `world_next_day` — день пачкой; параметр «сколько дней» (умолчание 1) даёт
|
||||
разгон вперёд одним нажимом;
|
||||
- `world_live_day` — один день в темпе живого режима.
|
||||
|
||||
**Выключатель** — без своей работы: `world_live` несёт расписание около двадцати
|
||||
пяти минут, создаётся на паузе и дёргает `world_live_day`
|
||||
через `TriggerDagRunOperator`, дожидаясь его завершения. Снят с паузы — стенд
|
||||
живёт день за днём; поставлен на паузу — мир встал на границе модельных суток.
|
||||
|
||||
Генератор задача зовёт через `DockerOperator`: тот поднимает образ
|
||||
`clickstream-generator:local` — тот самый канонический контейнер, внутри
|
||||
которого [спека генератора](../specs/2026-08-01-generator.md) обещает побайтовую
|
||||
воспроизводимость, — в сети стенда, командой `batch` или `live`.
|
||||
|
||||
Позиция на оси живёт переменной Airflow, как обещает раздел 8 той же спеки.
|
||||
**Ставит её тот, кто сыграл день** — работник, и только по успеху, значением
|
||||
«последний сыгранный день + 1». Выключатель к переменной не прикасается. День
|
||||
работник берёт из переменной, а не из логической даты прогона: календарь Airflow
|
||||
к оси мира отношения не имеет.
|
||||
|
||||
Пакетная автоматика — второй выключатель, дёргающий `world_next_day`, —
|
||||
**отложена, а не отвергнута**.
|
||||
|
||||
## Почему
|
||||
|
||||
**Работники без расписания, выключатели без работы.** Иначе кнопка паузы значит
|
||||
две вещи разом: «мир не едет сам» и «даг выключен». Расписание на самом
|
||||
работнике формально живёт с ручным запуском — пауза, по документации Airflow 3,
|
||||
запрещает планирование, но ручной запуск разрешает, — только в списке дагов
|
||||
работник выглядит выключенным, а нажимают его каждый день. У предшественника
|
||||
стоит ровно эта форма: `world_next_day` с расписанием и на паузе, к ней
|
||||
объясняющий комментарий в коде и сторож `assert_target_dag_not_paused` на случай
|
||||
триггера в спящий даг. Разделение снимает и комментарий, и сторожа: работника
|
||||
никогда не ставят на паузу, поэтому дёргать спящего некому.
|
||||
|
||||
**Канонический контейнер — условие обещания, а не вкус.** Детерминизм генератора
|
||||
обещан внутри зафиксированного образа, и обещание держится только там. Поставить
|
||||
пакет прямо в образ Airflow, как делал предшественник, здесь нельзя даже
|
||||
механически: генератор требует Python 3.14, а `apache/airflow:3.3.0` несёт 3.13.
|
||||
Собрать в образе Airflow второе окружение из того же `uv.lock` — можно, но это
|
||||
второе место установки генератора, пересборка образа Airflow на каждую правку
|
||||
мира и раскладка каталога товаров, повторённая монтированиями руками: образ
|
||||
генератора делает эту работу сам. Заодно `DockerOperator` держит генератор
|
||||
отдельной и заменяемой сущностью, как решил раздел 8 спеки, и показывает
|
||||
приём, у которого есть боевой родственник — так в бою запускают шаг подом.
|
||||
|
||||
**Выключатель ждёт, и от этого зависит устойчивость.** Не ждущий выключатель
|
||||
тикает по расписанию независимо от хода дня: живой день длится около двадцати
|
||||
четырёх минут, а у работника `max_active_runs = 1`, поэтому лишние триггеры
|
||||
скопились бы очередью прогонов, и мир потом промчался бы по ней без темпа. С
|
||||
ожиданием каденцию задаёт сама длина дня, а расписание остаётся грубым тиком «не
|
||||
пора ли снова»: пауза между днями равна остатку до следующего тика, до минуты.
|
||||
|
||||
**Ждём триггером, а не сенсором.** Различаются они тем, чем опознают прогон.
|
||||
`TriggerDagRunOperator` с `wait_for_completion` ждёт тот прогон, который сам и
|
||||
создал, — опрос идёт по его идентификатору. `ExternalTaskSensor` ждёт задачу
|
||||
чужого дага **за конкретную логическую дату**: пришлось бы передать работнику
|
||||
логическую дату и повторить её в сенсоре, то есть завести вторую бухгалтерию
|
||||
времени сразу после того, как решение выше её запретило. Сенсор здесь и как урок
|
||||
был бы поддельным: его дом там, где ждут события, которого сам не производишь, а
|
||||
у пульта производитель и потребитель — свои же даги. Тем же доводом
|
||||
[ADR 0008](0008-order-ingestion.md) снял сенсор дневного батча. Настоящая
|
||||
механика показана и без него: родитель, ждущий чужого прогона, со ссылкой на
|
||||
дочерний прогон в интерфейсе и явными состояниями отказа — упал день, покраснел
|
||||
и выключатель. Это [ADR 0003](0003-dag-run-state.md) в действии: зелёный конец
|
||||
графа не должен переживать отказ выше.
|
||||
|
||||
**Позиция ставится, а не увеличивается.** Правило дешевле сторожа. Если прогон
|
||||
выключателя наложится на ручной, худшее — день сыгран дважды: номера событий
|
||||
детерминированы, повтор схлопнет `ReplacingMergeTree`, а позиция останется
|
||||
верной. Увеличение на единицу в том же случае молча проглотило бы день, и мир
|
||||
получил бы дыру, которую никто не заметит. Ни пул как мьютекс, ни проверка «а не
|
||||
идёт ли другой прогон» после этого не нужны.
|
||||
|
||||
Худший исход у правила всё-таки есть, и он мягкий: работник, кончивший позже,
|
||||
ставит позицию по своему последнему дню и может отодвинуть её назад — так
|
||||
бывает, если долгий живой день закончится после разгона на три дня вперёд.
|
||||
Тогда часть дней переигрывается. Это дешевле дыры: переигранный день склеится,
|
||||
пропущенный не найдётся никогда.
|
||||
|
||||
**Плата — сокет докера внутри Airflow.** Доступ к нему равен праву root на
|
||||
машине, и пользователю `airflow` он открывается добавлением GID группы `docker`
|
||||
в контейнер. GID у каждой машины свой, поэтому он — локальная настройка и живёт
|
||||
в `.env` рядом с остальными такими, а не в описании стенда. Для локального учебного стенда размен принят —
|
||||
тот же, что уже принят для пароля в trace-журнале пробника, — но плата
|
||||
называется вслух и в README. Вторая плата мелкая: пока стенд живёт, из четырёх
|
||||
слотов `parallelism` заняты два, ждущий выключатель и работающий контейнер.
|
||||
Освободить слот ожидания умеет `deferrable`, но он требует компонент triggerer,
|
||||
которого стенд не поднимает; заводить службу ради одного ожидания дороже
|
||||
занятого слота.
|
||||
|
||||
**Пакетная автоматика отложена, потому что своего желания у неё пока одно.**
|
||||
«Шагни и стой» закрывает кнопка, «уедь на неделю сейчас» — параметр «сколько
|
||||
дней», «живи, пока я смотрю» — выключатель. Ей остаётся «едь сам быстрее, чем
|
||||
живёшь»: день за пять минут, часами, — каденция, которой у живого темпа нет
|
||||
(ускорение сверх ×60 спека отвергла как перемотку). Желание правдоподобное, но
|
||||
пока не названное, а форма известна и стоит тридцать строк: второй экземпляр
|
||||
выключателя на второго работника. Отложить дешевле, чем завести и объяснять.
|
||||
|
||||
**Что отвергнуто ещё.** Пульт на `make` с позицией в файле: дёшево сегодня, но
|
||||
раздел 8 спеки уже отдал позицию переменной Airflow, а этап 3 без дага не
|
||||
существует вовсе ([ADR 0008](0008-order-ingestion.md)) — платить пришлось бы
|
||||
дважды, и второй хранитель позиции разошёлся бы с первым. Один долгий прогон
|
||||
`live` на много дней: поток без швов, но позиция двигается только по успеху всего
|
||||
прогона, а такой прогон всегда обрывают — переменная не сдвинулась бы никогда, и
|
||||
обрыв на десятом дне откатывал бы все десять. Цепочка на ресурсах Airflow 3
|
||||
(работник выпускает событие, выключатель на него подписан): каденция вышла бы
|
||||
точной и без ожидания, но снять выключатель с паузы недостаточно — цепочку надо
|
||||
толкнуть первым ручным прогоном, а читателю разбирать петлю «работник → ресурс →
|
||||
выключатель → работник». Цена расшифровки выше урока.
|
||||
|
||||
## Следствия
|
||||
|
||||
Этапы 3 и 5 зовут генератор тем же приёмом: пакетный забор слепка заказов
|
||||
([ADR 0008](0008-order-ingestion.md)) прирастает к тому же работнику, а
|
||||
`etl_pipeline` этапа 5 получает готовую обвязку `DockerOperator` и готовое
|
||||
правило позиции.
|
||||
|
||||
Расхождения, внесённые тем же коммитом: раздел 8 спеки генератора (позицию
|
||||
двигал «только даг `next_day`»; теперь — всякий, кто сыграл день, значением
|
||||
«сыгранный + 1») и раздел 1 там же вместе со словарём (живой день «не постоянный
|
||||
фон»; теперь фон у него есть, и у фона есть выключатель).
|
||||
|
||||
## Что проверено
|
||||
|
||||
По документации Airflow 3 через MCP Context7 и живым замером образа, 13 августа
|
||||
2026 года.
|
||||
|
||||
- Пауза дага запрещает планирование, но **разрешает ручной запуск**
|
||||
(`core-concepts/dags.rst`).
|
||||
- `TriggerDagRunOperator` с `wait_for_completion` опрашивает прогон, который сам
|
||||
создал, по его идентификатору; без этого флага `allowed_states`,
|
||||
`failed_states` и `poke_interval` не действуют вовсе. Вариант `deferrable`
|
||||
требует компонент triggerer.
|
||||
- `ExternalTaskSensor` ждёт задачу чужого дага за конкретные логические даты;
|
||||
сдвиги задаются `execution_delta` и `execution_date_fn`.
|
||||
- Сенсор в режиме `poke` держит слот всё время, в режиме `reschedule` —
|
||||
только на время проверки.
|
||||
- У `DockerOperator` `network_mode` принимает имя пользовательской сети, есть
|
||||
`auto_remove` и `skip_on_exit_code`.
|
||||
- Образ `apache/airflow:3.3.0` несёт Python 3.13.14 (замер запуском образа);
|
||||
генератор требует `>=3.14,<3.15`.
|
||||
- Сокет докера на машине стенда — `root:docker` с правами `660`, GID группы 127;
|
||||
пользователь образа Airflow — `uid=50000(airflow) gid=0(root)`, в группе
|
||||
`docker` его нет. Значит доступ открывается добавлением GID в контейнер, а
|
||||
правка прав на хосте не нужна.
|
||||
|
||||
Осталось проверить при исполнении, и это работа тикета: какая версия провайдера
|
||||
`apache-airflow-providers-docker` встаёт к 3.3.0; что имя сети стенда
|
||||
(`${COMPOSE_PROJECT_NAME}_default`) достаётся дагу без ручной подстановки; что
|
||||
триггер доходит и дочерний прогон виден из выключателя; что два запуска подряд
|
||||
двигают позицию на два дня, а обрыв — ни на один.
|
||||
@@ -0,0 +1,37 @@
|
||||
# ADR 0010. Заказы в ODS: версии сущности вместо подмены слепка
|
||||
|
||||
Дата: 16 августа 2026 года. Статус: принято. Частично заменяет
|
||||
[ADR 0008](0008-order-ingestion.md).
|
||||
|
||||
## Решение
|
||||
|
||||
Пакетный забор заказов остаётся прямым чтением байтового Kafka-чтеца по
|
||||
команде Airflow, но порция чтения больше не считается полным слепком и не
|
||||
публикуется заменой партиции `snapshot_date`. Прочитанные строки получают
|
||||
`_load_id` запуска, разбираются из одного среза STG в годные строки и ошибки,
|
||||
а `ods.order_snapshot` хранит принятые версии заказа в
|
||||
`ReplacingMergeTree(updated_at)`. Ключ сущности — `order_id`, партиция строится
|
||||
от неизменного `created_at`, все версии ключа направляются на один шард.
|
||||
|
||||
`snapshot_date` остаётся датой наблюдения источника, `_load_id` — координатой
|
||||
запуска приёма, `_load_ts` — временем прибытия строки. Ни одна из них не
|
||||
заменяет бизнес-версию `updated_at`. Физическая таблица может показывать
|
||||
несколько версий; точное текущее состояние ODS открывает `ods.order_v`, которое
|
||||
скрывает `FINAL` или равносильный способ выбора последней версии.
|
||||
|
||||
Модель заказа в DDS этим решением не задаётся. DDS получает устойчивую
|
||||
типизированную поверхность ODS и отдельно решает зерно, связи и способ
|
||||
материализации своей модели.
|
||||
|
||||
## Почему отменена подмена партиции
|
||||
|
||||
Прямой `SELECT` Kafka Engine заканчивается после одной полученной порции, а
|
||||
протокол не несёт признака конца слепка. Поэтому `snapshot_date` не доказывает,
|
||||
что в STG собрана полная партиция, и её подмена могла бы удалить уже принятые
|
||||
версии прошлого дня. Маркер конца, опись или фиксация конечных офсетов сделали
|
||||
бы границу настоящей, но добавили бы новый протокол без нужного стенду урока.
|
||||
|
||||
Малый объём позволяет оставить одно чтение на запуск как проверяемое
|
||||
эксплуатационное допущение, а не границу полноты. Полный контракт разбора,
|
||||
граница брака и поведение повторов заданы в
|
||||
[спецификации приёма заказов](../architecture/orders/ingestion.md).
|
||||
@@ -0,0 +1,42 @@
|
||||
# ADR 0011. Устройство компонента — связный набор живых документов
|
||||
|
||||
Дата: 16 августа 2026 года. Статус: принято.
|
||||
|
||||
## Решение
|
||||
|
||||
Детальное устройство крупного компонента живёт в `docs/architecture/`
|
||||
подпапкой: индекс `README.md` — целевая картина и указатели — и отдельный
|
||||
файл на каждую часть устройства. Имена — слаги без дат; набор правится по
|
||||
мере постройки, как и остальные справочники этой папки.
|
||||
|
||||
Первый такой набор — [заказы бэкенда](../architecture/orders/README.md),
|
||||
собранный из спеки этапа 3 и спеки приёма. Датированные файлы
|
||||
`2026-08-16-orders.md` и `2026-08-16-order-ingestion.md` удалены, ссылки на
|
||||
них перенацелены; историю держит git.
|
||||
|
||||
## Почему
|
||||
|
||||
**Спека и базовая документация — разные жанры, а файл был один.** Спека —
|
||||
событие: проект изменения с датой в имени, замерзающий после приёмки. Но
|
||||
собранная спека заказов сразу стала и детальным устройством сервиса — тем
|
||||
документом, по которому этап 3 будут строить и с которым потом сверяться.
|
||||
Живому устройству дата в имени врёт, а замерзать ему нельзя.
|
||||
|
||||
**Один большой документ плохо читается обоими читателями.** Агент, строящий
|
||||
судьбу заказа, вынужден везти в контексте формат провода и приём; человек
|
||||
листает пятьсот строк ради одного раздела. Индекс с файлами по частям даёт
|
||||
обоим одно и то же: загружается только нужная часть, а карта целого — один
|
||||
экран.
|
||||
|
||||
**Долговечно только то, что в git.** Резолюции развилок живут в трекере, а
|
||||
трекер долговечным хранилищем не считается. Поэтому решения — вместе с
|
||||
отклонёнными вариантами при каждом правиле — лежат в файлах набора; ссылки
|
||||
на тикеты остаются вежливостью, не записью.
|
||||
|
||||
## Следствия
|
||||
|
||||
- `docs/specs/` остаётся событиям: мастер-спека и спека генератора живут как
|
||||
есть; переводить ли их в живую форму — отдельное решение, когда встанет.
|
||||
- Раздел «Структура» в AGENTS.md обновлён тем же коммитом.
|
||||
- Ссылки из ADR 0008 и 0010, мастер-спеки, спеки генератора, исследования
|
||||
формата и доки хранилища перенацелены на набор.
|
||||
@@ -78,6 +78,12 @@ tea api -X PATCH -d @patch.json repos/{owner}/{repo}/issues/<n>
|
||||
Тот же приём — для чек-листа родительской карты: пункт `- [ ] #NN` тикается
|
||||
правкой её тела, автоматически Gitea этого не делает.
|
||||
|
||||
Комментарий правится тем же способом, но путь у него свой — без номера issue:
|
||||
`tea api -X PATCH -d @patch.json repos/{owner}/{repo}/issues/comments/<id>`.
|
||||
Идентификатор берётся из `GET .../issues/<n>/comments`; это не порядковый номер
|
||||
комментария в ленте. Нужно это чаще, чем кажется: резолюции wayfinder-тикетов
|
||||
живут комментариями, и правка после ревью идёт в них.
|
||||
|
||||
**Отсюда общее правило.** `tea` удобен там, где команда создаёт объект или
|
||||
меняет его свойство: создать issue, добавить комментарий, повесить метку,
|
||||
назначить исполнителя, закрыть. Как только нужно **изменить уже написанный
|
||||
@@ -159,6 +165,9 @@ issue этапа — Gitea чек-листы сама не обновляет.
|
||||
|
||||
## Что проверено и когда
|
||||
|
||||
2026-08-13: правка комментария через `repos/{owner}/{repo}/issues/comments/<id>`
|
||||
снята живыми запросами при закрытии #72 — так дважды правилась резолюция тикета.
|
||||
|
||||
2026-08-06: правка тела issue через `tea api -X PATCH -d @файл` снята живыми
|
||||
запросами при закрытии #37 — так проставлены чекбоксы самого тикета и пункт
|
||||
в чек-листе карты #4. Тогда же проверено, что `tea issues edit --description`
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Заказы бэкенда
|
||||
|
||||
Устройство второго источника стенда: раз в модельный день бэкенд магазина
|
||||
выгружает полный слепок заказов окна изменяемости, и деньги в витринах
|
||||
считаются по нему, а не по трекеру. Набор собран картой #69 (этап 3) и
|
||||
правится по мере постройки.
|
||||
|
||||
Границы уже решены мастер-спекой [«Боевой реализм стенда
|
||||
(v2)»](../../specs/2026-07-30-stand-v2-realism.md): поля слепка, окно K = 7
|
||||
как константа мира, три статуса, приоритет классов расхождений, правило
|
||||
«поведение и атрибуцию считаем по трекеру, деньги — по бэкенду». Рамка, перед
|
||||
которой отвечает каждое решение, — «Чем меряется генератор» в [спеке
|
||||
генератора](../../specs/2026-08-01-generator.md): конструкция внутри
|
||||
оправдана только наблюдаемым эффектом на выходе.
|
||||
|
||||
## Целевая картина одним взглядом
|
||||
|
||||
- **Заказ — проекция, не порождение.** Торговая половина дня-функции уже
|
||||
посчитала корзину, цены, купон и номер заказа; заказная половина навешивает
|
||||
судьбу и собирает слепок. Ни одного нового броска в торговом подпотоке —
|
||||
[откуда берётся заказ](snapshot.md).
|
||||
- **Слепок дня D — чистая функция (зерно, D)**: состояние заказов, рождённых
|
||||
в дни D−6…D, снятое на границе суток D|D+1. Отправляет его следующий
|
||||
прогон — ночная выгрузка бэкенда за вчера; на проводе — один JSON-документ
|
||||
на заказ — [слепок и его доставка](snapshot.md).
|
||||
- **Судьба заказа решается при рождении** и обязана уложиться в окно K либо
|
||||
не случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled` —
|
||||
[судьба заказа](fate.md).
|
||||
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
|
||||
заказная и событийная стороны; броски независимы, пересечения выходят
|
||||
арифметикой, приоритет классов работает по-настоящему —
|
||||
[классы расхождений и опоздание](fate.md).
|
||||
- **Опись хранит только то, чего движение мира не меняет**: хеш байтов
|
||||
каждого слепка и счётчики наблюдаемых классов —
|
||||
[что хранит опись](inventory.md).
|
||||
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
|
||||
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным —
|
||||
[мост к склейке](identity.md).
|
||||
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
|
||||
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order_snapshot`
|
||||
принимает версии заказа на `ReplacingMergeTree(updated_at)` —
|
||||
[приём из Kafka в ODS](ingestion.md).
|
||||
- **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого
|
||||
дня клики есть, а заказов нет, и график выручки дозаполняется по ходу
|
||||
мира — [заказы в стартовом мире](start-world.md).
|
||||
|
||||
Правила, обязательные для кода этапа 3, собраны в
|
||||
[правилах кода](code-rules.md).
|
||||
|
||||
## Открытые решения
|
||||
|
||||
- **Имена подпотоков сторон судьбы** — при реализации; из мёртвых имён никто
|
||||
не бросает, переименование ничего не сдвигает (#72).
|
||||
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
|
||||
доли классов, стоимость доставки — калибровка при реализации; финальная
|
||||
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
|
||||
потребуют только числа, не устройство.
|
||||
- **Проверки приёма** — разовая приёмка допущения «один запуск — одно чтение»
|
||||
и опыты из [«Рисков и проверки»](ingestion.md) — тикет реализации приёма.
|
||||
- **`_load_id` выше ODS** — вместе с устройством `dds.order` (#85).
|
||||
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
|
||||
остаётся в тумане карты #69; естественное место разговора — этап 4.
|
||||
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
|
||||
механике заказов не привязан.
|
||||
|
||||
## Родословная
|
||||
|
||||
Собрано тикетом #87 по резолюциям развилок карты #69: приём (#70), генератор
|
||||
слепков (#71), расхождения и опоздания (#72), мост к склейке (#73), место в
|
||||
стартовом мире (#74), брак и версии в ODS (#80), форма записи на проводе
|
||||
(#81). Решения о приёме — [ADR 0008](../../adr/0008-order-ingestion.md) и
|
||||
[ADR 0010](../../adr/0010-order-versions-in-ods.md); контракт провода —
|
||||
мастер-спека, раздел 2, и [исследование формата
|
||||
слепка](../../research/2026-08-16-order-snapshot-wire-format.md). Форма
|
||||
набора — [ADR 0011](../../adr/0011-component-docs.md).
|
||||
@@ -0,0 +1,17 @@
|
||||
# Правила кода этапа 3
|
||||
|
||||
Хвосты резолюций, обязательные для реализации:
|
||||
|
||||
- **Броски заказной стороны — на полную длину дня**, а не на отобранных
|
||||
заказах (правило формы, [судьба заказа](fate.md)); моментов всегда два.
|
||||
- **Целочисленная случайность** наследуется правилом кода этапа 2: таблицы
|
||||
целых весов, никаких плавающих распределений; деньги — в целых копейках.
|
||||
- **Подпотоки — по позиции в дереве**: стороны судьбы ветвятся по дню рождения
|
||||
заказа; в `COMMERCE` новых бросков нет; `person_id` — последний бросок
|
||||
когорты.
|
||||
- **Окно K = 7 — константа мира** в конфигурации мира, рядом с D0 и поясом
|
||||
([исследование
|
||||
формата](../../research/2026-08-16-order-snapshot-wire-format.md)).
|
||||
- **Один сериализатор**: запись слепка собирает явная функция `serialize.py`,
|
||||
прямых `json.dumps` по коду нет.
|
||||
- **Значения идентификаторов — ниже 2^53** (`user_id` наравне с прочими).
|
||||
@@ -0,0 +1,140 @@
|
||||
# Судьба заказа и расхождения
|
||||
|
||||
Резолюция развилки [«Расхождения A–D и опоздания: механика, доли и что
|
||||
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72).
|
||||
|
||||
## Судьба заказа
|
||||
|
||||
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
|
||||
решённая при его рождении и уложенная в окно K целиком.
|
||||
|
||||
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты,
|
||||
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и
|
||||
дубль. Довод за разделение — различимость по описи: правка заказной механики
|
||||
не двигает хеши событий, правка событийной не двигает байты слепка, и по
|
||||
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
|
||||
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
|
||||
компонент называет часть мира; новые стороны занимают те же позиции, имена —
|
||||
при реализации. Отклонено: *один компонент на всю судьбу* — правка событийной
|
||||
механики молча меняла бы байты слепка; *компонент на класс* — пять имён под
|
||||
ручки калибровки, которые крутятся разом.
|
||||
|
||||
**Правило формы, без которого разделение не работает: броски заказной стороны
|
||||
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
|
||||
становится функцией доли, и правка одной доли перебрасывает весь подпоток
|
||||
после себя. Изоляцию даёт форма броска, а не число подпотоков.
|
||||
|
||||
**Путь по статусам.** Три исхода, все внутри окна: **оплачен**; **оплачен и
|
||||
отменён**; **не оплачен и отменён**. Инвариант на выходе из окна: заказ либо
|
||||
`paid`, либо `cancelled`; `created` — только промежуточное состояние. За окном
|
||||
будущего у заказа нет, а заказ, навсегда застрявший в `created`, — модельная
|
||||
небрежность, которой в выгрузке живого магазина соответствия нет. Поэтому
|
||||
нового значения `mismatch_class` не нужно: `cancelled` покрывает обе дороги
|
||||
отмены (шестое значение занято `awaiting_order`).
|
||||
|
||||
**Форма броска.**
|
||||
|
||||
1. **Исход** — таблица долей из трёх строк; доля неоплаченных пишется явной
|
||||
строкой, а не оставляется читателю складывать хвост в уме.
|
||||
2. **Моменты** — таблица целых весов «сколько часов от рождения — с каким
|
||||
весом», строки 0…143, плюс равномерная секунда внутри часа — чтобы разности
|
||||
времён аудита не давали точных равенств (урок правки #50).
|
||||
3. Моментов бросается **всегда два, на полную длину дня**; у одномоментных
|
||||
исходов второй выбрасывается. Где их два по существу, ранний считается
|
||||
оплатой — порядок выходит сортировкой, условной точки отсчёта не нужно.
|
||||
|
||||
143 часа — самый узкий край окна: у заказа, рождённого в конце суток, до
|
||||
последнего его слепка 144 часа. Таблица кончается там, где кончается окно у
|
||||
самого невезучего: вылезти нечему, сторожа не нужно. Цена — заказ, рождённый в
|
||||
начале суток, не использует почти сутки своего окна; в хвосте таблицы веса
|
||||
мизерные, в данных это не видно. Форма из #71 — «вес за краем окна означает
|
||||
„не оплачен никогда“» — этим отменена: такой заказ теперь отменяется, а край
|
||||
окна не выражается числом часов — иначе доля неоплаченных стала бы функцией
|
||||
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
|
||||
|
||||
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
|
||||
она делится между двумя дорогами — строки таблицы исходов. Точные числа —
|
||||
калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим.
|
||||
|
||||
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
|
||||
версия; различает их история версий: сырьё STG и физические версии
|
||||
`ods.order_snapshot` до фоновых слияний, а в витринах — выручка дня, которая
|
||||
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
|
||||
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
|
||||
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
|
||||
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда
|
||||
деться, кроме как остаться брошенным; *мгновенная отмена при рождении* —
|
||||
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1,
|
||||
он в резерве.
|
||||
|
||||
## Классы расхождений и опоздание
|
||||
|
||||
Классы и ориентиры долей — [мастер-спека,
|
||||
раздел 4](../../specs/2026-07-30-stand-v2-realism.md); здесь — механика
|
||||
каждого.
|
||||
|
||||
**Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии,
|
||||
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
|
||||
`items_total` меньше на её стоимость. Момента у неё нет — заказ приезжает
|
||||
урезанным во всех своих слепках: первый слепок снимается на границе суток,
|
||||
когда склад заказ уже собрал. Заказ из одной позиции дельты не получает —
|
||||
пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со
|
||||
спросом на доле 1–2% статистически ненаблюдаема — менти платил бы за неё
|
||||
таблицей чисел мира, а увидеть не мог бы ничем. Доводы за вычёркивание:
|
||||
остаток — сотни рублей, он торчит в витрине сверки сам; расхождение
|
||||
объясняется сравнением позиций — разбором вложенного JSON и `ARRAY JOIN`,
|
||||
ровно тем навыком, ради которого позиции разбираются; история рассказывается
|
||||
словами без легенды про генератор. Отклонено: *переоценка позиции* и *другое
|
||||
количество* — дельта в десятки рублей, её надо захотеть заметить; *чистая
|
||||
дельта без истории* — тупик, объяснить нечем; *врёт клиент, а не бэкенд* —
|
||||
заказ у нас проекция той же корзины.
|
||||
|
||||
**Потеря события (B) — точечная.** Уходит строка `purchase`, просмотр
|
||||
`/confirmation` остаётся: события уезжают разными запросами, потерять один и
|
||||
сохранить другой — обычное дело. Единственный вариант, при котором потеря
|
||||
видна со стороны трекера: до подтверждения дошли сто, покупок девяносто семь.
|
||||
|
||||
**Дубль события (D) — сюжетный.** Обновление страницы шлёт и просмотр, и
|
||||
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
|
||||
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
|
||||
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
|
||||
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где
|
||||
до следующего визита куки остаётся запас сверх таймаута: иначе сборка сессий у
|
||||
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID`
|
||||
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита,
|
||||
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с
|
||||
покупкой, по тому же правилу, что у подтверждения с торговым хвостом.
|
||||
Отклонено: *обе точечные* — дубль затирает урок потери; *обе сюжетные* —
|
||||
потеря перестаёт быть видна со стороны трекера.
|
||||
|
||||
**Гарантия моста сильнее порчи.** Назначенные планом покупки не теряются, и
|
||||
назначенные планом заказы не опаздывают: `dds.identity_map` строится из моста
|
||||
«`purchase` ↔ заказ», и выброшенное событие — как и заказ, не попавший ни в
|
||||
один снятый слепок, — уносит куку из карты. Это был бы отказ лабы склейки, а
|
||||
не расхождение в данных; менти различить не может.
|
||||
|
||||
**Опоздание — заказ прячется от ранних слепков.** `created_at` не
|
||||
подделывается — строка создана, когда заказ родился, — но в слепках дней
|
||||
d…d+δ−1 её нет, а с d+δ она появляется в том состоянии, до которого заказ
|
||||
дожил: отменённый на второй день и опоздавший на третий приедет в первом же
|
||||
своём слепке как `cancelled` — «выгрузка догоняет жизнь». Задержка — таблица
|
||||
весов из трёх строк: 0 на подавляющем весе, 1 и 2 — это и есть «D+1/D+2»
|
||||
мастер-спеки. Меряется она в днях снятия слепка, а не отправки: иначе сдвиг
|
||||
отправки удвоился бы, и обещанные D+1/D+2 стали бы D+2/D+3. Дальше таблица не
|
||||
идёт: заказ с δ = 6 приехал бы ровно в одном слепке, и обещание «пропущенный
|
||||
день ничего не ломает» на нём перестало бы быть верным; при δ ≤ 2 у всякого
|
||||
заказа слепков не меньше пяти. Легенда: заказ ушёл в ручную обработку и попал
|
||||
в выгрузку позже. Отклонено: *сдвиг `created_at`* — подделка аудита источника:
|
||||
день создания строки разошёлся бы с днём покупки, чьё равенство держит
|
||||
синхронная модель ([откуда берётся заказ](snapshot.md)), заказ уехал бы в
|
||||
чужую партицию, и «выручка дня D» перестала бы отвечать покупкам дня D —
|
||||
сломалась бы та самая сверка, ради которой всё строится.
|
||||
|
||||
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
|
||||
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
|
||||
решается только у выживших покупок, а назначенное планом не теряется и не
|
||||
опаздывает — дельта и отмена ему разрешены, моста они не рвут.
|
||||
Следствие для калибровки: брошенная доля и наблюдаемая в сверке — разные числа
|
||||
(часть заказов забирают победители по приоритету, часть у класса C недоступна —
|
||||
однопозиционных заказов больше половины). Отклонено: *один класс на заказ* —
|
||||
приоритет в SQL стал бы мёртвой веткой, которую менти читает как живую.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Мост к склейке: человек, `person_id`, `user_id`
|
||||
|
||||
Резолюция развилки [«Мост к склейке: user_id и двухкуковые
|
||||
пары»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/73).
|
||||
|
||||
Причинная модель: план состава порождает **человека**; ему принадлежат одна
|
||||
или две куки, каждая наблюдается в кликстриме как отдельный посетитель. Визит
|
||||
может оформить заказ — тогда заказная сторона представляет того же человека
|
||||
как **пользователя магазина**. Это не модель аккаунта: регистрации нет, люди
|
||||
без визитов не порождаются, внутреннее знание наружу не выдаётся.
|
||||
|
||||
План владеет устойчивой личностью и отношением «кука принадлежит человеку».
|
||||
Минимальная форма — непрозрачный внутренний `person_id`, выровненный по кукам:
|
||||
у двух кук пары он одинаков. Заказ выводит то же значение под родным именем
|
||||
`user_id` (`UInt64`); в кликстрим ни `person_id`, ни `user_id` не попадает —
|
||||
анонимность формата держится формой, а не забывчивостью сериализатора.
|
||||
|
||||
`person_id` — последний обычный бросок потока случайности когорты, после уже
|
||||
принятых свойств: добавление личности не сдвигает куки, пары, возвраты и
|
||||
паспорта. Для второй куки повторяется ID её человека. Раздельные прогоны
|
||||
ничего не хранят и не согласуют: генератор событий и команда слепка заново
|
||||
спрашивают один план и получают тот же `person_id`.
|
||||
|
||||
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
|
||||
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
|
||||
отдельные прогоны одного мира дают то же соответствие; принятый мир
|
||||
воспроизводит эффект склейки без случайных ложных объединений — конкретные
|
||||
числа пар контрактом описи не являются. Межкогортные столкновения принимаются
|
||||
по той же дисциплине, что у случайных `ClientID`: принимается конкретный
|
||||
канонический мир по внешнему результату.
|
||||
|
||||
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
|
||||
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь* —
|
||||
состояние между прогонами без нового внешнего эффекта; *первая кука как ID
|
||||
человека* — магазин оказался бы замаскированным продолжением трекера;
|
||||
*структурная координата, хеш или глобальная последовательность* — больше
|
||||
механики при тех же данных; *полная модель аккаунтов* — менти её не наблюдает.
|
||||
@@ -0,0 +1,170 @@
|
||||
# Приём заказов из Kafka в ODS
|
||||
|
||||
Учебный результат: менти различает версию бизнес-сущности, наблюдение источника
|
||||
и запуск загрузки, а затем читает физические версии через явную поверхность
|
||||
текущего состояния.
|
||||
|
||||
## Проблема
|
||||
|
||||
Заказы приезжают полным слепком окна изменяемости, но Kafka передаёт его
|
||||
отдельными сообщениями и не сообщает потребителю, где слепок закончился. Прямое
|
||||
чтение Kafka Engine возвращает одну порцию. Поэтому прежняя публикация через
|
||||
`REPLACE PARTITION snapshot_date` приравнивала дату наблюдения к отсутствующей
|
||||
транспортной границе и могла заменить день неполным набором строк.
|
||||
|
||||
Одновременно типизированный ODS не должен принимать правдоподобные значения по
|
||||
умолчанию из грязного JSON или останавливать весь пакет из-за одной строки.
|
||||
|
||||
## Цели
|
||||
|
||||
- сохранить пакетный забор как контраст потоковому приёму событий;
|
||||
- один раз принять байты в STG и независимо разложить строки на годные и брак;
|
||||
- хранить в ODS типизированные версии заказов, не привязывая идемпотентность к
|
||||
`snapshot_date`;
|
||||
- дать следующим слоям один корректный способ прочитать текущее состояние;
|
||||
- оставить код проверки коротким и ограничить его контрактом провода.
|
||||
|
||||
## Не входит
|
||||
|
||||
- модель заказа в DDS: её зерно, связи, материализация и способ наполнения;
|
||||
- проверка полей внутри `items`, переходов статуса и равенств денежных сумм;
|
||||
- удаление заказа по отсутствию в следующем слепке;
|
||||
- маркер конца слепка, опись ожидаемых строк и транзакция между целями ODS.
|
||||
|
||||
## Поток данных
|
||||
|
||||
После завершения генератора Airflow один раз читает байтовый Kafka-чтец и
|
||||
записывает полученную порцию в `stg.orders_raw`. Все строки получают `_load_id`,
|
||||
равный `run_id` Airflow. `_load_ts` вычисляется при этой записи и дальше
|
||||
переносится без пересчёта.
|
||||
|
||||
Один следующий `task_id` отвечает за весь переход STG → ODS. Внутри него два
|
||||
последовательных `INSERT SELECT` читают неизменный срез по `_load_id`: первый
|
||||
пишет годные строки в `ods.order_snapshot`, второй — брак в
|
||||
`ods.order_snapshot_errors`. Транзакции между запросами нет. При частичном сбое
|
||||
Airflow повторяет весь `task_id`; одинаковые исходные строки и служебные метки
|
||||
не вычисляются заново.
|
||||
|
||||
Условия запросов взаимодополняющие: один общий предикат определяет брак, а
|
||||
годная ветвь использует его буквальное отрицание. Все функции предиката
|
||||
возвращают результат без исключения, а сам предикат всегда заканчивается в
|
||||
`true` или `false`, не в `NULL`. Постоянный классификатор между STG и ODS для
|
||||
этого не нужен.
|
||||
|
||||
## Граница строгого приёма
|
||||
|
||||
Единица решения — одна строка `stg.orders_raw`. Корень должен быть
|
||||
JSON-объектом с точным набором ключей: `order_id`, `user_id`, `status`,
|
||||
`created_at`, `updated_at`, `items_total`, `discount`, `delivery`, `total`,
|
||||
`items`, `snapshot_date`.
|
||||
|
||||
Скалярные поля проверяются по типу JSON. Деньги дополнительно обязаны быть
|
||||
строками с ровно двумя знаками после точки, времена — строками RFC 3339 в UTC с
|
||||
обязательными миллисекундами, дата слепка — строкой `YYYY-MM-DD`. `items`
|
||||
проверяется только как JSON-массив. Каноническая форма и основания выбора
|
||||
зафиксированы в
|
||||
[исследовании формата](../../research/2026-08-16-order-snapshot-wire-format.md).
|
||||
|
||||
Проверять все верхнеуровневые поля здесь уместно: их одиннадцать, и десять
|
||||
скалярных значений непосредственно образуют типизированную строку заказа. У
|
||||
события из 47 полей проверяются только пять опорных; переносить то сокращение на
|
||||
малый контракт заказа нет причины. Граница строгости заканчивается на форме
|
||||
провода: содержимое позиций и бизнес-инварианты намеренно остаются ниже.
|
||||
|
||||
Класс брака выбирается первым совпадением:
|
||||
|
||||
1. `not_an_object`;
|
||||
2. `keyset_mismatch`;
|
||||
3. `field_invalid`.
|
||||
|
||||
Имя отдельного поля в класс не включается. В таблице ошибок остаются сырой
|
||||
текст, метаданные доставки и `_load_id`, поэтому единичный случай можно разобрать
|
||||
без постоянной детализации предиката.
|
||||
|
||||
## Роль ODS
|
||||
|
||||
`ods.order_snapshot_rep` хранит физически принятые версии в
|
||||
`ReplacingMergeTree(updated_at)`. Ключ сортировки — `order_id`, партиция — день
|
||||
неизменного `created_at`. `ods.order_snapshot_dist` шардирует по
|
||||
`cityHash64(order_id)`: только так все версии заказа попадают на один шард и
|
||||
`FINAL` даёт корректный результат через распределённую таблицу.
|
||||
|
||||
Четыре координаты отвечают на разные вопросы:
|
||||
|
||||
- `updated_at` — какая бизнес-версия заказа новее;
|
||||
- `snapshot_date` — в слепке какого модельного дня источник показал строку;
|
||||
- `_load_id` — какой запуск Airflow принял строку;
|
||||
- `_load_ts` — когда строка приехала в хранилище.
|
||||
|
||||
Обычное чтение `_dist` показывает физически сохранившиеся версии и нужно для
|
||||
диагностики. Их число зависит от фоновых слияний: ODS не служит архивом истории.
|
||||
`ods.order_v` сохраняет те же источник-ориентированные поля без обогащения
|
||||
данными модели, но возвращает одну актуальную версию на `order_id`. Сначала оно
|
||||
может быть простым представлением над `_dist FINAL`; способ выбора можно
|
||||
заменить, не меняя потребителей.
|
||||
|
||||
Это представление остаётся ответственностью ODS: оно скрывает механику чтения
|
||||
версий, но не строит бизнес-модель. DDS читает `ods.order_v` и отдельно решает,
|
||||
какие сущности, связи и производные признаки ему нужны. Нужен ли `_load_id`
|
||||
выше ODS, решается вместе с DDS, а не здесь.
|
||||
|
||||
## Одно чтение Kafka
|
||||
|
||||
Стандартный слепок содержит около полутора тысяч строк, тогда как предел одной
|
||||
порции на стенде — десятки тысяч сообщений. Поэтому один запуск Airflow делает
|
||||
один прямой `SELECT`, без цикла до пустоты и без фиксации конечных офсетов.
|
||||
|
||||
Это допущение о размере стенда, а не доказательство полноты слепка. Если Kafka
|
||||
вернёт короткую порцию, непрочитанный хвост останется в топике и приедет в один
|
||||
из следующих запусков. После отказа от замены партиции это задержка, а не потеря
|
||||
или публикация неполного дня.
|
||||
|
||||
## Отклонённые варианты
|
||||
|
||||
- Партиционная идемпотентность — `REPLACE PARTITION snapshot_date` или
|
||||
`ReplacingMergeTree` по `(snapshot_date, order_id)`: у потребителя нет
|
||||
признака полноты партиции, а дата наблюдения становится частью ключа
|
||||
сущности.
|
||||
- Обычный `MergeTree` в ODS с дедупликацией только в DDS: навсегда сохраняет
|
||||
технические повторы там, где семантика версии уже известна.
|
||||
- Маркер, опись, чтение до пустоты или конечные офсеты: добавляют протокол ради
|
||||
объёма, который с большим запасом помещается в одну порцию.
|
||||
- Два `task_id` или материализованный классификатор: дробят один короткий
|
||||
переход слоя, не добавляя транзакционности.
|
||||
- Представление текущего состояния в DDS и готовая схема `dds.order` в этой
|
||||
задаче: перекладывают механику ODS на следующий слой и преждевременно задают
|
||||
модель данных.
|
||||
|
||||
## Риски и проверка
|
||||
|
||||
- На стандартном мире сверить число отправленных заказов с числом строк,
|
||||
принятых одним прямым чтением. Это разовая приёмка допущения, не постоянный
|
||||
сторож.
|
||||
- На малой управляемой порции дать по одной строке каждого класса брака и две
|
||||
годные версии одного `order_id`. Две цели должны сохранить все непустые
|
||||
сообщения, а `ods.order_v` — вернуть новую версию независимо от фонового
|
||||
слияния.
|
||||
- Повторить переход с тем же `_load_id`: строка в `ods.order_v` и её `_load_ts`
|
||||
не должны измениться; версии одного заказа должны остаться на одном шарде и
|
||||
в одной партиции. Таблица ошибок может снова записать тот же брак: совпавшие
|
||||
`_load_id` и Kafka-координаты показывают повтор задачи.
|
||||
|
||||
## Что проверено
|
||||
|
||||
MCP Context7 в сессии проектирования был недоступен. На локальном ClickHouse
|
||||
`26.3.17.56` проверено, что прямой `SELECT` Kafka Engine завершается после
|
||||
одной порции, а `FINAL` через `Distributed` исполняется на таблицах шардов.
|
||||
Поэтому версии одного `order_id` направляются на один шард. Фоновое схлопывание
|
||||
`ReplacingMergeTree` и необходимость точного чтения сверены с
|
||||
[официальной документацией](https://clickhouse.com/docs/reference/engines/table-engines/mergetree-family/replacingmergetree),
|
||||
поведение чтения — с исходниками той же версии
|
||||
[`StorageKafka.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/StorageKafka.cpp) и
|
||||
[`KafkaSource.cpp`](https://github.com/ClickHouse/ClickHouse/blob/v26.3.17.56-lts/src/Storages/Kafka/KafkaSource.cpp).
|
||||
|
||||
## Связанные решения
|
||||
|
||||
- [ADR 0008](../../adr/0008-order-ingestion.md) сохраняет выбор пакетного
|
||||
забора, `RawBLOB`, одного чтеца и одной партиции топика.
|
||||
- [ADR 0010](../../adr/0010-order-versions-in-ods.md) заменяет публикацию
|
||||
слепка версионным ODS.
|
||||
- Вопрос `_load_id` выше ODS оставлен проектированию DDS в тикете #85.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Что хранит опись
|
||||
|
||||
Резолюции развилок [«Расхождения A–D и опоздания: механика, доли и что
|
||||
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72)
|
||||
и [«Места заказов в стартовом
|
||||
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
||||
Словарь описи — растяжка-хеш против опоры-счётчика — задан «Чем меряется
|
||||
генератор» в [спеке генератора](../../specs/2026-08-01-generator.md) и
|
||||
термином «Опись мира» в [CONTEXT.md](../../../CONTEXT.md).
|
||||
|
||||
> Опись хранит только то, чего движение мира не меняет.
|
||||
> Хеш кладём всегда, счётчик — только когда назван его читатель.
|
||||
|
||||
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
|
||||
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
|
||||
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же
|
||||
байты») опись начинает сторожить.
|
||||
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
|
||||
итоговой судьбе заказов дня; единица счёта — заказ, и опись называет её
|
||||
словом. Сойтись с запросом менти они могут только на днях с закрытым окном:
|
||||
при N сыгранных днях таких N − 7 (день d закрывается слепком d + 6, а
|
||||
последний отправленный слепок несёт день N − 2). Читатель у них придёт
|
||||
этапом 4 — проверка сверки; не окажется читателя — та же бритва режет и их.
|
||||
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
|
||||
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
|
||||
не выходит; генератор сторожит хеш, транспорт — счёт событий.
|
||||
- **Опись описывает мир, а не доставку.** Работа генератора кончается на
|
||||
Kafka: дошли ли байты до `ods.order_snapshot` — вопрос стенда и его
|
||||
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка
|
||||
приёма заказов — число заведётся вместе с ней.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Заказ и его слепок
|
||||
|
||||
Резолюция развилки [«Генератор слепков: где живёт и чем связан с
|
||||
событиями»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/71).
|
||||
|
||||
## Откуда берётся заказ
|
||||
|
||||
Заказ и событие `purchase` — не два порождения, а две проекции одного факта
|
||||
мира. Корзина, цены, купон и номер заказа посчитаны торговой половиной
|
||||
дня-функции; заказная половина берёт заказы дня готовой структурой — вторым
|
||||
выходом `commerce`, — навешивает на них жизнь заказа и собирает слепок. В
|
||||
подпоток `COMMERCE` не добавляется ни одного нового броска: мир не сдвигается,
|
||||
согласованность двух источников не удерживается, а получается по построению.
|
||||
|
||||
Следствия, которые уже решены соседями и здесь только связываются:
|
||||
|
||||
- у всякого заказа изначально ровно одно событие `purchase`; заказ без события
|
||||
в трекере — не отдельная порода, а класс B, и делает его событийная сторона
|
||||
выбрасыванием события после присвоения номера
|
||||
([классы расхождений](fate.md));
|
||||
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`,
|
||||
читаемый номер «день и порядковый номер покупки» ([спека
|
||||
генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все
|
||||
покупки, дошедшие до потока дня, — до всяких потерь;
|
||||
- скидка заказа выводится из промокода события по таблице «код → скидка» —
|
||||
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
|
||||
|
||||
В модели строка заказа в базе источника создаётся синхронно с покупкой,
|
||||
поэтому день рождения заказа и день создания строки совпадают.
|
||||
|
||||
Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой
|
||||
`ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что
|
||||
это разные источники; *независимая модель бэкенда* (заказ первичен, событие —
|
||||
эхо) — кто купил, решает воронка, а воронка — это трафик, то есть опрокидывание
|
||||
всего генератора; *слепок собирает SQL стенда из событий* — второй источник
|
||||
исчезает вместе с уроком «две версии правды».
|
||||
|
||||
## Слепок и его доставка
|
||||
|
||||
**Запуск.** Третья команда того же пакета — `snapshot --day D [--days N]`,
|
||||
свой приёмник, топик `orders`. Два источника — два запуска: трекер и бэкенд
|
||||
видны глазами как два производителя, каждый со своим топиком. Один прогон с
|
||||
двумя выходами отклонён: экономии он не даёт (со сдвигом отправки окно слепка
|
||||
и сыгранный день не пересекаются вовсе), а правило «приёмник выбирается тем,
|
||||
что для него назвали» ломает. Отклонены также: *отдельный пакет и образ* —
|
||||
библиотека на двоих ради одной команды; *генератор пишет слепок файлом, в
|
||||
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
|
||||
топиком `hits`* — убивает два режима приёма.
|
||||
|
||||
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и
|
||||
собирается переигровкой этих семи дней: заказы дня — производная всей воронки
|
||||
дня, дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; у
|
||||
начала оси окно усекается само. Отклонено: *кэш заказов на томе* — состояние
|
||||
между прогонами; *окно держит хранилище* — топик перестаёт нести слепок;
|
||||
*K = 1* — это страховочный срез 1 мастер-спеки, он в резерве.
|
||||
|
||||
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
|
||||
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
|
||||
бэкенда за вчера, как в бою. Содержимое слепка — чистая функция (зерно, D), от
|
||||
момента отправки не зависит. Следствия:
|
||||
|
||||
- живой день перестаёт быть особым случаем: своего дага у него нет, слепок
|
||||
живого дня отправит следующий прогон;
|
||||
- пропущенный день лечится окном: слепок переснимается и даёт те же байты,
|
||||
отдельного механизма самовосстановления нет;
|
||||
- покупки текущего дня в сверке всегда `awaiting_order` — сюжет «вчера не
|
||||
сходилось, сегодня сошлось», ради которого мастер-спека этот класс завела;
|
||||
- цена — один лишний проигрыш дня на прогон (окно и сыгранный день не
|
||||
пересекаются, проигрышей всегда восемь).
|
||||
|
||||
На старте оси дня −1 нет, поэтому прогон дня 0 не отправляет ничего; первый
|
||||
слепок — дня 0 — уезжает прогоном дня 1 ([исследование
|
||||
формата](../../research/2026-08-16-order-snapshot-wire-format.md)).
|
||||
|
||||
**Случайность.** Судьбу заказов бросает свой подпоток, ветвящийся по дню
|
||||
рождения заказа: слепок несёт семь дней рождения сразу, и судьбу каждого
|
||||
заказа обязан читать из его собственного дня. Вся судьба решается при
|
||||
рождении, поэтому слепок любого дня — чтение готовой судьбы, а не накопление
|
||||
состояния. Отклонено: *дописывать броски в конец `COMMERCE`* — правка заказа
|
||||
и правка торгового поведения стали бы одним рычагом; *бросать состояние в
|
||||
подпотоке дня слепка* — траектория заказа зависела бы от того, какие слепки
|
||||
снимали.
|
||||
|
||||
## Запись на проводе
|
||||
|
||||
Контракт провода — на заказ один JSON-документ: деньги строками с двумя
|
||||
знаками, времена RFC 3339 в UTC с миллисекундами, `items` обычным массивом —
|
||||
целиком описан мастер-спекой (раздел 2); основания, отклонённые варианты и
|
||||
проверка разбора — в [исследовании
|
||||
формата](../../research/2026-08-16-order-snapshot-wire-format.md) (резолюция
|
||||
развилки
|
||||
[«Форма записи слепка на проводе»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/81)).
|
||||
|
||||
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
|
||||
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
|
||||
его даром, а хешу слепка в описи нужен именно названный порядок.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Заказы в стартовом мире
|
||||
|
||||
Резолюция развилки [«Места заказов в стартовом
|
||||
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
|
||||
Стартовый мир — не полный мир, а мир, остановленный на границе суток 7|8.
|
||||
Генератор играет два источника с разными темпами — поток Метрики и ночную
|
||||
выгрузку магазина; разные темпы дают всё остальное.
|
||||
|
||||
`world-init` играет восемь дней одним прогоном `batch --day 0 --days 8`;
|
||||
слепки отправляет второй запуск — команда `snapshot` того же диапазона (два
|
||||
источника — два запуска, [слепок и его доставка](snapshot.md)), и со сдвигом
|
||||
отправки уезжают **слепки дней 0…6**. Слепок дня 7 снят на границе суток и
|
||||
уедет первым же ходом мира. Особого режима у стартового мира нет: правило
|
||||
отправки живёт в одном месте — в проигрывателе; генератору это решение не
|
||||
стоит ничего.
|
||||
|
||||
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
|
||||
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
|
||||
**График выручки заваливается к правому краю** и дозаполняется, пока мир едет.
|
||||
Это не издержка стенда, а главный наблюдаемый эффект второго источника: ночная
|
||||
выгрузка отстаёт, свежие дни предварительны. На свежем стенде лаба сверки
|
||||
видит целый день `awaiting_order` — норма, а не поломка; как это назвать
|
||||
менти — за витринами этапа 4.
|
||||
|
||||
Цена принята с открытыми глазами: у части пар стартового мира поздний
|
||||
назначенный заказ падает на день 7, и до первого хода мира этих пар в
|
||||
`dds.identity_map` нет. Опись пар не считает
|
||||
([что хранит опись](inventory.md)), поэтому красной проверки из этого не
|
||||
выходит. Отклонено: *дослать восьмой слепок* — исчезает `awaiting_order` на
|
||||
свежем стенде, в CLI заводится рычаг, стартовый мир становится особым случаем
|
||||
ровно там, где #71 его убирал; *счётчик пар учится спрашивать про слепки* —
|
||||
число верно ровно до первого хода мира; *отменить сдвиг отправки* —
|
||||
`awaiting_order` пропал бы навсегда; *подогнать план под горизонт* — мир
|
||||
перестал бы быть чистой функцией зерна.
|
||||
@@ -78,15 +78,16 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/`
|
||||
Ключи ко-локации названы заранее, потому что на них стоит политика соединений из
|
||||
раздела 6 спеки: обычное соединение разрешено только по ключу ко-локации, всё
|
||||
прочее — через `GLOBAL`. Значит `dds.session` и `dds.identity_map` шардируются по
|
||||
`cityHash64(ClientID)`, а `dds.order` и производные от заказа — по
|
||||
`cityHash64(order_id)`. Ключи витрин появятся вместе с самими витринами.
|
||||
`cityHash64(ClientID)`. Объекты DDS с зерном заказа должны сохранять ко-локацию
|
||||
по `cityHash64(order_id)`; ключи остальных частей будущей модели и витрин
|
||||
появятся вместе с ними.
|
||||
|
||||
Открытый вопрос на будущее — не сама замена партиций: операции с ними по
|
||||
локальным таблицам правило разрешает прямо. Вопрос в шаге до неё. Партиция-донор
|
||||
должна быть уже разложена по шардам по тому же ключу, а разложить её можно
|
||||
только вставкой через распределённую таблицу — значит у каждой пакетной сущности
|
||||
появится вторая пара объектов, и имени для неё конвенция пока не даёт. Решать
|
||||
это вместе со сборкой DDS, а не задним числом.
|
||||
Замена партиций не используется для `ods.order_snapshot`: версии прошлых дней
|
||||
доливаются, а прямое чтение Kafka не задаёт границы полного слепка ([ADR
|
||||
0010](../adr/0010-order-versions-in-ods.md)). Если партиционная пересборка
|
||||
понадобится будущим объектам DDS или DM, партиция-донор должна быть заранее
|
||||
разложена по шардам по тому же ключу. Форму донора следует решать вместе с таким
|
||||
объектом, а не переносить на ODS заранее.
|
||||
|
||||
## Служебные колонки
|
||||
|
||||
@@ -116,8 +117,10 @@ keeper, Kafka, каркас сервисов. Этап 2 идёт: в `sql/ddl/`
|
||||
колонка молча отвечала бы на другой вопрос.
|
||||
|
||||
Само сообщение лежит в колонке `raw` тем, чем пришло: чтец читает байты и ничего
|
||||
не проверяет, поэтому там оказываются и целые события, и мусор. Разбирается всё
|
||||
это ниже, в матвью ODS — см. [ADR 0005](../adr/0005-event-ingestion.md).
|
||||
не проверяет, поэтому там оказываются и целые сообщения, и мусор. События ниже
|
||||
разбирают матвью ODS ([ADR 0005](../adr/0005-event-ingestion.md)), заказы —
|
||||
пакетный шаг ([ADR 0008](../adr/0008-order-ingestion.md), [ADR
|
||||
0010](../adr/0010-order-versions-in-ods.md)).
|
||||
|
||||
Движок таблицы сырья — обычный `ReplicatedMergeTree`, `ORDER BY (kafka_partition,
|
||||
kafka_offset)`: разбор полётов идёт от «какое сообщение», другого ключа у сырья и
|
||||
@@ -125,15 +128,23 @@ kafka_offset)`: разбор полётов идёт от «какое сооб
|
||||
которого он заведён: повтор доставки в сырье обязан быть виден.
|
||||
|
||||
Метка времени загрузки зовётся `_load_ts`, тип `DateTime64(3, 'UTC')`. Ставится
|
||||
она один раз, в матвью приёма, и дальше переносится из STG в ODS как есть:
|
||||
колонка отвечает на вопрос «когда строка приехала в хранилище», а не «когда её
|
||||
разобрали». В ODS она же служит колонкой версии `ReplacingMergeTree`, и работа у
|
||||
этой версии ровно одна — схлопнуть повтор доставки. Содержимое у повтора то же
|
||||
самое, отличается только метка, поэтому какая из двух строк переживёт мерж,
|
||||
безразлично. Пакетной переобработки у ODS нет: слой наполняет матвью, а не
|
||||
задание Airflow, и работа с партициями начинается выше. Переделать разобранное
|
||||
руками можно — вставкой из сырья с фильтром по `_load_ts`, в пределах
|
||||
трёхсуточного окна; ничья по версии разрешается в пользу вставленного позже.
|
||||
она один раз при записи в STG: для событий — матвью приёма, для дневного слепка
|
||||
заказов — пакетным шагом. Дальше метка переносится в ODS как есть и отвечает на
|
||||
вопрос «когда строка приехала в хранилище», а не «когда её разобрали». В
|
||||
`ods.event` она же служит колонкой версии `ReplacingMergeTree` и схлопывает
|
||||
повтор доставки. У `ods.order_snapshot` версию задаёт `updated_at` источника;
|
||||
`_load_ts` только показывает, когда конкретная строка приехала.
|
||||
|
||||
`created_at` и `updated_at` заказа к служебным колонкам хранилища не относятся.
|
||||
Они приезжают в сообщении как аудит строки в БД источника и в ODS разбираются в
|
||||
`DateTime64(3, 'UTC')`; ClickHouse их не создаёт и добавляет рядом собственную
|
||||
`_load_ts`. Совпадение слов «техническое время» не делает эти часы одной осью.
|
||||
|
||||
Пакетной переобработки у событий в ODS нет: слой наполняют матвью, а не задание
|
||||
Airflow. Переделать разобранное руками можно вставкой из сырья с фильтром по
|
||||
`_load_ts` в пределах трёхсуточного окна; ничья по версии разрешается в пользу
|
||||
вставленного позже. Заказы разбирает пакетный шаг из неизменного среза STG по
|
||||
`_load_id`; дневные партиции ODS он не заменяет.
|
||||
|
||||
Имя согласовано с каноном служебных полей соседнего учебного стенда на
|
||||
Greenplum, чтобы словарь был общим у двух хранилищ; ведущее подчёркивание у
|
||||
@@ -141,10 +152,28 @@ Greenplum, чтобы словарь был общим у двух хранил
|
||||
её же используют Fivetran, Airbyte и Stitch. С правилом выше это не спорит:
|
||||
запрещено совпадать с именами виртуальных колонок, а не носить подчёркивание.
|
||||
|
||||
Идентификатора пачки загрузки (`_load_id`) пока нет. В STG и ODS данные приезжают
|
||||
потоком через матвью, у которого нет ни батча, ни `run_id`, и колонка была бы
|
||||
пустой формальностью. В слоях, которые наполняет Airflow, `run_id` появится
|
||||
по-настоящему — тогда и заведём, тем же стилем имени.
|
||||
Общего идентификатора пачки загрузки (`_load_id`) нет. У потока событий нет ни
|
||||
пачки, ни `run_id`, и колонка была бы пустой формальностью. У заказов читатель
|
||||
назван: `_load_id` равен `run_id` Airflow и переносится из STG в годную строку
|
||||
ODS и в таблицу ошибок. Нужен ли он выше ODS, решается вместе с моделью DDS.
|
||||
|
||||
## Версии заказов
|
||||
|
||||
`ods.order_snapshot_rep` хранит принятые версии в
|
||||
`ReplacingMergeTree(updated_at)`: ключ сортировки — `order_id`, партиция —
|
||||
`toDate(created_at)`. `ods.order_snapshot_dist` шардирует по
|
||||
`cityHash64(order_id)`. Все версии заказа лежат в одной партиции, чтобы их могли
|
||||
схлопывать фоновые слияния, и на одном шарде, чтобы распределённый `FINAL`
|
||||
выбрал одного победителя.
|
||||
|
||||
Физическая пара нужна для загрузки и диагностики. Обычное чтение показывает
|
||||
версии, которые ещё не убрали фоновые слияния, и не является архивом истории.
|
||||
`ods.order_v` служит поверхностью точного текущего состояния для следующих
|
||||
слоёв. Представление сохраняет язык источника и не решает, какой станет модель
|
||||
DDS. `snapshot_date` в нём остаётся датой наблюдения строки, а не ключом
|
||||
публикации. Полное решение — в
|
||||
[ADR 0010](../adr/0010-order-versions-in-ods.md) и
|
||||
[спецификации приёма заказов](orders/ingestion.md).
|
||||
|
||||
## Часовые пояса
|
||||
|
||||
@@ -171,7 +200,9 @@ Greenplum, чтобы словарь был общим у двух хранил
|
||||
абсолютны тоже — `DateTime64(3, 'UTC')`. DDS и витрины обслуживают человека с
|
||||
дашбордом, поэтому время там лежит местным, в поясе счётчика (`Europe/Samara`,
|
||||
UTC+4), и пересчёт идёт один раз при наполнении слоя: автор отчёта пояса не
|
||||
пишет, он берёт готовую колонку. `Date` не участвует вовсе — у типа пояса нет.
|
||||
пишет, он берёт готовую колонку. `Date` хранит только номер дня — чьи это
|
||||
сутки, в нём не записано. Поэтому время по `EventDate` считают, назвав пояс
|
||||
руками; промах виден сразу: часы суток уезжают за границы 0–23.
|
||||
|
||||
Объявить местным и ODS — `DateTime('Europe/Samara')` — соблазнительно: байты те
|
||||
же, меняется одна линза, и `toDate(UTCEventTime)` начинает совпадать с
|
||||
@@ -356,6 +387,12 @@ D0 и к реальному календарю не привязана; паке
|
||||
kafka_offset)`: смотрят такую таблицу от класса, а внутри класса — по координатам
|
||||
доставки.
|
||||
|
||||
`ods.order_snapshot_errors` держит тот же диагностический минимум и `_load_id`
|
||||
запуска. У заказов три класса по приоритету: `not_an_object`,
|
||||
`keyset_mismatch`, `field_invalid`. Сырой текст остаётся рядом, поэтому класс не
|
||||
разрастается до имени отдельного поля. Точная граница приёма — в
|
||||
[спецификации заказов](orders/ingestion.md).
|
||||
|
||||
## Раскладка DDL
|
||||
|
||||
Файлы лежат в `sql/ddl/` и применяются по порядку имён. Сначала все статичные
|
||||
|
||||
@@ -29,19 +29,47 @@
|
||||
|
||||
## Карта целей
|
||||
|
||||
Стенд нужен трём целям из семи. Цена — замер, см. «Что проверено»: `make test`,
|
||||
`make smoke`, `make check-clickhouse` и `make check-services` перемерены
|
||||
7 августа 2026 года на приёмке этапа 2; остальные три стоят с замера 6 августа.
|
||||
Цели живут за двумя дверями. В корне — цели стенда; в `generator/` — цели
|
||||
генератора: он отдельный пакет со своим `pyproject.toml`, локом и образом, и
|
||||
спрашивают его отдельно.
|
||||
|
||||
| Цель | Что утверждает | Стенд | Цена |
|
||||
|---|---|---|---|
|
||||
| `make lint` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||
| `make typecheck` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
||||
| `make test` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 71 с |
|
||||
| `make config-test` | Compose разбирается, файлы DAG синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
||||
| `make smoke` | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 9 с |
|
||||
| `make check-clickhouse` | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, счёт событий стартового мира против описи | нужен | 8 с |
|
||||
| `make check-services` | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 59 с |
|
||||
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено»:
|
||||
`make test`, `make smoke`, `make check-clickhouse` и `make check-services`
|
||||
перемерены 7 августа 2026 года на приёмке этапа 2, корневой `make lint` —
|
||||
9 августа при его появлении; остальные стоят с замера 6 августа.
|
||||
|
||||
| Цель | Откуда | Что утверждает | Стенд | Цена |
|
||||
|---|---|---|---|---|
|
||||
| `make config-test` | корень | Compose разбирается, файлы DAG синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
||||
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 9 с |
|
||||
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, счёт событий стартового мира против описи | нужен | 8 с |
|
||||
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 59 с |
|
||||
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
||||
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 71 с |
|
||||
|
||||
**Строк с именем `make lint` две, и это не опечатка.** Одноимённые цели за
|
||||
разными дверями охватывают разное: корневая не видит ни одного файла
|
||||
генератора, генераторная — ни одного файла стенда. Человек в корне видит
|
||||
зелёное и может решить, что зелёный весь репозиторий.
|
||||
|
||||
Две тонкости, которых не видно из таблицы:
|
||||
|
||||
- **Версия ruff у корневой цели закреплена в самом вызове** (`uvx ruff@0.16.1`)
|
||||
и равна той, что в `generator/uv.lock`. Равенство держится руками: обновили
|
||||
лок — поправьте и вызов, сверять их некому.
|
||||
- **Охват держат аргументы вызова, а не устройство дерева.** Python,
|
||||
положенный вне двух названных в таблице путей, не проверит ни одна из дверей.
|
||||
|
||||
**Корневого `typecheck` нет, и это решение, а не пропуск.** Проверке типов мало
|
||||
прочитать файл: чтобы понять `from airflow.sdk import dag`, ей нужен
|
||||
установленный Airflow, а он живёт в образе, не на машине — 132 пакета ради двух
|
||||
файлов пробников (замер 9 августа 2026 года, `uv pip compile`). Цена решения
|
||||
называется честно: две настоящие находки `ty` в `dags/test_kafka.py` постоянного
|
||||
сторожа не получают. Вопрос вернётся на этапе 5, когда придут настоящие даги:
|
||||
тогда окружение окупится, и условие возврата — именно это, а не «стало
|
||||
неудобно».
|
||||
|
||||
Цена самого `make up` — 2 м 50 с с нуля (`make clean` перед ним — ещё 23 с) и
|
||||
1 м 5 с на живом стенде. В цену целей она не входит, но записана здесь по той
|
||||
@@ -74,8 +102,9 @@ ClickHouse отвечает сразу.
|
||||
| PR или задача | плюс цели, которых правка касалась: DAG-и, Superset или Kafka — `check-services`; DDL, кластер или данные — `check-clickhouse` |
|
||||
| Приёмка этапа | `make up` с нуля и все три цели на стенде |
|
||||
|
||||
Правки генератора добавляют к этому `make lint`, `make typecheck` и
|
||||
`make test`: стенд им не нужен, а `make test` из них самая дорогая.
|
||||
Правки генератора добавляют к этому `make lint`, `make typecheck` и `make test`
|
||||
из каталога `generator/`: стенд им не нужен, а `make test` из них самая
|
||||
дорогая. Правки дагов и Superset — корневой `make lint`.
|
||||
|
||||
Тринадцать секунд из её семидесяти одной — пересборка восьми модельных дней
|
||||
для сверки с описью мира. Дешевле хеши не сравнить: чтобы узнать, тот ли
|
||||
@@ -192,6 +221,15 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
||||
|
||||
## Что проверено
|
||||
|
||||
**Замер 9 августа 2026 года при появлении корневого `lint` (#65).** Три прогона
|
||||
подряд — 0,39, 0,36 и 0,39 секунды на трёх файлах стенда; в таблице 0,4 с. На
|
||||
машине с непрогретым кешем первый прогон дороже: `uvx` сначала скачивает ruff.
|
||||
|
||||
Тогда же снято, что двери не перекрываются: `ruff check --show-files` из корня
|
||||
перечисляет ровно три файла стенда, из `generator/` — только файлы генератора.
|
||||
Корневой конфиг правила генератору не подменяет: конфиг ищется вверх по дереву
|
||||
от файла, и ближний выигрывает.
|
||||
|
||||
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
|
||||
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка:
|
||||
18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Документ собран из контракта схемы генератора
|
||||
(`generator/src/clickstream_generator/schema.py`). Руками не править —
|
||||
пересобрать: `make docs`.
|
||||
пересобрать: `make docs` из каталога `generator/`.
|
||||
|
||||
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
|
||||
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# Формат дневного слепка заказов на проводе
|
||||
|
||||
Дата исследования: 2026-08-16.
|
||||
|
||||
Учебный результат: менти различает бизнес-время, время источника, доставки и
|
||||
загрузки, не разбирая ради этого лишнюю инфраструктуру. Цена — несколько явных
|
||||
правил контракта; новых полей и универсального сериализатора не требуется.
|
||||
|
||||
## Короткий вывод
|
||||
|
||||
- `created_at` и `updated_at` — аудит строки в источнике, а не время покупки.
|
||||
Оба поля передаются в UTC с настоящей точностью до миллисекунд:
|
||||
`2026-06-03T14:21:07.123Z`.
|
||||
- В ClickHouse им соответствует `DateTime64(3, 'UTC')`. Неверная строка даёт
|
||||
`NULL` и уходит в `*_errors`, а не превращается в правдоподобную дату.
|
||||
- `snapshot_date` — дата завершившегося модельного дня, состояние которого
|
||||
снято на исходящей границе суток. Внутри одной выгрузки она одинакова, на
|
||||
следующем модельном дне меняется.
|
||||
- `items` на проводе — обычный массив JSON. Тип `String` в ODS означает, что
|
||||
из внешнего JSON извлекли сырой фрагмент массива, а не что источник дважды
|
||||
сериализовал JSON.
|
||||
- Генератору достаточно собрать один словарь с вложенным списком и один раз
|
||||
вызвать `orjson.dumps`. Отдельная иерархия кодеков урока не добавляет.
|
||||
|
||||
## Оси времени
|
||||
|
||||
В потоковой обработке время события принадлежит самой записи и не зависит от
|
||||
часов обработчика; время обработки отвечает на другой вопрос
|
||||
([Apache Flink: Event Time и Processing Time](https://nightlies.apache.org/flink/flink-docs-stable/docs/concepts/time/)).
|
||||
Debezium проводит ту же границу внутри одного сообщения: время изменения в
|
||||
исходной БД хранится отдельно от времени обработки коннектором, а их разность
|
||||
можно использовать как задержку
|
||||
([документация коннектора PostgreSQL](https://debezium.io/documentation/reference/stable/connectors/postgresql.html#postgresql-create-events)).
|
||||
|
||||
| Поле | Чьи часы | На какой вопрос отвечает | Форма |
|
||||
|---|---|---|---|
|
||||
| `UTCEventTime` события `purchase` | бизнес-событие, трекер | когда покупатель подтвердил покупку | отдельный контракт кликстрима; связь с заказом по `purchaseID = order_id` |
|
||||
| `created_at` | база источника | когда строка заказа впервые создана в источнике | RFC 3339 UTC с тремя знаками долей секунды |
|
||||
| `updated_at` | база источника | когда эта строка в последний раз изменена в источнике | тот же формат; версия состояния заказа |
|
||||
| `snapshot_date` | модельный календарь | состояние какого завершившегося дня снято на границе суток | `YYYY-MM-DD`, без времени |
|
||||
| `kafka_timestamp` | транспорт | когда брокер пометил доставленное сообщение | служебная колонка хранилища |
|
||||
| `_load_ts` | хранилище | когда строка впервые приехала в хранилище | `DateTime64(3, 'UTC')` |
|
||||
|
||||
`updated_at` как метка последнего изменения исходной строки совпадает с
|
||||
рекомендованным смыслом `updated_at` в timestamp-стратегии dbt snapshots;
|
||||
время выполнения самого слепка dbt хранит отдельно
|
||||
([официальная документация dbt](https://docs.getdbt.com/docs/build/snapshots#timestamp-strategy-recommended)).
|
||||
Служебные метки ETL также являются отдельными метаданными процесса, а не
|
||||
бизнес-фактами
|
||||
([Kimball Group: Audit Dimension](https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/audit-dimension/)).
|
||||
В этом проекте транспортная и складская оси уже разведены в
|
||||
[конвенции хранилища](../architecture/storage.md): `_load_ts` ставится один раз,
|
||||
а миллисекундный `kafka_timestamp` не округляется.
|
||||
`created_at` и `updated_at` приезжают в сообщении источника; ClickHouse их не
|
||||
создаёт и добавляет рядом собственную `_load_ts`.
|
||||
|
||||
Следствие для контракта: `created_at` нельзя называть временем покупки, а
|
||||
`updated_at - created_at` — длительностью бизнес-перехода. Это время между
|
||||
созданием и последним изменением строки в источнике. Бизнес-время живёт в
|
||||
событии `purchase` и связывается с заказом по уже существующему ключу.
|
||||
|
||||
### Партиция и бизнес-время
|
||||
|
||||
`created_at` и `updated_at` — аудит строки источника. Поэтому
|
||||
`toDate(created_at)` в
|
||||
[спецификации приёма заказов](../architecture/orders/ingestion.md)
|
||||
используется как стабильный технический ключ партиции `ods.order_snapshot`:
|
||||
это день создания строки
|
||||
источника, а не доказательство дня бизнес-события.
|
||||
|
||||
Минимальное решение — не добавлять `ordered_at` на всякий случай. Пока модель
|
||||
создаёт исходную строку синхронно с покупкой, существующий ключ партиции можно
|
||||
оставить, но в описании называть его днём создания строки. Время покупки для
|
||||
сверки берётся из `purchase.UTCEventTime`. Отдельное поле в заказе понадобится
|
||||
только тогда, когда появится самостоятельный учебный запрос к бизнес-времени
|
||||
заказа или источник начнёт сохранять заказ асинхронно. Так различие остаётся
|
||||
честным, но не порождает поле без потребителя.
|
||||
|
||||
## Точность и строгий разбор
|
||||
|
||||
RFC 3339 — профиль ISO 8601 для обмена датой и временем. Он разрешает дробную
|
||||
часть секунды переменной длины и как `Z`, так и числовое смещение
|
||||
([RFC 3339, §5.6](https://www.rfc-editor.org/rfc/rfc3339#section-5.6)). Значит
|
||||
миллисекунды не следуют из названия стандарта сами по себе. Наш более узкий
|
||||
контракт фиксирует ровно три цифры и UTC:
|
||||
|
||||
```text
|
||||
YYYY-MM-DDTHH:mm:ss.SSSZ
|
||||
```
|
||||
|
||||
Например: `2026-06-03T14:21:07.123Z`. Одинаковое число цифр дробной части и
|
||||
одинаковая зона дают хронологическую сортировку таких строк в лексикографическом
|
||||
порядке
|
||||
([RFC 3339, §5.1](https://www.rfc-editor.org/rfc/rfc3339#section-5.1)). Три
|
||||
цифры выбраны потому, что источник моделирует миллисекунды. Сериализатор всегда
|
||||
выводит все три цифры, в том числе `.000` для значения точно на границе секунды.
|
||||
Нельзя только выдавать секундную модель за миллисекундную простым дополнением
|
||||
нулей.
|
||||
|
||||
В ClickHouse `DateTime64(3, 'UTC')` хранит три десятичных знака долей секунды,
|
||||
то есть миллисекунды; пояс колонки используется при разборе и показе значения
|
||||
([DateTime64](https://clickhouse.com/docs/sql-reference/data-types/datetime64)).
|
||||
Для этого узкого формата подходит обнуляемый разбор по точному шаблону:
|
||||
|
||||
```sql
|
||||
parseDateTime64InJodaSyntaxOrNull(
|
||||
value,
|
||||
'yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\'',
|
||||
'UTC'
|
||||
)
|
||||
```
|
||||
|
||||
`OrNull` возвращает `NULL` при несовпадении, а три `S` задают точность
|
||||
`DateTime64(3)`
|
||||
([документация функции](https://clickhouse.com/docs/sql-reference/functions/type-conversion-functions#parsedatetime64injodasyntaxornull),
|
||||
[исходный код ClickHouse](https://github.com/ClickHouse/ClickHouse/blob/master/src/Functions/parseDateTime.cpp)).
|
||||
Это строже, чем `parseDateTime64BestEffortOrNull`: функция Best Effort по
|
||||
назначению принимает несколько представлений даты, тогда как здесь форма сама
|
||||
является частью учебного контракта
|
||||
([документация Best Effort](https://clickhouse.com/docs/sql-reference/functions/type-conversion-functions#parsedatetime64besteffortornull)).
|
||||
|
||||
На проектном ClickHouse 26.3.17.56 это выражение локально проверено. Оно
|
||||
возвращает `Nullable(DateTime64(3, 'UTC'))` для строки с `.123Z` и `NULL` для
|
||||
строки без миллисекунд, с четырьмя цифрами, со смещением `+00:00` вместо `Z`
|
||||
или с хвостовым мусором. Поэтому один и тот же результат разбора можно
|
||||
использовать и для типизированной строки, и для маршрутизации ошибки; нулевая
|
||||
дата не нужна.
|
||||
|
||||
`updated_at` допустим как колонка версии `ReplacingMergeTree`: ClickHouse
|
||||
явно разрешает для `ver` тип `DateTime64` и оставляет строку с максимальной
|
||||
версией
|
||||
([ReplacingMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replacingmergetree)).
|
||||
Если две версии одного заказа имеют одинаковый `updated_at`, среди них
|
||||
побеждает вставленная позже. Для учебной модели достаточно гарантировать
|
||||
монотонные миллисекундные `updated_at` на один заказ; отдельный счётчик версий
|
||||
без такого сценария был бы лишним.
|
||||
|
||||
## Дата слепка и константы мира
|
||||
|
||||
Периодический слепок имеет зерно заранее заданного периода — например, дня, —
|
||||
а не отдельной транзакции
|
||||
([Kimball Group: Periodic Snapshot Fact Tables](https://www.kimballgroup.com/data-warehouse-business-intelligence-resources/kimball-techniques/dimensional-modeling-techniques/periodic-snapshot-fact-table/)).
|
||||
Поэтому `snapshot_date` — значение пачки: дата завершившегося модельного дня D,
|
||||
состояние которого снято на границе D|D+1. Это не UTC-дата отправки и не
|
||||
глобальная константа.
|
||||
|
||||
На один проход генератор вычисляет дату один раз и кладёт её во все записи; на
|
||||
следующем модельном дне значение меняется. Настоящие константы мира — начало
|
||||
модельной оси `ORIGIN`, пояс счётчика `COUNTER_TIMEZONE_MINUTES` и окно K = 7.
|
||||
Первые две уже заданы в
|
||||
[модели времени](../../generator/src/clickstream_generator/world.py), окно
|
||||
добавится в конфигурацию мира вместе с заказами. Глобальная `SNAPSHOT_DATE`
|
||||
смешала бы правило календаря с результатом его вычисления.
|
||||
|
||||
На старте оси дня −1 нет, поэтому прогон дня 0 ничего не отправляет. Первый
|
||||
слепок с `snapshot_date = ORIGIN` уезжает прогоном дня 1.
|
||||
|
||||
`as_of_date` тоже могло бы означать дату, по состоянию на которую показаны
|
||||
данные. Но `snapshot_date` уже является языком спеки и ADR о приёме заказов.
|
||||
Переименование не добавляет урока и может спутать дату
|
||||
выгрузки с периодом бизнес-действия записи. Для этого стенда оставляем
|
||||
`snapshot_date`.
|
||||
|
||||
## JSON и сериализация
|
||||
|
||||
В JSON массив и строка — разные типы значения: массив содержит значения
|
||||
непосредственно, а строка содержит последовательность символов
|
||||
([RFC 8259, §§3, 5 и 7](https://www.rfc-editor.org/rfc/rfc8259)). Поэтому форма
|
||||
на проводе такая:
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "20260603-0001",
|
||||
"user_id": 42,
|
||||
"status": "paid",
|
||||
"created_at": "2026-06-03T14:21:07.123Z",
|
||||
"updated_at": "2026-06-03T14:24:18.456Z",
|
||||
"items_total": "1299.90",
|
||||
"discount": "0.00",
|
||||
"delivery": "199.00",
|
||||
"total": "1498.90",
|
||||
"items": [
|
||||
{"sku": "sku-17", "qty": 1, "price": "1299.90"}
|
||||
],
|
||||
"snapshot_date": "2026-06-07"
|
||||
}
|
||||
```
|
||||
|
||||
ClickHouse `JSONExtractRaw(raw, 'items')` возвращает выбранный фрагмент JSON
|
||||
неразобранной строкой
|
||||
([официальная документация](https://clickhouse.com/docs/sql-reference/functions/json-functions#jsonextractraw)).
|
||||
На проектной версии локальная проверка обычного внешнего JSON показала
|
||||
`JSONType(..., 'items') = 'Array'`, а `JSONExtractRaw` вернул компактный текст
|
||||
массива, пригодный для колонки ODS `String`. Строка с JSON внутри потребовала
|
||||
бы экранировать массив при первой сериализации и разбирать его второй раз, не
|
||||
меняя результат в ODS.
|
||||
|
||||
`orjson.dumps` умеет сериализовать вложенные словари и списки напрямую и
|
||||
возвращает JSON в UTF-8
|
||||
([официальный репозиторий orjson](https://github.com/ijl/orjson)). Поэтому
|
||||
KISS-вариант для [существующего модуля сериализации](../../generator/src/clickstream_generator/serialize.py)
|
||||
— подготовить канонические строки времени и денег, положить `items` списком в
|
||||
общий словарь и сделать один внешний `dumps` на запись. Класс кодеков, реестр
|
||||
схем и повторный `dumps` для `items` здесь ничего не учат.
|
||||
|
||||
Граница этого решения: `toDecimal64OrNull(..., 2)` проверяет числовую
|
||||
преобразуемость, но не лексическое правило «ровно два знака» — локально строки
|
||||
`1299.90`, `1299.9` и `1299.900` дали одно значение. Проверка денежного формата
|
||||
не нужна сериализатору этого слепка: он сам выпускает ровно два знака. Приём ODS
|
||||
проверяет ту же каноническую форму и считает остальные формы браком; граница
|
||||
строгого приёма зафиксирована в
|
||||
[спецификации заказов](../architecture/orders/ingestion.md).
|
||||
@@ -190,36 +190,52 @@ Ecommerce (заполнены только у торговых событий):
|
||||
выручка дня D «дышит» K дней, потом замерзает. Боевой аналог окна есть и у
|
||||
трекеров: лог Метрики «доформировывается» ещё около трёх дней.
|
||||
- Запись слепка — состояние заказа на момент выгрузки, «родной» экспорт
|
||||
бэкенда в snake_case:
|
||||
бэкенда в snake_case. Таблица задаёт тип после разбора в ODS:
|
||||
|
||||
| Поле | Тип | Комментарий |
|
||||
| Поле | Тип в ODS | Комментарий |
|
||||
|---|---|---|
|
||||
| `order_id` | String | номер заказа; равен клиентскому `purchaseID` |
|
||||
| `user_id` | UInt64 | пользователь магазина — мост к склейке |
|
||||
| `status` | String | `created` → `paid` → `cancelled` |
|
||||
| `created_at`, `updated_at` | DateTime | |
|
||||
| `status` | String | `created` → `paid` → `cancelled`; неоплаченный отменяется прямым переходом `created` → `cancelled` |
|
||||
| `created_at`, `updated_at` | DateTime64(3, 'UTC') | аудит строки в БД источника: создание и последнее изменение; не бизнес-время покупки и не время загрузки в ClickHouse |
|
||||
| `items_total`, `discount`, `delivery`, `total` | Decimal(18,2) | деньги бэкенда — в Decimal |
|
||||
| `items` | String | позиции вложенным JSON: `[{sku, qty, price}]` |
|
||||
| `snapshot_date` | Date | дата слепка (день выгрузки) |
|
||||
| `items` | String | сырой текст массива позиций `[{sku, qty, price}]`, извлечённый из внешнего JSON |
|
||||
| `snapshot_date` | Date | завершившийся модельный день, состояние которого снято на исходящей границе суток |
|
||||
|
||||
- Приём идемпотентный, но дедуп расщеплён на два слоя:
|
||||
- `ods.order_snapshot` — партиция по `snapshot_date`, **без дедупа**,
|
||||
хранит «как приехало»; идемпотентность повторного прогона — заменой
|
||||
партиции дня слепка, а не ReplacingMergeTree.
|
||||
- Дедуп до последней версии — **argMax** в трансформации при сборке
|
||||
`dds.order`. `dds.order` — единственная дедуплицированная таблица:
|
||||
партиция по дню заказа (`toDate(created_at)`),
|
||||
ReplacingMergeTree(`updated_at`), `ORDER BY order_id` — заказ всегда
|
||||
лежит в одной партиции, дедуп работает.
|
||||
На проводе один заказ — один документ JSON. Деньги, включая `items[].price`,
|
||||
передаются строками с ровно двумя знаками после точки; `created_at` и
|
||||
`updated_at` — строками RFC 3339 в UTC с обязательными миллисекундами
|
||||
(`YYYY-MM-DDTHH:mm:ss.SSSZ`); `snapshot_date` — строкой `YYYY-MM-DD`;
|
||||
`items` — обычным массивом JSON, не строкой с JSON внутри. Порядок внешних
|
||||
ключей совпадает с порядком полей в таблице контракта, у позиции — `sku`,
|
||||
`qty`, `price`: так байты воспроизводимы без сортировки ключей. Разница с
|
||||
кликстримом намеренна: там значения `purchaseRevenue` приезжают JSON-числами
|
||||
и разбираются как `Array(Float64)`, а бэкенд передаёт деньги строками для
|
||||
точного `Decimal`. Это показывает расхождение представлений денег в двух
|
||||
источниках.
|
||||
Запись собирает явная функция существующего канонического сериализатора:
|
||||
один словарь с вложенным списком и один `orjson.dumps`, без универсального
|
||||
слоя кодеков.
|
||||
Обоснование и проверка разбора — в
|
||||
[исследовании формата](../research/2026-08-16-order-snapshot-wire-format.md).
|
||||
|
||||
- `ods.order_snapshot` принимает версии заказа в
|
||||
`ReplacingMergeTree(updated_at)`: `ORDER BY order_id`, партиция по дню
|
||||
неизменного `created_at`, шардирование по `cityHash64(order_id)`.
|
||||
`snapshot_date` остаётся датой наблюдения источника, но не задаёт публикацию
|
||||
или идемпотентность. Физическое чтение может видеть несколько версий;
|
||||
`ods.order_v` возвращает точное текущее состояние. Устройство модели DDS и
|
||||
способ её материализации решаются отдельно.
|
||||
|
||||
Пропущенный день ничего не ломает, следующий слепок самовосстанавливает.
|
||||
- Разбор JSON-позиций — **один раз**, в трансформации ODS → DDS; дальше
|
||||
витрины работают с плоскими массивами `dds.order`: `item_sku`
|
||||
Array(String), `item_qty` Array(UInt64), `item_price` Array(Decimal(18,2))
|
||||
— одной длины, порядок как в JSON. Это единственный носитель навыка
|
||||
«вложенный JSON в ClickHouse» на стенде.
|
||||
- `items` остаётся сырой строкой JSON в ODS. Разбирать позиции следует на
|
||||
границе ODS → DDS, но их представление определяется вместе с будущей моделью
|
||||
заказов. Это остаётся носителем навыка «вложенный JSON в ClickHouse», не
|
||||
превращая приём в преждевременную модель данных.
|
||||
- Статусы держим все три: смена `created` → `paid` и есть причина «дыхания»
|
||||
выручки внутри окна; сужение до двух — резервный срез 1.
|
||||
выручки внутри окна; сужение до двух — резервный срез 1. На выходе из окна
|
||||
заказ либо `paid`, либо `cancelled`: неоплаченного отменяют, «навсегда
|
||||
`created`» не бывает ([судьба заказа](../architecture/orders/fate.md)).
|
||||
|
||||
## 3. Каталог товаров
|
||||
|
||||
@@ -242,9 +258,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
|
||||
| | Расхождение | Механика в генераторе | Ориентир доли |
|
||||
|---|---|---|---|
|
||||
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов |
|
||||
| A | Отмена | заказ дошёл до `cancelled`, `purchase` остался | ~5% заказов — отменённые вообще, обе дороги отмены вместе |
|
||||
| B | Потерянное событие | заказ есть, `purchase` не доехал | ~3% |
|
||||
| C | Дельта суммы | сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — только необъяснённый остаток после этого, и создаёт его генератор намеренно: деньги считаются целыми копейками, поэтому Float64 сам по себе не плывёт | ~1–2% |
|
||||
| C | Дельта суммы | вычеркнутая позиция: товара не оказалось в наличии, у заказа на одну позицию меньше, чем в клиентских массивах. Сверка приведена к сравнимой базе (`items_total`, не `total`); `amount_delta` — остаток, не объяснённый этим приведением, и сравнением позиций он объясняется — на этом стоит урок класса | ~1–2% |
|
||||
| D | Дубль события | повторный `purchase` от обновления `/confirmation`: новый `WatchID` с тем же `purchaseID` — бизнес-дубль, не технический; дедуп ReplacingMergeTree его не съедает и не должен | ~2% |
|
||||
|
||||
Классы пересекаются — приоритет: `cancelled` > `lost_event` >
|
||||
@@ -253,8 +269,8 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
||||
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
||||
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
|
||||
опись хранит точные счётчики по каждому классу расхождений (отмены,
|
||||
потери, дубли).
|
||||
опись хранит счётчики наблюдаемых классов после приоритета — в заказах и
|
||||
только по дням с закрытым окном (раздел 8).
|
||||
|
||||
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
||||
механически дублирует B.
|
||||
@@ -286,8 +302,9 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
|
||||
Константа мира: каждый двухкуковый покупатель делает минимум по одному
|
||||
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
|
||||
(она строится только из покупок) и лаба не воспроизводится. Опись
|
||||
хранит число именно таких пар.
|
||||
(она строится только из покупок) и лаба не воспроизводится. Число таких
|
||||
пар растёт, пока мир едет, и в опись не кладётся
|
||||
([что хранит опись](../architecture/orders/inventory.md)).
|
||||
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
||||
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
||||
|
||||
@@ -307,12 +324,17 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
- Роли нод: нода 1 — инициатор DDL и подключение Airflow; **Superset — на
|
||||
ноду 2**. Это осознанная ловушка правильных ошибок: забытый ON CLUSTER или
|
||||
VIEW поверх локальной таблицы проявляются в дашборде сами.
|
||||
- **Приём Kafka**: Kafka-таблицы и MV — на обеих нодах, одна consumer group,
|
||||
2 партиции на топик; MV пишут в Distributed-цели. Раскладку решает ключ:
|
||||
- **Приём Kafka**: у событий — Kafka-таблицы и MV на обеих нодах, одна
|
||||
consumer group, 2 партиции на топик `hits`; MV пишут в Distributed-цели.
|
||||
У заказов приём другого рода — пакетный забор без MV, чтец на одной ноде,
|
||||
топик `orders` в одну партицию ([ADR 0008](../adr/0008-order-ingestion.md)).
|
||||
Раскладку в обоих случаях решает ключ:
|
||||
события — по `cityHash64(ClientID)` (см. 1.3), заказы —
|
||||
`cityHash64(order_id)`, сырьё STG — `cityHash64(сырой строки)`; полный
|
||||
список и доводы — в [доке хранилища](../architecture/storage.md). Урок:
|
||||
«какая нода читала топик — меняется между прогонами, куда легли данные — нет».
|
||||
список и доводы — в [доке хранилища](../architecture/storage.md). Урок
|
||||
«какая нода читала топик — меняется между прогонами, куда легли данные —
|
||||
нет» живёт на событиях: при пакетном заборе читает та нода, которую
|
||||
спросили.
|
||||
- **Приём строгий**: пять опорных колонок — `WatchID`, `VisitID`, `ClientID`,
|
||||
`EventDate`, `UTCEventTime` — разбираются как `Nullable`, а набор ключей
|
||||
сообщения сверяется с контрактным; строка с NULL среди опорных колонок
|
||||
@@ -337,18 +359,18 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
||||
— только явный GLOBAL; `NOT IN` — только `GLOBAL NOT IN`. Сверка
|
||||
`purchase`↔заказ —
|
||||
легитимная GLOBAL-витрина (заказы малы).
|
||||
- **Конвейер без TRUNCATE**: поток — append-only в ReplacingMergeTree (дедуп
|
||||
через argMax); батчевая переобработка — по дневным партициям
|
||||
- **Конвейер без TRUNCATE**: поток версий — в ReplacingMergeTree, точное чтение
|
||||
— через `FINAL` или равносильный выбор последней версии; батчевая
|
||||
переобработка нижележащих объектов — по дневным партициям
|
||||
(`DROP/REPLACE PARTITION ON CLUSTER`); `TRUNCATE ... ON CLUSTER` в конвейере не
|
||||
применяется вовсе — полный сброс стенда делается `make clean && make up`, то
|
||||
есть вместе с томами. `DROP/REPLACE PARTITION` работает только по
|
||||
**локальным** таблицам ON CLUSTER, не по Distributed; замена через
|
||||
DROP+INSERT неатомарна — дашборд в середине прогона честно моргает (это
|
||||
осознанная цена, не баг).
|
||||
- **Поздние заказы поглощает только ODS** (`ods.order_snapshot` — новая
|
||||
партиция дня слепка, без переделки старого); материализованное ниже —
|
||||
нет. Каждый прогон ETL перестраивает партиции последних K+1 дней у
|
||||
заказозависимых объектов (`dds.order` и производные, `dm.dq_summary`).
|
||||
- **Поздние заказы поглощает ODS** как новую версию `order_id`. Как их
|
||||
подхватывают материализованные объекты DDS и DM, решается вместе с их моделью,
|
||||
а не при проектировании приёма.
|
||||
Сессии перестраиваются только за текущий день: правило мира — сессия
|
||||
режется по границе модельных суток, дневная партиция самодостаточна.
|
||||
- Для ETL-вставок — `distributed_foreground_insert = 1` (раньше называлась
|
||||
@@ -382,13 +404,14 @@ README.
|
||||
|
||||
| Слой | Объект | Что это |
|
||||
|---|---|---|
|
||||
| Kafka | `hits`, `orders` | два топика, по 2 партиции |
|
||||
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV; для orders — развилка этапа 3, не решена (ниже) | сырые строки, Kafka Engine на обеих нодах |
|
||||
| Kafka | `hits`, `orders` | два топика: `hits` — 2 партиции, `orders` — одна |
|
||||
| STG | `stg.hits_raw_kafka`, `stg.hits_raw` + MV | сырые строки событий, Kafka Engine на обеих нодах |
|
||||
| STG | `stg.orders_raw_kafka`, `stg.orders_raw`, без MV | сырые строки слепка; чтец на ноде 1, забирает пакетный шаг |
|
||||
| ODS | `ods.event` (+`_errors`) | типизированное широкое событие, ReplacingMergeTree |
|
||||
| ODS | `ods.order_snapshot` (+`_errors`) | слепки заказов как приехали, партиция по `snapshot_date`, без дедупа |
|
||||
| ODS | `ods.order_snapshot` (+`_errors`), `ods.order_v` | типизированные версии заказов, брак и точное текущее состояние |
|
||||
| DDS | `dds.session` | сборка сессий из событий (наследник `dds.click`) |
|
||||
| DDS | `dds.event_v` | представление над `ods.event`: snake_case-имена, расшифровка кодов `DeviceCategory`; витрины DM читают его, а не ODS напрямую |
|
||||
| DDS | `dds.order` | единственная дедуплицированная таблица заказа: партиция по дню заказа (`toDate(created_at)`), ReplacingMergeTree(`updated_at`), `ORDER BY order_id`, дедуп до последней версии — argMax в трансформации при сборке |
|
||||
| DDS | модель заказов | зерно, связи и материализация проектируются на этапе DDS |
|
||||
| DDS | `dds.identity_map` | карта кука↔пользователь |
|
||||
| DDS | словарь `products` | каталог из CSV |
|
||||
| DM | витрины `dm.*_v`, `dm.dq_summary` | см. ниже |
|
||||
@@ -397,18 +420,23 @@ README.
|
||||
суффиксов; она же задаёт служебные колонки, нарезку и срок хранения сырья —
|
||||
см. [доку хранилища](../architecture/storage.md).
|
||||
|
||||
Как принимаются заказы — развилка этапа 3, и она не решена. Событиям выбран
|
||||
приём сырья байтами с разбором функциями ([ADR 0005](../adr/0005-event-ingestion.md));
|
||||
заказам этот же способ идёт только вместе с ответом на вопрос, нужен ли им слой
|
||||
сырья вообще — у них слепок, а не поток. Нужен — и типизированный чтец даст двух
|
||||
чтецов на один топик, а такую схему ADR 0005 отверг; не нужен — и слои
|
||||
перестают быть единообразными. Разбирать грилингом, когда дойдём до заказов;
|
||||
как учебное сравнение двух способов приёма это записано и в опорных точках
|
||||
раздела 12.
|
||||
Заказы принимаются **пакетным забором** ([ADR
|
||||
0008](../adr/0008-order-ingestion.md), [ADR
|
||||
0010](../adr/0010-order-versions-in-ods.md)): чтец топика байтовый, как у событий, но
|
||||
матвью к нему не привязана, и сырьё забирает шаг, которым управляет Airflow —
|
||||
одно прямое чтение вставляет порцию в `stg.orders_raw` с `_load_id` запуска.
|
||||
Один следующий `task_id` двумя последовательными запросами пишет годные строки
|
||||
и ошибки из того же среза. `ods.order_snapshot` хранит версии по `updated_at`,
|
||||
а не публикует партицию `snapshot_date`. Слой сырья остаётся точкой повтора и
|
||||
разбора одной принятой порции.
|
||||
|
||||
Стенд получает от этого два режима приёма рядом, поток и слепок, и сравнение
|
||||
из опорных точек раздела 12 переформулировано под них.
|
||||
|
||||
Состав служебных колонок задаёт дока хранилища. Спеке важны два следствия:
|
||||
`ods.event` и `ods.order_snapshot` получают метку загрузки `_load_ts`, и у
|
||||
`ods.event` она же служит колонкой версии ReplacingMergeTree; а таблицы
|
||||
`ods.event` и `ods.order_snapshot` получают метку загрузки `_load_ts`, но у
|
||||
заказа бизнес-версию задаёт `updated_at`; `_load_id` проходит через STG и обе
|
||||
цели ODS. Таблицы
|
||||
`stg.*_raw` хранят метаданные доставки Kafka вместе с именем читавшей ноды —
|
||||
без них урок «какая нода читала топик» ненаблюдаем.
|
||||
Модельного дня в STG нет: `EventDate` — свойство содержимого, а содержимое
|
||||
@@ -437,8 +465,9 @@ Kafka день переигрывается генератором заново:
|
||||
`total`: промокод и доставка клиенту не видны), `status`, `mismatch_class`
|
||||
(`match` / `cancelled` / `lost_event` / `duplicate_event` / `amount_delta`,
|
||||
в порядке приоритета — классы пересекаются, побеждает более ранний).
|
||||
`match` — большинство строк; `amount_delta` — только необъяснённый остаток
|
||||
после приведения к сравнимой базе; его создаёт генератор намеренно
|
||||
`match` — большинство строк; `amount_delta` — остаток, не объяснённый
|
||||
приведением к сравнимой базе: сравнением позиций он объясняется, и на этом
|
||||
стоит урок класса C; создаёт его генератор намеренно
|
||||
(~1–2% заказов, см. раздел 4).
|
||||
Строка «`purchase` без заказа» внутри живого окна — опоздание, ждущее
|
||||
слепка, а не расхождение: она получает служебный класс `awaiting_order`
|
||||
@@ -476,11 +505,15 @@ Kafka день переигрывается генератором заново:
|
||||
карты #10) закрыта этим же ходом: версионируется опись.
|
||||
Контрольные числа описи:
|
||||
|
||||
- заказная сторона: заказы и выручка по дням; опись хранит точные
|
||||
счётчики по каждому классу расхождений (отмены, потери, дубли, дельты сумм) —
|
||||
самопроверка лабы сверки;
|
||||
- идентичность: uniq кук, uniq известных пользователей, число двухкуковых
|
||||
покупателей — лаба склейки получает самопроверку.
|
||||
- по строке на каждый отправленный слепок — хеш байтов: побайтовое обещание
|
||||
«слепок переснимается и даёт те же байты» опись сторожит наравне с днями;
|
||||
- заказная сторона: заказы, выручка и счётчики наблюдаемых классов
|
||||
расхождений после приоритета, в заказах — по дням с закрытым окном
|
||||
(самопроверка лабы сверки; при N сыгранных днях таких дней N − 7);
|
||||
- контрольные числа идентичности не заводятся: uniq известных пользователей
|
||||
и число двухкуковых покупателей растут, пока мир едет, а счётчик без
|
||||
названного читателя в опись не кладётся
|
||||
([что хранит опись](../architecture/orders/inventory.md)).
|
||||
|
||||
Снимок вырастет против v1 (ecommerce-массивы, заказы) — размер проверить
|
||||
при пересборке.
|
||||
@@ -495,7 +528,7 @@ v2 стартует пустым, поэтому объём ниже — это
|
||||
| Генератор | с нуля: модель v1 не переносится (другая модель данных, плюс известные проблемы производительности v1); широкое событие, таксономия, анонимность, N:1, заказы слепками, расхождения A–D, каталог; масштаб — ~4–5 тыс. строк с тестами | L |
|
||||
| Инфраструктура | compose: 2 ноды CH + keeper + остальной стенд; конфиги кластера, макросы; make/скрипты | M — ~10–12 файлов |
|
||||
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
|
||||
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка, ожидание дневного батча заказов — сенсор/Datasets), world_init/next_day, helpers | M — ~5–6 файлов |
|
||||
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка), world_init/next_day, helpers; первый даг — переливка слепка — приходит раньше, этапом 3 | M — ~5–6 файлов |
|
||||
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
|
||||
| Эталонный мир | пересборка снимка на месте, счётчики описи, чек-скрипты | M–L |
|
||||
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
|
||||
@@ -537,10 +570,13 @@ v2 стартует пустым, поэтому объём ниже — это
|
||||
целиком). В конце этапа фиксируется маленький стартовый мир для
|
||||
стабильных приёмок следующих этапов (полная пересборка эталонного мира —
|
||||
отдельный этап 7).
|
||||
3. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь.
|
||||
4. Трансформации и витрины: сессии, identity_map, выручка, сверка A+C.
|
||||
5. Airflow: `etl_pipeline` (партиционная переобработка, ожидание дневного
|
||||
батча заказов — сенсор/Datasets).
|
||||
3. Заказы и каталог: генератор слепков, STG/ODS заказа, словарь и даг приёма
|
||||
слепка ([ADR 0008](../adr/0008-order-ingestion.md)). Модель заказа в DDS
|
||||
уехала этапу 4 — у служебной колонки и зерна читатель появляется там
|
||||
(нарезка этапа, #89).
|
||||
4. Трансформации и витрины: модель DDS заказа, сессии, identity_map,
|
||||
выручка, сверка A+C.
|
||||
5. Airflow: `etl_pipeline` (партиционная переобработка).
|
||||
6. Расхождения B+D и опоздания; счётчики описи.
|
||||
7. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация
|
||||
на amd64 и arm64.
|
||||
@@ -567,6 +603,8 @@ v2 стартует пустым, поэтому объём ниже — это
|
||||
стенд работает, но учебного пути на нём ещё нет.
|
||||
- Инкрементальный ETL (#8) — свой issue.
|
||||
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
|
||||
- TLS между нодами, настоящее хранилище и ротация секретов, внешние учётные
|
||||
записи, квоты, политики строк и аудит запросов — за границей учебного стенда.
|
||||
- Полный словарь торговых событий Метрики (detail, remove, impressions),
|
||||
пять уровней категорий, блоки `purchasedProduct*`/`impressions*`.
|
||||
- Механика `Sign`/CollapsingMergeTree — кандидат на потом (дом — поток
|
||||
@@ -603,9 +641,15 @@ Kafka Engine на двух нодах снят с этого списка при
|
||||
- Размер артефакта эталонного мира после пересборки.
|
||||
- Спорные API (Airflow Datasets/сенсоры, ClickHouse DDL) — перед кодом
|
||||
сверять через MCP Context7 (правило AGENTS.md).
|
||||
- Airflow 3.x: версия фиксируется на этапе 1 (каркас); DAG'и этапа 5 пишутся
|
||||
под API третьей версии (Datasets → Assets) — актуальные операторы и сенсоры
|
||||
проверить через Context7.
|
||||
- Airflow 3.x: версия фиксируется на этапе 1 (каркас); DAG'и пишутся под API
|
||||
третьей версии (Datasets → Assets) — актуальные операторы проверить через
|
||||
Context7. Первый даг приходит этапом 3, а не 5 ([ADR
|
||||
0008](../adr/0008-order-ingestion.md)).
|
||||
- Пакетный забор слепка из Kafka: прямое чтение коммитит офсеты и возвращает
|
||||
одну порцию. На стандартном мире около полутора тысяч заказов помещаются в
|
||||
неё с запасом; это проверяемое допущение стенда, а не граница полноты слепка
|
||||
([ADR 0008](../adr/0008-order-ingestion.md), [ADR
|
||||
0010](../adr/0010-order-versions-in-ods.md)).
|
||||
- Генератор: рабочее решение — Python с производительной архитектурой
|
||||
(батчевая генерация вместо посточной, быстрая JSON-сериализация,
|
||||
распараллеливание по модельным дням). Читаемость генератора для менти —
|
||||
@@ -653,18 +697,18 @@ v2, этап 0).
|
||||
синтетическая постановка — осознанный приём;
|
||||
- лекция «`Sign` и CollapsingMergeTree»: почему на стенде `sum(Sign)` =
|
||||
`count()`, а в бою — нет; частый вопрос на собеседованиях;
|
||||
- два способа принять топик, рядом на одном стенде: сырьё байтами с разбором
|
||||
функциями (`hits`, [ADR 0005](../adr/0005-event-ingestion.md)) против
|
||||
типизированного чтеца с `kafka_handle_error_mode` — сравнение цены и
|
||||
наблюдаемости как задание. **Развилка этапа 3, не решена**: типизированный
|
||||
чтец идёт заказам только вместе с ответом на вопрос, нужен ли им слой сырья.
|
||||
Нужен — и чтецов на один топик станет два, а эту схему ADR 0005 отверг; не
|
||||
нужен — и слои перестают быть единообразными. Разбирать грилингом, когда
|
||||
дойдём до заказов;
|
||||
- матвью как рабочий механизм, а не диковина: их видно на приёме и на сборке
|
||||
ODS, а пакетная работа начинается выше. Отдельным заданием — как читать из ODS
|
||||
последние версии, через `FINAL` или оконной функцией: что нагляднее, решаем на
|
||||
месте;
|
||||
- два способа принять топик, рядом на одном стенде: **поток против слепка,
|
||||
push против pull**. События тянет матвью на лету ([ADR
|
||||
0005](../adr/0005-event-ingestion.md)), слепок заказов забирает пакетный шаг
|
||||
по команде дага ([ADR 0008](../adr/0008-order-ingestion.md)); чтец в обоих
|
||||
случаях байтовый, так что различает их ровно режим. Сравнение цены,
|
||||
наблюдаемости и того, чем платит каждый режим, — как задание. Третий способ,
|
||||
типизированный чтец с `kafka_handle_error_mode`, на стенде не живёт: он
|
||||
отвергнут обоими ADR, и остаётся материалом для рассказа;
|
||||
- матвью как рабочий механизм, а не диковина: события проходят из STG в ODS на
|
||||
лету, заказы — пакетным заданием. `ods.order_v` показывает границу между
|
||||
физическими версиями и точным текущим состоянием; сравнение `FINAL` с
|
||||
альтернативными способами чтения остаётся материалом задания;
|
||||
- лекция про идентичность «как в бою»: `setUserID` и first-party id,
|
||||
детерминированная против вероятностной склейки, identity graph,
|
||||
кросс-девайс, CDP — с рамкой «мы склеили через транзакции, потому что трекер
|
||||
|
||||
@@ -25,6 +25,32 @@
|
||||
касается этапа 7 — расширение подтверждено владельцем в резолюции
|
||||
«Производительности».
|
||||
|
||||
## Чем меряется генератор
|
||||
|
||||
Рамка владельца, по которой принимались решения ниже. Она шире этапа 2 и
|
||||
живёт дальше него: спеки следующих этапов ссылаются сюда, а не переписывают.
|
||||
|
||||
- **Генератор меряется тем, что приезжает на выход, а не устройством
|
||||
внутри.** Он играет два источника с разными темпами: поток Метрики и
|
||||
ночную выгрузку бэкенда магазина. Любое внутреннее решение отвечает перед
|
||||
одним вопросом — правдоподобны ли данные на выходе.
|
||||
- **Менти внутрь не смотрит.** Он видит внешние эффекты и по ним составляет
|
||||
представление, как работает магазин и обо что там спотыкаются. Значит,
|
||||
конструкция внутри оправдана только наблюдаемым эффектом: без него это
|
||||
сложность, за которую никто не заплатил.
|
||||
- **Повторимы должны быть эффекты, а не конкретные числа.** Правка свойств
|
||||
мира сдвигает почти все числа разом — генератор случайных чисел выдаёт
|
||||
другую последовательность. Опираться на конкретные числа поэтому нечем.
|
||||
Хеши описи обещают не постоянство чисел, а то, что их сдвиг не пройдёт
|
||||
молча: пересобрал опись — увидел diff.
|
||||
- **Цена ошибки здесь мала.** В худшем случае испорчен урок, и чинится он
|
||||
правкой генератора после взгляда на получившиеся распределения. Оборона,
|
||||
которая дороже ошибки, не заводится.
|
||||
- **Сложность изолирована нарочно.** Генератор вынесен отдельной службой,
|
||||
чтобы его сложность не расползалась по стенду. Плата за изоляцию —
|
||||
дешевизна правки: выбирается устройство, которое дёшево менять, а не то,
|
||||
которое всё предусмотрело.
|
||||
|
||||
## Целевая картина одним взглядом
|
||||
|
||||
- **Мир — функция, не состояние.** Состав мира — чистая функция зерна;
|
||||
@@ -93,9 +119,10 @@
|
||||
человеку предыстории, чьё окно активности таких дней не оставляет,
|
||||
пара не назначается. День-функция обязана назначенные заказы
|
||||
реализовать; остальные покупки — вольные, их решает генератор торговых
|
||||
событий (#40). Опись считает пары, реализованные в горизонте
|
||||
снимка. Условность в данных не видна: дни назначены той же
|
||||
случайностью, просто брошенной планом один раз.
|
||||
событий (#40). План считает пары, реализованные в запрошенном горизонте;
|
||||
опись это число не хранит — оно растёт вместе с миром. Условность в данных
|
||||
не видна: дни назначены той же случайностью, просто брошенной планом один
|
||||
раз.
|
||||
- **Своя ось модельного времени.** Ось событий начинается в
|
||||
фиксированный день D0 (понедельник — см. раздел 5); реальный
|
||||
календарь в модели не участвует. В `EventDate`/`UTCEventTime` дни оси
|
||||
@@ -105,7 +132,8 @@
|
||||
- **Два режима движения по одной оси.** Пошаговый — базовый для лаб: старт
|
||||
с эталонного снимка, дальше «прожить следующий день» — явное действие.
|
||||
Живой день — текущий день проигрывается с ускорением, дашборд и мониторинг
|
||||
«дышат»; включается по требованию, не постоянный фон.
|
||||
«дышат»; включается по требованию — постоянным фоном идёт, только пока
|
||||
включён выключатель пульта ([ADR 0009](../adr/0009-world-control.md)).
|
||||
- **Граница суток — единственный структурный шов.** Сессии режутся по ней,
|
||||
дневная партиция самодостаточна; в конце модельного дня — слепок заказов.
|
||||
День проживается целиком, полдня не бывает: недожитый из-за обрыва день
|
||||
@@ -170,7 +198,11 @@
|
||||
целое, поэтому у предыстории своя ветвь состава, отдельная от оси
|
||||
(уточнение при исполнении #38, 2026-08-02);
|
||||
(зерно, день) → подпоток дня → именованные подпотоки компонентов: трафик,
|
||||
торговые события, расхождения, опоздания — в фиксированном порядке.
|
||||
торговые события, заказная и событийная стороны (судьба заказов и порча
|
||||
событийного потока) — в фиксированном порядке (уточнение [судьбой
|
||||
заказа](../architecture/orders/fate.md): прежние «расхождения» и
|
||||
«опоздания» были названы по классам витрины, а компонент называет часть
|
||||
мира).
|
||||
По построению: параллельный прогон равен последовательному; продление
|
||||
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
|
||||
только его часть снимка — в описи меняются хеши только затронутых
|
||||
@@ -445,10 +477,12 @@
|
||||
теперь есть.
|
||||
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
|
||||
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
||||
- **Этап 5, позиция на оси времени.** Проигрыватель состояния не хранит
|
||||
(раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт она
|
||||
переменной Airflow: отсутствие переменной означает мир в стартовом
|
||||
состоянии, заводит и двигает её только даг `next_day` и только по успеху.
|
||||
- **Позиция на оси времени** (хвост отдан пульту мира раньше этапа 5 —
|
||||
[ADR 0009](../adr/0009-world-control.md))**.** Проигрыватель состояния не
|
||||
хранит (раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт
|
||||
она переменной Airflow: отсутствие переменной означает мир в стартовом
|
||||
состоянии, а ставит её тот даг пульта, который сыграл день, — значением
|
||||
«последний сыгранный + 1» и только по успеху.
|
||||
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
||||
хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с
|
||||
данными ClickHouse, так что позиция и мир чистятся одной командой.
|
||||
@@ -903,7 +937,8 @@
|
||||
`KubernetesPodOperator`. Побочно это единственный вариант, при котором
|
||||
«генератор — отдельная и заменяемая сущность» перестаёт быть словами: его
|
||||
зависимости не смешиваются с окружением Airflow. Хостовый `uv` остаётся для
|
||||
`make test`, `make lint` и `make typecheck`. Цена названа и принимается:
|
||||
целей генератора — `make test`, `make lint` и `make typecheck` из каталога
|
||||
`generator/`. Цена названа и принимается:
|
||||
чтобы даг запускал контейнер, в Airflow пробрасывается сокет докера, а это
|
||||
доступ, равный root на хосте; на учебном стенде размен допустим, но
|
||||
записывается уроком — в бою так не делают. Отклонено: *генератор в образе
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.PHONY: lint typecheck test docs inventory
|
||||
|
||||
# --- Проверки ---
|
||||
|
||||
lint:
|
||||
uv run ruff check
|
||||
uv run ruff format --check
|
||||
|
||||
typecheck:
|
||||
uv run ty check
|
||||
|
||||
test:
|
||||
uv run pytest
|
||||
|
||||
# --- Сборка из кода ---
|
||||
|
||||
docs:
|
||||
uv run python -m clickstream_generator.schema_doc \
|
||||
../docs/formats/clickstream-event.md
|
||||
|
||||
inventory:
|
||||
uv run python -m clickstream_generator.inventory \
|
||||
../data/world-inventory.json
|
||||
+7
-2
@@ -92,12 +92,17 @@ D0 живёт предыстория, поэтому любой день соб
|
||||
|
||||
Проиграть день — [быстрый старт](../README.md#как-позвать-генератор) и `--help`.
|
||||
|
||||
Из корня репозитория:
|
||||
Из этого каталога — цели генератора живут здесь, а не в корне:
|
||||
|
||||
- `make test` — тесты генератора;
|
||||
- `make lint` — ruff: проверка и формат;
|
||||
- `make typecheck` — ty: проверка типов;
|
||||
- `make docs` — пересобрать описание выгрузки.
|
||||
- `make docs` — пересобрать описание выгрузки;
|
||||
- `make inventory` — пересобрать опись мира.
|
||||
|
||||
В корне репозитория `make lint` тоже есть, но он про код стенда — даги и
|
||||
Superset. Одноимённые цели за разными дверями охватывают разное:
|
||||
[карта проверок](../docs/architecture/testing.md).
|
||||
|
||||
Python и зависимости — через `uv`, версии закреплены в `uv.lock`: на этом
|
||||
держится обещание побайтовой воспроизводимости (спека, раздел 2).
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
пересчитывается он и обычным `sha256sum` по сыгранному в файл дню (как
|
||||
именно — в README репозитория).
|
||||
|
||||
Собирается опись из корня репозитория целью `make inventory`, а свежесть её
|
||||
Собирается опись из каталога генератора целью `make inventory`, а свежесть её
|
||||
сторожит тест — как и у «описания выгрузки».
|
||||
"""
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
содержание берётся из контракта (`schema`), правится только там; свежесть
|
||||
документа сторожит тест.
|
||||
|
||||
Запуск — из корня репозитория целью `make docs`.
|
||||
Запуск — из каталога генератора целью `make docs`.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -18,7 +18,7 @@ PREAMBLE = """# Описание выгрузки: событие кликстр
|
||||
|
||||
Документ собран из контракта схемы генератора
|
||||
(`generator/src/clickstream_generator/schema.py`). Руками не править —
|
||||
пересобрать: `make docs`.
|
||||
пересобрать: `make docs` из каталога `generator/`.
|
||||
|
||||
Одно событие — одна строка: хит по образцу облачной выгрузки Яндекс Метрики.
|
||||
Многозначное лежит в параллельных массивах, плюс одно сырое JSON-поле
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
ARG AIRFLOW_BASE_IMAGE
|
||||
FROM ${AIRFLOW_BASE_IMAGE}
|
||||
FROM apache/airflow:3.3.0
|
||||
|
||||
USER root
|
||||
|
||||
# Официальный провайдер Kafka использует тот же confluent-kafka. Пробникам
|
||||
# не нужны его подключения и обёртки, поэтому в образ добавлен сам клиент.
|
||||
#
|
||||
# Провайдера docker, которым пульт мира зовёт генератор, ставить не нужно:
|
||||
# база несёт его сама — 4.5.7 к Airflow 3.3.0, замер 13 августа 2026 года.
|
||||
RUN uv pip install --python /home/airflow/.local/bin/python --no-cache \
|
||||
"clickhouse-connect==1.6.0" \
|
||||
"confluent-kafka==2.15.0" \
|
||||
|
||||
@@ -26,6 +26,7 @@ runuser -u airflow -- airflow connections add clickhouse_default \
|
||||
--conn-type generic \
|
||||
--conn-host clickhouse-01 \
|
||||
--conn-port 8123 \
|
||||
--conn-login default \
|
||||
--conn-login etl \
|
||||
--conn-password "$CLICKHOUSE_ETL_PASSWORD" \
|
||||
--conn-schema default \
|
||||
--conn-description "ClickHouse, нода 1; типизированный провайдер появится на этапе ETL"
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
-->
|
||||
<remote_servers>
|
||||
<clickstream_cluster>
|
||||
<!--
|
||||
ClickHouse 26.3 передаёт с секретом пользователя-инициатора на
|
||||
соседний шард; секрет сверяется на обеих нодах.
|
||||
-->
|
||||
<secret from_env="CLICKHOUSE_CLUSTER_SECRET"/>
|
||||
<shard>
|
||||
<internal_replication>true</internal_replication>
|
||||
<replica>
|
||||
@@ -33,4 +38,3 @@
|
||||
<path>/clickhouse/task_queue/ddl</path>
|
||||
</distributed_ddl>
|
||||
</clickhouse>
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<clickhouse>
|
||||
<!--
|
||||
Пользователи и роли объявлены файлом: повторный подъём стенда не хранит
|
||||
доступ в томах. Пароли приходят из .env через Compose.
|
||||
-->
|
||||
<roles>
|
||||
<etl_writer>
|
||||
<grants>
|
||||
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW ON default.*</query>
|
||||
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON stg.*</query>
|
||||
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON ods.*</query>
|
||||
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON dds.*</query>
|
||||
<query>GRANT SELECT, INSERT, ALTER, CREATE TABLE, CREATE VIEW, DROP TABLE, DROP VIEW, CREATE DATABASE ON dm.*</query>
|
||||
<query>GRANT SELECT ON system.*</query>
|
||||
<query>GRANT CLUSTER ON *.*</query>
|
||||
<query>GRANT REMOTE ON *.*</query>
|
||||
<!-- В 26.3 движок Kafka требует право на внешний источник. -->
|
||||
<query>GRANT KAFKA ON *.*</query>
|
||||
</grants>
|
||||
</etl_writer>
|
||||
<!-- Оба читателя: clickhouse-connect читает system.settings при подключении. -->
|
||||
<bi_reader>
|
||||
<grants>
|
||||
<query>GRANT SELECT ON dds.*</query>
|
||||
<query>GRANT SELECT ON dm.*</query>
|
||||
<query>GRANT SELECT ON system.settings</query>
|
||||
</grants>
|
||||
</bi_reader>
|
||||
<analyst_reader>
|
||||
<grants>
|
||||
<query>GRANT SELECT ON stg.*</query>
|
||||
<query>GRANT SELECT ON ods.*</query>
|
||||
<query>GRANT SELECT ON dds.*</query>
|
||||
<query>GRANT SELECT ON dm.*</query>
|
||||
<query>GRANT SELECT ON system.settings</query>
|
||||
</grants>
|
||||
</analyst_reader>
|
||||
</roles>
|
||||
|
||||
<users>
|
||||
<default>
|
||||
<password from_env="CLICKHOUSE_DEFAULT_PASSWORD"/>
|
||||
</default>
|
||||
<etl>
|
||||
<password from_env="CLICKHOUSE_ETL_PASSWORD"/>
|
||||
<quota>default</quota>
|
||||
<grants>
|
||||
<query>GRANT etl_writer</query>
|
||||
</grants>
|
||||
</etl>
|
||||
<bi>
|
||||
<password from_env="CLICKHOUSE_BI_PASSWORD"/>
|
||||
<quota>default</quota>
|
||||
<grants>
|
||||
<query>GRANT bi_reader</query>
|
||||
</grants>
|
||||
</bi>
|
||||
<analyst>
|
||||
<password from_env="CLICKHOUSE_ANALYST_PASSWORD"/>
|
||||
<quota>default</quota>
|
||||
<grants>
|
||||
<query>GRANT analyst_reader</query>
|
||||
</grants>
|
||||
</analyst>
|
||||
</users>
|
||||
</clickhouse>
|
||||
@@ -1,5 +1,4 @@
|
||||
ARG SUPERSET_BASE_IMAGE
|
||||
FROM ${SUPERSET_BASE_IMAGE}
|
||||
FROM apache/superset:6.1.0
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
database_name: ClickHouse
|
||||
sqlalchemy_uri: clickhousedb://default@clickhouse-02:8123/default
|
||||
sqlalchemy_uri: clickhousedb://bi@clickhouse-02:8123/default
|
||||
uuid: 4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d
|
||||
cache_timeout: null
|
||||
expose_in_sqllab: true
|
||||
|
||||
@@ -5,6 +5,15 @@ import os
|
||||
SQLALCHEMY_DATABASE_URI = os.environ["SUPERSET__SQLALCHEMY_DATABASE_URI"]
|
||||
SECRET_KEY = os.environ["SUPERSET_SECRET_KEY"]
|
||||
|
||||
|
||||
# Источник данных пока один, поэтому штатному хранилищу паролей Superset
|
||||
# достаточно вернуть один секрет из окружения.
|
||||
def clickhouse_password(_url):
|
||||
return os.environ["CLICKHOUSE_BI_PASSWORD"]
|
||||
|
||||
|
||||
SQLALCHEMY_CUSTOM_PASSWORD_STORE = clickhouse_password
|
||||
|
||||
SESSION_COOKIE_NAME = "superset_session"
|
||||
SESSION_COOKIE_SECURE = False
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Настройка ruff для кода стенда — дагов и Superset. У генератора своя,
|
||||
# в `generator/pyproject.toml`: конфиг ruff ищется вверх по дереву от файла,
|
||||
# поэтому ближний к генератору выигрывает и этот его не подменяет.
|
||||
#
|
||||
# Отдельный файл, а не `pyproject.toml`: пакета в корне нет и заводить его
|
||||
# ради настройки линтера незачем.
|
||||
|
||||
# На каком Python побежит код, ruff сам не знает: у генератора он берёт ответ
|
||||
# из `requires-python`, а у стенда спрашивать некого — даги и Superset живут
|
||||
# внутри образов. Стоит версия дагов: их читают и правят, и идиомы им нужны
|
||||
# те, на которых они работают. Замер 9 августа 2026 года:
|
||||
# `apache/airflow:3.3.0` несёт Python 3.13.14, `apache/superset:6.1.0` — 3.10.20.
|
||||
# Настройке Superset от этого пока ни холодно ни жарко: модернизировать в ней
|
||||
# нечего. Предложит ruff что-то не по её возрасту — заведём исключение тогда,
|
||||
# с настоящим поводом.
|
||||
target-version = "py313"
|
||||
|
||||
[lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
@@ -18,7 +18,12 @@ compose() {
|
||||
query() {
|
||||
local service="$1"
|
||||
local sql="$2"
|
||||
compose exec -T "$service" clickhouse-client --query "$sql" </dev/null
|
||||
compose exec -T "$service" sh -c '
|
||||
clickhouse-client \
|
||||
--user default \
|
||||
--password "$CLICKHOUSE_DEFAULT_PASSWORD" \
|
||||
--query "$1"
|
||||
' _ "$sql" </dev/null
|
||||
}
|
||||
|
||||
query_with_timeout() {
|
||||
@@ -27,7 +32,12 @@ query_with_timeout() {
|
||||
local sql="$3"
|
||||
timeout --foreground "${timeout_seconds}s" \
|
||||
docker compose --project-directory "$ROOT_DIR" exec -T "$service" \
|
||||
clickhouse-client --query "$sql"
|
||||
sh -c '
|
||||
clickhouse-client \
|
||||
--user default \
|
||||
--password "$CLICKHOUSE_DEFAULT_PASSWORD" \
|
||||
--query "$1"
|
||||
' _ "$sql"
|
||||
}
|
||||
|
||||
fail() {
|
||||
|
||||
+11
-7
@@ -11,23 +11,27 @@ cleanup() {
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
"${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" config --quiet
|
||||
config_json="$("${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" config --format json)"
|
||||
"${COMPOSE_CMD[@]}" \
|
||||
--project-directory "$ROOT_DIR" \
|
||||
--env-file "$ROOT_DIR/.env.example" \
|
||||
config --quiet
|
||||
config_json="$(
|
||||
"${COMPOSE_CMD[@]}" \
|
||||
--project-directory "$ROOT_DIR" \
|
||||
--env-file "$ROOT_DIR/.env.example" \
|
||||
config --format json
|
||||
)"
|
||||
# Контекст сборки образов — корень репозитория, и локальный `.env` с настоящими
|
||||
# паролями уехал бы в слой образа молча. Это единственная здешняя ошибка, о
|
||||
# которой никто не узнает, пока образ не окажется у чужого.
|
||||
for private_path in '.env' '.env.*' '*.pem' '*.key' '*.crt' 'secrets/' 'credentials/'; do
|
||||
grep -qxF "$private_path" "$ROOT_DIR/.dockerignore"
|
||||
done
|
||||
grep -qx 'ARG SUPERSET_BASE_IMAGE' "$ROOT_DIR/infra/superset/Dockerfile"
|
||||
jq -e '
|
||||
.services.superset.image == "clickstream-superset:local" and
|
||||
(.services.superset.build.args.SUPERSET_BASE_IMAGE | length > 0)
|
||||
.services.superset.image == "clickstream-superset:local"
|
||||
' >/dev/null <<<"$config_json"
|
||||
grep -qx 'ARG AIRFLOW_BASE_IMAGE' "$ROOT_DIR/infra/airflow/Dockerfile"
|
||||
jq -e '
|
||||
.services["airflow-init"].image == "clickstream-airflow:local" and
|
||||
(.services["airflow-init"].build.args.AIRFLOW_BASE_IMAGE | length > 0) and
|
||||
all(
|
||||
.services[];
|
||||
((.environment // {}) | has("_PIP_ADDITIONAL_REQUIREMENTS") | not)
|
||||
|
||||
@@ -237,6 +237,7 @@ check_airflow_probes() {
|
||||
}
|
||||
|
||||
check_superset() {
|
||||
local clickhouse_password
|
||||
local config
|
||||
local login
|
||||
local metadata_tables
|
||||
@@ -253,6 +254,7 @@ check_superset() {
|
||||
config="$(compose config --format json 2>/dev/null || true)"
|
||||
user="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_USER // empty' <<<"$config")"
|
||||
password="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_PASSWORD // empty' <<<"$config")"
|
||||
clickhouse_password="$(jq -r '.services.superset.environment.CLICKHOUSE_BI_PASSWORD // empty' <<<"$config")"
|
||||
superset_user="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_USER // empty' <<<"$config")"
|
||||
superset_password="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_PASSWORD // empty' <<<"$config")"
|
||||
port="$(published_port superset 8088)"
|
||||
@@ -284,10 +286,10 @@ check_superset() {
|
||||
if [[ "$metadata_tables" == '1' ]] && \
|
||||
[[ "$metadata_engine" == "postgresql|${superset_user}|postgres-metadata|5432|superset" ]] && \
|
||||
[[ "$stored_uuid" == '4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d' ]] && \
|
||||
[[ "$stored_uri" == 'clickhousedb://default@clickhouse-02:8123/default' ]]; then
|
||||
[[ "$stored_uri" == "clickhousedb://bi:${clickhouse_password}@clickhouse-02:8123/default" ]]; then
|
||||
pass 'метаданные Superset живут в Postgres, подготовленное подключение указывает на clickhouse-02'
|
||||
else
|
||||
fail "Superset не подтвердил Postgres и подготовленное подключение к ноде 2: таблицы=${metadata_tables:-нет}, движок=${metadata_engine:-нет}, UUID=${stored_uuid:-нет}, URI=${stored_uri:-нет}"
|
||||
fail "Superset не подтвердил Postgres и подготовленное подключение bi к ноде 2: таблицы=${metadata_tables:-нет}, движок=${metadata_engine:-нет}, UUID=${stored_uuid:-нет}"
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ check_grafana_datasource() {
|
||||
check_airflow() {
|
||||
local airflow_port
|
||||
local airflow_token
|
||||
local clickhouse_password
|
||||
local config
|
||||
local connection
|
||||
local dag
|
||||
@@ -166,6 +167,7 @@ check_airflow() {
|
||||
config="$(compose config --format json 2>/dev/null || true)"
|
||||
user="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_USER // empty' <<<"$config")"
|
||||
password="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_PASSWORD // empty' <<<"$config")"
|
||||
clickhouse_password="$(jq -r '.services["airflow-scheduler"].environment.CLICKHOUSE_ETL_PASSWORD // empty' <<<"$config")"
|
||||
airflow_port="$(published_port airflow-apiserver 8080)"
|
||||
|
||||
health="$(curl -sf --max-time 10 \
|
||||
@@ -211,12 +213,13 @@ check_airflow() {
|
||||
.connection_id == "clickhouse_default" and
|
||||
.host == "clickhouse-01" and
|
||||
.port == 8123 and
|
||||
.login == "default" and
|
||||
.login == "etl" and
|
||||
.schema == "default"
|
||||
' >/dev/null 2>&1 <<<"$connection" && \
|
||||
timeout 20s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \
|
||||
exec -T airflow-scheduler \
|
||||
curl -sf 'http://clickhouse-01:8123/?query=SELECT%201' \
|
||||
curl -sf -u "etl:${clickhouse_password}" \
|
||||
'http://clickhouse-01:8123/?query=SELECT%201' \
|
||||
2>/dev/null | grep -qx '1'; then
|
||||
pass 'подготовленное подключение Airflow указывает на clickhouse-01, нода доступна из контейнера'
|
||||
else
|
||||
|
||||
@@ -42,7 +42,12 @@ fail() {
|
||||
|
||||
query() {
|
||||
"${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" exec -T clickhouse-01 \
|
||||
clickhouse-client --query "$1" </dev/null
|
||||
sh -c '
|
||||
clickhouse-client \
|
||||
--user default \
|
||||
--password "$CLICKHOUSE_DEFAULT_PASSWORD" \
|
||||
--query "$1"
|
||||
' _ "$1" </dev/null
|
||||
}
|
||||
|
||||
expected="$(jq '[.days[].events] | add' "$INVENTORY")"
|
||||
|
||||
Reference in New Issue
Block a user