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:
@@ -2,6 +2,7 @@
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly INVENTORY="${ROOT_DIR}/data/world-inventory.json"
|
||||
readonly CLUSTER="clickstream_cluster"
|
||||
readonly LOCAL_TABLE="smoke_replicated_local"
|
||||
readonly DISTRIBUTED_TABLE="smoke_distributed"
|
||||
@@ -76,6 +77,58 @@ on_signal() {
|
||||
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() {
|
||||
local service="$1"
|
||||
local phase="$2"
|
||||
@@ -93,7 +146,7 @@ ensure_stand_running
|
||||
trap on_exit EXIT
|
||||
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_01="$(query clickhouse-01 "$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" "неверная топология на второй ноде"
|
||||
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_01="$(query clickhouse-01 "$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 "макросы нод не должны совпадать"
|
||||
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-02 "SELECT name FROM system.zookeeper WHERE path = '/' ORDER BY name FORMAT Null"
|
||||
printf 'ЗЕЛЁНО: system.zookeeper доступна с обеих нод.\n'
|
||||
|
||||
cleanup_tables || fail "не удалось очистить объекты предыдущего запуска"
|
||||
|
||||
printf 'Проверка 4/8: ReplicatedMergeTree создаётся через ON CLUSTER...\n'
|
||||
printf 'Проверка 4/9: ReplicatedMergeTree создаётся через ON CLUSTER...\n'
|
||||
query clickhouse-01 "
|
||||
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")" "локальная таблица не создана на второй ноде"
|
||||
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"
|
||||
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")" "неверные путь или имя реплики на второй ноде"
|
||||
printf 'ЗЕЛЁНО: пути собраны из shard (/01/ и /02/), имя реплики собрано из макроса replica.\n'
|
||||
|
||||
printf 'Проверка 6/8: Distributed создаётся ON CLUSTER и передаёт данные между нодами...\n'
|
||||
printf 'Проверка 6/9: Distributed создаётся ON CLUSTER и передаёт данные между нодами...\n'
|
||||
query clickhouse-01 "
|
||||
CREATE TABLE default.${DISTRIBUTED_TABLE} ON CLUSTER ${CLUSTER}
|
||||
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 использует неверный ключ шардирования"
|
||||
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-02 'после CREATE'
|
||||
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.${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"
|
||||
@@ -179,4 +232,10 @@ assert_ddl_queue_completed clickhouse-01 'после DROP'
|
||||
assert_ddl_queue_completed clickhouse-02 'после DROP'
|
||||
trap - EXIT INT TERM
|
||||
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