feat(world): жизненный цикл передан Airflow
- Зачем: - Compose должен поднимать инфраструктуру, а Airflow — владеть прикладным миром. - Что: - добавлены даги создания и пересоздания мира с явными состояниями позиции; - цели make переведены на REST API Airflow, старые одноразовые службы удалены; - добавлена ограниченная учётная запись ClickHouse для прикладного DDL. - Проверка: - `make lint`, `make config-test`, `bash -n scripts/run-dag.sh infra/airflow/init.sh`; - `make lint` и `make typecheck` в `generator/`; - холодный `make up`, повторный `make up`, `make rebuild-storage` и сценарий оборванной очистки; - `make smoke`, `make check-clickhouse`, `make check-services`.
This commit is contained in:
1 parent
0340b060f2
commit
fc5244cc67
14 files changed
+619
-338
No files matched your search
@@ -57,6 +57,23 @@ jq -e '
|
||||
((.environment // {}) | has("_PIP_ADDITIONAL_REQUIREMENTS") | not)
|
||||
)
|
||||
' >/dev/null <<<"$config_json"
|
||||
jq -e '
|
||||
.services as $services |
|
||||
["kafka-init", "clickhouse-init", "world-init", "world-snapshots"] |
|
||||
all(. as $name | $services | has($name) | not)
|
||||
' >/dev/null <<<"$config_json"
|
||||
jq -e '
|
||||
.services.generator as $generator |
|
||||
((($generator.depends_on // {}) | has("kafka-init")) | not) and
|
||||
((($generator.depends_on // {}) | has("clickhouse-init")) | not)
|
||||
' >/dev/null <<<"$config_json"
|
||||
jq -e '
|
||||
.services["airflow-scheduler"] as $service |
|
||||
($service.environment.CLICKHOUSE_LIFECYCLE_PASSWORD | length > 0) and
|
||||
any($service.volumes[]; .target == "/opt/airflow/world/generator" and .read_only) and
|
||||
any($service.volumes[]; .target == "/opt/airflow/world/data" and .read_only) and
|
||||
any($service.volumes[]; .target == "/opt/airflow/world/.dockerignore" and .read_only)
|
||||
' >/dev/null <<<"$config_json"
|
||||
jq -e '
|
||||
.services["airflow-init"] as $service |
|
||||
($service.environment.AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE ==
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Последний шаг `make up`: запускает единственный путь приёма заказов и ждёт
|
||||
# его конца. Слепки к этому моменту уже лежат в Kafka, а события доехали до ODS.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly DAG_ID='orders_ingest'
|
||||
# Потолок каждого ожидания — 300 с: больше чем всемеро от замеренных 40 с
|
||||
# между запуском сценария и концом дага (#96). Это предел, а не бюджет времени.
|
||||
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
|
||||
}
|
||||
|
||||
compose() {
|
||||
"${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" "$@"
|
||||
}
|
||||
|
||||
config="$(compose config --format json)"
|
||||
user="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_USER // empty' <<<"$config")"
|
||||
password="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_PASSWORD // empty' <<<"$config")"
|
||||
binding="$(compose port airflow-apiserver 8080)"
|
||||
port="${binding##*:}"
|
||||
[[ -n "$port" ]] || fail 'не найден отображённый порт API Airflow'
|
||||
|
||||
response="$(curl -sf --max-time 10 -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -cn --arg username "$user" --arg password "$password" \
|
||||
'{username: $username, password: $password}')" \
|
||||
"http://127.0.0.1:${port}/auth/token" 2>/dev/null || true)"
|
||||
token="$(jq -r '.access_token // empty' <<<"$response")"
|
||||
[[ -n "$token" ]] || fail 'Airflow не принял учётные данные администратора'
|
||||
|
||||
# После `up --wait` обработчик DAG здоров, но файл мог ещё не попасть в список.
|
||||
# Ограниченный опрос закрывает эту гонку без паузы наугад.
|
||||
dag=''
|
||||
for ((attempt = 1; attempt <= ATTEMPTS; attempt++)); do
|
||||
dag="$(curl -sf --max-time 10 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"http://127.0.0.1:${port}/api/v2/dags/${DAG_ID}" \
|
||||
2>/dev/null || true)"
|
||||
jq -e --arg dag_id "$DAG_ID" '.dag_id == $dag_id' \
|
||||
>/dev/null 2>&1 <<<"$dag" && break
|
||||
sleep "$PAUSE_SECONDS"
|
||||
done
|
||||
jq -e --arg dag_id "$DAG_ID" '.dag_id == $dag_id' \
|
||||
>/dev/null 2>&1 <<<"$dag" || \
|
||||
fail "даг ${DAG_ID} не появился в Airflow за $((ATTEMPTS * PAUSE_SECONDS)) с"
|
||||
|
||||
# В API v2 Airflow 3.3 logical_date=null означает событийный ручной запуск.
|
||||
# Это уместно для дага без расписания: календарь Airflow не подменяет дату
|
||||
# слепка, которая уже записана в сообщениях.
|
||||
response="$(curl -sf --max-time 10 -X POST \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"logical_date":null}' \
|
||||
"http://127.0.0.1:${port}/api/v2/dags/${DAG_ID}/dagRuns" \
|
||||
2>/dev/null || true)"
|
||||
run_id="$(jq -r '.dag_run_id // empty' <<<"$response")"
|
||||
[[ -n "$run_id" ]] || fail "Airflow не запустил даг ${DAG_ID}"
|
||||
|
||||
encoded_run_id="$(jq -rn --arg value "$run_id" '$value | @uri')"
|
||||
printf 'Ждём приём стартовых слепков дагом %s...\n' "$DAG_ID"
|
||||
state=''
|
||||
for ((attempt = 1; attempt <= ATTEMPTS; attempt++)); do
|
||||
response="$(curl -sf --max-time 10 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"http://127.0.0.1:${port}/api/v2/dags/${DAG_ID}/dagRuns/${encoded_run_id}" \
|
||||
2>/dev/null || true)"
|
||||
state="$(jq -r '.state // empty' <<<"$response")"
|
||||
case "$state" in
|
||||
success)
|
||||
printf 'ЗЕЛЁНО: стартовые слепки приняты в ODS.\n'
|
||||
exit 0
|
||||
;;
|
||||
failed)
|
||||
fail "даг ${DAG_ID} завершился с ошибкой; запуск ${run_id}"
|
||||
;;
|
||||
esac
|
||||
if ((attempt % REPORT_EVERY == 0)); then
|
||||
printf 'Приём ещё не завершён: состояние %s, ждём дальше (%d с)...\n' \
|
||||
"${state:-неизвестно}" "$((attempt * PAUSE_SECONDS))"
|
||||
fi
|
||||
sleep "$PAUSE_SECONDS"
|
||||
done
|
||||
|
||||
fail "даг ${DAG_ID} не завершился за $((ATTEMPTS * PAUSE_SECONDS)) с:
|
||||
состояние ${state:-неизвестно}, запуск ${run_id}"
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# Запускает публичный даг Airflow и возвращает его итог вызывающему make.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly DAG_ID="${1:-}"
|
||||
# Каждый цикл ограничен двадцатью минутами: успешная пересборка заняла 3 м 9 с.
|
||||
readonly ATTEMPTS=400
|
||||
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
|
||||
}
|
||||
|
||||
compose() {
|
||||
"${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" "$@"
|
||||
}
|
||||
|
||||
next_step() {
|
||||
case "$DAG_ID" in
|
||||
world_initialize)
|
||||
printf 'посмотрите журнал; повторите make up, а для недействительного мира — make rebuild-storage'
|
||||
;;
|
||||
world_recreate) printf 'устраните причину и повторите make rebuild-storage' ;;
|
||||
*) printf 'проверьте журнал дага и повторите команду' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
[[ -n "$DAG_ID" ]] || fail 'укажите идентификатор дага'
|
||||
[[ $# -eq 1 ]] || fail 'ожидался один аргумент: идентификатор дага'
|
||||
|
||||
config="$(compose config --format json)"
|
||||
user="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_USER // empty' <<<"$config")"
|
||||
password="$(jq -r '.services["airflow-apiserver"].environment.AIRFLOW_ADMIN_PASSWORD // empty' <<<"$config")"
|
||||
binding="$(compose port airflow-apiserver 8080 2>/dev/null || true)"
|
||||
port="${binding##*:}"
|
||||
[[ -n "$user" && -n "$password" ]] || fail 'не найдены учётные данные Airflow в Compose'
|
||||
[[ -n "$port" ]] || fail 'API Airflow не запущен; сначала поднимите инфраструктуру'
|
||||
|
||||
# В API v2 logical_date=null означает событийный ручной запуск. Даты мира уже
|
||||
# записаны в данных, календарь Airflow здесь не должен придумывать свою.
|
||||
if ! response="$(curl -sS --fail-with-body --max-time 10 -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -cn --arg username "$user" --arg password "$password" \
|
||||
'{username: $username, password: $password}')" \
|
||||
"http://127.0.0.1:${port}/auth/token" 2>/dev/null)"; then
|
||||
fail 'Airflow не принял учётные данные администратора'
|
||||
fi
|
||||
token="$(jq -r '.access_token // empty' <<<"$response")"
|
||||
[[ -n "$token" ]] || fail 'Airflow не вернул токен доступа'
|
||||
|
||||
# Здоровый обработчик DAG ещё не означает, что новый файл уже появился в API.
|
||||
dag=''
|
||||
for ((attempt = 1; attempt <= ATTEMPTS; attempt++)); do
|
||||
dag="$(curl -sf --max-time 10 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"http://127.0.0.1:${port}/api/v2/dags/${DAG_ID}" \
|
||||
2>/dev/null || true)"
|
||||
jq -e --arg dag_id "$DAG_ID" '.dag_id == $dag_id' \
|
||||
>/dev/null 2>&1 <<<"$dag" && break
|
||||
sleep "$PAUSE_SECONDS"
|
||||
done
|
||||
jq -e --arg dag_id "$DAG_ID" '.dag_id == $dag_id' \
|
||||
>/dev/null 2>&1 <<<"$dag" || \
|
||||
fail "даг ${DAG_ID} не появился за $((ATTEMPTS * PAUSE_SECONDS)) с; $(next_step)"
|
||||
|
||||
if ! response="$(curl -sS --fail-with-body --max-time 10 -X POST \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"logical_date":null}' \
|
||||
"http://127.0.0.1:${port}/api/v2/dags/${DAG_ID}/dagRuns" 2>/dev/null)"; then
|
||||
detail="$(jq -r '.detail // .message // empty' <<<"$response" 2>/dev/null || true)"
|
||||
fail "Airflow не запустил даг ${DAG_ID}${detail:+: ${detail}}; $(next_step)"
|
||||
fi
|
||||
run_id="$(jq -r '.dag_run_id // empty' <<<"$response")"
|
||||
state="$(jq -r '.state // "queued"' <<<"$response")"
|
||||
[[ -n "$run_id" ]] || fail "Airflow не вернул идентификатор запуска ${DAG_ID}"
|
||||
|
||||
encoded_run_id="$(jq -rn --arg value "$run_id" '$value | @uri')"
|
||||
printf 'Airflow: даг %s, запуск %s, состояние %s.\n' "$DAG_ID" "$run_id" "$state"
|
||||
|
||||
for ((attempt = 1; attempt <= ATTEMPTS; attempt++)); do
|
||||
response="$(curl -sf --max-time 10 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"http://127.0.0.1:${port}/api/v2/dags/${DAG_ID}/dagRuns/${encoded_run_id}" \
|
||||
2>/dev/null || true)"
|
||||
state="$(jq -r '.state // empty' <<<"$response" 2>/dev/null || true)"
|
||||
case "$state" in
|
||||
success)
|
||||
printf 'ЗЕЛЁНО: даг %s, запуск %s, состояние success.\n' "$DAG_ID" "$run_id"
|
||||
exit 0
|
||||
;;
|
||||
failed)
|
||||
fail "даг ${DAG_ID}, запуск ${run_id}, состояние ${state}; $(next_step)"
|
||||
;;
|
||||
esac
|
||||
if ((attempt % REPORT_EVERY == 0)); then
|
||||
printf 'Airflow: даг %s, запуск %s, состояние %s; ждём (%d с).\n' \
|
||||
"$DAG_ID" "$run_id" "${state:-неизвестно}" "$((attempt * PAUSE_SECONDS))"
|
||||
fi
|
||||
sleep "$PAUSE_SECONDS"
|
||||
done
|
||||
|
||||
fail "даг ${DAG_ID}, запуск ${run_id}, состояние ${state:-неизвестно} не завершился за $((ATTEMPTS * PAUSE_SECONDS)) с; $(next_step)"
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/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`. Ждать здесь одних годных
|
||||
# событий значило бы висеть пять минут вместо внятного ответа.
|
||||
#
|
||||
# Обещание у скрипта ровно одно и только на пустом стенде: «мир доехал». На
|
||||
# живом стенде мир уже лежит в ODS, условие истинно на первой же попытке, и
|
||||
# ждать тут нечего — повторная заливка ничего не добавляет, её схлопнет
|
||||
# дедупликация. Утверждать после неё «доехала именно эта заливка» скрипт не
|
||||
# может и не пытается: у события нет отметки, которым прогоном оно приехало.
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly INVENTORY="${ROOT_DIR}/data/world-inventory.json"
|
||||
# Потолок ожидания — 300 с: больше чем вдесятеро от замеренных 22,5 с, за
|
||||
# которые заливка отдаёт восемь дней в Kafka (карта проверок, «Что проверено»).
|
||||
# Это не бюджет, а предел, за которым ждать бессмысленно и надо читать журнал.
|
||||
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 \
|
||||
sh -c '
|
||||
clickhouse-client \
|
||||
--user default \
|
||||
--password "$CLICKHOUSE_DEFAULT_PASSWORD" \
|
||||
--query "$1"
|
||||
' _ "$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