feat(stand): make up наполняет стенд стартовым миром, опись сторожит его
Зачем Стенд поднимался пустым, и всякая приёмка следующих этапов начиналась с ручной заливки данных. Теперь `make up` сам приводит стенд к одному и тому же состоянию, а в git лежит то, чем это состояние проверяется. Что - Опись мира `data/world-inventory.json`: паспорт (зерно, версия генератора, хеш каталога) и по строке на каждый из восьми дней — дата, число событий, хеш байтов. Собирается `make inventory`, свежесть сторожит `test_inventory.py` — тем же способом, что свежесть описания выгрузки. - Разовая служба `world-init` вышла из-под профиля и играет в топик восемь дней при каждом подъёме; зависимый у неё — `airflow-init`, иначе `--wait` считает успешно отработавшую службу упавшей. - `scripts/wait-for-world.sh` — вторая половина `make up`: приём асинхронный, поэтому ждать надо доезда до `ods.event`, а не завершения заливки. Ограниченный цикл опроса, не пауза наугад. - Девятая проверка `make check-clickhouse`: подневный счёт событий против описи, рамка по датам стартового мира, счёт через `FINAL`. При расхождении называет, где искать, — в событиях или в браке. - Порог «день ≤ 30 с» снят из спеки генератора в обоих местах: замер дал 1,7 с, порог был выше факта в восемнадцать раз. На его месте — замеры с датой. Раздел 9 спеки закрыт: открытых вопросов не осталось. - Слова: «манифест» стал описью мира, «зерновой мир» — стартовым миром (решение владельца). Оба заведены в словарь CONTEXT.md. Проверка `make clean && make up` с нуля — 2 м 50 с, доехало ровно 401 185 событий. `make check-clickhouse` зелёный (8 с), `make smoke` зелёный (9 с), `make test` — 407 тестов за 71 с, `make lint`, `make typecheck`, `make config-test` зелёные. Что проверка умеет краснеть, снято двумя поломками: снос партиции 2026-06-03 дал диагноз «не доехали до ODS», негодная строка в сырье — «сломан разбор». Строки опыта убраны, день переигран, счёт вернулся. Тест свежести проверен молчаливой правкой цены в каталоге: покраснел. Ссылка: #42
This commit is contained in:
+13
@@ -95,6 +95,19 @@ ClickHouse. Форма файла решена, длина — нет: стро
|
|||||||
первого дня D0; реальный календарь в модели не участвует. Между прогонами
|
первого дня D0; реальный календарь в модели не участвует. Между прогонами
|
||||||
живут только зерно и позиция на оси.
|
живут только зерно и позиция на оси.
|
||||||
|
|
||||||
|
**Стартовый мир**:
|
||||||
|
Первые восемь дней оси (понедельник по понедельник), которыми стенд
|
||||||
|
наполняется при каждом `make up`. Маленький кусок эталонного мира, всегда
|
||||||
|
один и тот же: на нём принимаются следующие этапы.
|
||||||
|
_Избегать_: зерновой мир
|
||||||
|
|
||||||
|
**Опись мира**:
|
||||||
|
`data/world-inventory.json` — единственное, что о мире хранится в git:
|
||||||
|
паспорт (зерно, версия генератора, хеш каталога) и по строке на день с
|
||||||
|
датой, числом событий и хешем его байтов. Сам мир в git не лежит — он
|
||||||
|
пересчитывается. Опись отвечает на один вопрос: тот ли это мир.
|
||||||
|
_Избегать_: манифест, мини-манифест
|
||||||
|
|
||||||
**Пошаговый режим**:
|
**Пошаговый режим**:
|
||||||
Базовый способ движения по оси модельного времени: «прожить следующий
|
Базовый способ движения по оси модельного времени: «прожить следующий
|
||||||
день» — явное действие.
|
день» — явное действие.
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ GENERATOR_DAY ?= 0
|
|||||||
GENERATOR_LIMIT ?=
|
GENERATOR_LIMIT ?=
|
||||||
GENERATOR_SPEED ?=
|
GENERATOR_SPEED ?=
|
||||||
|
|
||||||
.PHONY: up down clean ps logs generate-batch generate-live config-test lint typecheck test docs smoke check-clickhouse check-services
|
.PHONY: up down clean ps logs generate-batch generate-live config-test lint typecheck test docs inventory smoke check-clickhouse check-services
|
||||||
|
|
||||||
|
# Второй шаг: `--wait` дожидается служб, а приём событий асинхронный — довод
|
||||||
|
# целиком в шапке скрипта.
|
||||||
up:
|
up:
|
||||||
$(COMPOSE) up --detach --build --wait --wait-timeout 600
|
$(COMPOSE) up --detach --build --wait --wait-timeout 600
|
||||||
|
COMPOSE_BIN="$(COMPOSE)" ./scripts/wait-for-world.sh
|
||||||
|
|
||||||
down:
|
down:
|
||||||
$(COMPOSE) down --remove-orphans
|
$(COMPOSE) down --remove-orphans
|
||||||
@@ -44,6 +47,10 @@ docs:
|
|||||||
cd generator && uv run python -m clickstream_generator.schema_doc \
|
cd generator && uv run python -m clickstream_generator.schema_doc \
|
||||||
../docs/formats/clickstream-event.md
|
../docs/formats/clickstream-event.md
|
||||||
|
|
||||||
|
inventory:
|
||||||
|
cd generator && uv run python -m clickstream_generator.inventory \
|
||||||
|
../data/world-inventory.json
|
||||||
|
|
||||||
smoke:
|
smoke:
|
||||||
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh
|
COMPOSE_BIN="$(COMPOSE)" ./scripts/stand-smoke.sh
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ Prometheus, Grafana и общая база Postgres для метаданных.
|
|||||||
кликстрима в [`generator/`](generator/) собран целиком со стороны клиента: у
|
кликстрима в [`generator/`](generator/) собран целиком со стороны клиента: у
|
||||||
него есть контракт схемы события, из которого собрано
|
него есть контракт схемы события, из которого собрано
|
||||||
[описание выгрузки](docs/formats/clickstream-event.md), модельный мир и
|
[описание выгрузки](docs/formats/clickstream-event.md), модельный мир и
|
||||||
проигрыватель, отправляющий дни в Kafka или в файл.
|
проигрыватель, отправляющий дни в Kafka или в файл. События доезжают до
|
||||||
|
типизированного `ods.event`, а `make up` наполняет стенд стартовым миром —
|
||||||
|
первой неделей модельного времени.
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
@@ -26,10 +28,12 @@ Prometheus, Grafana и общая база Postgres для метаданных.
|
|||||||
хватает, стенд одинаково хорошо живёт на недорогом VPS.
|
хватает, стенд одинаково хорошо живёт на недорогом VPS.
|
||||||
|
|
||||||
Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`,
|
Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`,
|
||||||
`grep`, `sed`, `tail`, `sleep` и `timeout`. По умолчанию должны быть свободны
|
`grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им
|
||||||
|
читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны
|
||||||
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
|
порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090`
|
||||||
и `29092`. Проверкам без стенда — `make config-test`, `make lint`,
|
и `29092`. Проверкам без стенда — `make config-test`, `make lint`,
|
||||||
`make typecheck`, `make test` — и сборке документации `make docs` нужен `uv`.
|
`make typecheck`, `make test` — и сборкам `make docs` и `make inventory` нужен
|
||||||
|
`uv`.
|
||||||
|
|
||||||
Стенд запускается без `.env`:
|
Стенд запускается без `.env`:
|
||||||
|
|
||||||
@@ -70,6 +74,7 @@ make smoke
|
|||||||
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
|
`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow,
|
||||||
подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет
|
подготавливает администратора и подключение к `clickhouse-01`. Второй обновляет
|
||||||
Superset, создаёт администратора и импортирует подключение к `clickhouse-02`.
|
Superset, создаёт администратора и импортирует подключение к `clickhouse-02`.
|
||||||
|
С нуля подъём занимает около трёх минут, на живом стенде — около минуты.
|
||||||
|
|
||||||
`make smoke` за секунды спрашивает, собран ли стенд: зависимости машины,
|
`make smoke` за секунды спрашивает, собран ли стенд: зависимости машины,
|
||||||
здоровье контейнеров, устройство keeper, ответ Kafka с машины через отображённый
|
здоровье контейнеров, устройство keeper, ответ Kafka с машины через отображённый
|
||||||
@@ -105,7 +110,10 @@ Kafka по той же причине спрашивают снаружи. Её
|
|||||||
|
|
||||||
`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse:
|
`make check-clickhouse` запускает отдельную глубокую проверку ClickHouse:
|
||||||
описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`,
|
описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`,
|
||||||
`Distributed`, очередь распределённых DDL и очистку временных таблиц.
|
`Distributed`, очередь распределённых DDL и очистку временных таблиц. Последняя
|
||||||
|
из девяти проверок — единственная на настоящих данных: она подневно сверяет
|
||||||
|
события стартового мира с описью и при расхождении говорит, где искать —
|
||||||
|
в событиях или в браке.
|
||||||
|
|
||||||
`make config-test` проверяет Compose, синтаксис файлов DAG и пробельные ошибки
|
`make config-test` проверяет Compose, синтаксис файлов DAG и пробельные ошибки
|
||||||
в diff без запуска стенда.
|
в diff без запуска стенда.
|
||||||
@@ -119,6 +127,38 @@ Kafka по той же причине спрашивают снаружи. Её
|
|||||||
полного сброса с удалением всех именованных томов используйте `make clean`.
|
полного сброса с удалением всех именованных томов используйте `make clean`.
|
||||||
Повторный `make up` безопасен: одноразовая подготовка приложений идемпотентна.
|
Повторный `make up` безопасен: одноразовая подготовка приложений идемпотентна.
|
||||||
|
|
||||||
|
## Стартовый мир
|
||||||
|
|
||||||
|
Стенд поднимается не пустым: разовая служба `world-init` играет в топик `hits`
|
||||||
|
первые восемь дней модельного времени — понедельник по понедельник, 401 185
|
||||||
|
событий. Дальше их обычным путём разбирает хранилище, и к концу `make up` они
|
||||||
|
лежат в `ods.event`. Так у всякой лабы есть данные, и всегда одни и те же.
|
||||||
|
|
||||||
|
Ждать приходится дольше, чем работает заливка: приём асинхронный, поэтому
|
||||||
|
вторым шагом `make up` зовёт `scripts/wait-for-world.sh` — тот опрашивает
|
||||||
|
ClickHouse, пока мир не доедет. Повторный `make up` заливает мир заново; это
|
||||||
|
не ошибка, а свойство: номера событий те же, и повтор схлопнет
|
||||||
|
`ReplacingMergeTree`.
|
||||||
|
|
||||||
|
Сам мир в git не хранится — он чистая функция зерна, и держать его в
|
||||||
|
репозитории значило бы держать там кэш. Вместо него лежит **опись мира**,
|
||||||
|
[`data/world-inventory.json`](data/world-inventory.json): зерно, версия
|
||||||
|
генератора, хеш каталога товаров и по строке на каждый день — дата, число
|
||||||
|
событий и хеш его байтов. Опись отвечает на единственный вопрос: тот ли это
|
||||||
|
мир, что был вчера.
|
||||||
|
|
||||||
|
Спрашивают её двое. `make test` сверяет опись с тем, что собирается из кода
|
||||||
|
сегодня: правка генератора меняет мир, и опись надо пересобрать —
|
||||||
|
`make inventory`. `make check-clickhouse` сверяет с описью то, что доехало до
|
||||||
|
`ods.event`, подневно. Хеш дня можно пересчитать и руками — это обычный
|
||||||
|
`sha256sum` файла, который пишет файловый приёмник:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --project generator python -m clickstream_generator batch \
|
||||||
|
--day 0 --file tmp/day0.jsonl
|
||||||
|
sha256sum tmp/day0.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
## Как позвать генератор
|
## Как позвать генератор
|
||||||
|
|
||||||
События производит генератор из [`generator/`](generator/). Модельный день —
|
События производит генератор из [`generator/`](generator/). Модельный день —
|
||||||
@@ -160,6 +200,10 @@ make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000
|
|||||||
docker compose --profile generator build generator
|
docker compose --profile generator build generator
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Тот же образ несёт заливка стартового мира, поэтому свежим его держит и
|
||||||
|
обычный `make up`: он собирает образы всего стенда, и генератор теперь среди
|
||||||
|
них.
|
||||||
|
|
||||||
Разведено это нарочно: собрать образ и запустить контейнер — разные действия, и
|
Разведено это нарочно: собрать образ и запустить контейнер — разные действия, и
|
||||||
цель запуска, молча пересобирающая образ, стирает между ними границу. Что образ
|
цель запуска, молча пересобирающая образ, стирает между ними границу. Что образ
|
||||||
устарел, видно по собственному прогону — это обратная связь, а не ловушка.
|
устарел, видно по собственному прогону — это обратная связь, а не ловушка.
|
||||||
|
|||||||
+50
-19
@@ -52,6 +52,28 @@ x-airflow-common: &airflow-common
|
|||||||
- airflow_logs:/opt/airflow/logs
|
- airflow_logs:/opt/airflow/logs
|
||||||
- airflow_auth:/opt/airflow/auth
|
- airflow_auth:/opt/airflow/auth
|
||||||
|
|
||||||
|
x-generator-common: &generator-common
|
||||||
|
image: clickstream-generator:local
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: generator/Dockerfile
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
kafka-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
# Читать топик движок Kafka начинает не при своём создании, а когда над ним
|
||||||
|
# появляется матвью приёма, и она создаётся последней по номеру файла
|
||||||
|
# (sql/ddl/40-stg-views.sql). Отправь генератор события раньше — они лягут в
|
||||||
|
# топик и молча минуют хранилище.
|
||||||
|
clickhouse-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
# Адрес брокера и имя топика — факты стенда, и называет их стенд.
|
||||||
|
# Остальное (день, зерно, число дней, предел пачки) приходит аргументами
|
||||||
|
# от того, кто запускает: у службы нет позиции на оси мира.
|
||||||
|
KAFKA_BOOTSTRAP_SERVERS: ${KAFKA_BOOTSTRAP_SERVERS:-kafka:9092}
|
||||||
|
KAFKA_TOPIC: ${KAFKA_TOPIC:-hits}
|
||||||
|
|
||||||
x-superset-common: &superset-common
|
x-superset-common: &superset-common
|
||||||
image: clickstream-superset:local
|
image: clickstream-superset:local
|
||||||
build:
|
build:
|
||||||
@@ -194,25 +216,6 @@ services:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
generator:
|
|
||||||
profiles: ["generator"]
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: generator/Dockerfile
|
|
||||||
restart: "no"
|
|
||||||
depends_on:
|
|
||||||
kafka-init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
environment:
|
|
||||||
# Адрес брокера и имя топика — факты стенда, и называет их стенд.
|
|
||||||
# Остальное (день, зерно, число дней, предел пачки) приходит аргументами
|
|
||||||
# от того, кто запускает: у службы нет позиции на оси мира.
|
|
||||||
KAFKA_BOOTSTRAP_SERVERS: ${KAFKA_BOOTSTRAP_SERVERS:-kafka:9092}
|
|
||||||
KAFKA_TOPIC: ${KAFKA_TOPIC:-hits}
|
|
||||||
command: ["batch"]
|
|
||||||
# До #42 от этой разовой службы нет зависимого: включать её в обычный
|
|
||||||
# `up --wait` нельзя, иначе успешное завершение будет считаться сбоем.
|
|
||||||
|
|
||||||
# DDL применяется с ноды 1 по порядку имён файлов после готовности всего кластера.
|
# DDL применяется с ноды 1 по порядку имён файлов после готовности всего кластера.
|
||||||
clickhouse-init:
|
clickhouse-init:
|
||||||
image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
|
image: ${CLICKHOUSE_IMAGE:-clickhouse/clickhouse-server:26.3.17.56}
|
||||||
@@ -244,6 +247,27 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./sql/ddl:/ddl:ro
|
- ./sql/ddl:/ddl:ro
|
||||||
|
|
||||||
|
# Стартовый мир: восемь модельных дней уезжают в топик при каждом подъёме.
|
||||||
|
# Что это за дни и каким мир обязан выйти — data/world-inventory.json.
|
||||||
|
#
|
||||||
|
# Число дней стоит здесь числом: YAML не читает Python, и одно из двух мест
|
||||||
|
# (второе — `STARTING_DAYS` в inventory.py) лишнее по построению. Разъедутся
|
||||||
|
# они — покраснеют счётчики `make check-clickhouse`.
|
||||||
|
#
|
||||||
|
# Повторный `make up` заливает мир заново, и это не оплошность: `WatchID` у
|
||||||
|
# событий те же, ReplacingMergeTree схлопнет повтор в ODS. Сырьё в STG при
|
||||||
|
# этом честно удвоится — свойство слоя, описанное в storage.md.
|
||||||
|
world-init:
|
||||||
|
<<: *generator-common
|
||||||
|
command: ["batch", "--day", "0", "--days", "8"]
|
||||||
|
|
||||||
|
# Тот же образ для ручных прогонов: `make generate-batch`, `make generate-live`.
|
||||||
|
# Под профилем — чтобы обычный подъём стенда её не трогал.
|
||||||
|
generator:
|
||||||
|
<<: *generator-common
|
||||||
|
profiles: ["generator"]
|
||||||
|
command: ["batch"]
|
||||||
|
|
||||||
postgres-metadata:
|
postgres-metadata:
|
||||||
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
|
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -285,6 +309,13 @@ services:
|
|||||||
# упавшим для --wait.
|
# упавшим для --wait.
|
||||||
clickhouse-init:
|
clickhouse-init:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
# По той же причине — и ещё по одной. Заливка стартового мира идёт
|
||||||
|
# последней в цепи разовых служб, и зависимого ей взять больше негде.
|
||||||
|
# Заодно это правда про порядок: даг `next_day` этапа 5 продолжает ось с
|
||||||
|
# того дня, на котором заливка остановилась, — Airflow приходит в мир,
|
||||||
|
# который уже есть.
|
||||||
|
world-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
entrypoint: ["/bin/bash"]
|
entrypoint: ["/bin/bash"]
|
||||||
command: ["/opt/airflow/init.sh"]
|
command: ["/opt/airflow/init.sh"]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"seed": 20260601,
|
||||||
|
"generator_version": "0.1.0",
|
||||||
|
"catalog_sha256": "4fffceb3ec278c0b364ec84d936d991946ddd2a0912ed9c36471ff7a55c7483f",
|
||||||
|
"days": [
|
||||||
|
{
|
||||||
|
"day": 0,
|
||||||
|
"date": "2026-06-01",
|
||||||
|
"events": 50626,
|
||||||
|
"sha256": "81ad7f402bdd4c8b56425f6439a024b9996e5ca95dd207d444290d20a70ed407"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 1,
|
||||||
|
"date": "2026-06-02",
|
||||||
|
"events": 49722,
|
||||||
|
"sha256": "5f7aa0301832008db612eff05899b1210af958c888ac2e9e89f4b7a552569eff"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 2,
|
||||||
|
"date": "2026-06-03",
|
||||||
|
"events": 52361,
|
||||||
|
"sha256": "10ac9ce65ca6dc171689edf78ef5cd785e8c8a80e54d236bf2b9d6dbf6997070"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 3,
|
||||||
|
"date": "2026-06-04",
|
||||||
|
"events": 53157,
|
||||||
|
"sha256": "7bf4deadd8f69a6c479fceabfe2d1e8ea8db76919b10a8b9e780006708da31c3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 4,
|
||||||
|
"date": "2026-06-05",
|
||||||
|
"events": 50078,
|
||||||
|
"sha256": "b9e758fb72f937aa5ad1f2e90dee5ec147b16cfd89e49e51a00582f909458c29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 5,
|
||||||
|
"date": "2026-06-06",
|
||||||
|
"events": 47164,
|
||||||
|
"sha256": "f08a9f9a439f1da9e4ad8d94b9f65b7f3329256e41446d22d900cc4825f2480c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 6,
|
||||||
|
"date": "2026-06-07",
|
||||||
|
"events": 48683,
|
||||||
|
"sha256": "7e45d9dd59c902067fa6847d813669b1ab9a4ee88d2f192382577a5ed8d590a7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"day": 7,
|
||||||
|
"date": "2026-06-08",
|
||||||
|
"events": 49394,
|
||||||
|
"sha256": "286ccc2451847ac8de215b5e902c328371021ad5502bd4afca0494b6f313aef7"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -227,10 +227,10 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
|
|||||||
роняет вставку и останавливает потребление до починки
|
роняет вставку и останавливает потребление до починки
|
||||||
([ADR 0005](../adr/0005-event-ingestion.md)).
|
([ADR 0005](../adr/0005-event-ingestion.md)).
|
||||||
|
|
||||||
Постоянной сверки счётчиков при этом нет и не должно быть. У сырья срок жизни
|
Постоянной сверки **слоёв между собой** при этом нет и не должно быть. У сырья
|
||||||
трое суток, а ODS хранит всё, поэтому равенство «сырьё = события + ошибки»
|
срок жизни трое суток, а ODS хранит всё, поэтому равенство «сырьё = события +
|
||||||
разъедется само: сырьё истечёт раньше, а перезаливка модельного дня задвоит его,
|
ошибки» разъедется само: сырьё истечёт раньше, а перезаливка модельного дня
|
||||||
тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не
|
задвоит его, тогда как в ODS тот же повтор схлопнется. Правило, красное в норме, учит не
|
||||||
смотреть на оповещения
|
смотреть на оповещения
|
||||||
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым
|
([ADR 0002](../adr/0002-monitoring-scope.md)). Равенство проверено разовым
|
||||||
опытом при исполнении #43, на управляемой пачке: отправили N сообщений —
|
опытом при исполнении #43, на управляемой пачке: отправили N сообщений —
|
||||||
@@ -241,6 +241,15 @@ Airflow она стоит — там это обычный `SETTINGS` у зап
|
|||||||
`ReplacingMergeTree` зависит от того, сколько мержей успело пройти, и спека это
|
`ReplacingMergeTree` зависит от того, сколько мержей успело пройти, и спека это
|
||||||
прямо запрещает (раздел 6).
|
прямо запрещает (раздел 6).
|
||||||
|
|
||||||
|
**Постоянная сверка одна, и она другого рода** — не слой против слоя, а ODS
|
||||||
|
против [описи мира](../../data/world-inventory.json), лежащей в git
|
||||||
|
(`make check-clickhouse`, пришла с #42). Разъехаться сама она не может, и в
|
||||||
|
этом вся разница: опись описывает восемь дней стартового мира, счёт обрамлён
|
||||||
|
их датами, повтор заливки схлопывается под `FINAL`, а срок хранения ей не
|
||||||
|
помеха — ODS хранит всё. Обе стороны равенства зафиксированы: одна кодом
|
||||||
|
генератора, другая файлом в git. У межслойной сверки такой опоры нет ни с
|
||||||
|
одной стороны.
|
||||||
|
|
||||||
## Срок жизни сырья
|
## Срок жизни сырья
|
||||||
|
|
||||||
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно
|
Сырьё в STG живёт трое суток реального времени и уходит само. Трое — это окно
|
||||||
|
|||||||
@@ -23,25 +23,29 @@
|
|||||||
заметит потерянного подключения Superset: он про ClickHouse и только.
|
заметит потерянного подключения Superset: он про ClickHouse и только.
|
||||||
|
|
||||||
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
|
Отсюда правило для новой проверки: **спроси, кого она спрашивает.** Счётчики
|
||||||
против манифеста — вопрос к ClickHouse: строки в `ods.event` считает сам
|
против описи — вопрос к ClickHouse: строки в `ods.event` считает сам
|
||||||
сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене
|
сервер и отвечает сразу, значит дом им в `check-clickhouse`, даже если по цене
|
||||||
они подошли бы смоуку.
|
они подошли бы смоуку.
|
||||||
|
|
||||||
## Карта целей
|
## Карта целей
|
||||||
|
|
||||||
Стенд нужен трём целям из семи. Цена — замер 6 августа 2026 года, см. «Что
|
Стенд нужен трём целям из семи. Цена — замер 7 августа 2026 года, см. «Что
|
||||||
проверено».
|
проверено».
|
||||||
|
|
||||||
| Цель | Что утверждает | Стенд | Цена |
|
| Цель | Что утверждает | Стенд | Цена |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `make lint` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
| `make lint` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
|
||||||
| `make typecheck` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
| `make typecheck` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
|
||||||
| `make test` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранное из неё [описание выгрузки](../formats/clickstream-event.md) — свежим | не нужен | 40 с |
|
| `make test` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 71 с |
|
||||||
| `make config-test` | Compose разбирается, файлы DAG синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
| `make config-test` | Compose разбирается, файлы DAG синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
|
||||||
| `make smoke` | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 8 с |
|
| `make smoke` | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 9 с |
|
||||||
| `make check-clickhouse` | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL | нужен | 7 с |
|
| `make check-clickhouse` | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, счёт событий стартового мира против описи | нужен | 8 с |
|
||||||
| `make check-services` | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 44 с |
|
| `make check-services` | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 44 с |
|
||||||
|
|
||||||
|
Цена самого `make up` — 2 м 50 с с нуля (`make clean` перед ним — ещё 23 с) и
|
||||||
|
1 м 5 с на живом стенде. В цену целей она не входит, но записана здесь по той
|
||||||
|
же причине: с #42 подъём стенда заливает стартовый мир и стал заметно дороже.
|
||||||
|
|
||||||
## Правило: смоук обязан оставаться быстрым
|
## Правило: смоук обязан оставаться быстрым
|
||||||
|
|
||||||
`make smoke` — быстрая проверка для регулярного прогона: её гоняют не
|
`make smoke` — быстрая проверка для регулярного прогона: её гоняют не
|
||||||
@@ -72,6 +76,14 @@ ClickHouse отвечает сразу.
|
|||||||
Правки генератора добавляют к этому `make lint`, `make typecheck` и
|
Правки генератора добавляют к этому `make lint`, `make typecheck` и
|
||||||
`make test`: стенд им не нужен, а `make test` из них самая дорогая.
|
`make test`: стенд им не нужен, а `make test` из них самая дорогая.
|
||||||
|
|
||||||
|
Тринадцать секунд из её семидесяти одной — пересборка восьми модельных дней
|
||||||
|
для сверки с описью мира. Дешевле хеши не сравнить: чтобы узнать, тот ли
|
||||||
|
получается мир, его надо получить. Место выбрано по той же оси «кого
|
||||||
|
спрашивают» — вопрос обращён к коду генератора, стенд ему не нужен, — и
|
||||||
|
краснеет проверка там, где надо: сразу после правки генератора. Не будь её,
|
||||||
|
расхождение всплыло бы получасом позже, на поднятом стенде, где выглядит
|
||||||
|
поломкой хранилища, а не забытой пересборкой.
|
||||||
|
|
||||||
## Порогов по времени здесь нет
|
## Порогов по времени здесь нет
|
||||||
|
|
||||||
Цена в таблице — замеренное число с датой замера, а не назначенный порог.
|
Цена в таблице — замеренное число с датой замера, а не назначенный порог.
|
||||||
@@ -117,9 +129,16 @@ ClickHouse отвечает сразу.
|
|||||||
же механизм, на котором стоит переобработка дня X.
|
же механизм, на котором стоит переобработка дня X.
|
||||||
|
|
||||||
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
|
Что тогда стережёт цепочку постоянно — проверки на **настоящих** данных, тех,
|
||||||
что стенд произвёл сам: счётчики против манифеста зернового мира ловят
|
что стенд произвёл сам: подневный счёт событий против описи мира ловит и
|
||||||
сломанный разбор (события уехали в брак — счёт разошёлся), ничего при этом не
|
сломанный разбор (события уехали в брак — счёт разошёлся), и потерю по дороге,
|
||||||
вкладывая.
|
ничего при этом не вкладывая. Это единственный постоянный сторож цепочки
|
||||||
|
Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
|
||||||
|
|
||||||
|
Правило, которое такая проверка обязана выдержать и которое стоит держать в
|
||||||
|
голове для следующей: **на растущих данных утверждать можно только про
|
||||||
|
зафиксированный кусок.** Мир растёт — менти переиграет день, этап 5 добавит
|
||||||
|
следующий, — и всякое утверждение про таблицу целиком однажды покраснеет
|
||||||
|
законно, то есть впустую. Как это сделано здесь, написано в самом скрипте.
|
||||||
|
|
||||||
## Корректность процессов живёт в дагах DQ, а не в целях `make`
|
## Корректность процессов живёт в дагах DQ, а не в целях `make`
|
||||||
|
|
||||||
@@ -145,6 +164,13 @@ ClickHouse отвечает сразу.
|
|||||||
| `make check-services` | `scripts/stand-services.sh` |
|
| `make check-services` | `scripts/stand-services.sh` |
|
||||||
| `make check-clickhouse` | `scripts/check-clickhouse.sh` |
|
| `make check-clickhouse` | `scripts/check-clickhouse.sh` |
|
||||||
|
|
||||||
|
Особняком — `scripts/wait-for-world.sh`: он не проверка, а вторая половина
|
||||||
|
`make up`. `docker compose up --wait` дожидается служб, в том числе успешно
|
||||||
|
отработавшей заливки, но «заливка кончилась» значит «события в топике», а не
|
||||||
|
«события в хранилище»: приём асинхронный. Скрипт ждёт доезда ограниченным
|
||||||
|
циклом опроса. Без него `make check-clickhouse` следом краснел бы по
|
||||||
|
устройству, а не по поломке.
|
||||||
|
|
||||||
Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две
|
Общее у смоука и `check-services` — счёт проверок, обращение к Compose и две
|
||||||
проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается.
|
проверки — вынесено в `scripts/stand-common.sh`; сам он не запускается.
|
||||||
Оттуда же приходят два решения, которые видно по счёту прогонов:
|
Оттуда же приходят два решения, которые видно по счёту прогонов:
|
||||||
@@ -165,11 +191,29 @@ ClickHouse отвечает сразу.
|
|||||||
|
|
||||||
## Что проверено
|
## Что проверено
|
||||||
|
|
||||||
Замеры 6 августа 2026 года, стенд поднят заранее; время `make up` в цену целей
|
**Перезамер 7 августа 2026 года при исполнении #42.** Подъём стенда с нуля
|
||||||
не входит. Время взято по `time` и совпадает с тем, что цель печатает сама. Оно
|
(`make clean && make up`) — 2 м 50 с, из них 22,5 с занимает сама заливка:
|
||||||
плавает от прогона к прогону: смоук дал 8 секунд дважды (без проверки Kafka,
|
18,7 с генерация восьми дней и 3,8 с доставка 401 185 сообщений в Kafka.
|
||||||
до её появления, — 5 и 6), `check-services` — 43 и 44. В таблице стоит большее
|
Повторный `make up` на живом стенде — 1 м 5 с: мир заливается заново, и это
|
||||||
из замеренных.
|
не оплошность — `WatchID` те же, повтор схлопнет `ReplacingMergeTree`.
|
||||||
|
Смоук — 9 с, `check-clickhouse` — 8 с с новой девятой проверкой. `make test`
|
||||||
|
вырос до 71 с: 58 с прежних тестов плюс 13 с на пересборку восьми дней для
|
||||||
|
сверки с описью. Прежние 40 с в таблице устарели ещё до #42 — тесты добавляли
|
||||||
|
#41 и #43.
|
||||||
|
|
||||||
|
Что новая проверка **умеет краснеть**, снято двумя поломками того же дня, и
|
||||||
|
проверялись обе ветви её диагноза. Снесли партицию `2026-06-03` в
|
||||||
|
`ods.event_rep` — проверка покраснела, показала недостающий день и назвала
|
||||||
|
адрес: «события не доехали до ODS, начните с чтеца топика». Затем положили в
|
||||||
|
сырьё заведомо негодную строку — брак появился, и проверка сменила диагноз на
|
||||||
|
«сломан разбор, начните с `ods.event_errors_dist`». Строки опыта убраны, день
|
||||||
|
переигран `make generate-batch GENERATOR_DAY=2`; счёт вернулся к 401 185, и
|
||||||
|
это заодно показало дедупликацию: повтор дня не удвоил счёт под `FINAL`.
|
||||||
|
|
||||||
|
Замеры 6 августа 2026 года, стенд поднят заранее. Время взято по `time` и
|
||||||
|
совпадает с тем, что цель печатает сама. Оно плавает от прогона к прогону:
|
||||||
|
смоук дал 8 секунд дважды (без проверки Kafka, до её появления, — 5 и 6),
|
||||||
|
`check-services` — 43 и 44. В таблице стоит большее из замеренных.
|
||||||
|
|
||||||
До деления `scripts/stand-smoke.sh` шёл 48 секунд на 25 проверок, из них
|
До деления `scripts/stand-smoke.sh` шёл 48 секунд на 25 проверок, из них
|
||||||
42 секунды съедали шесть: Kafka с машины, два запуска пробников Airflow и три
|
42 секунды съедали шесть: Kafka с машины, два запуска пробников Airflow и три
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
Пятое — **опоздание** — бесплатно даёт формат доставки: часть заказов
|
||||||
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
впервые появляется в слепке D+1/D+2 («вчера не сходилось, сегодня сошлось»),
|
||||||
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
|
ориентир ~10%. Точные доли фиксируются при пересборке эталонного мира;
|
||||||
манифест хранит точные счётчики по каждому классу расхождений (отмены,
|
опись хранит точные счётчики по каждому классу расхождений (отмены,
|
||||||
потери, дубли).
|
потери, дубли).
|
||||||
|
|
||||||
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
Не берём: сироту-фрод (`purchase` есть, а заказа не будет никогда) —
|
||||||
@@ -286,7 +286,7 @@ CSV в репозитории (`data/catalog/products.csv`: `sku`, `name`, `cate
|
|||||||
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
|
`uniq(посетителей) > uniq(людей)`, менти выводит расхождение сам.
|
||||||
Константа мира: каждый двухкуковый покупатель делает минимум по одному
|
Константа мира: каждый двухкуковый покупатель делает минимум по одному
|
||||||
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
|
заказу с каждой куки — иначе вторая кука не попадает в карту соответствий
|
||||||
(она строится только из покупок) и лаба не воспроизводится. Манифест
|
(она строится только из покупок) и лаба не воспроизводится. Опись
|
||||||
хранит число именно таких пар.
|
хранит число именно таких пар.
|
||||||
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
- Витрины разводят имена честно: **«посетители»** (`uniq(ClientID)`) и
|
||||||
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
**«известные пользователи»** (после склейки) — оба числа рядом в дашборде.
|
||||||
@@ -464,19 +464,19 @@ Kafka день переигрывается генератором заново:
|
|||||||
пачками), Postgres остаётся только служебной базой Airflow. Смешанность
|
пачками), Postgres остаётся только служебной базой Airflow. Смешанность
|
||||||
ландшафта выражена режимами и частотами, а не второй трубой.
|
ландшафта выражена режимами и частотами, а не второй трубой.
|
||||||
|
|
||||||
## 8. Эталонный мир и манифест
|
## 8. Эталонный мир и опись
|
||||||
|
|
||||||
В git хранится только манифест эталонного мира; сам снимок (14 модельных
|
В git хранится только опись эталонного мира; сам снимок (14 модельных
|
||||||
дней) генерируется на месте — при `make up` и при проверках (решение
|
дней) генерируется на месте — при `make up` и при проверках (решение
|
||||||
развилки «Производительность» карты #26, подробности —
|
развилки «Производительность» карты #26, подробности —
|
||||||
[спека генератора](2026-08-01-generator.md), раздел 5). Манифест несёт
|
[спека генератора](2026-08-01-generator.md), раздел 5). Опись несёт
|
||||||
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
|
паспорт мира (каноническое зерно, версия генератора), контрольные счётчики
|
||||||
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
и хеши по дням; проверки «пустой git diff» и «пересгенерируй день N —
|
||||||
сравни хеш» живут на нём. Политика версионирования артефакта (бывший туман
|
сравни хеш» живут на ней. Политика версионирования артефакта (бывший туман
|
||||||
карты #10) закрыта этим же ходом: версионируется манифест.
|
карты #10) закрыта этим же ходом: версионируется опись.
|
||||||
Контрольные числа манифеста:
|
Контрольные числа описи:
|
||||||
|
|
||||||
- заказная сторона: заказы и выручка по дням; манифест хранит точные
|
- заказная сторона: заказы и выручка по дням; опись хранит точные
|
||||||
счётчики по каждому классу расхождений (отмены, потери, дубли, дельты сумм) —
|
счётчики по каждому классу расхождений (отмены, потери, дубли, дельты сумм) —
|
||||||
самопроверка лабы сверки;
|
самопроверка лабы сверки;
|
||||||
- идентичность: uniq кук, uniq известных пользователей, число двухкуковых
|
- идентичность: uniq кук, uniq известных пользователей, число двухкуковых
|
||||||
@@ -497,7 +497,7 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
|
| SQL | DDL по слоям и ролям (ON CLUSTER, Replicated*, Distributed; раскладка файлов — в доке хранилища) + трансформации событий, заказов, identity, сверки + словарь | L — ~12–15 файлов, главная сложность |
|
||||||
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка, ожидание дневного батча заказов — сенсор/Datasets), world_init/next_day, helpers | M — ~5–6 файлов |
|
| Airflow | DAG'и по образцу v1: etl_pipeline (партиционная переобработка, ожидание дневного батча заказов — сенсор/Datasets), world_init/next_day, helpers | M — ~5–6 файлов |
|
||||||
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
|
| Superset | датасеты + дашборд с тремя новыми сюжетами | M — 2 файла |
|
||||||
| Эталонный мир | пересборка снимка на месте, манифест-счётчики, чек-скрипты | M–L |
|
| Эталонный мир | пересборка снимка на месте, счётчики описи, чек-скрипты | M–L |
|
||||||
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
|
| Мониторинг | дашборды Grafana «данные», «кластер», «запросы»; ClickHouse источником данных, панели на SQL; Prometheus тонким полом (ADR 0002) | M — конфиги и дашборды |
|
||||||
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
|
| Документация | доки v2 пишутся заново (см. раздел 12) | M, в тех же PR |
|
||||||
|
|
||||||
@@ -534,15 +534,15 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
2. DDL и генератор (слиты в один этап — DDL проверяется только настоящими
|
2. DDL и генератор (слиты в один этап — DDL проверяется только настоящими
|
||||||
данными): базы и таблицы событий ON CLUSTER, приём `hits` обеими нодами;
|
данными): базы и таблицы событий ON CLUSTER, приём `hits` обеими нодами;
|
||||||
широкое событие, таксономия, анонимность, N:1 (клиентская сторона
|
широкое событие, таксономия, анонимность, N:1 (клиентская сторона
|
||||||
целиком). В конце этапа фиксируется маленький «зерновой» мир для
|
целиком). В конце этапа фиксируется маленький стартовый мир для
|
||||||
стабильных приёмок следующих этапов (полная пересборка эталонного мира —
|
стабильных приёмок следующих этапов (полная пересборка эталонного мира —
|
||||||
отдельный этап 7).
|
отдельный этап 7).
|
||||||
3. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь.
|
3. Заказы и каталог: генератор слепков, STG/ODS/DDS заказа, словарь.
|
||||||
4. Трансформации и витрины: сессии, identity_map, выручка, сверка A+C.
|
4. Трансформации и витрины: сессии, identity_map, выручка, сверка A+C.
|
||||||
5. Airflow: `etl_pipeline` (партиционная переобработка, ожидание дневного
|
5. Airflow: `etl_pipeline` (партиционная переобработка, ожидание дневного
|
||||||
батча заказов — сенсор/Datasets).
|
батча заказов — сенсор/Datasets).
|
||||||
6. Расхождения B+D и опоздания; счётчики манифеста.
|
6. Расхождения B+D и опоздания; счётчики описи.
|
||||||
7. Эталонный мир: манифест и пересборка снимка, чек-скрипты; CI-генерация
|
7. Эталонный мир: опись и пересборка снимка, чек-скрипты; CI-генерация
|
||||||
на amd64 и arm64.
|
на amd64 и arm64.
|
||||||
8. Superset-дашборд v2.
|
8. Superset-дашборд v2.
|
||||||
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
9. Мониторинг и runbook «keeper упал / DDL повис в очереди». Состав дашбордов
|
||||||
@@ -563,7 +563,7 @@ v2 стартует пустым, поэтому объём ниже — это
|
|||||||
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
|
- Лабы и курс: v2 — другой стенд, лабы для него пишутся с нуля отдельной
|
||||||
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
|
работой после этой спеки; редизайн лаб v1 (#7) остаётся в v1 и сюда не
|
||||||
переносится. Спека даёт будущим лабам только опорные точки — контрольные
|
переносится. Спека даёт будущим лабам только опорные точки — контрольные
|
||||||
числа манифеста (сверка, идентичность). Явное следствие: после этапа 9
|
числа описи (сверка, идентичность). Явное следствие: после этапа 9
|
||||||
стенд работает, но учебного пути на нём ещё нет.
|
стенд работает, но учебного пути на нём ещё нет.
|
||||||
- Инкрементальный ETL (#8) — свой issue.
|
- Инкрементальный ETL (#8) — свой issue.
|
||||||
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
|
- Реплики (2×2), HAProxy, репликационная эксплуатация — в лекцию, не в стенд.
|
||||||
|
|||||||
@@ -31,14 +31,14 @@
|
|||||||
модельный день D — функция (зерно, D). Между прогонами живут только зерно
|
модельный день D — функция (зерно, D). Между прогонами живут только зерно
|
||||||
и позиция на оси времени.
|
и позиция на оси времени.
|
||||||
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
|
- **Детерминизм до байта.** Одно зерно — побайтово тот же снимок; сверка —
|
||||||
хешами манифеста. Транспорт (офсеты Kafka, темп) — вне обещания.
|
хешами описи. Транспорт (офсеты Kafka, темп) — вне обещания.
|
||||||
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
|
- **Схема — контракт генератора.** Python-модуль с чистыми данными;
|
||||||
хранилище строится по рендеренной документации, границу сторожит строгий
|
хранилище строится по рендеренной документации, границу сторожит строгий
|
||||||
приём на стороне хранилища.
|
приём на стороне хранилища.
|
||||||
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
|
- **Один сериализатор, глупые приёмники.** День-функция выдаёт канонические
|
||||||
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
|
байты; приёмники — файл, Kafka пачкой, Kafka с темпом.
|
||||||
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней;
|
- **Числа.** Средний день ~50 тыс. событий; эталонный снимок — 14 дней;
|
||||||
в git — только манифест; автоматический порог один — день ≤ 30 с.
|
в git — только опись мира; автоматических порогов по времени нет.
|
||||||
|
|
||||||
## 1. Модель мира
|
## 1. Модель мира
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
человеку предыстории, чьё окно активности таких дней не оставляет,
|
человеку предыстории, чьё окно активности таких дней не оставляет,
|
||||||
пара не назначается. День-функция обязана назначенные заказы
|
пара не назначается. День-функция обязана назначенные заказы
|
||||||
реализовать; остальные покупки — вольные, их решает генератор торговых
|
реализовать; остальные покупки — вольные, их решает генератор торговых
|
||||||
событий (#40). Манифест считает пары, реализованные в горизонте
|
событий (#40). Опись считает пары, реализованные в горизонте
|
||||||
снимка. Условность в данных не видна: дни назначены той же
|
снимка. Условность в данных не видна: дни назначены той же
|
||||||
случайностью, просто брошенной планом один раз.
|
случайностью, просто брошенной планом один раз.
|
||||||
- **Своя ось модельного времени.** Ось событий начинается в
|
- **Своя ось модельного времени.** Ось событий начинается в
|
||||||
@@ -117,19 +117,19 @@
|
|||||||
Отклонено с доводами:
|
Отклонено с доводами:
|
||||||
|
|
||||||
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
|
- *Мутирующее состояние мира* («мир стареет»): ломает параллельность по
|
||||||
дням, требует чекпоинтов, счётчики манифеста узнаваемы только постфактум;
|
дням, требует чекпоинтов, счётчики описи узнаваемы только постфактум;
|
||||||
ни один урок стенда на старении не стоит.
|
ни один урок стенда на старении не стоит.
|
||||||
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
|
- *Чистая функция без слоя состава*: глобальные инварианты пришлось бы
|
||||||
выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
|
выводить в каждом дне заново — тот же план мира, но неявный и размазанный.
|
||||||
- *Привязка модельного времени к реальному календарю* (T-1 с догоном):
|
- *Привязка модельного времени к реальному календарю* (T-1 с догоном):
|
||||||
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает
|
конфликтует с ускорением ×60 — за вечер мир уезжает в будущее — и делает
|
||||||
даты эталонного мира зависимыми от даты запуска, манифест теряет
|
даты эталонного мира зависимыми от даты запуска, опись теряет
|
||||||
воспроизводимость.
|
воспроизводимость.
|
||||||
|
|
||||||
Отклонено при исполнении #38 (2026-08-02):
|
Отклонено при исполнении #38 (2026-08-02):
|
||||||
|
|
||||||
- *Разгон вместо предыстории* («первые дни малы — магазин запустился»):
|
- *Разгон вместо предыстории* («первые дни малы — магазин запустился»):
|
||||||
зерновой мир и половина снимка оказались бы на разгоне, недельная лаба
|
стартовый мир и половина снимка оказались бы на разгоне, недельная лаба
|
||||||
сравнивала бы несравнимые недели, «средний день ~50 тыс.» перестал бы
|
сравнивала бы несравнимые недели, «средний день ~50 тыс.» перестал бы
|
||||||
быть средним — пришлось бы двигать принятые числа раздела 5.
|
быть средним — пришлось бы двигать принятые числа раздела 5.
|
||||||
- *Материализованный план на горизонт*: горизонт становится обязательным
|
- *Материализованный план на горизонт*: горизонт становится обязательным
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
константы — потолка одновременно живущих кук, которого в жизни нет и
|
константы — потолка одновременно живущих кук, которого в жизни нет и
|
||||||
который менти нечем объяснить.
|
который менти нечем объяснить.
|
||||||
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
|
- *Вероятностная гарантия пар* («почти наверняка купит с обеих кук»): не
|
||||||
гарантия — однажды манифест покраснеет, а чинить нечем, кроме смены
|
гарантия — однажды опись покраснеет, а чинить нечем, кроме смены
|
||||||
зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев,
|
зерна; при этом несклеенная пара в данных неотличима от двух незнакомцев,
|
||||||
так что реализм этой лотереи невидим.
|
так что реализм этой лотереи невидим.
|
||||||
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
|
- *Покупка пары в первый визит куки*: гарантия железная и дешёвая, но узор
|
||||||
@@ -154,14 +154,14 @@
|
|||||||
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
|
- **Обещание — содержимое до байта.** Два прогона с одним зерном дают тот же
|
||||||
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
|
набор событий: те же `WatchID`/`VisitID`, поля, метки модельного времени.
|
||||||
Снимок при пересборке побайтово совпадает: канонический порядок ключей и
|
Снимок при пересборке побайтово совпадает: канонический порядок ключей и
|
||||||
строк; сверка — по хешам манифеста, а два локально пересобранных снимка
|
строк; сверка — по хешам описи, а два локально пересобранных снимка
|
||||||
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне
|
сравнимы обычным diff — пустой означает «ничего не изменилось». Вне
|
||||||
обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала,
|
обещания — транспорт: офсеты и партиции Kafka, какая нода прочитала,
|
||||||
`_load_ts`, темп живого дня.
|
`_load_ts`, темп живого дня.
|
||||||
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
|
- **Условия обещания.** Детерминизм держится при зафиксированном `uv.lock`
|
||||||
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL
|
и внутри канонического контейнера — то есть везде Linux, на маке и в WSL
|
||||||
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет
|
тоже; единственная переменная — архитектура CPU. Истина — CI на Linux; сходимость любой машины проверяет
|
||||||
скрипт «пересгенерируй день N — сравни хеш с манифестом». Расхождение на
|
скрипт «пересгенерируй день N — сравни хеш с описью». Расхождение на
|
||||||
любой платформе — баг генератора, а не допуск.
|
любой платформе — баг генератора, а не допуск.
|
||||||
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
|
- **Раздача зерна — иерархией подпотоков.** Корневое зерно → подпоток
|
||||||
состава мира, ветвящийся по номеру дня на когорты плана (состав
|
состава мира, ветвящийся по номеру дня на когорты плана (состав
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
торговые события, расхождения, опоздания — в фиксированном порядке.
|
торговые события, расхождения, опоздания — в фиксированном порядке.
|
||||||
По построению: параллельный прогон равен последовательному; продление
|
По построению: параллельный прогон равен последовательному; продление
|
||||||
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
|
истории днём N+1 не трогает дни 1…N; правка одного компонента меняет
|
||||||
только его часть снимка — в манифесте меняются хеши только затронутых
|
только его часть снимка — в описи меняются хеши только затронутых
|
||||||
дней, дифф двух локальных пересборок читаем.
|
дней, дифф двух локальных пересборок читаем.
|
||||||
- **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через
|
- **Механизм подпотоков — `numpy.random.SeedSequence`.** Сверено через
|
||||||
Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей
|
Context7 по документации numpy (2026-08-01): `spawn(n)` порождает детей
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
представление в клиентском `purchase` (урок мастер-спеки о расхождениях).
|
представление в клиентском `purchase` (урок мастер-спеки о расхождениях).
|
||||||
- **Канонический seed и паспорт мира.** Эталонный мир собирается одним
|
- **Канонический seed и паспорт мира.** Эталонный мир собирается одним
|
||||||
каноническим зерном — константой репозитория; свои зёрна менти крутит без
|
каноническим зерном — константой репозитория; свои зёрна менти крутит без
|
||||||
гарантий манифеста. Манифест хранит паспорт мира — зерно и версию
|
гарантий описи. Опись хранит паспорт мира — зерно и версию
|
||||||
генератора; чек-скрипты сверяют паспорт раньше счётчиков.
|
генератора; чек-скрипты сверяют паспорт раньше счётчиков.
|
||||||
- **Суточный профиль интенсивности задаёт день-функция.** Форма — волны:
|
- **Суточный профиль интенсивности задаёт день-функция.** Форма — волны:
|
||||||
ночной провал, обеденный и вечерний пики, различие будней и выходных;
|
ночной провал, обеденный и вечерний пики, различие будней и выходных;
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
- *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно
|
- *Обещание детерминизма поверх обновления зависимостей*: numpy сознательно
|
||||||
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
|
улучшает алгоритмы распределений между версиями (NEP 19), Faker меняет
|
||||||
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная
|
словари. Фиксация — `uv.lock`; обновление зависимостей — осознанная
|
||||||
пересборка манифеста одним PR.
|
пересборка описи одним PR.
|
||||||
|
|
||||||
## 3. Контракт схемы
|
## 3. Контракт схемы
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
|
- **Один канонический сериализатор, глупые приёмники.** День-функция выдаёт
|
||||||
упорядоченный поток канонических байтов — единственное место, где событие
|
упорядоченный поток канонических байтов — единственное место, где событие
|
||||||
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш
|
превращается в JSON. Приёмники не знают о содержимом: файл (локальный кэш
|
||||||
для пересборки и проверок манифеста), Kafka пачкой — пакетный режим,
|
для пересборки и проверок описи), Kafka пачкой — пакетный режим,
|
||||||
Kafka с темпом ×60 — живой день. Новых топиков нет.
|
Kafka с темпом ×60 — живой день. Новых топиков нет.
|
||||||
- **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу
|
- **Одно событие — одно сообщение Kafka.** «Пачкой» относится к темпу
|
||||||
отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое
|
отправки, а не к упаковке: приёмник шлёт события подряд без пауз, но каждое
|
||||||
@@ -288,7 +288,7 @@
|
|||||||
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
|
- **Рабочий выбор сериализатора — orjson**: быстрее stdlib json в 5–14 раз,
|
||||||
numpy-массивы и datetime сериализует нативно (заметка исследования #31).
|
numpy-массивы и datetime сериализует нативно (заметка исследования #31).
|
||||||
Смена библиотеки меняет канонические байты, поэтому проходит как
|
Смена библиотеки меняет канонические байты, поэтому проходит как
|
||||||
обновление зависимости: осознанная пересборка манифеста одним PR.
|
обновление зависимости: осознанная пересборка описи одним PR.
|
||||||
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
|
- **Промежуточные файлы не хранятся.** Файл дня — кэш чистой функции:
|
||||||
потерял — пересчитал. В git снимок не попадает (раздел 5).
|
потерял — пересчитал. В git снимок не попадает (раздел 5).
|
||||||
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
|
- **Обрыв любого режима — переигровка дня целиком**; дедуп склеивает
|
||||||
@@ -306,7 +306,7 @@
|
|||||||
|
|
||||||
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
|
- *Отдельный топик / Kafka как хранилище дней*: офсеты и партиции вне
|
||||||
обещания детерминизма, retention конечен, в git топик не положишь, хеш с
|
обещания детерминизма, retention конечен, в git топик не положишь, хеш с
|
||||||
манифестом не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
|
описью не сверишь; Kafka на стенде — труба, не хранилище (раздел 7
|
||||||
мастер-спеки).
|
мастер-спеки).
|
||||||
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
|
- *Файл как обязательная станция доставки*: доигрывание обрыва уже решено
|
||||||
через дедуп, канон держит единственный сериализатор, а не диск; файл
|
через дедуп, канон держит единственный сериализатор, а не диск; файл
|
||||||
@@ -327,15 +327,15 @@
|
|||||||
- **Эталонный снимок — 14 дней**: две полные календарные недели, D0 —
|
- **Эталонный снимок — 14 дней**: две полные календарные недели, D0 —
|
||||||
понедельник. Самая короткая длина, при которой есть замороженная зона за
|
понедельник. Самая короткая длина, при которой есть замороженная зона за
|
||||||
окном K = 7, дышащая зона и две волны недельной сезонности. Удлинение до
|
окном K = 7, дышащая зона и две волны недельной сезонности. Удлинение до
|
||||||
месяца — дешёвый ход (пересборка манифеста), если понадобится.
|
месяца — дешёвый ход (пересборка описи), если понадобится.
|
||||||
- **В git — только манифест, снимок не хранится.** Снимок генерируется при
|
- **В git — только опись, снимок не хранится.** Снимок генерируется при
|
||||||
`make up` и при проверках: артефакт — кэш чистой функции, кэш в git не
|
`make up` и при проверках: артефакт — кэш чистой функции, кэш в git не
|
||||||
хранят. Манифест несёт паспорт мира, счётчики и хеши по дням; проверки
|
хранят. Опись несёт паспорт мира, счётчики и хеши по дням; проверки
|
||||||
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на нём.
|
«пустой git diff» и «пересгенерируй день N — сравни хеш» живут на ней.
|
||||||
Каждый `make up` — живая демонстрация детерминизма. Честная потеря —
|
Каждый `make up` — живая демонстрация детерминизма. Честная потеря —
|
||||||
страховка на случай платформенного бага: раньше менти с расходящимися
|
страховка на случай платформенного бага: раньше менти с расходящимися
|
||||||
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
|
байтами мог взять готовый снимок из git, теперь он упрётся в красный чек
|
||||||
манифеста; смягчение — CI гоняет генерацию на amd64 и arm64.
|
описи; смягчение — CI гоняет генерацию на amd64 и arm64.
|
||||||
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
|
- **Живой день — ×60 по умолчанию**: модельные сутки за 24 реальные минуты,
|
||||||
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
|
суточная волна разворачивается на глазах; темп в среднем ~35 событий/с,
|
||||||
в пиковые часы сильных дней — до ~100. Число —
|
в пиковые часы сильных дней — до ~100. Число —
|
||||||
@@ -348,34 +348,37 @@
|
|||||||
Схема двухъярусная — урок ADR 0004: пороги впритык к расчёту на разном
|
Схема двухъярусная — урок ADR 0004: пороги впритык к расчёту на разном
|
||||||
железе кончаются ритуальным удалением проверки.
|
железе кончаются ритуальным удалением проверки.
|
||||||
|
|
||||||
Ориентиры на референсной машине — в спеке, без автоматики:
|
Ориентиры на референсной машине — в спеке, без автоматики. Где стоит замер,
|
||||||
|
там он с датой; остальное — порядок величины, пока не мерили:
|
||||||
|
|
||||||
| Операция | Ориентир |
|
| Операция | Ориентир |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Генерация одного дня | секунды |
|
| Генерация одного дня | 1,7–2,8 с (замер 7 августа 2026 года) |
|
||||||
| Пересборка эталонного мира (14 дней + манифест, без транспорта) | до минуты |
|
| Пересборка эталонного мира (14 дней + опись, без транспорта) | до минуты |
|
||||||
| Заливка снимка при `make up` (Kafka → матвью → ODS) | минуты |
|
| Заливка стартового мира при `make up` (8 дней, Kafka → матвью → ODS) | 22,5 с (замер 7 августа 2026 года) |
|
||||||
| Лаг живого дня | секунды |
|
| Лаг живого дня | секунды |
|
||||||
|
|
||||||
**Автоматический порог один: полный день (50 тыс. событий) генерируется
|
**Автоматических порогов нет ни одного** — решение владельца 7 августа
|
||||||
≤ 30 с.** Расчёт по планке исследования (~2–5×10⁵ событий/с на ядро) — доли
|
2026 года при исполнении #42. Здесь стоял единственный: «полный день
|
||||||
секунды; порог держит машинный разброс ×2–5 и ловит деградацию на 1–2
|
генерируется ≤ 30 с». Назначен он был до того, как генератор написали, а
|
||||||
порядка: Faker в горячем цикле, случайная квадратичность. Реализация —
|
первый замер настоящего кода дал **1,7 секунды на день в 50 626 событий**
|
||||||
pytest-тест с маркером `perf` и таймаутом-обрубанием: обязателен в CI,
|
(машина стенда, 7 августа 2026 года). Порог оказался в восемнадцать раз выше
|
||||||
исключён из быстрой локальной петли, зовётся отдельной целью при правках
|
факта: зелен при любой правдоподобной регрессии, то есть не сторож, а
|
||||||
горячего цикла.
|
украшение. Как здесь говорят о времени, к тому дню уже решила [карта
|
||||||
|
целей](../architecture/testing.md): цена — замеренное число с датой, а не
|
||||||
|
назначенный предел.
|
||||||
|
|
||||||
Остальное — наблюдаемость без порогов: `make up` и smoke печатают тайминги
|
Наблюдаемость без порогов остаётся и делает всю работу: проигрыватель
|
||||||
(генерация и доставка отдельно), проигрыватель логирует лаг. Прототип-замер
|
печатает тайминги генерации и доставки раздельно, лаг живого дня — в его
|
||||||
до этапа 2 не нужен: числа назначены с запасом порядок и больше от планки
|
логе, а `make up` после #42 гоняет генератор по восемь дней при каждом
|
||||||
исследования, планка подтверждена локальной проверкой на машине стенда;
|
подъёме стенда: замедлись он на порядок — подъём стенда встанет колом, и
|
||||||
первый замер настоящего кода — порог этапа 2.
|
заметит это первый же человек, который его поднял.
|
||||||
|
|
||||||
Отклонено с доводами:
|
Отклонено с доводами:
|
||||||
|
|
||||||
- *Четыре жёстких CI-ворот на все бюджеты*: машинный разброс против порогов
|
- *Четыре жёстких CI-ворот на все бюджеты*: машинный разброс против порогов
|
||||||
впритык — повторение истории с памятью (ADR 0004); порог оставлен один,
|
впритык — повторение истории с памятью (ADR 0004). Оставленный было один
|
||||||
грубый, между «×5 шума» и «×100 беды».
|
грубый порог снят при исполнении #42 (выше).
|
||||||
- *Снимок в git* (статус-кво раздела 8 мастер-спеки): основание из v1 —
|
- *Снимок в git* (статус-кво раздела 8 мастер-спеки): основание из v1 —
|
||||||
медленный генератор — съедено детерминизмом и скоростью; остаётся только
|
медленный генератор — съедено детерминизмом и скоростью; остаётся только
|
||||||
раздутый репозиторий. *Снимок вложением релиза Gitea*: страховка без
|
раздутый репозиторий. *Снимок вложением релиза Gitea*: страховка без
|
||||||
@@ -411,9 +414,9 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
|
- **Раздел 1.4**: «из контракта выводятся DDL и валидация» заменено на data
|
||||||
contract — хранилище пишется по документации, границу сторожит строгий
|
contract — хранилище пишется по документации, границу сторожит строгий
|
||||||
приём (раздел 3 здесь).
|
приём (раздел 3 здесь).
|
||||||
- **Раздел 8**: артефакт `data/startup_history/` в git заменён манифестом;
|
- **Раздел 8**: артефакт `data/startup_history/` в git заменён описью;
|
||||||
снимок генерируется на месте (раздел 5 здесь). Туман «политика
|
снимок генерируется на месте (раздел 5 здесь). Туман «политика
|
||||||
версионирования артефакта» закрыт этим же ходом: версионируется манифест.
|
версионирования артефакта» закрыт этим же ходом: версионируется опись.
|
||||||
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования
|
- **Раздел 11**: пункт «до этапа 3 зафиксировать требования
|
||||||
производительности» закрыт числами раздела 5.
|
производительности» закрыт числами раздела 5.
|
||||||
- Мелкие согласования там, где текст опирался на артефакт в git: источник
|
- Мелкие согласования там, где текст опирался на артефакт в git: источник
|
||||||
@@ -444,7 +447,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
|
||||||
- **Этап 5, позиция на оси времени.** Проигрыватель состояния не хранит
|
- **Этап 5, позиция на оси времени.** Проигрыватель состояния не хранит
|
||||||
(раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт она
|
(раздел 9), поэтому вести позицию — работа того, кто его зовёт. Живёт она
|
||||||
переменной Airflow: отсутствие переменной означает мир в зерновом
|
переменной Airflow: отсутствие переменной означает мир в стартовом
|
||||||
состоянии, заводит и двигает её только даг `next_day` и только по успеху.
|
состоянии, заводит и двигает её только даг `next_day` и только по успеху.
|
||||||
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
Довод — генератор отдельная и заменяемая сущность, привязывать его к
|
||||||
хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с
|
хранилищу незачем, а `make clean` сносит том метаданных Airflow вместе с
|
||||||
@@ -452,7 +455,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
Расхождение переменной с данными (менти почистил партицию руками) лечится
|
Расхождение переменной с данными (менти почистил партицию руками) лечится
|
||||||
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
|
документацией, а не сторожем: постоянная сверка была бы проверкой, красной
|
||||||
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
|
в норме ([ADR 0002](../adr/0002-monitoring-scope.md)).
|
||||||
- **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт;
|
- **Этап 7 (эталонный мир)**: пересборка — это опись, не артефакт;
|
||||||
CI-генерация на amd64 и arm64.
|
CI-генерация на amd64 и arm64.
|
||||||
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
- **Будущие лабы**: перезаливка дня X пакетным режимом проигрывателя —
|
||||||
готовая демонстрация идемпотентности конвейера.
|
готовая демонстрация идемпотентности конвейера.
|
||||||
@@ -503,11 +506,11 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
возвратами предыстории), 170 пар.
|
возвратами предыстории), 170 пар.
|
||||||
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
|
- **Конфигурация мира — модуль чистых данных** рядом с контрактом схемы:
|
||||||
все числа мира в одном месте, написанном как приглашение любопытному
|
все числа мира в одном месте, написанном как приглашение любопытному
|
||||||
менти крутить. Правка модуля — смена мира: чек манифеста честно
|
менти крутить. Правка модуля — смена мира: чек описи честно
|
||||||
краснеет, манифест сторожит только канон. Вне модуля — лишь то, что
|
краснеет, опись сторожит только канон. Вне модуля — лишь то, что
|
||||||
мира не меняет: своё зерно и транспортные флаги проигрывателя.
|
мира не меняет: своё зерно и транспортные флаги проигрывателя.
|
||||||
Отклонено: внешний конфиг и env-переопределения — переменная мира,
|
Отклонено: внешний конфиг и env-переопределения — переменная мира,
|
||||||
которую паспорт манифеста не видит; файл-конфиг в репозитории — по
|
которую паспорт описи не видит; файл-конфиг в репозитории — по
|
||||||
смыслу равен модулю, но платит загрузчиком и валидацией (довод
|
смыслу равен модулю, но платит загрузчиком и валидацией (довод
|
||||||
раздела 3 против YAML).
|
раздела 3 против YAML).
|
||||||
|
|
||||||
@@ -590,7 +593,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
посетителю 6% до корзины и те же 45% и 55%, помеченному покупателю — 18%
|
посетителю 6% до корзины и те же 45% и 55%, помеченному покупателю — 18%
|
||||||
и 60%. Перемеренный средний день: 9 879 визитов, 47 433 pageview, 4,80
|
и 60%. Перемеренный средний день: 9 879 визитов, 47 433 pageview, 4,80
|
||||||
страницы на визит, конверсия визита 2,4%; полные числа — в блоке #40,
|
страницы на визит, конверсия визита 2,4%; полные числа — в блоке #40,
|
||||||
оттуда же они лягут в манифест.
|
оттуда же они лягут в опись.
|
||||||
- **Недельная волна применяется один раз.** Профиль ведёт приток, трафик
|
- **Недельная волна применяется один раз.** Профиль ведёт приток, трафик
|
||||||
наследует его через дневную аудиторию; измеренный размах трафика — ±7%
|
наследует его через дневную аудиторию; измеренный размах трафика — ±7%
|
||||||
против ±10% у притока. Второе умножение удвоило бы недельный размах. Если
|
против ±10% у притока. Второе умножение удвоило бы недельный размах. Если
|
||||||
@@ -611,8 +614,8 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
|
|
||||||
Решено при исполнении #40 (2026-08-02) — решения владельца до реализации.
|
Решено при исполнении #40 (2026-08-02) — решения владельца до реализации.
|
||||||
Три решения выходят за границы тикета: правятся план состава (#38), воронка
|
Три решения выходят за границы тикета: правятся план состава (#38), воронка
|
||||||
(#39) и каталог товаров (#39). Сейчас это дёшево — манифест ещё не собран
|
(#39) и каталог товаров (#39). Сейчас это дёшево — опись ещё не собрана
|
||||||
(#42); позже обошлось бы его пересборкой. Часть решений принята после
|
(#42); позже обошлось бы её пересборкой. Часть решений принята после
|
||||||
холодного ревью первой редакции блока.
|
холодного ревью первой редакции блока.
|
||||||
|
|
||||||
- **Корзина шире заказа.** Посетитель кладёт в корзину товары тех карточек,
|
- **Корзина шире заказа.** Посетитель кладёт в корзину товары тех карточек,
|
||||||
@@ -872,7 +875,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
цены и по медиане, потому что одни только края списка обманываются
|
цены и по медиане, потому что одни только края списка обманываются
|
||||||
четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом
|
четырьмя исключениями. Хвост соседям: словарь ClickHouse над этим файлом
|
||||||
ещё не построен (#37/#43) — колонку надо взять в его описание сразу, а не
|
ещё не построен (#37/#43) — колонку надо взять в его описание сразу, а не
|
||||||
догонять правкой; манифесту нужен хеш каталога (хвост #42) тем более,
|
догонять правкой; описи нужен хеш каталога (хвост #42) тем более,
|
||||||
потому что теперь в файле живёт ещё и поведение.
|
потому что теперь в файле живёт ещё и поведение.
|
||||||
- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%
|
- **Доля просмотров карточек, доходящих до корзины, выросла с 7,3% до 8,6%
|
||||||
и подошла к потолку вилки.** Иначе не сошлось: треть кладущих визитов вне
|
и подошла к потолку вилки.** Иначе не сошлось: треть кладущих визитов вне
|
||||||
@@ -885,7 +888,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
пока число правдоподобно, само по себе оно ничего не сторожит, и
|
пока число правдоподобно, само по себе оно ничего не сторожит, и
|
||||||
подгонять поведение под его край не надо. Настоящий предел здесь другой и
|
подгонять поведение под его край не надо. Настоящий предел здесь другой и
|
||||||
считается в другой валюте — дневной бюджет событий: события корзины
|
считается в другой валюте — дневной бюджет событий: события корзины
|
||||||
входят в те самые «около 50 тыс.», на которых стоят манифест (#42) и
|
входят в те самые «около 50 тыс.», на которых стоят опись (#42) и
|
||||||
порог скорости дня. Упрётся будущая правка — двигать надо бюджет и его
|
порог скорости дня. Упрётся будущая правка — двигать надо бюджет и его
|
||||||
причины, а не долю.
|
причины, а не долю.
|
||||||
|
|
||||||
@@ -926,7 +929,7 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
итог виден кодом возврата. Общее у режимов: `--seed`, `--day`, `--days` и
|
итог виден кодом возврата. Общее у режимов: `--seed`, `--day`, `--days` и
|
||||||
приёмник — `--file` либо `--brokers` с `--topic`. Состояния нет: позицию на
|
приёмник — `--file` либо `--brokers` с `--topic`. Состояния нет: позицию на
|
||||||
оси ведёт зовущий (хвост этапу 5 — раздел 8). `--days` играет несколько
|
оси ведёт зовущий (хвост этапу 5 — раздел 8). `--days` играет несколько
|
||||||
дней подряд одним запуском — этим зальётся зерновой мир (#42), восемь
|
дней подряд одним запуском — этим зальётся стартовый мир (#42), восемь
|
||||||
запусков службы внутри `make up` были бы плохим ответом. Отклонено:
|
запусков службы внутри `make up` были бы плохим ответом. Отклонено:
|
||||||
*режимы флагом `--speed 0`* — различие тогда прячется за числом, тогда как
|
*режимы флагом `--speed 0`* — различие тогда прячется за числом, тогда как
|
||||||
у режимов разное устройство: у пакетного есть пачка и нет ожидания, у
|
у режимов разное устройство: у пакетного есть пачка и нет ожидания, у
|
||||||
@@ -986,11 +989,30 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
|
|||||||
модуль его не находит. Проверено запуском в контейнере; заодно день,
|
модуль его не находит. Проверено запуском в контейнере; заодно день,
|
||||||
сыгранный в образе, совпал побайтово с днём, сыгранным на машине.
|
сыгранный в образе, совпал побайтово с днём, сыгранным на машине.
|
||||||
|
|
||||||
Остаётся открытым, за тикетами:
|
Решено при исполнении #42 (2026-08-07):
|
||||||
|
|
||||||
- как фиксируется «зерновой» мир конца этапа 2 (раздел 9 мастер-спеки):
|
- **Опись мира одна, и она же растёт до эталонной.** Стартовый мир конца
|
||||||
с манифестным решением напрашивается мини-манифест зернового мира —
|
этапа 2 — первые восемь дней оси, понедельник по понедельник (решение
|
||||||
форма за #42;
|
владельца при нарезке, 2026-08-01): полная неделя с выходными и первый
|
||||||
- паспорт мира в манифесте (зерно и версия генератора) файл каталога не
|
замкнутый цикл окна K = 7. Отдельного «младшего» файла для них не
|
||||||
накрывает: правка цены в CSV меняет мир молча. Манифесту нужен хеш
|
заводится: `data/world-inventory.json` — та самая опись, которую раздел 5
|
||||||
каталога — хвост для #42.
|
обещает эталонному миру, пока короткая. Этап 7 продлит её до четырнадцати
|
||||||
|
дней, а не заведёт вторую. Форма — JSON: паспорт мира (зерно, версия
|
||||||
|
генератора, хеш каталога) и по строке на день с датой, числом событий и
|
||||||
|
хешем. Собирается целью `make inventory`, свежесть сторожит тест
|
||||||
|
`test_inventory.py` — тем же способом, что свежесть «описания выгрузки».
|
||||||
|
Отклонено: *два файла, «мини» и полный* — две правды об одном мире и
|
||||||
|
лишнее слово в словаре.
|
||||||
|
- **Хеш каталога — в паспорте, и работа у него объяснительная.** Правка
|
||||||
|
цены в `data/catalog/products.csv` меняет мир так же молча, как правка
|
||||||
|
кода, но поймают её и без хеша: хеши дней разойдутся. Хеш каталога
|
||||||
|
отвечает на следующий вопрос — **что** правили: разошлись дни и каталог —
|
||||||
|
CSV; разошлись только дни — код.
|
||||||
|
- **Слова.** «Манифест» по всей спеке переименован в **опись мира**, а
|
||||||
|
«зерновой мир» — в **стартовый мир** (решение владельца 2026-08-07).
|
||||||
|
Довод — правило языка репозитория: есть обычное русское слово — берётся
|
||||||
|
оно. Заодно исчезла пара «манифест / мини-манифест», в которой читателю
|
||||||
|
пришлось бы различать две сущности там, где вещь одна.
|
||||||
|
|
||||||
|
Открытых вопросов за разделом не осталось: интерфейс запуска закрыт #41,
|
||||||
|
форма описи и хеш каталога — здесь.
|
||||||
|
|||||||
+5
-1
@@ -28,7 +28,7 @@ D0 живёт предыстория, поэтому любой день соб
|
|||||||
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
|
бросок достаётся тому, кто спросил k-м. Приписать новый бросок в конец
|
||||||
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
|
функции безопасно: у прежних он ничего не отнимает. Вставить в середину —
|
||||||
значит сдвинуть все броски после него, а с ними и весь мир: события того же
|
значит сдвинуть все броски после него, а с ними и весь мир: события того же
|
||||||
дня станут другими, счётчики канонического мира разойдутся с манифестом, и
|
дня станут другими, счётчики канонического мира разойдутся с описью, и
|
||||||
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
|
поймается это не ошибкой, а красным чеком. Ровно поэтому паспорта кук в
|
||||||
`plan.cohort` бросаются последними.
|
`plan.cohort` бросаются последними.
|
||||||
|
|
||||||
@@ -78,6 +78,10 @@ D0 живёт предыстория, поэтому любой день соб
|
|||||||
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
- `src/clickstream_generator/schema_doc.py` — сборка «описания выгрузки»
|
||||||
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
([`docs/formats/clickstream-event.md`](../docs/formats/clickstream-event.md))
|
||||||
из контракта. Документ руками не правят — пересобирают.
|
из контракта. Документ руками не правят — пересобирают.
|
||||||
|
- `src/clickstream_generator/inventory.py` — сборка описи мира
|
||||||
|
([`data/world-inventory.json`](../data/world-inventory.json)): паспорт мира
|
||||||
|
и хеши восьми дней, которыми наполняется стенд. Руками не правят —
|
||||||
|
пересобирают целью `make inventory`.
|
||||||
- `tests/` — инварианты контракта, свежесть описания и обещания мира:
|
- `tests/` — инварианты контракта, свежесть описания и обещания мира:
|
||||||
чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
|
чистота от зерна, приток, гарантия двухкуковых пар, форма суточной волны
|
||||||
и сборка визитов по задокументированным правилам. Там же побайтовое
|
и сборка визитов по задокументированным правилам. Там же побайтовое
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
Зовущих трое, и все трое видны в форме команд:
|
Зовущих трое, и все трое видны в форме команд:
|
||||||
|
|
||||||
- даги `world_init` и `next_day` этапа 5 — по дню за запуск, приёмник Kafka;
|
- даги `world_init` и `next_day` этапа 5 — по дню за запуск, приёмник Kafka;
|
||||||
- заливка зернового мира (#42) — восемь дней подряд одним запуском: `--days`;
|
- заливка стартового мира — восемь дней подряд одним запуском: `--days`;
|
||||||
- проверки хранилища (#43) — ограниченная пачка в файл: `--limit` и `--file`.
|
- проверки хранилища (#43) — ограниченная пачка в файл: `--limit` и `--file`.
|
||||||
|
|
||||||
**Режимы разведены командами, а не флагом**, потому что различаются не темпом
|
**Режимы разведены командами, а не флагом**, потому что различаются не темпом
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Опись мира: чем стенд наполняется при подъёме и каким это обязано выйти.
|
||||||
|
|
||||||
|
Мир — чистая функция зерна (спека генератора, раздел 2), поэтому в git лежит не
|
||||||
|
он сам, а опись: паспорт мира, число событий по дням и хеш байтов каждого дня.
|
||||||
|
Сам мир пересчитывается когда угодно, а опись отвечает на единственный вопрос —
|
||||||
|
**тот ли это мир, что был вчера**. Разошлись хеши — мир уехал, и дальше уже
|
||||||
|
неважно, чего от него ждали проверки.
|
||||||
|
|
||||||
|
Дней в описи восемь: столько заливается в стенд при `make up`. Понедельник по
|
||||||
|
понедельник — полная неделя с выходными и первый замкнутый цикл окна K = 7.
|
||||||
|
Эталонный снимок в четырнадцать дней придёт на этапе 7 и станет продолжением
|
||||||
|
этой же описи, а не вторым файлом.
|
||||||
|
|
||||||
|
**Сторожат мир хеши, а не паспорт.** Паспорт отвечает на другой вопрос — «чем
|
||||||
|
это сделано»: зерно и версия генератора. Поменяй кто-нибудь код так, что мир
|
||||||
|
сдвинется, — версия останется прежней, а хеши покраснеют; наоборот не бывает.
|
||||||
|
|
||||||
|
Хеш каталога стоит здесь по третьему основанию — ни сторожить, ни описывать, а
|
||||||
|
**объяснять**. Правка цены в `data/catalog/products.csv` меняет мир так же
|
||||||
|
молча, как правка кода, и по одним хешам эти два случая неразличимы. С хешем
|
||||||
|
каталога различимы: разошлись хеши дней и каталога — правили CSV; разошлись
|
||||||
|
только дни — правили код.
|
||||||
|
|
||||||
|
Хеш дня — sha256 тех самых байтов, что уезжают в Kafka, с переводом строки
|
||||||
|
после каждого события. Это ровно то, что пишет файловый приёмник, поэтому
|
||||||
|
пересчитывается он и обычным `sha256sum` по сыгранному в файл дню (как
|
||||||
|
именно — в README репозитория).
|
||||||
|
|
||||||
|
Собирается опись из корня репозитория целью `make inventory`, а свежесть её
|
||||||
|
сторожит тест — как и у «описания выгрузки».
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from datetime import timedelta
|
||||||
|
from importlib.metadata import version
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from clickstream_generator import day as day_module
|
||||||
|
from clickstream_generator import serialize, world
|
||||||
|
from clickstream_generator.catalog import CATALOG_PATH
|
||||||
|
from clickstream_generator.seeds import CANONICAL_SEED
|
||||||
|
|
||||||
|
# Сколько дней оси заливается в стенд при подъёме. То же число стоит у службы
|
||||||
|
# `world-init` в compose.yaml: YAML не читает Python, и одно из двух мест —
|
||||||
|
# лишнее по построению. Расхождение поймают счётчики make check-clickhouse.
|
||||||
|
STARTING_DAYS = 8
|
||||||
|
|
||||||
|
|
||||||
|
def build() -> dict[str, Any]:
|
||||||
|
"""Опись целиком: паспорт мира и по строке на каждый его день."""
|
||||||
|
return {
|
||||||
|
"seed": CANONICAL_SEED,
|
||||||
|
"generator_version": version("clickstream-generator"),
|
||||||
|
"catalog_sha256": _digest(CATALOG_PATH.read_bytes()),
|
||||||
|
"days": [_day(number) for number in range(STARTING_DAYS)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render() -> str:
|
||||||
|
"""Опись текстом файла: отступы в два пробела, кириллица как есть."""
|
||||||
|
return json.dumps(build(), ensure_ascii=False, indent=2) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _day(number: int) -> dict[str, Any]:
|
||||||
|
"""Строка описи: номер дня, его дата, число событий и хеш байтов.
|
||||||
|
|
||||||
|
Дата считается от D0 арифметикой, а не берётся из событий: ось модельного
|
||||||
|
времени так и определена (`world.ORIGIN`), и по этой же дате счётчики
|
||||||
|
стенда обрамляют счёт в `ods.event`. Соври она — подневная сверка это и
|
||||||
|
покажет, каждый день сразу.
|
||||||
|
"""
|
||||||
|
payloads = serialize.events(day_module.stream(CANONICAL_SEED, number))
|
||||||
|
return {
|
||||||
|
"day": number,
|
||||||
|
"date": (world.ORIGIN + timedelta(days=number)).isoformat(),
|
||||||
|
"events": len(payloads),
|
||||||
|
"sha256": _digest(b"".join(payload + b"\n" for payload in payloads)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(payload: bytes) -> str:
|
||||||
|
return hashlib.sha256(payload).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Собирает опись мира: паспорт, счётчики и хеши дней."
|
||||||
|
)
|
||||||
|
parser.add_argument("output", type=Path, help="путь к файлу описи")
|
||||||
|
output = parser.parse_args().output
|
||||||
|
output.write_text(render(), encoding="utf-8")
|
||||||
|
print(f"Опись мира собрана: {output}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
Тайминги печатаются раздельно — генерация и доставка, как требует спека
|
Тайминги печатаются раздельно — генерация и доставка, как требует спека
|
||||||
(раздел 5): это разные машины разной природы, и сложенные в одно число они
|
(раздел 5): это разные машины разной природы, и сложенные в одно число они
|
||||||
перестают что-либо говорить. Сериализация считается частью генерации: она
|
перестают что-либо говорить. Сериализация считается частью генерации: она
|
||||||
рождает те самые байты, которые сторожит манифест.
|
рождает те самые байты, которые сторожит опись.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ from enum import IntEnum
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
# Каноническое зерно эталонного мира — константа репозитория; манифест хранит
|
# Каноническое зерно эталонного мира — константа репозитория; опись хранит
|
||||||
# его в паспорте мира. Свои зёрна менти крутит без гарантий манифеста.
|
# его в паспорте мира. Свои зёрна менти крутит без гарантий описи.
|
||||||
CANONICAL_SEED = 20260601
|
CANONICAL_SEED = 20260601
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
Модуль — приглашение крутить: поменяйте число, пересоберите снимок и
|
Модуль — приглашение крутить: поменяйте число, пересоберите снимок и
|
||||||
посмотрите, что стало с данными. Правка любой константы здесь — смена мира,
|
посмотрите, что стало с данными. Правка любой константы здесь — смена мира,
|
||||||
поэтому чек манифеста честно покраснеет: манифест сторожит только канонический
|
поэтому чек описи честно покраснеет: опись сторожит только канонический мир,
|
||||||
мир, свои миры менти собирает без его гарантий (спека генератора, раздел 9).
|
свои миры менти собирает без её гарантий (спека генератора, раздел 9).
|
||||||
|
|
||||||
Числа решены спекой и связаны между собой; связки сторожат тесты
|
Числа решены спекой и связаны между собой; связки сторожат тесты
|
||||||
`test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего.
|
`test_world.py`, чтобы правка одного числа не рассыпала вывод соседнего.
|
||||||
@@ -26,7 +26,7 @@ COUNTER_TIMEZONE_MINUTES = 240
|
|||||||
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
|
# D0 — первый день оси модельного времени, понедельник. Реальный календарь в
|
||||||
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
|
# модели не участвует: дата нужна лишь затем, чтобы дни оси легли в
|
||||||
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
|
# `EventDate`/`UTCEventTime` конкретными числами. От даты запуска мир не
|
||||||
# зависит — иначе манифест перестал бы быть воспроизводимым.
|
# зависит — иначе опись перестала бы быть воспроизводимой.
|
||||||
ORIGIN = date(2026, 6, 1)
|
ORIGIN = date(2026, 6, 1)
|
||||||
|
|
||||||
# Приток: сколько новых людей приходит в мир в средний день. Каждый приводит
|
# Приток: сколько новых людей приходит в мир в средний день. Каждый приводит
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Проверка описи мира: та ли она, что собирается из кода сегодня.
|
||||||
|
|
||||||
|
Опись собирается из кода, значит разойтись они могут только одним способом —
|
||||||
|
код правили, опись не пересобрали. Ровно это здесь и сторожится, тем же
|
||||||
|
способом, что свежесть «описания выгрузки».
|
||||||
|
|
||||||
|
Проверка дорогая — она пересчитывает восемь модельных дней целиком, и это
|
||||||
|
единственный способ сравнить хеши: дешевле мир не пересобрать. Зато краснеет
|
||||||
|
она там, где надо, — сразу после правки генератора, а не через полчаса на
|
||||||
|
поднятом стенде, где расхождение счётчиков выглядит поломкой хранилища.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from clickstream_generator.inventory import build
|
||||||
|
|
||||||
|
INVENTORY_PATH = Path(__file__).resolve().parents[2] / "data" / "world-inventory.json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inventory_is_up_to_date():
|
||||||
|
stored = json.loads(INVENTORY_PATH.read_text(encoding="utf-8"))
|
||||||
|
assert stored == build(), (
|
||||||
|
"опись мира отстала от кода — пересоберите: make inventory."
|
||||||
|
" Разошлись хеши дней и каталога — правили data/catalog/products.csv;"
|
||||||
|
" разошлись только дни — правили генератор"
|
||||||
|
)
|
||||||
@@ -98,7 +98,7 @@ def _run_apart(path) -> None:
|
|||||||
def test_days_play_in_a_row(tmp_path, monkeypatch):
|
def test_days_play_in_a_row(tmp_path, monkeypatch):
|
||||||
"""Дни идут подряд от названного, а пачка считается на весь прогон.
|
"""Дни идут подряд от названного, а пачка считается на весь прогон.
|
||||||
|
|
||||||
Восемь дней одним запуском — то, чем зальётся зерновой мир (#42), поэтому
|
Восемь дней одним запуском — то, чем заливается стартовый мир, поэтому
|
||||||
порядок дней проверяется, а не предполагается. День здесь подменён коротким:
|
порядок дней проверяется, а не предполагается. День здесь подменён коротким:
|
||||||
проверяется ход проигрывателя, а не содержимое дня, и платить за полсотни
|
проверяется ход проигрывателя, а не содержимое дня, и платить за полсотни
|
||||||
тысяч событий трижды незачем.
|
тысяч событий трижды незачем.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def test_every_event_carries_every_column(events):
|
|||||||
|
|
||||||
«Пусто» по контракту — пустое значение, а не отсутствие ключа: пропавший
|
«Пусто» по контракту — пустое значение, а не отсутствие ключа: пропавший
|
||||||
ключ уводит событие в брак целиком (ADR 0005). Порядок ключей — часть
|
ключ уводит событие в брак целиком (ADR 0005). Порядок ключей — часть
|
||||||
канона: от него зависят байты, а значит и хеши манифеста.
|
канона: от него зависят байты, а значит и хеши описи.
|
||||||
"""
|
"""
|
||||||
names = [column.name for column in schema.COLUMNS]
|
names = [column.name for column in schema.COLUMNS]
|
||||||
for event in events:
|
for event in events:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
readonly INVENTORY="${ROOT_DIR}/data/world-inventory.json"
|
||||||
readonly CLUSTER="clickstream_cluster"
|
readonly CLUSTER="clickstream_cluster"
|
||||||
readonly LOCAL_TABLE="smoke_replicated_local"
|
readonly LOCAL_TABLE="smoke_replicated_local"
|
||||||
readonly DISTRIBUTED_TABLE="smoke_distributed"
|
readonly DISTRIBUTED_TABLE="smoke_distributed"
|
||||||
@@ -76,6 +77,58 @@ on_signal() {
|
|||||||
exit 130
|
exit 130
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Единственный постоянный сторож цепочки Kafka → STG → ODS.
|
||||||
|
#
|
||||||
|
# Проверка спрашивает не «есть ли данные», а «те ли это данные»: подневный счёт
|
||||||
|
# в `ods.event` против описи мира, лежащей в git. Поэтому она умеет краснеть на
|
||||||
|
# сломанном разборе — уехали события в брак, счёт разошёлся, — и на потере по
|
||||||
|
# дороге, а не только на пустой таблице.
|
||||||
|
#
|
||||||
|
# **Счёт обрамлён датами стартового мира.** Мир растёт: менти переиграет день,
|
||||||
|
# этап 5 добавит следующий, — и голый счёт по таблице разойдётся с описью
|
||||||
|
# законно, без всякой поломки.
|
||||||
|
#
|
||||||
|
# **Счёт идёт через FINAL** — правило репозитория для ODS, довод в storage.md:
|
||||||
|
# голый `count()` по ReplacingMergeTree зависит от числа прошедших мержей.
|
||||||
|
#
|
||||||
|
# Таблица брака здесь не второе утверждение, а объяснение первого. Утверждай мы
|
||||||
|
# «брака нет», проверка краснела бы навсегда после первого же урока, где менти
|
||||||
|
# нарочно отправил в топик мусор, — и краснела бы не о том.
|
||||||
|
check_starting_world() {
|
||||||
|
local expected actual broken first_date last_date
|
||||||
|
|
||||||
|
expected="$(jq -r '.days[] | "\(.date)\t\(.events)"' "$INVENTORY")"
|
||||||
|
first_date="$(jq -r '.days | first | .date' "$INVENTORY")"
|
||||||
|
last_date="$(jq -r '.days | last | .date' "$INVENTORY")"
|
||||||
|
|
||||||
|
actual="$(query clickhouse-01 "
|
||||||
|
SELECT EventDate, count()
|
||||||
|
FROM ods.event_dist FINAL
|
||||||
|
WHERE EventDate BETWEEN '${first_date}' AND '${last_date}'
|
||||||
|
GROUP BY EventDate
|
||||||
|
ORDER BY EventDate
|
||||||
|
FORMAT TSV")"
|
||||||
|
|
||||||
|
if [[ "$actual" == "$expected" ]]; then
|
||||||
|
printf 'ЗЕЛЁНО: события всех дней стартового мира на месте, счёт сходится с описью.\n'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
broken="$(query clickhouse-01 "
|
||||||
|
SELECT error_class, count()
|
||||||
|
FROM ods.event_errors_dist
|
||||||
|
GROUP BY error_class
|
||||||
|
ORDER BY count() DESC
|
||||||
|
FORMAT TSV")"
|
||||||
|
printf 'Опись мира ожидает (дата, событий):\n%s\n' "$expected" >&2
|
||||||
|
printf 'В ods.event лежит:\n%s\n' "${actual:-— ничего —}" >&2
|
||||||
|
if [[ -n "$broken" ]]; then
|
||||||
|
printf 'В ods.event_errors по классам брака:\n%s\n' "$broken" >&2
|
||||||
|
fail 'счёт разошёлся с описью, и в таблице брака есть строки: сломан разбор — начните с ods.event_errors_dist и матвью ods.event_mv'
|
||||||
|
fi
|
||||||
|
fail 'счёт разошёлся с описью, а таблица брака пуста: события не доехали до ODS — начните с чтеца топика stg.hits_raw_kafka и матвью приёма stg.hits_raw_mv'
|
||||||
|
}
|
||||||
|
|
||||||
assert_ddl_queue_completed() {
|
assert_ddl_queue_completed() {
|
||||||
local service="$1"
|
local service="$1"
|
||||||
local phase="$2"
|
local phase="$2"
|
||||||
@@ -93,7 +146,7 @@ ensure_stand_running
|
|||||||
trap on_exit EXIT
|
trap on_exit EXIT
|
||||||
trap on_signal INT TERM
|
trap on_signal INT TERM
|
||||||
|
|
||||||
printf 'Проверка 1/8: описание кластера одинаково на обеих нодах...\n'
|
printf 'Проверка 1/9: описание кластера одинаково на обеих нодах...\n'
|
||||||
cluster_sql="SELECT cluster, shard_num, replica_num, host_name, port FROM system.clusters WHERE cluster = '${CLUSTER}' ORDER BY shard_num, replica_num FORMAT TSV"
|
cluster_sql="SELECT cluster, shard_num, replica_num, host_name, port FROM system.clusters WHERE cluster = '${CLUSTER}' ORDER BY shard_num, replica_num FORMAT TSV"
|
||||||
cluster_01="$(query clickhouse-01 "$cluster_sql")"
|
cluster_01="$(query clickhouse-01 "$cluster_sql")"
|
||||||
cluster_02="$(query clickhouse-02 "$cluster_sql")"
|
cluster_02="$(query clickhouse-02 "$cluster_sql")"
|
||||||
@@ -102,7 +155,7 @@ assert_equal "$expected_cluster" "$cluster_01" "неверная тополог
|
|||||||
assert_equal "$expected_cluster" "$cluster_02" "неверная топология на второй ноде"
|
assert_equal "$expected_cluster" "$cluster_02" "неверная топология на второй ноде"
|
||||||
printf 'ЗЕЛЁНО: обе ноды видят ожидаемые два шарда: clickhouse-01 и clickhouse-02.\n'
|
printf 'ЗЕЛЁНО: обе ноды видят ожидаемые два шарда: clickhouse-01 и clickhouse-02.\n'
|
||||||
|
|
||||||
printf 'Проверка 2/8: у нод разные макросы shard и replica...\n'
|
printf 'Проверка 2/9: у нод разные макросы shard и replica...\n'
|
||||||
macros_sql="SELECT macro, substitution FROM system.macros WHERE macro IN ('shard', 'replica') ORDER BY macro FORMAT TSV"
|
macros_sql="SELECT macro, substitution FROM system.macros WHERE macro IN ('shard', 'replica') ORDER BY macro FORMAT TSV"
|
||||||
macros_01="$(query clickhouse-01 "$macros_sql")"
|
macros_01="$(query clickhouse-01 "$macros_sql")"
|
||||||
macros_02="$(query clickhouse-02 "$macros_sql")"
|
macros_02="$(query clickhouse-02 "$macros_sql")"
|
||||||
@@ -111,14 +164,14 @@ assert_equal $'replica\tclickhouse-02\nshard\t02' "$macros_02" "неверные
|
|||||||
[[ "$macros_01" != "$macros_02" ]] || fail "макросы нод не должны совпадать"
|
[[ "$macros_01" != "$macros_02" ]] || fail "макросы нод не должны совпадать"
|
||||||
printf 'ЗЕЛЁНО: clickhouse-01=(shard 01, replica clickhouse-01), clickhouse-02=(shard 02, replica clickhouse-02).\n'
|
printf 'ЗЕЛЁНО: clickhouse-01=(shard 01, replica clickhouse-01), clickhouse-02=(shard 02, replica clickhouse-02).\n'
|
||||||
|
|
||||||
printf 'Проверка 3/8: keeper отвечает обеим нодам...\n'
|
printf 'Проверка 3/9: keeper отвечает обеим нодам...\n'
|
||||||
query clickhouse-01 "SELECT name FROM system.zookeeper WHERE path = '/' ORDER BY name FORMAT Null"
|
query clickhouse-01 "SELECT name FROM system.zookeeper WHERE path = '/' ORDER BY name FORMAT Null"
|
||||||
query clickhouse-02 "SELECT name FROM system.zookeeper WHERE path = '/' ORDER BY name FORMAT Null"
|
query clickhouse-02 "SELECT name FROM system.zookeeper WHERE path = '/' ORDER BY name FORMAT Null"
|
||||||
printf 'ЗЕЛЁНО: system.zookeeper доступна с обеих нод.\n'
|
printf 'ЗЕЛЁНО: system.zookeeper доступна с обеих нод.\n'
|
||||||
|
|
||||||
cleanup_tables || fail "не удалось очистить объекты предыдущего запуска"
|
cleanup_tables || fail "не удалось очистить объекты предыдущего запуска"
|
||||||
|
|
||||||
printf 'Проверка 4/8: ReplicatedMergeTree создаётся через ON CLUSTER...\n'
|
printf 'Проверка 4/9: ReplicatedMergeTree создаётся через ON CLUSTER...\n'
|
||||||
query clickhouse-01 "
|
query clickhouse-01 "
|
||||||
CREATE TABLE default.${LOCAL_TABLE} ON CLUSTER ${CLUSTER}
|
CREATE TABLE default.${LOCAL_TABLE} ON CLUSTER ${CLUSTER}
|
||||||
(
|
(
|
||||||
@@ -136,13 +189,13 @@ assert_equal $'smoke_replicated_local\tReplicatedMergeTree' "$(query clickhouse-
|
|||||||
assert_equal $'smoke_replicated_local\tReplicatedMergeTree' "$(query clickhouse-02 "$tables_sql")" "локальная таблица не создана на второй ноде"
|
assert_equal $'smoke_replicated_local\tReplicatedMergeTree' "$(query clickhouse-02 "$tables_sql")" "локальная таблица не создана на второй ноде"
|
||||||
printf 'ЗЕЛЁНО: ReplicatedMergeTree видна в system.tables на обеих нодах.\n'
|
printf 'ЗЕЛЁНО: ReplicatedMergeTree видна в system.tables на обеих нодах.\n'
|
||||||
|
|
||||||
printf 'Проверка 5/8: путь в keeper собран из макроса shard...\n'
|
printf 'Проверка 5/9: путь в keeper собран из макроса shard...\n'
|
||||||
path_sql="SELECT zookeeper_path, replica_name FROM system.replicas WHERE database = 'default' AND table = '${LOCAL_TABLE}' FORMAT TSV"
|
path_sql="SELECT zookeeper_path, replica_name FROM system.replicas WHERE database = 'default' AND table = '${LOCAL_TABLE}' FORMAT TSV"
|
||||||
assert_equal "/clickhouse/tables/01/${LOCAL_TABLE}"$'\t'"clickhouse-01" "$(query clickhouse-01 "$path_sql")" "неверные путь или имя реплики на первой ноде"
|
assert_equal "/clickhouse/tables/01/${LOCAL_TABLE}"$'\t'"clickhouse-01" "$(query clickhouse-01 "$path_sql")" "неверные путь или имя реплики на первой ноде"
|
||||||
assert_equal "/clickhouse/tables/02/${LOCAL_TABLE}"$'\t'"clickhouse-02" "$(query clickhouse-02 "$path_sql")" "неверные путь или имя реплики на второй ноде"
|
assert_equal "/clickhouse/tables/02/${LOCAL_TABLE}"$'\t'"clickhouse-02" "$(query clickhouse-02 "$path_sql")" "неверные путь или имя реплики на второй ноде"
|
||||||
printf 'ЗЕЛЁНО: пути собраны из shard (/01/ и /02/), имя реплики собрано из макроса replica.\n'
|
printf 'ЗЕЛЁНО: пути собраны из shard (/01/ и /02/), имя реплики собрано из макроса replica.\n'
|
||||||
|
|
||||||
printf 'Проверка 6/8: Distributed создаётся ON CLUSTER и передаёт данные между нодами...\n'
|
printf 'Проверка 6/9: Distributed создаётся ON CLUSTER и передаёт данные между нодами...\n'
|
||||||
query clickhouse-01 "
|
query clickhouse-01 "
|
||||||
CREATE TABLE default.${DISTRIBUTED_TABLE} ON CLUSTER ${CLUSTER}
|
CREATE TABLE default.${DISTRIBUTED_TABLE} ON CLUSTER ${CLUSTER}
|
||||||
AS default.${LOCAL_TABLE}
|
AS default.${LOCAL_TABLE}
|
||||||
@@ -164,12 +217,12 @@ sharding_sql="SELECT countIf(_shard_num != cityHash64(ClientID) % 2 + 1), uniqEx
|
|||||||
assert_equal $'0\t2' "$(query clickhouse-02 "$sharding_sql")" "Distributed использует неверный ключ шардирования"
|
assert_equal $'0\t2' "$(query clickhouse-02 "$sharding_sql")" "Distributed использует неверный ключ шардирования"
|
||||||
printf 'ЗЕЛЁНО: локальная строка первой ноды читается со второй; ключ cityHash64(ClientID) разложил строки по двум шардам.\n'
|
printf 'ЗЕЛЁНО: локальная строка первой ноды читается со второй; ключ cityHash64(ClientID) разложил строки по двум шардам.\n'
|
||||||
|
|
||||||
printf 'Проверка 7/8: в очереди распределённых DDL нет незавершённых заданий...\n'
|
printf 'Проверка 7/9: в очереди распределённых DDL нет незавершённых заданий...\n'
|
||||||
assert_ddl_queue_completed clickhouse-01 'после CREATE'
|
assert_ddl_queue_completed clickhouse-01 'после CREATE'
|
||||||
assert_ddl_queue_completed clickhouse-02 'после CREATE'
|
assert_ddl_queue_completed clickhouse-02 'после CREATE'
|
||||||
printf 'ЗЕЛЁНО: очередь содержит задания CREATE, незавершённых среди них нет.\n'
|
printf 'ЗЕЛЁНО: очередь содержит задания CREATE, незавершённых среди них нет.\n'
|
||||||
|
|
||||||
printf 'Проверка 8/8: временные таблицы удаляются через ON CLUSTER...\n'
|
printf 'Проверка 8/9: временные таблицы удаляются через ON CLUSTER...\n'
|
||||||
query clickhouse-01 "DROP TABLE default.${DISTRIBUTED_TABLE} ON CLUSTER ${CLUSTER} SYNC" >/dev/null
|
query clickhouse-01 "DROP TABLE default.${DISTRIBUTED_TABLE} ON CLUSTER ${CLUSTER} SYNC" >/dev/null
|
||||||
query clickhouse-01 "DROP TABLE default.${LOCAL_TABLE} ON CLUSTER ${CLUSTER} SYNC" >/dev/null
|
query clickhouse-01 "DROP TABLE default.${LOCAL_TABLE} ON CLUSTER ${CLUSTER} SYNC" >/dev/null
|
||||||
remaining_sql="SELECT count() FROM system.tables WHERE database = 'default' AND name IN ('${LOCAL_TABLE}', '${DISTRIBUTED_TABLE}') FORMAT TSVRaw"
|
remaining_sql="SELECT count() FROM system.tables WHERE database = 'default' AND name IN ('${LOCAL_TABLE}', '${DISTRIBUTED_TABLE}') FORMAT TSVRaw"
|
||||||
@@ -179,4 +232,10 @@ assert_ddl_queue_completed clickhouse-01 'после DROP'
|
|||||||
assert_ddl_queue_completed clickhouse-02 'после DROP'
|
assert_ddl_queue_completed clickhouse-02 'после DROP'
|
||||||
trap - EXIT INT TERM
|
trap - EXIT INT TERM
|
||||||
printf 'ЗЕЛЁНО: временные таблицы удалены; проверены завершённые задания CREATE и DROP.\n'
|
printf 'ЗЕЛЁНО: временные таблицы удалены; проверены завершённые задания CREATE и DROP.\n'
|
||||||
printf 'ИТОГ: все 8 проверок кластера ClickHouse прошли.\n'
|
|
||||||
|
# После снятия ловушек: своих объектов эта проверка не заводит и прибирать за
|
||||||
|
# собой ей нечего — она только смотрит на то, что стенд произвёл сам.
|
||||||
|
printf 'Проверка 9/9: стартовый мир в ods.event сходится с описью...\n'
|
||||||
|
check_starting_world
|
||||||
|
|
||||||
|
printf 'ИТОГ: все 9 проверок кластера ClickHouse прошли.\n'
|
||||||
|
|||||||
Executable
+79
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Ждёт, пока стартовый мир доедет до ODS. Второй шаг `make up`, и вот зачем он.
|
||||||
|
#
|
||||||
|
# `docker compose up --wait` дожидается служб, в том числе успешно отработавшей
|
||||||
|
# заливки. Но «заливка кончилась» — это «события лежат в топике», а не «события
|
||||||
|
# в хранилище»: приём асинхронный. Движок Kafka копит блок и отдаёт его по
|
||||||
|
# размеру либо по `stream_flush_interval_ms` (умолчание 7,5 с), а стартовый мир —
|
||||||
|
# около 400 тыс. сообщений. Верни `make up` управление сразу — и `make
|
||||||
|
# check-clickhouse` следом покраснел бы по устройству, а не по поломке.
|
||||||
|
#
|
||||||
|
# Ждём ограниченным циклом с потолком, а не паузой наугад: пауза либо коротка на
|
||||||
|
# медленной машине, либо ворует минуту на быстрой.
|
||||||
|
#
|
||||||
|
# Условие ожидания — «всё доехало», а не «всё разобралось»: сумма событий и
|
||||||
|
# брака. Сломайся разбор — события уедут в `*_errors`, сумма сойдётся, ожидание
|
||||||
|
# кончится, и поломку назовёт `make check-clickhouse`. Ждать здесь одних годных
|
||||||
|
# событий значило бы висеть пять минут вместо внятного ответа.
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
readonly INVENTORY="${ROOT_DIR}/data/world-inventory.json"
|
||||||
|
readonly ATTEMPTS=100
|
||||||
|
readonly PAUSE_SECONDS=3
|
||||||
|
# Как часто отчитываться о ходе: молчащая минуту команда выглядит зависшей.
|
||||||
|
readonly REPORT_EVERY=5
|
||||||
|
|
||||||
|
read -r -a COMPOSE_CMD <<<"${COMPOSE_BIN:-docker compose}"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'ОШИБКА: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
query() {
|
||||||
|
"${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" exec -T clickhouse-01 \
|
||||||
|
clickhouse-client --query "$1" </dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
expected="$(jq '[.days[].events] | add' "$INVENTORY")"
|
||||||
|
first_date="$(jq -r '.days | first | .date' "$INVENTORY")"
|
||||||
|
last_date="$(jq -r '.days | last | .date' "$INVENTORY")"
|
||||||
|
|
||||||
|
printf 'Ждём стартовый мир в ods.event: %s событий за %s — %s...\n' \
|
||||||
|
"$expected" "$first_date" "$last_date"
|
||||||
|
|
||||||
|
# Счёт без FINAL: здесь спрашивают «доехало ли», а не «сколько их на самом
|
||||||
|
# деле». Повторная заливка даст больше ожидаемого — и это тоже «доехало».
|
||||||
|
arrived_sql="
|
||||||
|
SELECT
|
||||||
|
(SELECT count() FROM ods.event_dist
|
||||||
|
WHERE EventDate BETWEEN '${first_date}' AND '${last_date}')
|
||||||
|
+ (SELECT count() FROM ods.event_errors_dist)
|
||||||
|
"
|
||||||
|
|
||||||
|
for ((attempt = 1; attempt <= ATTEMPTS; attempt++)); do
|
||||||
|
arrived="$(query "$arrived_sql" 2>/dev/null || true)"
|
||||||
|
if [[ "$arrived" =~ ^[0-9]+$ ]] && ((arrived >= expected)); then
|
||||||
|
if ((arrived > expected)); then
|
||||||
|
# Повторный `make up` заливает мир заново. Промолчи мы об этом —
|
||||||
|
# удвоенное число под «ЗЕЛЁНО» выглядело бы поломкой.
|
||||||
|
printf 'ЗЕЛЁНО: стартовый мир доехал; строк %s при ожидаемых %s —' \
|
||||||
|
"$arrived" "$expected"
|
||||||
|
printf ' мир заливали повторно, повтор схлопнет ReplacingMergeTree.\n'
|
||||||
|
else
|
||||||
|
printf 'ЗЕЛЁНО: стартовый мир доехал: %s строк.\n' "$arrived"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ((attempt % REPORT_EVERY == 0)); then
|
||||||
|
printf 'Доехало %s из %s, ждём дальше (%d с)...\n' \
|
||||||
|
"${arrived:-0}" "$expected" "$((attempt * PAUSE_SECONDS))"
|
||||||
|
fi
|
||||||
|
sleep "$PAUSE_SECONDS"
|
||||||
|
done
|
||||||
|
|
||||||
|
fail "стартовый мир не доехал за $((ATTEMPTS * PAUSE_SECONDS)) с:
|
||||||
|
доехало ${arrived:-0} из ${expected}. Смотрите журнал заливки
|
||||||
|
(docker compose logs world-init) и чтеца топика:
|
||||||
|
SELECT * FROM system.kafka_consumers"
|
||||||
Reference in New Issue
Block a user