Files
clickstream-data-platform/scripts/check-clickhouse.sh
T
ddadmin 7c9eeedc40 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
2026-08-07 18:37:01 +03:00

242 lines
14 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
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"
compose() {
docker compose --project-directory "$ROOT_DIR" "$@"
}
# Ввод закрыт намеренно: у запроса INSERT clickhouse-client дочитывает данные
# из стандартного ввода и ждёт его конца. Если проверку запустили не из
# терминала, а из фонового процесса с открытым вводом, конца не наступает
# никогда, и команда висит без сообщений.
query() {
local service="$1"
local sql="$2"
compose exec -T "$service" clickhouse-client --query "$sql" </dev/null
}
query_with_timeout() {
local timeout_seconds="$1"
local service="$2"
local sql="$3"
timeout --foreground "${timeout_seconds}s" \
docker compose --project-directory "$ROOT_DIR" exec -T "$service" \
clickhouse-client --query "$sql"
}
fail() {
printf 'ОШИБКА: %s\n' "$1" >&2
exit 1
}
assert_equal() {
local expected="$1"
local actual="$2"
local message="$3"
[[ "$actual" == "$expected" ]] || fail "$message: ожидалось «$expected», получено «$actual»"
}
ensure_stand_running() {
local running_services
local service
running_services="$(compose ps --status running --services 2>/dev/null || true)"
for service in clickhouse-keeper clickhouse-01 clickhouse-02; do
if ! grep -qx "$service" <<<"$running_services"; then
fail "стенд запущен не полностью; выполните make up и дождитесь здорового состояния контейнеров"
fi
done
}
cleanup_tables() {
local cleanup_failed=0
query_with_timeout 5 clickhouse-01 "DROP TABLE IF EXISTS default.${DISTRIBUTED_TABLE} ON CLUSTER ${CLUSTER} SYNC" >/dev/null 2>&1 || cleanup_failed=1
query_with_timeout 5 clickhouse-01 "DROP TABLE IF EXISTS default.${LOCAL_TABLE} ON CLUSTER ${CLUSTER} SYNC" >/dev/null 2>&1 || cleanup_failed=1
return "$cleanup_failed"
}
on_exit() {
local status=$?
trap - EXIT
if [[ "$status" -ne 0 ]]; then
printf 'Сбой проверки: выполняется быстрая очистка временных таблиц...\n' >&2
fi
cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make check-clickhouse.\n' >&2
exit "$status"
}
on_signal() {
trap - EXIT INT TERM
printf 'Проверка прервана пользователем: выполняется быстрая очистка временных таблиц...\n' >&2
cleanup_tables || printf 'ПРЕДУПРЕЖДЕНИЕ: очистка не завершена; после восстановления стенда повторите make check-clickhouse.\n' >&2
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"
local queue_state
local total
local unfinished
queue_state="$(query "$service" "SELECT count(), countIf(status NOT IN ('Finished', 'Removing')) FROM system.distributed_ddl_queue FORMAT TSV")"
total="${queue_state%%$'\t'*}"
unfinished="${queue_state##*$'\t'}"
[[ "$total" -gt 0 ]] || fail "на ${service} очередь DDL пуста ${phase}"
assert_equal "0" "$unfinished" "на ${service} есть незавершённые DDL ${phase}"
}
ensure_stand_running
trap on_exit EXIT
trap on_signal INT TERM
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")"
expected_cluster=$'clickstream_cluster\t1\t1\tclickhouse-01\t9000\nclickstream_cluster\t2\t1\tclickhouse-02\t9000'
assert_equal "$expected_cluster" "$cluster_01" "неверная топология на первой ноде"
assert_equal "$expected_cluster" "$cluster_02" "неверная топология на второй ноде"
printf 'ЗЕЛЁНО: обе ноды видят ожидаемые два шарда: clickhouse-01 и clickhouse-02.\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")"
assert_equal $'replica\tclickhouse-01\nshard\t01' "$macros_01" "неверные макросы первой ноды"
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/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/9: ReplicatedMergeTree создаётся через ON CLUSTER...\n'
query clickhouse-01 "
CREATE TABLE default.${LOCAL_TABLE} ON CLUSTER ${CLUSTER}
(
ClientID UInt64,
value String
)
ENGINE = ReplicatedMergeTree(
'/clickhouse/tables/{shard}/${LOCAL_TABLE}',
'{replica}'
)
ORDER BY ClientID
" >/dev/null
tables_sql="SELECT name, engine FROM system.tables WHERE database = 'default' AND name = '${LOCAL_TABLE}' FORMAT TSV"
assert_equal $'smoke_replicated_local\tReplicatedMergeTree' "$(query clickhouse-01 "$tables_sql")" "локальная таблица не создана на первой ноде"
assert_equal $'smoke_replicated_local\tReplicatedMergeTree' "$(query clickhouse-02 "$tables_sql")" "локальная таблица не создана на второй ноде"
printf 'ЗЕЛЁНО: ReplicatedMergeTree видна в system.tables на обеих нодах.\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/9: Distributed создаётся ON CLUSTER и передаёт данные между нодами...\n'
query clickhouse-01 "
CREATE TABLE default.${DISTRIBUTED_TABLE} ON CLUSTER ${CLUSTER}
AS default.${LOCAL_TABLE}
ENGINE = Distributed(
'${CLUSTER}',
'default',
'${LOCAL_TABLE}',
cityHash64(ClientID)
)
" >/dev/null
distributed_sql="SELECT engine FROM system.tables WHERE database = 'default' AND name = '${DISTRIBUTED_TABLE}' FORMAT TSVRaw"
assert_equal "Distributed" "$(query clickhouse-01 "$distributed_sql")" "Distributed-таблица не создана на первой ноде"
assert_equal "Distributed" "$(query clickhouse-02 "$distributed_sql")" "Distributed-таблица не создана на второй ноде"
query clickhouse-01 "INSERT INTO default.${LOCAL_TABLE} VALUES (42, 'из первой ноды')"
read_back="$(query clickhouse-02 "SELECT ClientID, value FROM default.${DISTRIBUTED_TABLE} WHERE ClientID = 42 FORMAT TSV")"
assert_equal $'42\tиз первой ноды' "$read_back" "вторая нода не прочитала вставленную строку"
query clickhouse-01 "INSERT INTO default.${DISTRIBUTED_TABLE} SETTINGS distributed_foreground_insert = 1 SELECT number + 1000, 'через Distributed' FROM numbers(16)"
sharding_sql="SELECT countIf(_shard_num != cityHash64(ClientID) % 2 + 1), uniqExact(_shard_num) FROM default.${DISTRIBUTED_TABLE} WHERE value = 'через Distributed' FORMAT TSV"
assert_equal $'0\t2' "$(query clickhouse-02 "$sharding_sql")" "Distributed использует неверный ключ шардирования"
printf 'ЗЕЛЁНО: локальная строка первой ноды читается со второй; ключ cityHash64(ClientID) разложил строки по двум шардам.\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/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"
assert_equal "0" "$(query clickhouse-01 "$remaining_sql")" "временные таблицы остались на первой ноде"
assert_equal "0" "$(query clickhouse-02 "$remaining_sql")" "временные таблицы остались на второй ноде"
assert_ddl_queue_completed clickhouse-01 'после DROP'
assert_ddl_queue_completed clickhouse-02 'после DROP'
trap - EXIT INT TERM
printf 'ЗЕЛЁНО: временные таблицы удалены; проверены завершённые задания CREATE и DROP.\n'
# После снятия ловушек: своих объектов эта проверка не заводит и прибирать за
# собой ей нечего — она только смотрит на то, что стенд произвёл сам.
printf 'Проверка 9/9: стартовый мир в ods.event сходится с описью...\n'
check_starting_world
printf 'ИТОГ: все 9 проверок кластера ClickHouse прошли.\n'